15 Commits

Author SHA1 Message Date
prosolis
f57dcd93a5 Fix streak grace period, UNO earnings, hospital UX, wordle/lottery atomicity
- Streak grace now checks LastDeathDate instead of LastActionDate (was
  suppressing streak updates for all active players)
- UNO single-player earnings use net payout (minus wager) not gross
- Hospital sends error message on save failure instead of silent no-op
- Wordle total_earned update moved into stats transaction
- Lottery ticket inserts + community pot add wrapped in single transaction
  with euro refund on failure
- Lottery fixed-tier ticket prize updated with actual prorated amount
- Added last_death_date column to adventure_characters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:54:58 -07:00
prosolis
26d2846481 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>
2026-04-08 00:24:51 -07:00
prosolis
ec4574f469 Add Dependabot for weekly Go module updates and tidy go.mod
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 23:49:33 -07:00
prosolis
05cf6657f5 Add hospital revival, Robbie bandit, MW/shop fixes, float64 scoring
- St. Guildmore's Memorial Hospital: !hospital command with Nurse Joy,
  procedural itemized billing, same-day revival, 6-hour dead timer,
  hospital ad after death, 2-hour nudge follow-up
- Robbie the Friendly Bandit: automated inventory cleaner, random daily
  visits (8-21 UTC, 40% chance), collects sub-tier gear at €50/item,
  donates to community pot, drops Get Out of Medical Debt Free card
- Fix MW auto-equip: T1 MW no longer replaces better equipped gear
- Fix shop MW block: allow purchasing upgrades over lower-tier MW
- Float64 equipment scoring: advEffectiveTier and advEquipmentScore
  return float64, no mid-calculation truncation (MW 1.25x, Arena 1.5x)
- Fix markdown rendering: convert *asterisk* to _underscore_ italics
  across all flavor text files (shop, blacksmith, rival, hospital)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 17:04:03 -07:00
prosolis
6c6d74fb1b Add blacksmith repair system, community lottery, and audit fixes
Blacksmith: equipment repair with tier-based pricing, DM confirmation flow,
masterwork/arena surcharges, full flavor text pools. Added to main adventure
menu as option 6 (rest bumped to 7).

Lottery: weekly draw (Friday 23:59 UTC), ticket purchases with 100/week cap,
Fisher-Yates number generation, fixed+jackpot prize tiers, community pot
funding, Thursday reminders, draw history.

Audit fixes: TOCTOU in blacksmith repair costs (recompute from fresh equipment),
user lock in DM slot selection, partial repair refund tracking, error logging
on save failures, Fisher-Yates bias correction, communityPotDebit return value
checks in draw execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-04 09:37:21 -07:00
prosolis
ad6e652755 Add rival duels, babysitting service, multilingual !define, economy tuning
Rival System:
- RPS-based duels between combat level 5+ players (best of 3, ties re-prompt)
- Stakes scale with level, split 50/50 between winner and community pot
- 3-4 day randomized challenge interval, 7-day same-pair cooldown
- 24h response window with auto-forfeit
- Full flavor text pools, character sheet integration, !adventure rivals

Babysitting Service:
- Weekly/monthly auto-play service targeting weakest skill
- Babysitter rerolls death, claims items, credits gold and XP
- Rivals automatically declined during service
- End-of-service summary with diaper report

Revive fixes:
- On-demand revive in handleMenu, parseAndResolveChoice, handleStatus
- Morning DM respawn check now runs before babysit interception
- Players no longer stuck dead after DeadUntil expires mid-day

Other changes:
- Multilingual !define searches all DreamDict languages by default
- TwinBee empty haul now shows "jackshit" instead of blank
- Wordle solve payouts increased (e.g. 1-guess: €100 -> €500)
- Per-message euro payouts doubled across all tiers
- Audit fixes: user locks on RPS/babysit handlers, TOCTOU on gold
  transfers, dead code removal, deprecated strings.Title replaced,
  error logging on silent failures

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-03 20:11:51 -07:00
prosolis
2d99af5310 Update README for new dictionary commands, Wordle and WOTD improvements
Add !antonym, !pronounce, !etymology, !difficulty, !rhyme to Lookup
section. Update Wordle docs (DreamDict-powered, 5-20 letters, guess
persistence, expiry announcements). Add etymology to WOTD, difficulty
tier to Hangman, antonyms to !define. Bump plugin count to 50.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 07:03:17 -07:00
prosolis
78cbd895f4 Add dictionary commands, antonym/etymology integrations, and audit fixes
New DictionaryPlugin with !antonym, !pronounce, !etymology, !difficulty,
and !rhyme commands. Integrate antonyms into !define (concurrent 500ms
fetch), difficulty tier into Hangman headers, and etymology into WOTD.
Fix rhyme limit bug (request 50, display 7), variable shadowing, and
add spec-required emoji prefixes to all dictionary command outputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 06:45:25 -07:00
prosolis
a0573e67a5 Persist Wordle guesses, fix hardcoded rendering, announce expired puzzles
- Add wordle_guesses table to persist individual guesses to DB
- Reload guesses during rehydration so they survive bot restarts
- Clear stale guesses when puzzle is replaced (skip+new)
- Fix all hardcoded /6 and 🟩🟩🟩🟩🟩 in rendering to use puzzle.MaxGuesses
  and puzzle.WordLength
- Announce unsolved puzzles at midnight rollover instead of silently replacing
- Mark expired puzzles as failed and update stats on rollover

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 22:11:01 -07:00
prosolis
b4d5080f48 Use DreamDict for Wordle word selection, expand to 20 letters, fix WOTD English word leak
Wordle:
- Use DreamDict RandomWord API as primary word source, local pools as fallback
- Expand word length support from 5-7 to 5-20
- Validate DreamDict words are correct length and alpha-only
- Keep max guesses at 6 regardless of word length
- Use wordleMaxGuesses() instead of hardcoded 6 for rehydration

WOTD:
- Reject non-English candidates that are valid English words (fixes "puzzlingly" as Portuguese WOTD)
- Make cross-language translations optional (DreamDict pt-PT coverage is sparse)
- Use LLM fallback to translate foreign words to English when DreamDict lacks translations
- Remove hasTranslations re-prefetch guard (no longer needed with optional translations)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 18:58:33 -07:00
prosolis
f61bf89c71 Add overlevel penalty and per-location cooldown to prevent low-tier farming
- Overlevel penalty reduces loot and XP when effective level exceeds location minimum (gap >3: -15%/level, floor 5%)
- 3-hour per-location cooldown after successful runs, persisted via activity log
- Applies to all activity types (dungeons, mining, foraging, fishing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 19:20:26 -07:00
prosolis
3d6bc1a066 Add WOTD language rotation, fancy word detection, arena improvements, and death consolidation
- Rotate WOTD between Portuguese, French, and English daily with cognate filtering
- Replace LLM-based fancy word detection with DreamDict frequency data (batch API + cache)
- Track fancy word usage per user and add Wordsmith archetype
- Restore full arena combat flavor text and add player miss actions
- Consolidate death logic into Kill() method with 2-hour lockout
- Fix arena death XP grant missing level-up check
- Fix LLM wotd_used classification by injecting actual WOTD into prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 18:47:49 -07:00
prosolis
306b4c2ae8 Fix WOTD translation requirements, LLM sentiment accuracy, arena death timer, and fancy word detection
WOTD: Require both English and French translations, validate before
display, fall back to English word if pt-PT search exhausts 100 attempts.

LLM passive: Inject actual WOTD into classification prompt instead of
letting LLM guess at "unusual words". Add DreamDict-based fancy word
detection using frequency data — react with 🎓 for rare vocabulary.

Arena: Reduce death lockout from next-midnight-UTC to 2 hours.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 21:28:27 -07:00
prosolis
2c6f4e48c9 Add multi-tag personality archetype system
Replace single first-match archetype with comprehensive multi-tag system
spanning 25 archetypes across 8 categories (Communication, Temporal,
Emotional, Economy, Games, Adventure, Social). Archetypes are computed
nightly via cron job querying 15+ tables and cached in user_archetypes.
Thresholds calibrated against real community data. Integrates with
!personality, !superstatsexplusalpha, !whois, and milk carton flavor text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 17:11:45 -07:00
prosolis
06153880e0 Add UNO addbot, fix audit issues, add fishing to superstats, update README
UNO addbot:
- !uno addbot / !uno removebot for lobby bot management (WinBee, GwinBee)
- Bots ante from community pot, increasing the total pot
- Single human can start a game if bots fill remaining slots
- Per-bot display names throughout (replaces global unoBotName)

Audit fixes:
- Lobby display now always shows both humans and bots (was omitting bots on join)
- Slot check accounts for bots (was only counting humans)
- Color Roulette bot victim name fixed (was showing acting bot's name)
- Lobby display built under lock to prevent stale reads
- Bot name list separated from env var to prevent duplicates
- Extracted shared buildLobbyDisplay helper
- Inlined unnecessary nameOf closure in sudden death

Other:
- T5 masterwork drop rate reduced from 1.5% to 0.5%
- Added fishing stats to !superstatsexplusalpha
- Updated README with all new features

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:51:28 -07:00
51 changed files with 7795 additions and 796 deletions

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"

113
README.md
View File

@@ -1,6 +1,6 @@
# GogoBee
Matrix community bot with E2EE, 49 plugins, passive tracking, scheduled posts, and optional LLM features.
Matrix community bot with E2EE, 50 plugins, passive tracking, scheduled posts, and optional LLM features.
Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage.
@@ -29,11 +29,11 @@ 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.
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
- **49 plugins** with dependency injection and ordered registration
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict), 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-7 letter words, 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
- **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, 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.
- **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), 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
- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries
- **Markdown rendering** - auto-detects `**bold**`, `_italic_`, and `` `code` `` in messages and sends proper HTML to Matrix clients
- **Encrypted quote wall** - AES-256-GCM encrypted quotes at rest, reply-to-save, search, leaderboard
@@ -107,7 +107,7 @@ Everything is configured through environment variables or a `.env` file.
| Variable | Service | Used By |
|----------|---------|---------|
| `RAWG_API_KEY` | [RAWG](https://rawg.io/apidocs) | `!game`, `!retro`, `!releases` |
| `DREAMDICT_URL` | DreamDict (self-hosted) | `!translate`, `!wotd`, Hangman multilingual clues, Wordle (guess validation + definitions) |
| `DREAMDICT_URL` | DreamDict (self-hosted) | `!translate`, `!wotd`, `!antonym`, `!pronounce`, `!etymology`, `!difficulty`, `!rhyme`, `!define` (antonyms), Hangman (multilingual clues + difficulty tier), Wordle (word selection + guess validation + definitions) |
| `CALENDARIFIC_API_KEY` | [Calendarific](https://calendarific.com) | Holiday posts |
| `OPENWEATHER_API_KEY` | [OpenWeather](https://openweathermap.org/api) | `!weather` |
| `FINNHUB_API_KEY` | [Finnhub](https://finnhub.io) | `!stock` |
@@ -121,7 +121,7 @@ Everything is configured through environment variables or a `.env` file.
|----------|-------------|
| `OLLAMA_HOST` | Ollama server URL, e.g. `http://localhost:11434` |
| `OLLAMA_MODEL` | Model name, e.g. `llama3.2` |
| `DREAMDICT_URL` | DreamDict instance for `!translate`, `!wotd`, Hangman clues (e.g. `http://127.0.0.1:7777`) |
| `DREAMDICT_URL` | DreamDict instance for `!translate`, `!wotd`, dictionary commands, Hangman clues, Wordle words (e.g. `http://127.0.0.1:7777`) |
| `LLM_SAMPLE_RATE` | Fraction of messages to classify (0.01.0, default `0.15`) |
### Encryption
@@ -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_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 |
| `DISABLE_WOTD_POST` | Set to `true` to suppress daily WOTD auto-post (the `!wotd` command and passive WOTD usage tracking still work) |
### Games & Economy
@@ -184,7 +185,7 @@ Everything is configured through environment variables or a `.env` file.
| `HOLDEM_NPC_NAME` | `TwinBee` | NPC bot display name |
| `HOLDEM_NPC_HOUSE_BALANCE` | `10000` | NPC starting bankroll |
| `HOLDEM_CFR_POLICY` | `data/policy.gob` | Path to CFR policy file |
| `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5, 6, or 7) |
| `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5-20) |
| `ADVENTURE_MORNING_HOUR` | `8` | Hour (UTC) to send morning DMs with daily choices |
| `ADVENTURE_SUMMARY_HOUR` | `20` | Hour (UTC) to post daily summary to games room |
@@ -284,7 +285,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
### Stats & Personality
| 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 |
| `!rankings [category]` | Rankings by words, links, questions, or emojis |
| `!personality` | Your community archetype |
@@ -336,7 +337,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
### UNO (games channel only)
UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All gameplay happens in DMs. The games channel is used for lobby management and public announcements.
UNO can be played solo (vs GogoBee) or multiplayer (1-4 players + bots). All gameplay happens in DMs. The games channel is used for lobby management and public announcements.
| Command | Description |
|---------|-------------|
@@ -347,7 +348,9 @@ UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All game
| `!uno start nomercy €amount` | Multiplayer No Mercy lobby |
| `!uno start nomercy 7-0 €amount` | Multiplayer No Mercy with 7-0 rule |
| `!uno join` | Join an open lobby |
| `!uno go` | Start the game (host only, 2+ players required) |
| `!uno addbot` | Add a bot opponent to the lobby (max 2, funded from community pot) |
| `!uno removebot` | Remove the last added bot from the lobby |
| `!uno go` | Start the game (host only, 2+ participants required) |
| `!uno leave` | Leave the lobby (refunds ante) |
| `!uno cancel` | Cancel the lobby (host/admin, refunds all) |
@@ -385,6 +388,14 @@ Enabled by adding `7-0` to the command. When active:
- **Play a 7** — swap hands with another player of your choice (in multiplayer, you pick the target)
- **Play a 0** — all players pass their hand to the next player in play direction
#### Sudden Death
When a 2-player game (including bot matchups) exceeds 80 turns, a 20-turn countdown begins. At turn 100, both hands are scored by UNO card point values — lowest total wins. Tiebreaker: fewer cards, then advantage to the non-active player.
#### Multiplayer Bots
The default bot (GogoBee) is always present in multiplayer games. Up to 2 additional bots (WinBee, GwinBee) can be added via `!uno addbot` while the lobby is open. Addbot bots ante from the community pot, increasing the total pot. A single human can start a game if bots fill the remaining slots.
### Texas Hold'em (games channel only)
No-limit Texas Hold'em poker for 2-9 players. Buy-in is debited from your euro balance when you sit down; your remaining stack is cashed out when you leave. Private cards and coaching tips are delivered via DM — the room only sees start/end announcements.
@@ -448,7 +459,7 @@ Progress is logged with overall completion percentage and ETA. Checkpoints are s
### Wordle (games channel only)
Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection and validation powered by the Wordnik API (`WORDNIK_API_KEY`). A new puzzle auto-posts at midnight UTC. Word length is configurable (5-7 letters). Falls back to a bundled word list if the API is unavailable. The puzzle pool includes video game themed words (loaded from `data/wordle_games.txt`) — when one is selected, a category hint is shown. Duplicate prevention ensures the same word won't appear within the last 500 puzzles.
Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection powered by DreamDict with a bundled word list as fallback. A new puzzle auto-posts at midnight UTC. Word length is configurable (5-20 letters). Guesses persist across bot restarts. When a puzzle expires at midnight, the bot reveals the answer and announces it to the room. The puzzle pool includes video game themed words (loaded from `data/wordle_games.txt`) — when one is selected, a category hint is shown. Duplicate prevention ensures the same word won't appear within the last 500 puzzles.
| Command | Description |
|---------|-------------|
@@ -456,57 +467,63 @@ Daily cooperative Wordle — one puzzle per day, the community works together wi
| `!wordle grid` | Re-post the current puzzle grid |
| `!wordle stats` | All-time leaderboard with community streak |
| `!wordle new` | Start a new puzzle (admin) |
| `!wordle new <5\|6\|7>` | New puzzle with specific word length (admin) |
| `!wordle new <5-20>` | New puzzle with specific word length (admin) |
| `!wordle skip` | Reveal answer and end puzzle (admin) |
| `!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)
A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, 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 |
|---------|-------------|
| `!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 shop` | Browse equipment for sale (sent via DM) |
| `!adventure buy <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) |
| `!adventure sell <item>` | Sell an inventory item (credits euro balance) |
| `!adventure sell all` | Sell entire inventory |
| `!adventure equip` | Equip masterwork gear from inventory |
| `!adventure inventory` | List current inventory |
| `!adventure leaderboard` | Top adventurers |
| `!adventure revive @user` | Revive a dead player (admin) |
| `!adventure respond <choice>` | Reply to today's action prompt (alternative to DM reply) |
| `!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.
#### Activities
Three activity types across 5 tiers of locations (15 total). Higher tiers require higher character level and equipment.
Four activity types across 5 tiers of locations. Higher tiers require higher character level and equipment.
- **Dungeon** — combat XP, chance of death, best loot potential
- **Mining** — mining XP, cave-in risk, ore and gem drops
- **Fishing** — fishing XP, hazard risk, catch drops
- **Foraging** — foraging XP, wildlife hazards, herb and reagent drops
#### Mechanics
- **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.
- **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.
- **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** — 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.
- **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.
#### Arena (`!arena`)
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC.
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC. Each fight produces a turn-based combat log (Dragon Quest style) with fabricated HP pools and action narration — the outcome is determined by the roll, the log is assembled backward from the result. Arena losses award +60 participation XP.
| Command | Description |
|---------|-------------|
@@ -594,13 +611,18 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
| `!movie watch\|watching\|unwatch` | Movie watchlist |
| `!upcoming movies` | Upcoming movies |
### Lookup
### Lookup & Reference
| Command | Description |
|---------|-------------|
| `!wiki <topic>` | Wikipedia summary |
| `!define <word>` | Dictionary definition |
| `!define <word> [lang]` | Dictionary definition — searches all languages via DreamDict by default, or specify one (en/fr/pt-PT/zh). Includes antonyms when available. Falls back to free dictionary API for English if DreamDict is unavailable. |
| `!urban <term>` | Urban Dictionary |
| `!translate <word> [lang]` | Cross-language word lookup (en/fr/pt-PT, auto-detects source) |
| `!antonym <word> [lang]` | Antonyms for a word (en/fr/pt-PT/zh) |
| `!pronounce <word> [lang]` | Pronunciation data — IPA, CMU phonemes (en), Pinyin (zh) |
| `!etymology <word> [lang]` | Word origin and history from Wiktionary |
| `!difficulty <word> [lang]` | Difficulty score (Easy/Medium/Hard/Brutal) with frequency info |
| `!rhyme <word>` | Rhyming words via CMU phoneme matching (English only, `--all` for full list) |
### Personal
| Command | Description |
@@ -711,7 +733,7 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
| Command | Description |
|---------|-------------|
| `!achievements [@user]` | Unlocked achievements |
| `!wotd` | Today's Palavra do Dia — Portuguese word with en/fr translations (use it in chat for 25 XP) |
| `!wotd` | Today's Palavra do Dia — Portuguese word with en/fr translations, etymology when available (use it in chat for 25 XP) |
| `!wotd force` | Pick a new word for today (moderator only) |
| `!botinfo` | Bot diagnostics (admin only) |
| `!help` | DMs the full command list |
@@ -735,7 +757,7 @@ All of these run in the background without any commands:
- **URL previews** - OG tag extraction (feature-flagged, off by default)
- **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).
- **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.
---
@@ -746,10 +768,9 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
| Time | Job | What it does |
|------|-----|--------------|
| 00:05 | Prefetch | Grabs WOTD data ahead of time |
| 06:00 | Birthdays | Birthday shoutouts + 100 XP + €1,000 |
| 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 |
| 10:00 | Anime | Anime airing today |
| 11:00 | Movies | Movie releases today |
@@ -822,22 +843,22 @@ Assigned based on your message patterns:
## 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 |
|-----------|-------|-------------|---------|
| 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" |
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.
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" |
---
@@ -848,7 +869,7 @@ All optional. The bot works fine without any of them, you just won't have those
| Service | Free? | What for |
|---------|-------|----------|
| [RAWG](https://rawg.io/apidocs) | Yes | Game lookups, releases |
| DreamDict | Self-host | Translations, WOTD, Hangman clues, Wordle validation |
| DreamDict | Self-host | Translations, WOTD (etymology), Hangman (clues + difficulty), Wordle (word selection + validation), dictionary commands (antonyms, pronunciation, etymology, difficulty, rhymes) |
| [Calendarific](https://calendarific.com) | Yes (1k/mo) | Holiday calendar |
| [HebCal](https://www.hebcal.com) | Yes, no key | Jewish holidays |
| [Aladhan](https://aladhan.com/prayer-times-api) | Yes, no key | Islamic dates |
@@ -917,6 +938,7 @@ gogobee/
│ │ ├── birthday.go # Birthdays
│ │ ├── retro.go # Game lookups (RAWG)
│ │ ├── lookup.go # Wiki, dictionary, urban, translate
│ │ ├── dictionary.go # DreamDict commands (antonym, pronounce, etymology, difficulty, rhyme)
│ │ ├── countdown.go # Countdowns
│ │ ├── stocks.go # Stocks
│ │ ├── market.go # Market indices (Yahoo Finance + Finnhub fallback)
@@ -938,8 +960,9 @@ gogobee/
│ │ ├── hangman.go # Collaborative Hangman
│ │ ├── blackjack.go # Multiplayer Blackjack
│ │ ├── uno.go # Solo UNO vs bot (DM-based)
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns)
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns + addbot)
│ │ ├── uno_nomercy.go # No Mercy mode (deck, stacking, mercy rule, 7-0, bot AI)
│ │ ├── uno_test.go # UNO tests (sudden death, scoring)
│ │ ├── holdem.go # Texas Hold'em plugin (commands, game lifecycle, NPC)
│ │ ├── holdem_game.go # Game state, player types, deck, position labels
│ │ ├── holdem_betting.go # Blinds, action validation, side pots
@@ -961,12 +984,16 @@ gogobee/
│ │ ├── adventure_twinbee.go # TwinBee NPC, reward distribution, buff gifts
│ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard
│ │ ├── 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_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates)
│ │ ├── adventure_masterwork.go # Masterwork gear (15 tiered items, drop logic, equip command)
│ │ ├── adventure_arena.go # Arena commands, combat resolution, DB CRUD, auto-cashout
│ │ ├── adventure_arena_combat.go # Arena combat log generator (action pools, HP narration)
│ │ ├── adventure_arena_monsters.go # 5 tiers, 20 monsters, death messages
│ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard)
│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, 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
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
│ │ └── ratelimits.go # Rate limiting

6
go.mod
View File

@@ -4,12 +4,15 @@ go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/chehsunliu/poker v0.1.0
github.com/expr-lang/expr v1.17.5
github.com/fogleman/gg v1.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/olebedev/when v1.1.0
github.com/robfig/cron/v3 v3.0.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.36.0
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.37.1
)
@@ -18,9 +21,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/chehsunliu/poker v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -37,7 +38,6 @@ require (
go.mau.fi/util v0.9.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect

2
go.sum
View File

@@ -30,6 +30,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914 h1:yAIlIiOkdoJvqd5xtWzM9tNDpLZrFfJdpnNSKha78G8=
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -42,6 +43,7 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=

View File

@@ -73,6 +73,16 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_inventory ADD COLUMN slot TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_inventory ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE user_stats ADD COLUMN fancy_words INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_active INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_expires_at DATETIME`,
`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 robbie_visit_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE wordle_stats ADD COLUMN total_earned INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -243,6 +253,7 @@ CREATE TABLE IF NOT EXISTS user_stats (
total_emojis INTEGER DEFAULT 0,
night_messages INTEGER DEFAULT 0,
morning_messages INTEGER DEFAULT 0,
fancy_words INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT (unixepoch())
);
@@ -820,6 +831,17 @@ CREATE TABLE IF NOT EXISTS wordle_puzzles (
PRIMARY KEY (puzzle_id, room_id)
);
CREATE TABLE IF NOT EXISTS wordle_guesses (
puzzle_id TEXT NOT NULL,
room_id TEXT NOT NULL,
guess_num INTEGER NOT NULL,
word TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
guessed_at DATETIME NOT NULL,
PRIMARY KEY (puzzle_id, room_id, guess_num)
);
-- Space groups (rooms with overlapping membership)
CREATE TABLE IF NOT EXISTS space_groups (
room_id TEXT PRIMARY KEY,
@@ -1027,6 +1049,50 @@ CREATE TABLE IF NOT EXISTS arena_stats (
updated_at INTEGER NOT NULL
);
-- Rival System
CREATE TABLE IF NOT EXISTS adventure_rival_records (
user_id TEXT NOT NULL,
rival_id TEXT NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
last_duel_at DATETIME,
PRIMARY KEY (user_id, rival_id)
);
CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
challenge_id TEXT PRIMARY KEY,
challenger_id TEXT NOT NULL,
challenged_id TEXT NOT NULL,
stake INTEGER NOT NULL,
round INTEGER NOT NULL DEFAULT 1,
player_score INTEGER NOT NULL DEFAULT 0,
rival_score INTEGER NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
CREATE TABLE IF NOT EXISTS community_pot (
id INTEGER PRIMARY KEY DEFAULT 1,
balance INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Babysitting Service
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
log_date DATE NOT NULL,
activity TEXT NOT NULL,
outcome TEXT NOT NULL,
gold_earned INTEGER NOT NULL DEFAULT 0,
xp_gained INTEGER NOT NULL DEFAULT 0,
items_dropped TEXT DEFAULT NULL,
rival_refused TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_babysit_log_user ON adventure_babysit_log(user_id);
-- Forex
CREATE TABLE IF NOT EXISTS forex_rates (
currency TEXT NOT NULL,
@@ -1081,6 +1147,46 @@ CREATE TABLE IF NOT EXISTS market_daily_summary (
generated_at INTEGER
);
-- Archetype cache (recalculated nightly)
CREATE TABLE IF NOT EXISTS user_archetypes (
user_id TEXT NOT NULL,
archetype TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
signal_score REAL NOT NULL DEFAULT 0,
flavor TEXT NOT NULL DEFAULT '',
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, archetype)
);
CREATE INDEX IF NOT EXISTS idx_user_archetypes_user ON user_archetypes(user_id, signal_score DESC);
-- Lottery tickets
CREATE TABLE IF NOT EXISTS lottery_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
week_start DATE NOT NULL,
numbers TEXT NOT NULL,
match_count INTEGER DEFAULT NULL,
prize INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_lottery_tickets_week ON lottery_tickets(week_start, user_id);
-- Lottery draw history
CREATE TABLE IF NOT EXISTS lottery_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_date DATE NOT NULL,
winning_numbers TEXT NOT NULL,
jackpot_winners INTEGER DEFAULT 0,
jackpot_amount INTEGER DEFAULT 0,
match4_winners INTEGER DEFAULT 0,
match3_winners INTEGER DEFAULT 0,
match2_winners INTEGER DEFAULT 0,
match1_winners INTEGER DEFAULT 0,
pot_total INTEGER NOT NULL,
rolled_over INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -64,8 +65,10 @@ func (c *Client) IsValidWord(word, lang string) (bool, error) {
}
// RandomWord returns a random word for a language with optional filters.
// pos can be empty. min/max of 0 means no filter.
func (c *Client) RandomWord(lang, pos string, min, max int) (string, error) {
// pos can be empty. min/max of 0 means no filter. minFreq of 0 means no
// frequency filter; values > 0 pass min_freq to the server and also veto
// results that come back below the threshold (belt-and-suspenders).
func (c *Client) RandomWord(lang, pos string, min, max, minFreq int) (string, error) {
params := url.Values{"lang": {lang}}
if pos != "" {
params.Set("pos", pos)
@@ -76,6 +79,9 @@ func (c *Client) RandomWord(lang, pos string, min, max int) (string, error) {
if max > 0 {
params.Set("max", strconv.Itoa(max))
}
if minFreq > 0 {
params.Set("min_freq", strconv.Itoa(minFreq))
}
u := c.baseURL + "/random?" + params.Encode()
resp, err := c.httpClient.Get(u)
@@ -92,11 +98,18 @@ func (c *Client) RandomWord(lang, pos string, min, max int) (string, error) {
}
var result struct {
Word string `json:"word"`
Word string `json:"word"`
Frequency int `json:"frequency"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("dreamdict: random: decode: %w", err)
}
// Client-side veto in case the server doesn't support min_freq yet.
if minFreq > 0 && result.Frequency > 0 && result.Frequency < minFreq {
return "", fmt.Errorf("dreamdict: random: word below frequency threshold")
}
return result.Word, nil
}
@@ -166,6 +179,176 @@ func (c *Client) Translate(word, from, to string) ([]string, error) {
return result.Translations, nil
}
// Frequency returns the frequency score for a word in a language.
// Higher values indicate more common words. Returns 0 if unknown.
func (c *Client) Frequency(word, lang string) (int, error) {
u := c.baseURL + "/frequency?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return 0, fmt.Errorf("dreamdict: frequency: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("dreamdict: frequency: status %d", resp.StatusCode)
}
var result struct {
Frequency int `json:"frequency"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("dreamdict: frequency: decode: %w", err)
}
return result.Frequency, nil
}
// FrequencyBatch returns frequency scores for multiple words in a single request.
func (c *Client) FrequencyBatch(words []string, lang string) (map[string]int, error) {
u := c.baseURL + "/frequency/batch?" + url.Values{
"words": {strings.Join(words, ",")},
"lang": {lang},
}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: frequency batch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: frequency batch: status %d", resp.StatusCode)
}
var result struct {
Frequencies map[string]int `json:"frequencies"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: frequency batch: decode: %w", err)
}
return result.Frequencies, nil
}
// Antonyms returns antonyms for a word in a language.
func (c *Client) Antonyms(word, lang string) ([]string, error) {
u := c.baseURL + "/antonyms?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: antonyms: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: antonyms: status %d", resp.StatusCode)
}
var result struct {
Antonyms []string `json:"antonyms"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: antonyms: decode: %w", err)
}
return result.Antonyms, nil
}
// Pronunciation holds pronunciation data for a word.
type Pronunciation struct {
Format string `json:"format"`
Value string `json:"value"`
Source string `json:"source"`
}
// Pronunciations returns pronunciation data for a word in a language.
func (c *Client) Pronunciations(word, lang string) ([]Pronunciation, error) {
u := c.baseURL + "/pronunciation?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: pronunciation: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: pronunciation: status %d", resp.StatusCode)
}
var result struct {
Pronunciations []Pronunciation `json:"pronunciations"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: pronunciation: decode: %w", err)
}
return result.Pronunciations, nil
}
// Etymology returns the etymology text for a word in a language.
func (c *Client) Etymology(word, lang string) (string, error) {
u := c.baseURL + "/etymology?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return "", fmt.Errorf("dreamdict: etymology: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("dreamdict: etymology: status %d", resp.StatusCode)
}
var result struct {
Etymology string `json:"etymology"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("dreamdict: etymology: decode: %w", err)
}
return result.Etymology, nil
}
// Difficulty returns the difficulty score (0.0-1.0) for a word in a language.
// Returns -1 if the word has no difficulty score.
func (c *Client) Difficulty(word, lang string) (float64, error) {
u := c.baseURL + "/difficulty?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return 0, fmt.Errorf("dreamdict: difficulty: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("dreamdict: difficulty: status %d", resp.StatusCode)
}
var result struct {
Difficulty float64 `json:"difficulty"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("dreamdict: difficulty: decode: %w", err)
}
return result.Difficulty, nil
}
// Rhymes returns words that rhyme with the given word (English only).
func (c *Client) Rhymes(word string, limit int) ([]string, error) {
params := url.Values{"word": {word}}
if limit > 0 {
params.Set("limit", fmt.Sprintf("%d", limit))
}
u := c.baseURL + "/rhyme?" + params.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: rhyme: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: rhyme: status %d", resp.StatusCode)
}
var result struct {
Rhymes []string `json:"rhymes"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: rhyme: decode: %w", err)
}
return result.Rhymes, nil
}
// Health checks if the DreamDict service is reachable and returns stats.
func (c *Client) Health() (*HealthResponse, error) {
resp, err := c.httpClient.Get(c.baseURL + "/health")

View File

@@ -27,6 +27,8 @@ type AdventurePlugin struct {
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
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
}
@@ -117,6 +119,9 @@ func (p *AdventurePlugin) Init() error {
go p.midnightTicker()
go p.eventTicker()
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()
@@ -153,6 +158,11 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
return p.dispatchArenaCommand(ctx)
}
// 1b. Hospital commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "hospital") {
return p.handleHospitalCmd(ctx)
}
// 2. Check if this is a DM reply from a registered player
p.mu.Lock()
playerID, isDM := p.dmToPlayer[ctx.RoomID]
@@ -163,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
}
@@ -172,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 {
@@ -199,6 +212,16 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleEventRespond(ctx)
case lower == "help":
return p.SendDM(ctx.Sender, advHelpText)
case lower == "rivals":
return p.handleRivalsCmd(ctx)
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
case lower == "blacksmith" || lower == "repair":
return p.handleBlacksmithCmd(ctx)
case lower == "repair all":
return p.handleRepairAllCmd(ctx)
case strings.HasPrefix(lower, "repair "):
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -216,6 +239,12 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!adventure help`" + ` — This message
**Arena:**
@@ -238,8 +267,22 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
}
if !char.Alive {
text := renderAdvDeathStatusDM(char)
return p.SendDM(ctx.Sender, text)
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
text := renderAdvRespawnDM(char)
p.SendDM(ctx.Sender, text)
// Fall through to show menu
}
}
if !char.Alive {
text := renderAdvDeathStatusDM(char)
return p.SendDM(ctx.Sender, text)
}
}
if char.ActionTakenToday {
@@ -284,25 +327,57 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
}
equip, _ := loadAdvEquipment(ctx.Sender)
items, _ := loadAdvInventory(ctx.Sender)
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
// On-demand revive if death timer has expired
if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
}
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load equipment for status", "user", ctx.Sender, "err", err)
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load inventory for status", "user", ctx.Sender, "err", err)
}
treasures, err := loadAdvTreasureBonuses(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load treasures for status", "user", ctx.Sender, "err", err)
}
balance := p.euro.GetBalance(ctx.Sender)
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) error {
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error {
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
showAll := strings.Contains(strings.ToLower(args), "show all")
category := strings.TrimSpace(strings.Replace(strings.ToLower(args), "show all", "", 1))
p.shopSessionStart(ctx.Sender)
if category == "" {
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
text := luigiShopGreeting(ctx.Sender, equip, balance, showAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
slot := advParseShopCategory(category)
@@ -310,7 +385,15 @@ func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) err
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category))
}
return p.SendDM(ctx.Sender, advShopCategory(slot, equip, balance))
text := luigiCategoryView(ctx.Sender, slot, equip, balance, showAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_item",
Data: &advPendingShopItem{Slot: slot, ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
@@ -413,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)
}
@@ -429,6 +513,7 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
return p.resolvePendingInteraction(ctx, interaction)
}
p.pending.Delete(string(ctx.Sender))
p.shopSessionEnd(ctx.Sender)
p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
}
@@ -453,6 +538,20 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.handleMasterworkEquipReply(ctx, interaction)
case "masterwork_equip_confirm":
return p.handleMasterworkEquipConfirm(ctx, interaction)
case "rival_rps":
return p.resolveRivalRPSRound(ctx, interaction)
case "blacksmith_slot":
return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction)
case "shop_category":
return p.resolveShopCategoryChoice(ctx, interaction)
case "shop_item":
return p.resolveShopItemChoice(ctx, interaction)
case "shop_confirm":
return p.resolveShopConfirm(ctx, interaction)
case "hospital_pay":
return p.resolveHospitalPay(ctx, interaction)
}
return nil
}
@@ -499,7 +598,19 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
}
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
}
if char.ActionTakenToday {
@@ -530,16 +641,19 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
lower := strings.ToLower(body)
// Parse "6" or "rest"
if lower == "6" || lower == "rest" {
// Parse "7" or "rest"
if lower == "7" || lower == "rest" {
return p.resolveRest(ctx, char)
}
// Parse "6" or "blacksmith"
if lower == "6" || lower == "blacksmith" {
return p.handleBlacksmithCmd(ctx)
}
// Parse "5" or "shop"
if lower == "5" || lower == "shop" {
equip, _ := loadAdvEquipment(ctx.Sender)
balance := p.euro.GetBalance(ctx.Sender)
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
return p.handleShopCmd(ctx, "")
}
// Parse activity + location
@@ -632,6 +746,19 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
return p.SendDM(ctx.Sender, fmt.Sprintf("You are not eligible for %s. Your level or equipment tier is too low.", loc.Name))
}
// Per-location cooldown: 3 hours after a successful run
if remaining := advLocationCooldown(char.UserID, loc.Name); remaining > 0 {
mins := int(remaining.Minutes())
if mins < 1 {
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in less than a minute, or pick a different location.", loc.Name))
}
h, m := mins/60, mins%60
if h > 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %dh %dm, or pick a different location.", loc.Name, h, m))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m))
}
// Resolve the action
result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
@@ -652,6 +779,9 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Check level up
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
if result.LeveledUp && result.XPSkill == "combat" {
p.checkRivalPoolUnlock(char)
}
// Handle death
deathReprieved := false
@@ -675,10 +805,7 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
}
} else {
char.Alive = false
now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil
char.Kill()
char.GrudgeLocation = loc.Name
}
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
@@ -759,6 +886,11 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
}
// Send hospital ad on death (delayed, arrives after resolution DM)
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
p.sendHospitalAd(ctx.Sender, char)
}
// Check for treasure drop
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
p.checkTreasureDrop(ctx.Sender, char, loc)

View File

@@ -3,10 +3,15 @@ package plugin
import (
"math"
"math/rand/v2"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
const advLocationCooldownDuration = 3 * time.Hour
// ── Activity & Outcome Types ─────────────────────────────────────────────────
type AdvActivityType string
@@ -339,6 +344,33 @@ func computeAdvBonuses(treasures []AdvTreasureBonus, buffs []AdvBuff, streak int
return b
}
// ── Location Cooldown ───────────────────────────────────────────────────────
// advLocationCooldown returns how long until a player can run the same location
// again. Returns 0 if no cooldown is active. Only successful runs trigger cooldown.
func advLocationCooldown(userID id.UserID, location string) time.Duration {
d := db.Get()
var loggedAt string
err := d.QueryRow(
`SELECT logged_at FROM adventure_activity_log
WHERE user_id = ? AND location = ? AND outcome IN ('success', 'exceptional')
ORDER BY logged_at DESC LIMIT 1`,
string(userID), location,
).Scan(&loggedAt)
if err != nil {
return 0
}
t, err := time.Parse("2006-01-02 15:04:05", loggedAt)
if err != nil {
return 0
}
remaining := advLocationCooldownDuration - time.Since(t)
if remaining <= 0 {
return 0
}
return remaining
}
// ── Eligibility ──────────────────────────────────────────────────────────────
// advIsEligible checks if a character can enter a location.
@@ -394,7 +426,7 @@ type advProbabilities struct {
}
func calculateAdvProbabilities(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) advProbabilities {
eqScore := float64(advEquipmentScore(equip))
eqScore := advEquipmentScore(equip)
skillLevel := float64(advEffectiveSkill(char, loc.Activity, bonuses))
deathPct := loc.BaseDeathPct - (eqScore * 0.8) - (skillLevel * 0.5) + bonuses.DeathModifier
@@ -578,6 +610,20 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS
return broken
}
// ── Overlevel Penalty ───────────────────────────────────────────────────────
// advOverlevelMultiplier returns a multiplier (0.051.0) that reduces XP and
// loot when a character's effective level far exceeds the location's minimum.
// Gap 0-3: no penalty. Gap 4+: 15% per level over 3, floor 5%.
func advOverlevelMultiplier(effectiveLevel, minLevel int) float64 {
gap := effectiveLevel - minLevel
if gap <= 3 {
return 1.0
}
mult := 1.0 - 0.15*float64(gap-3)
return math.Max(0.05, mult)
}
// ── Outcome Resolution ───────────────────────────────────────────────────────
type AdvActionResult struct {
@@ -606,6 +652,10 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
// Overlevel penalty — reduces loot and XP for farming low-tier content
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
overlevelMult := advOverlevelMultiplier(skillLevel, loc.MinLevel)
// Roll outcome
roll := rand.Float64() * 100
@@ -629,6 +679,12 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
// Generate loot for success/exceptional
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
// Apply overlevel penalty to loot values
if overlevelMult < 1.0 {
for i := range result.LootItems {
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
}
}
for _, item := range result.LootItems {
result.TotalLootValue += item.Value
}
@@ -656,6 +712,10 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
if advEquippedArenaSets(equip)["ironclad"] {
xp = int(float64(xp) * 1.05)
}
// Apply overlevel penalty to XP
if overlevelMult < 1.0 {
xp = max(1, int(float64(xp)*overlevelMult))
}
result.XPGained = xp
// Equipment degradation on bad outcomes

View File

@@ -369,6 +369,9 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun
}
char.CombatXP += battleXP
leveled, newLevel := checkAdvLevelUp(char, "combat")
if leveled {
p.checkRivalPoolUnlock(char)
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err)
}
@@ -534,13 +537,13 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
lostEarnings := run.Earnings
// Kill the character (locked out until next midnight UTC)
char.Alive = false
char.Kill()
now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil
char.ArenaLosses++
char.CombatXP += arenaParticipationXP // +60 flat participation XP
if leveled, _ := checkAdvLevelUp(char, "combat"); leveled {
p.checkRivalPoolUnlock(char)
}
if err := saveAdvCharacter(char); err != nil {
slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err)
}
@@ -569,7 +572,14 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer)
text += fmt.Sprintf("\nLost earnings: €%d\n", lostEarnings)
return p.SendDM(ctx.Sender, text)
if err := p.SendDM(ctx.Sender, text); err != nil {
slog.Error("arena: failed to send death DM", "user", ctx.Sender, "err", err)
}
// Send hospital ad (delayed)
p.sendHospitalAd(ctx.Sender, char)
return nil
}
func (p *AdventurePlugin) arenaCompleteTier5(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, prefixText string) error {

View File

@@ -199,7 +199,12 @@ func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, pic
if currentPlayerHP < 0 {
currentPlayerHP = 0
}
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
// Mix in player-miss actions (~30% of enemy_hit rounds)
if rand.IntN(100) < 30 {
r.Text = pickFrom(arenaPlayerMissActions, picker.playerMiss, dmg)
} else {
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
}
case "block":
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
@@ -297,6 +302,7 @@ func splitDamage(total, n int) []int {
type actionPicker struct {
enemy map[int]bool
player map[int]bool
playerMiss map[int]bool
block map[int]bool
environment map[int]bool
}
@@ -305,6 +311,7 @@ func newActionPicker() *actionPicker {
return &actionPicker{
enemy: make(map[int]bool),
player: make(map[int]bool),
playerMiss: make(map[int]bool),
block: make(map[int]bool),
environment: make(map[int]bool),
}
@@ -407,12 +414,12 @@ func arenaLoseCloser(winnerName string, lastRound int) string {
// Enemy actions — hit the player. %d is damage.
var arenaEnemyActions = []string{
"The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots.",
"The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity hurts more than the hit. %d damage.",
"The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots. They do not go with that top on this planet nor any other.",
"The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity of the move hurts more than the hit itself. %d damage.",
"The enemy questions your life choices. You pause to genuinely reflect. They hit you during the pause. %d damage.",
"The enemy delivers a full monologue. You listen to the whole thing. It was actually pretty good. %d damage from the time lost.",
"The enemy compliments you unexpectedly. You thank them. They snicker. %d damage.",
"The enemy points at something behind you. You don't fall for it. They throw a projectile that bounces off the wall and hits you in the back of the head. %d damage.",
"The enemy compliments you unexpectedly. You thank them. They snicker because you actually believed them and revealed to everyone that you're somehow a bigger buffoon than previously known. %d damage.",
"The enemy points at something behind you. You don't fall for it. They throw a projectile which bounces off the wall and hits you in the back of the head. What an amazing trick shot. %d damage. The crowd roars in laughter at the spectacle. But mostly at you.",
"The enemy pulls out their phone and starts filming. You perform for the camera. This was a mistake. %d damage.",
"The enemy sneezes directly in your face. You lose your turn being disgusted. %d damage while you process this.",
"The enemy whispers something. You lean in to hear it. %d damage. There was nothing worth hearing.",
@@ -422,57 +429,76 @@ var arenaEnemyActions = []string{
"The enemy yawns mid-fight. Not performatively. Genuinely. %d damage while you process the disrespect.",
"The enemy pauses to stretch before attacking. You wait. You don't know why you waited. %d damage when they finish.",
"The enemy hits you with the flat of their blade. A choice. A message. %d damage. The message is received.",
"The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. %d damage.",
"The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. This was the plan. %d damage.",
"The enemy sighs before hitting you. Like they had somewhere better to be. %d damage.",
"The enemy recounts a mildly interesting story mid-fight. You get drawn in. %d damage before the ending, which was not worth it.",
"The enemy raises one eyebrow at you and then attacks. The eyebrow did more damage than the hit. %d damage total.",
"The enemy adjusts their grip, rolls their shoulders, and hits you with the bare minimum of effort. %d damage.",
"The enemy adjusts their grip, rolls their shoulders, and hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.",
}
// Player actions — hit the enemy. %d is damage.
// Player actions — hit the enemy. %d is damage to enemy.
var arenaPlayerHitActions = []string{
"You make a joke using a painfully dated reference. While the enemy ponders what on earth you could mean, you strike. %d damage.",
"You make a joke using a painfully dated reference. While the enemy stands there pondering what on earth you could possibly be referring to, you seize the opportunity and land a critical hit. %d damage. Your jokes are always great at leaving people dazed and confused.",
"You attempt a battle cry. It comes out as a question. The enemy is briefly confused. You hit them for %d damage before they recover.",
"You wind up for a big hit and connect for %d damage. You pulled something. The enemy doesn't know this yet.",
"You hit the enemy for %d damage. They seem fine. You are less fine about this than they are.",
"You connect cleanly for %d damage and immediately look at your hand like you're surprised it worked. You were.",
"You score a clean hit for %d damage and immediately start explaining to no one how you did that. Nobody asked.",
"You score a clean hit for %d damage and immediately start explaining to no one in particular how you did that. Nobody asked. The fight is still happening.",
"You land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.",
}
// Player actions — player's turn goes wrong. %d is damage to player.
var arenaPlayerMissActions = []string{
"You reach for your weapon and grab the wrong item. You are holding a receipt. The enemy hits you for %d damage. You find this receipt later and it's actually useful.",
"You make prolonged eye contact with a spectator. It goes on too long. The enemy hits you for %d damage. The spectator looks away first.",
"Your shoelace comes untied. You are wearing boots. You address this. The enemy does not wait. %d damage.",
"You sneeze at a critical moment. The enemy respectfully waits. Then hits you for %d damage. There was no respect involved actually.",
"You perform a move you saw in a film once. It does not work like in the film. %d damage. The physics were always wrong in that film.",
"You get distracted by a food vendor passing the arena perimeter. So does the enemy. You recover second. %d damage.",
"You attempt to intimidate the enemy. They laugh. Genuinely. This is worse than if they hadn't. You take %d damage from the experience.",
"You slip on something. There is nothing to slip on. %d damage. The arena floor is flat and dry. You will be thinking about this.",
"You decide mid-swing to do something different. The original plan was better. %d damage.",
"You attempt a combo you've been mentally rehearsing for weeks. It goes fine until the third move. %d damage.",
"You feint left. The enemy doesn't move. You feint right. They still don't move. You just stand there feinting at someone who is not playing along. The enemy hits you. %d damage.",
"You remember reading something about fighting once. You implement it. It was about chess. %d damage.",
"You close your eyes for the strike because it feels more dramatic. You miss. The enemy doesn't. %d damage.",
"You decide this is the moment for something new. It is not the moment for something new. %d damage. File this under lessons.",
}
// Block/dodge actions — no damage.
var arenaBlockActions = []string{
"You swing with conviction. The enemy sidesteps it with the energy of someone who has somewhere else to be. Nothing happens. You both reset.",
"The enemy lunges. You step aside. They continue past you for several feet and have to walk back. The pause is awkward for everyone.",
"You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally.",
"You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally. You don't know their relationship so it probably did, but also you were faster.",
"The enemy's attack grazes you but doesn't connect. They seem more annoyed by this than you are relieved.",
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.",
"The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked.",
"You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it.",
"The enemy blocks your poor-timed strike with their forearm. This speaks more about your striking abilities than their forearm.",
"You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The pillar gets no credit.",
"The enemy telegraphs the attack so clearly that you block it before they've finished committing. They look briefly embarrassed.",
"You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional.",
"The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena.",
"You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other.",
"The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects.",
"You sidestep a strike that wasn't aimed at you. The enemy had already redirected. Both end up slightly confused.",
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was. Then the fight continues.",
"The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked. You will be thinking about how unnecessary it was.",
"You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it. It was still a good parry.",
"The enemy blocks your hilariously poor-timed strike with their forearm. This speaks less about the strength of their forearm and much more so about the pathetic nature of your striking abilities.",
"You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The enemy didn't do that. The pillar gets no credit either.",
"The enemy telegraphs the attack so clearly that you block it before they've finished committing to it. They look briefly embarrassed. They recover. The fight continues.",
"You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional. The enemy hesitates, which was also not intentional. Nothing lands.",
"The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena. They retrieve a backup weapon from somewhere. Nobody asks where they got it.",
"You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other. Someone has to move first. It's them. The fight continues.",
"The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects. You land. The fight continues.",
"You sidestep a strike that wasn't aimed at you. The enemy had already redirected. You both end up slightly confused about where the other one is. The round resolves without damage.",
"A referee walks through the arena on the way to somewhere else. Eye contact is made with both fighters. They keep walking. There is a beat. The fight resumes.",
}
// Environmental actions — damage to player. %d is damage.
var arenaEnvironmentalActions = []string{
"Your mother calls in the middle of battle asking about grandchildren. The enemy hits you for %d damage while you answer on speakerphone.",
"A bird lands between you and the enemy. Both stop. The bird leaves. The enemy recovers first. %d damage.",
"A spectator is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.",
"The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage.",
"An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. The enemy hits you for %d damage during this.",
"Your mother calls in the middle of battle asking when you're giving her grandchildren. The enemy hits you for %d damage while you work out how to answer that on speakerphone.",
"A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.",
"A spectator in the front row is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.",
"The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage. The announcer mispronounces it again.",
"An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. Mutual acknowledgment. The enemy hits you for %d damage during this social transaction.",
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
"A cloud passes in front of the sun at the wrong moment. %d damage. The cloud did not mean anything by it.",
"The arena's background music cuts out unexpectedly. The silence is louder than the fight. %d damage in the disorientation.",
"The arena PA crackles and announces something completely unrelated. You both look up. The enemy looks back down first. %d damage.",
"The arena's background music cuts out unexpectedly. The silence is louder than the fight. The enemy hits you for %d damage in the disorientation.",
"The arena PA crackles and announces something completely unrelated to your fight. You both look up. The enemy looks back down first. %d damage.",
"Something falls from the spectator area. Nobody claims it. You both look at it. The enemy decides faster. %d damage.",
"A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. The enemy uses the reset better. %d damage.",
"The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round working out if that changes anything. %d damage.",
"A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. You both needed that break more than you'd like to admit. The enemy uses the reset better. %d damage.",
"The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round trying to work out if that changes anything. It does not. %d damage while you calculate.",
"The arena sells a limited merch item at exactly this moment. The announcement is enthusiastic. You are briefly curious. %d damage.",
"The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear. %d damage.",
"The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear from the enemy's corner. %d damage.",
}

View File

@@ -0,0 +1,455 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Pricing ─────────────────────────────────────────────────────────────────
func babysitDailyCost(combatLevel int) int {
return 100 + (combatLevel * 20)
}
// ── Weakest Skill ───────────────────────────────────────────────────────────
func babysitWeakestSkill(char *AdventureCharacter) string {
skills := []struct {
name string
level int
}{
{"mining", char.MiningSkill},
{"fishing", char.FishingSkill},
{"foraging", char.ForagingSkill},
}
minLevel := skills[0].level
for _, s := range skills[1:] {
if s.level < minLevel {
minLevel = s.level
}
}
// Collect ties
var tied []string
for _, s := range skills {
if s.level == minLevel {
tied = append(tied, s.name)
}
}
return tied[rand.IntN(len(tied))]
}
// skillToActivity maps a skill name to its activity type.
func skillToActivity(skill string) AdvActivityType {
switch skill {
case "mining":
return AdvActivityMining
case "fishing":
return AdvActivityFishing
case "foraging":
return AdvActivityForaging
}
return AdvActivityMining
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) error {
lower := strings.ToLower(strings.TrimSpace(args))
switch {
case lower == "status":
return p.handleBabysitStatus(ctx)
case lower == "cancel":
return p.handleBabysitCancel(ctx)
case lower == "week":
return p.handleBabysitPurchase(ctx, 7)
case lower == "month":
return p.handleBabysitPurchase(ctx, 30)
default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+
"`!adventure babysit status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)")
}
}
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
if char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
}
daily := babysitDailyCost(char.CombatLevel)
totalCost := daily * days
balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs €%d for %d days. You have €%.0f. The service has standards. Not many, but some.", totalCost, days, balance))
}
// Debit gold
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
}
// Set babysit fields
skill := babysitWeakestSkill(char)
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
char.BabysitActive = true
char.BabysitExpiresAt = &expires
char.BabysitSkillFocus = skill
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
// Refund
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
}
confirm := pickBabysitFlavor(babysitConfirmLines)
durLabel := "1 week"
if days == 30 {
durLabel = "1 month"
}
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+
"Cost: €%d\n"+
"Focus: %s (currently level %d)\n"+
"Rival duels: declined on your behalf\n\n"+
"Daily DMs are suspended until the service ends.\n\n"+
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 No active babysitting service. Use `!adventure babysit week` or `!adventure babysit month` to start.")
}
remaining := "unknown"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day"
} else {
remaining = fmt.Sprintf("%d days", days)
}
}
// Load log stats
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
"Time remaining: %s\n"+
"Skill focus: %s\n"+
"Days completed: %d\n"+
"Gold earned: €%d\n"+
"XP gained: %d\n"+
"Items claimed by babysitter: %d\n"+
"Rivals declined: %d",
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
}
// Compile partial summary
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(char, logs)
// Clear babysit state
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
}
// Clear logs
clearBabysitLogs(char.UserID)
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
}
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
activity := skillToActivity(char.BabysitSkillFocus)
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
// Pick highest-tier eligible location for the focus skill
bonuses := &AdvBonusSummary{}
eligible := advEligibleLocations(char, equip, activity, bonuses)
if len(eligible) == 0 {
slog.Warn("babysit: no eligible locations", "user", char.UserID, "skill", char.BabysitSkillFocus)
return
}
// Pick the last one (highest tier since they're returned in order)
loc := eligible[len(eligible)-1].Location
inPenalty := eligible[len(eligible)-1].InPenaltyZone
// Resolve action
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
// Babysitter never lets the adventurer die — reroll death to empty
if result.Outcome == AdvOutcomeDeath {
result.Outcome = AdvOutcomeEmpty
result.LootItems = nil
result.TotalLootValue = 0
result.EquipDamage = nil
result.EquipBroken = nil
}
// Apply XP
switch result.XPSkill {
case "mining":
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
checkAdvLevelUp(char, result.XPSkill)
// Credit gold to player
if result.TotalLootValue > 0 {
p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul")
}
// Items are claimed by the babysitter (not added to player inventory)
var itemNames []string
for _, item := range result.LootItems {
itemNames = append(itemNames, item.Name)
}
// No treasure drops during babysitting
result.TreasureFound = nil
// Mark action taken
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
}
// Log to babysit table
itemsJSON := ""
if len(itemNames) > 0 {
itemsJSON = strings.Join(itemNames, ", ")
}
logBabysitActivity(char.UserID, string(activity), string(result.Outcome),
int(result.TotalLootValue), result.XPGained, itemsJSON)
}
// ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
now := time.Now().UTC()
for _, char := range chars {
if !char.BabysitActive {
continue
}
if char.BabysitExpiresAt == nil || now.Before(*char.BabysitExpiresAt) {
continue
}
// Service expired — compile summary and send DM
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(&char, logs)
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(&char); err != nil {
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
continue
}
clearBabysitLogs(char.UserID)
if err := p.SendDM(char.UserID, summary); err != nil {
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
}
}
}
// ── Summary Rendering ───────────────────────────────────────────────────────
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
var sb strings.Builder
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs)))
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs)))
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus)))
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold))
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP))
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed))
if rivalsRefused > 0 {
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
// Pick a rival refusal flavor (generic — no specific rival name available)
for _, log := range logs {
if log.RivalRefused != "" {
line := pickBabysitFlavor(babysitRivalRefusalLines)
sb.WriteString(fmt.Sprintf(" %s\n", fmt.Sprintf(line, log.RivalRefused, log.LogDate)))
}
}
}
// Diaper line
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
// Closing
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
return sb.String()
}
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
type babysitLogEntry struct {
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
}
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, gold_earned, xp_gained, items_dropped)
VALUES (?, DATE('now'), ?, ?, ?, ?, ?)`,
string(userID), activity, outcome, gold, xp, items)
if err != nil {
slog.Error("babysit: failed to log activity", "user", userID, "err", err)
}
}
func logBabysitRivalRefusal(userID id.UserID, rivalName string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, rival_refused)
VALUES (?, DATE('now'), 'rival_refused', 'declined', ?)`,
string(userID), rivalName)
if err != nil {
slog.Error("babysit: failed to log rival refusal", "user", userID, "err", err)
}
}
func loadBabysitLogs(userID id.UserID) ([]babysitLogEntry, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, user_id, log_date, activity, outcome, gold_earned, xp_gained,
COALESCE(items_dropped,''), COALESCE(rival_refused,'')
FROM adventure_babysit_log WHERE user_id = ? ORDER BY log_date`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var logs []babysitLogEntry
for rows.Next() {
var l babysitLogEntry
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.Activity, &l.Outcome,
&l.GoldEarned, &l.XPGained, &l.ItemsDropped, &l.RivalRefused); err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}
func clearBabysitLogs(userID id.UserID) {
d := db.Get()
if _, err := d.Exec(`DELETE FROM adventure_babysit_log WHERE user_id = ?`, string(userID)); err != nil {
slog.Error("babysit: failed to clear logs", "user", userID, "err", err)
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
func babysitSkillLevel(char *AdventureCharacter, skill string) int {
switch skill {
case "mining":
return char.MiningSkill
case "fishing":
return char.FishingSkill
case "foraging":
return char.ForagingSkill
}
return 0
}
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
for _, l := range logs {
totalGold += l.GoldEarned
totalXP += l.XPGained
if l.ItemsDropped != "" {
// Count comma-separated items
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
}
if l.RivalRefused != "" {
rivalsRefused++
}
}
return
}

View File

@@ -0,0 +1,405 @@
package plugin
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Pricing ─────────────────────────────────────────────────────────────────
var blacksmithBaseRates = [6]int{1, 3, 8, 20, 55, 150}
func blacksmithRepairCost(eq *AdvEquipment) int {
if eq == nil || eq.Condition >= 100 {
return 0
}
// Masterwork and Arena items use the next tier's base rate.
tier := eq.Tier
if eq.Masterwork || eq.ArenaTier > 0 {
tier++
if tier > 5 {
tier = 5
}
}
if tier < 0 {
tier = 0
}
if tier > 5 {
tier = 5
}
baseRate := float64(blacksmithBaseRates[tier])
damage := float64(100 - eq.Condition)
condMult := 1.0 + damage/100.0
costPerPoint := baseRate * condMult
return int(math.Ceil(costPerPoint * damage))
}
// ── Pending Interaction Types ───────────────────────────────────────────────
type advPendingBlacksmithSlot struct{}
type advPendingBlacksmithConfirm struct {
Slots []EquipmentSlot
Costs map[EquipmentSlot]int
Total int
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleBlacksmithCmd(ctx MessageContext) error {
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
text := renderBlacksmithShop(ctx.Sender, equip)
// Store pending interaction for slot selection.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "blacksmith_slot",
Data: &advPendingBlacksmithSlot{},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleRepairAllCmd(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
return p.buildRepairAllConfirm(ctx.Sender, equip)
}
func (p *AdventurePlugin) handleRepairSlotCmd(ctx MessageContext, slotName string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
return p.buildRepairSlotConfirm(ctx.Sender, equip, slotName)
}
// ── Slot Selection Resolution ───────────────────────────────────────────────
func (p *AdventurePlugin) resolveBlacksmithSlotChoice(ctx MessageContext, interaction *advPendingInteraction) error {
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply != "all" && parseSlotName(reply) == "" {
// Re-store pending and reprompt.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Reply with a slot name (weapon, armor, helmet, boots, tool) or \"all\".")
}
// Lock and reload fresh equipment (the cached data may be stale).
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if reply == "all" {
return p.buildRepairAllConfirm(ctx.Sender, equip)
}
return p.buildRepairSlotConfirm(ctx.Sender, equip, reply)
}
// ── Confirm Resolution ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveBlacksmithConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingBlacksmithConfirm)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "yes" || reply == "y" || reply == "confirm" {
return p.executeRepair(ctx.Sender, data)
}
return p.SendDM(ctx.Sender, "Repair cancelled. The blacksmith watches you leave. They'll be here when you're ready.")
}
// ── Builders ────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) buildRepairAllConfirm(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) error {
var damaged []EquipmentSlot
costs := make(map[EquipmentSlot]int)
total := 0
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
continue
}
cost := blacksmithRepairCost(eq)
if cost > 0 {
damaged = append(damaged, slot)
costs[slot] = cost
total += cost
}
}
if len(damaged) == 0 {
flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
return p.SendDM(userID, "⚒️ All your equipment is at full condition. "+flavor)
}
balance := p.euro.GetBalance(userID)
if balance < float64(total) {
// Can't afford everything — suggest cheapest slot.
cheapestSlot := damaged[0]
cheapestCost := costs[damaged[0]]
for _, s := range damaged[1:] {
if costs[s] < cheapestCost {
cheapestSlot = s
cheapestCost = costs[s]
}
}
return p.SendDM(userID, fmt.Sprintf("⚒️ Repairing everything costs €%d. You have €%.0f.\n\n"+
"The blacksmith suggests starting with your %s (€%d). They say this with some reluctance — they wanted to do everything.\n\n"+
"Use `!adventure repair %s` to repair just that slot.",
total, balance, slotTitle(cheapestSlot), cheapestCost, string(cheapestSlot)))
}
// Build breakdown.
var sb strings.Builder
sb.WriteString("⚒️ **Repair All — Confirmation**\n\n")
for _, slot := range damaged {
eq := equip[slot]
sb.WriteString(fmt.Sprintf(" %s %s: [%d/100] → [100/100] €%d\n",
slotEmoji(slot), slotTitle(slot), eq.Condition, costs[slot]))
}
sb.WriteString(fmt.Sprintf("\n**Total: €%d**\n\nReply **yes** to confirm or **no** to cancel.", total))
p.pending.Store(string(userID), &advPendingInteraction{
Type: "blacksmith_confirm",
Data: &advPendingBlacksmithConfirm{Slots: damaged, Costs: costs, Total: total},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(userID, sb.String())
}
func (p *AdventurePlugin) buildRepairSlotConfirm(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, slotName string) error {
slot := parseSlotName(slotName)
if slot == "" {
return p.SendDM(userID, "Unknown slot. Valid slots: weapon, armor, helmet, boots, tool.")
}
eq := equip[slot]
if eq == nil {
return p.SendDM(userID, fmt.Sprintf("You don't have any %s equipped.", slotTitle(slot)))
}
if eq.Condition >= 100 {
flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
return p.SendDM(userID, fmt.Sprintf("⚒️ %s %s — %s", slotEmoji(slot), eq.Name, flavor))
}
cost := blacksmithRepairCost(eq)
balance := p.euro.GetBalance(userID)
if balance < float64(cost) {
return p.SendDM(userID, fmt.Sprintf("⚒️ Repairing your %s costs €%d. You have €%.0f. The blacksmith waits.",
slotTitle(slot), cost, balance))
}
// Build inspection message.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚒️ %s %s — **%s** [%d/100]\n\n",
slotEmoji(slot), slotTitle(slot), eq.Name, eq.Condition))
// Special flavor for masterwork, arena, or broken.
if eq.Condition == 0 {
f, _ := advPickFlavor(blacksmithBrokenCondition, userID, "bs_broken")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
} else if eq.Masterwork {
f, _ := advPickFlavor(blacksmithMasterwork, userID, "bs_master")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
} else if eq.ArenaTier > 0 {
f, _ := advPickFlavor(blacksmithArena, userID, "bs_arena")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
} else {
f, _ := advPickFlavor(blacksmithInspection, userID, "bs_inspect")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
}
sb.WriteString(fmt.Sprintf("Repair to 100: **€%d**\n\nReply **yes** to confirm or **no** to cancel.", cost))
costs := map[EquipmentSlot]int{slot: cost}
p.pending.Store(string(userID), &advPendingInteraction{
Type: "blacksmith_confirm",
Data: &advPendingBlacksmithConfirm{Slots: []EquipmentSlot{slot}, Costs: costs, Total: cost},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(userID, sb.String())
}
// ── Repair Execution ────────────────────────────────────────────────────────
func (p *AdventurePlugin) executeRepair(userID id.UserID, data *advPendingBlacksmithConfirm) error {
// Reload equipment fresh and recompute costs to avoid stale-price TOCTOU.
equip, err := loadAdvEquipment(userID)
if err != nil {
return p.SendDM(userID, "Something went wrong loading your equipment.")
}
// Recompute actual costs from current condition.
actualTotal := 0
actualCosts := make(map[EquipmentSlot]int)
for _, slot := range data.Slots {
eq := equip[slot]
if eq == nil {
continue
}
cost := blacksmithRepairCost(eq)
if cost > 0 {
actualCosts[slot] = cost
actualTotal += cost
}
}
if actualTotal == 0 {
return p.SendDM(userID, "⚒️ Your equipment is already at full condition. No repair needed.")
}
// Cap at the quoted price so the user never pays more than they agreed to.
if actualTotal > data.Total {
actualTotal = data.Total
}
if !p.euro.Debit(userID, float64(actualTotal), "blacksmith_repair") {
return p.SendDM(userID, "Payment failed. You don't have enough gold.")
}
var repaired []string
repairedCost := 0
for _, slot := range data.Slots {
eq := equip[slot]
if eq == nil || eq.Condition >= 100 {
continue
}
eq.Condition = 100
if err := saveAdvEquipment(userID, eq); err != nil {
slog.Error("blacksmith: failed to save repaired equipment", "user", userID, "slot", slot, "err", err)
continue
}
repaired = append(repaired, fmt.Sprintf("%s %s", slotEmoji(slot), eq.Name))
repairedCost += actualCosts[slot]
}
if len(repaired) == 0 {
p.euro.Credit(userID, float64(actualTotal), "blacksmith_refund")
return p.SendDM(userID, "Nothing was repaired. Gold refunded.")
}
// Partial refund if some slots failed to save.
if repairedCost < actualTotal {
refund := actualTotal - repairedCost
p.euro.Credit(userID, float64(refund), "blacksmith_partial_refund")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚒️ **Repair Complete** — €%d\n\n", repairedCost))
for _, item := range repaired {
sb.WriteString(fmt.Sprintf(" %s → [100/100]\n", item))
}
payFlavor, _ := advPickFlavor(blacksmithPayment, userID, "bs_pay")
doneFlavor, _ := advPickFlavor(blacksmithCompletion, userID, "bs_done")
sb.WriteString(fmt.Sprintf("\n*%s*\n\n", payFlavor))
sb.WriteString(fmt.Sprintf("*%s*", doneFlavor))
return p.SendDM(userID, sb.String())
}
// ── Shop Display ────────────────────────────────────────────────────────────
func renderBlacksmithShop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) string {
var sb strings.Builder
greet, _ := advPickFlavor(blacksmithGreetings, userID, "bs_greet")
sb.WriteString("⚒️ **The Blacksmith**\n\n")
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
sb.WriteString("Your equipment:\n")
hasDamaged := false
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
continue
}
marker := ""
if eq.Masterwork {
marker = " \u2b50"
} else if eq.ArenaTier > 0 {
marker = " \u2694\ufe0f"
}
if eq.Condition >= 100 {
sb.WriteString(fmt.Sprintf(" %s %s:%s T%d [100/100] \u2713 No repair needed\n",
slotEmoji(slot), padRight(eq.Name+marker, 22), "", eq.Tier))
} else {
hasDamaged = true
cost := blacksmithRepairCost(eq)
sb.WriteString(fmt.Sprintf(" %s %s:%s T%d [%d/100] Repair to 100: \u20ac%d\n",
slotEmoji(slot), padRight(eq.Name+marker, 22), "", eq.Tier, eq.Condition, cost))
}
}
if !hasDamaged {
fullFlavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
sb.WriteString(fmt.Sprintf("\n*%s*", fullFlavor))
} else {
sb.WriteString("\nReply with the slot name to repair (weapon / armor / helmet / boots / tool) or \"all\" to repair everything.")
}
return sb.String()
}
// ── Helpers ─────────────────────────────────────────────────────────────────
func parseSlotName(s string) EquipmentSlot {
switch strings.ToLower(strings.TrimSpace(s)) {
case "weapon", "wep", "sword":
return SlotWeapon
case "armor", "armour", "chest":
return SlotArmor
case "helmet", "helm", "hat", "head":
return SlotHelmet
case "boots", "boot", "shoes", "feet":
return SlotBoots
case "tool", "pick", "pickaxe":
return SlotTool
}
return ""
}
func padRight(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}

View File

@@ -49,8 +49,16 @@ type AdventureCharacter struct {
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
DeathReprieveLast *time.Time
DeathReprieveLast *time.Time
MasterworkDropsReceived int
RivalPool int
RivalUnlockedNotified bool
BabysitActive bool
BabysitExpiresAt *time.Time
BabysitSkillFocus string
HospitalVisits int
RobbieVisitCount int
LastDeathDate string
}
type AdvEquipment struct {
@@ -195,40 +203,56 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool {
return time.Since(*c.DeathReprieveLast) >= 168*time.Hour
}
// Kill marks the character as dead with a 6-hour respawn timer.
func (c *AdventureCharacter) Kill() {
c.Alive = false
deadUntil := time.Now().UTC().Add(6 * time.Hour)
c.DeadUntil = &deadUntil
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
}
// ── Equipment Score ──────────────────────────────────────────────────────────
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
score := 0
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) float64 {
score := 0.0
arenaSets := advEquippedArenaSets(equip)
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok {
continue
}
tierContrib := eq.Tier
// Arena gear: 1.5x effectiveness
if eq.ArenaTier > 0 {
tierContrib = int(float64(tierContrib) * 1.5)
} else if eq.Masterwork {
// Masterwork: 1.25x effectiveness
tierContrib = int(float64(tierContrib) * 1.25)
}
contrib := advEffectiveTier(eq)
if slot == SlotWeapon {
tierContrib *= 2
contrib *= 2
}
// Condition modifier: below 50 halves contribution
if eq.Condition < 50 {
tierContrib /= 2
contrib /= 2
}
score += tierContrib
score += contrib
}
// Champion's set: Commanding Presence — +10% equipment score
if arenaSets["champions"] {
score = int(float64(score) * 1.10)
score *= 1.10
}
return score
}
// advEffectiveTier returns the effective tier of a piece of equipment,
// accounting for Masterwork (1.25x) and Arena (1.5x) bonuses.
func advEffectiveTier(eq *AdvEquipment) float64 {
if eq == nil {
return 0
}
if eq.ArenaTier > 0 {
return float64(eq.Tier) * 1.5
}
if eq.Masterwork {
return float64(eq.Tier) * 1.25
}
return float64(eq.Tier)
}
// ── XP & Level-Up ────────────────────────────────────────────────────────────
const maxAdvLevel = 50
@@ -282,8 +306,8 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
d := db.Get()
c := &AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil, reprieveLast sql.NullTime
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp sql.NullTime
err := d.QueryRow(`
SELECT user_id, display_name,
@@ -293,7 +317,10 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -303,6 +330,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
)
if err != nil {
return nil, err
@@ -310,12 +340,17 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
return c, nil
}
@@ -362,6 +397,14 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if char.HolidayActionTaken {
holidayTaken = 1
}
rivalUnlocked := 0
if char.RivalUnlockedNotified {
rivalUnlocked = 1
}
babysitAct := 0
if char.BabysitActive {
babysitAct = 1
}
_, err := d.Exec(`
UPDATE adventure_characters SET
@@ -371,14 +414,21 @@ func saveAdvCharacter(char *AdventureCharacter) error {
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
masterwork_drops_received = ?
masterwork_drops_received = ?,
rival_pool = ?, rival_unlocked_notified = ?,
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
alive, char.DeadUntil, actionTaken, holidayTaken,
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
char.DeathReprieveLast, char.MasterworkDropsReceived, string(char.UserID),
char.DeathReprieveLast, char.MasterworkDropsReceived,
char.RivalPool, rivalUnlocked,
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
string(char.UserID),
)
return err
}
@@ -492,7 +542,9 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus
FROM adventure_characters`)
if err != nil {
return nil, err
@@ -502,8 +554,8 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
var chars []AdventureCharacter
for rows.Next() {
c := AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil, reprieveLast sql.NullTime
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp sql.NullTime
if err := rows.Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -513,18 +565,25 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
); err != nil {
return nil, err
}
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
chars = append(chars, c)
}
return chars, rows.Err()

View File

@@ -0,0 +1,37 @@
package plugin
import "math/rand/v2"
// ── Babysitting Service Flavor Pools ────────────────────────────────────────
// babysitConfirmLines are shown on purchase confirmation.
var babysitConfirmLines = []string{
"Your adventurer is now in capable hands. Relatively speaking. We've done this before.",
"Service begins immediately. Go do whatever it is you do when you're not doing this.",
"Payment received. Your adventurer will be fine. Probably.",
"We've looked at your skill levels. We know what needs work. We'll handle it. You handle whatever you're handling.",
}
// babysitRivalRefusalLines are used when a rival showed up during babysitting.
// All lines use exactly two %s: (rival display name, date string).
var babysitRivalRefusalLines = []string{
"%s came by on %s with what appeared to be prepared remarks. They were turned away at the door. Their remarks went undelivered. This is probably for the best.",
"%s attempted to initiate a duel on %s. The babysitter informed them this was not possible. They stood there for a moment. Then left.",
"%s showed up on %s. The babysitter made them regret it in ways you could only dream of doing.",
}
// babysitDiaperLines appear once per summary, rotated.
var babysitDiaperLines = []string{
"The diapers have been handled. We don't discuss the diapers.",
"Diaper situation: resolved. No further details will be provided.",
"Standard diaper protocols were followed. The adventurer was cooperative. Mostly.",
"The diapers. They happened. They were handled. Moving on.",
}
// pickBabysitFlavor returns a random entry from the given pool.
func pickBabysitFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -0,0 +1,66 @@
package plugin
// ── Blacksmith Flavor Text Pools ────────────────────────────────────────────
var blacksmithGreetings = []string{
"_wipes hands on apron and looks you over slowly_ You've seen some action. I can tell. Come inside. Let me experience what you've been working with.",
"_sets down hammer_ Don't be shy. I've seen worse. Bring it here. I'll get it back to its full size in no time.",
"I don't judge the condition things arrive in but I _always_ know how to make them feel... oh so much better. Weapons and armor too.",
"_gestures broadly at the forge_ Everything in here gets my full and undivided attention. You have my word on that. Unless a ball gag is involved. \u2764\ufe0f",
"You look like someone who's been putting their equipment through a lot. That's what I'm here for. Show me everything.",
}
var blacksmithInspection = []string{
"Oh. Oh that's seen some use. _runs thumb along the edge_ Don't worry. I know exactly what this needs.",
"This one's been neglected. I can tell. People don't appreciate what they have until it's been beaten down until it can't take any more. Then it's my job to _slowly_ build you... it... back up completely from bottom to the top.",
"_whistles low_ That's a lot of damage. I'm not complaining. I'm just saying I'm going to need some time with this one.",
"Good bones. Good bones. A little beaten up but my skilled fingers have brought worse back from the brink to highs rarely seen nor experienced on Earth! ...Oh I can fix your item too.",
"_holds it up to the light_ I've had my hands on a lot of these. This one needs work. The good kind of work. _looks you deeply in the eyes_ The only kind of work I know how to do.",
}
var blacksmithPayment = []string{
"_pockets the gold without looking at it_ Money is fine. The work is the reward.",
"Generous. I'd have done it for less. Don't tell anyone.",
"_nods slowly_ Fair. Now let me get to it. I work better when I'm not being watched. Come back in a bit.",
"You're paying for the expertise. Anyone can hit metal. Not everyone knows where to hit it. Or how to hit it to get it quivering after each strike until it's begging for more! _there's an awkward silence_ Anyway. Time to get to work.",
"The gold is appreciated. Now. Unless you have need for my services from the _ahem_ hidden menu, come back later.",
}
var blacksmithCompletion = []string{
"There. _slides it across the counter_ Good as new. Better, actually. I know what I'm doing.",
"_breathes heavily_ Done. That one took something out of me. Worth it though. Always worth it.",
"You feel that? That's what it's supposed to feel like. Full. Firm. Ready for anything at any time. Just like me.",
"I worked it until it couldn't take any more. Then I worked it a little more. I'm more than happy to give you a demonstration sometime.",
"_wipes forehead_ The difficult ones are always the most satisfying. Take it. Come back when it needs attention again. And it will need attention again.",
"It's hot right now. Give it a moment before you put it on. Trust me on that.",
"_stares at the finished piece for a moment longer than necessary_ Beautiful. Okay. Take it. Go.",
}
var blacksmithMasterwork = []string{
"_pauses_ This is Masterwork. _sets everything else down_ This gets my complete focus. Nothing else exists right now.",
"I don't rush Masterwork. I don't care how long it takes. Some things deserve to be done properly.",
"_closes eyes briefly_ I've been waiting for one of these to come through here. You have no idea how long I've been waiting.",
"Extra charge for Masterwork. Not because it's harder. Because it deserves more of me. It demands more of me. And oh god it will get it, don't you worry at all.",
}
var blacksmithArena = []string{
"_goes very still_ Is that... Arena gear? _looks up at you_ Where did you get this. Never mind. Don't tell me. Just leave it.",
"I've heard about this. I've never had one on my table before. _voice drops_ Close the door.",
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
}
var blacksmithFullCondition = []string{
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
"Nothing to fix. Come back when you've broken something. Or you need me to break you. Special offer. Just for you.",
}
var blacksmithBrokenCondition = []string{
"_long pause_ ...How. _another pause_ You know what. It doesn't matter. I've seen things. Sit down.",
"This is what happens when people don't come see me regularly. _not accusatory. somehow worse than accusatory._",
"I can fix this. It'll take longer. And cost more. And I'm going to need you to just... not talk while I work. Can you do that.",
"_picks it up gingerly_ It's not dead. It just needs someone who knows what they're doing. Lucky for you I always know what to do in _every_ situation. \u2764\ufe0f",
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
}

View File

@@ -191,14 +191,11 @@ var ClosingFailure = []string{
var ClosingDeath = []string{
"─────────────────────────────\n" +
"That's your day. Healthcare has the rest.\n\n" +
"A separate DM is inbound with the details.\n" +
"The details are not good. You already know the details.\n\n" +
"Next action: after recovery\n" +
"Morning DM: {morning_time} UTC — subject to medical clearance.",
"🏥 A DM from St. Guildmore's is on its way.\n" +
"Type `!hospital` for same-day revival.",
"─────────────────────────────\n" +
"Day over. Healthcare is involved.\n" +
"Expect a DM shortly with the full situation.\n\n" +
"The full situation: {location} won today.\n" +
"Tomorrow is pending insurance confirmation.",
"🏥 St. Guildmore's has been notified.\n\n" +
"Check your DMs, or type `!hospital`.",
}

View File

@@ -0,0 +1,283 @@
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
)
// ── St. Guildmore's Memorial Hospital — Nurse Joy Flavor Text ──────────────
// nurseJoyFirstVisit is shown once on the player's first hospital visit.
var nurseJoyFirstVisit = "🏥 **Welcome to St. Guildmore's Memorial Hospital!**\n\n" +
"We're the official healthcare provider for members of the Adventurer's Guild. " +
"Even better, we've modeled our pricing after the United States of America, " +
"which means we offer only the quickest and best care. _whispers_ ...for those who can afford it.\n\n" +
"Your Guild insurance is on file. Nurse Joy will be right with you.\n\n"
var nurseJoyAdmission = []string{
"_looks up from clipboard with a bright smile_ Hi! Name? Guild card? Oh you're covered under the Collective, that's wonderful! Let me get Nurse Joy to come take a look at you. _is Nurse Joy_",
"Oh you're with the Guild? That's great, we love Guild members! Very comprehensive plan. Someone will be right with you! _gestures at chairs_ Can I get you anything while you wait? We have water. It's complimentary. The water is complimentary.",
"_already typing, still cheerful_ Cause of death? Temporary obviously, just for the record! And your Guild card number? Perfect! We'll get you all sorted out. _sorted out does not mean quickly but she means it warmly_",
"Welcome to St. Guildmore's! You're in such good hands here. _has said this ten thousand times and means it every single time_ Insurance on file? Wonderful. Take a number and we'll be right with you!",
"_looks up, immediately sympathetic in a clinical way_ Oh you don't look great! That's okay, that's what we're here for. Guild member? Amazing. Nurse Joy will be right out. _is Nurse Joy. will be right out._",
}
var nurseJoyBillDelivery = []string{
"_slides it across with a smile_ So that's the total before insurance! Your plan is really good though -- the Collective is wonderful. We're going to call them right now. _already reaching for the phone, still smiling_",
"Here's the full rate! _sets it down cheerfully_ Don't worry about that number, insurance is going to take care of so much of it. So much. Give us just a sec! _hums while dialing_",
"That's what we call the pre-insurance figure! It's a big number but honestly that's just how we price things here. The Collective will bring it way down. Way down. _wanders off still visibly pleased with how this is going_",
"_hands it over like she's giving you a gift_ Your Guild plan is really comprehensive -- one of the better ones we see honestly. Let me just get them on the line! _does not register your expression at all_",
}
// nurseJoyAfterInsurance — format with afterInsurance cost.
// The last entry has two %d placeholders (intentional — she says the number twice).
var nurseJoyAfterInsurance = []string{
"_practically skips back in_ Great news! The Collective was amazing -- they covered so much of it. Your portion is only **€%d**! _says \"only\" like she's reporting a minor inconvenience_",
"_beaming_ Okay so the insurance came through and honestly? Really good result today. You're looking at **€%d** out of pocket! That's after everything they covered. _genuinely proud of this outcome_",
"_sits across from you, clasps hands_ I have such good news. The Collective took care of the bulk of it. Your share is just **€%d**! I know it sounds like a lot but you should see what it was before. _you have seen what it was before_ Actually you did see it. But still -- this is so much better!",
"_slides over a new, smaller, still alarming piece of paper_ So they covered everything above your threshold! You just owe **€%d**. _taps it encouragingly_ And then you're all set! Good as new! _has never once considered that **€%d** might be a problem_",
}
var nurseJoyCantAfford = []string{
"Oh! It looks like your balance isn't quite there today! _signals to the orderlies_ That's okay! We'll get you back to where we found you and you can rest up overnight! _waves as the stretcher is wheeled back out_",
"Hmm! So the balance isn't quite covering it! No worries at all -- _already nodding to someone behind you_ -- the natural recovery process works just as well! We'll get you back to your ditch! Have a great night!",
"Oh that's a little short! That's okay! _cheerfully_ We're going to pop you back where you were and you'll be right as rain tomorrow morning! _you are deposited back in the ditch_",
}
var nurseJoyNudge = []string{
"Hi! This is Nurse Joy at St. Guildmore's Memorial! We noticed you haven't come in yet.\n\n" +
"We have a bed ready and your insurance is all on file! We even have Derek standing by but that's only relevant if there's a billing issue and I'm sure there won't be!\n\n" +
"Come in whenever you're ready! We're open! _we are always open_\n\n" +
"Type `!hospital` to check in.",
}
var nurseJoyAlive = []string{
"_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_",
"_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_",
}
var nurseJoyAlreadyRevived = "You seem to have recovered on your own! " +
"That's the body's natural healing process at work. Beautiful thing. " +
"No charge! _beams_"
// ── Room Announcements ───────────────────────────────────────────────────<E29480><E29480>
var hospitalDischargeAnnounce = "🏥 %s has been discharged from St. Guildmore's Memorial Hospital. " +
"Recovered. Back in action. The bill has been described as \"a lot.\""
var hospitalDitchAnnounce = "🏥 %s was brought into St. Guildmore's on a stretcher. " +
"They have been returned to the ditch. They'll be fine tomorrow."
// ── Itemized Bill Generation ───────────────────────────────────────────────
type hospitalBillItem struct {
Name string
Note string // optional italicized sub-line
Min float64 // min fraction of total
Max float64 // max fraction of total
}
// hospitalBillPool is the pool of possible line items. Each bill picks 8-12.
var hospitalBillPool = []hospitalBillItem{
{"Resuscitation services (standard)", "", 0.04, 0.10},
{"Resuscitation services (premium)", "(performed simultaneously with standard, non-optional)", 0.03, 0.08},
{"Facility fee", "", 0.02, 0.05},
{"Facility fee (after hours)", "(you died at 2pm. this is contested. we will not contest it.)", 0.02, 0.04},
{"Physician consultation", "", 0.03, 0.06},
{"Physician consultation (second opinion)", "(same physician. different hat.)", 0.03, 0.06},
{"Gauze", "", 0.01, 0.02},
{"Gauze (application fee)", "", 0.01, 0.02},
{"Gauze (removal, future, pre-billed)", "", 0.01, 0.03},
{"Convenience fee", "(thank you for allowing us to exist)", 0.02, 0.05},
{"Inconvenience fee", "(deterrent to prevent emergency room abuse or usage in general)", 0.02, 0.05},
{"Guild membership verification fee", "(your guild card was checked. this takes resources.)", 0.01, 0.03},
{"Administrative processing", "", 0.01, 0.03},
{"Spiritual realignment surcharge", "(your soul was briefly elsewhere. retrieval is billable.)", 0.02, 0.04},
{"Bed occupancy charge", "(per minute, retroactive to estimated time of death)", 0.02, 0.05},
{"Oxygen (ambient)", "(you breathed hospital air. the air is not free.)", 0.01, 0.03},
{"Emotional support services", "(Nurse Joy smiled at you. that's a service.)", 0.01, 0.02},
{"Post-mortem orientation fee", "(the pamphlet you did not read.)", 0.01, 0.02},
{"Equipment sterilization", "(your gear was near our equipment. proximity counts.)", 0.01, 0.03},
{"Discharge planning", "(we are already planning your discharge. this costs money.)", 0.01, 0.03},
}
// hospitalHolidaySurcharge is included only on holidays.
var hospitalHolidaySurcharge = hospitalBillItem{
"Holiday surcharge", "(applied if death occurs on or adjacent to a recognized holiday)", 0.02, 0.02,
}
// hospitalFrequentCustomer is included when hospitalVisits > 0.
// It's a "discount" that is actually a positive charge. Because healthcare.
var hospitalFrequentCustomer = hospitalBillItem{
"Frequent customer discount", "(thank you for your continued patronage.)", 0.01, 0.02,
}
// generateItemizedBill builds a procedural hospital bill that sums to the given total.
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)
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
count := 8 + rand.IntN(5) // 8-12
if count > len(pool) {
count = len(pool)
}
items := pool[:count]
// Add conditional items
if isHoliday {
items = append(items, hospitalHolidaySurcharge)
}
if hospitalVisits > 0 {
items = append(items, hospitalFrequentCustomer)
}
// Assign amounts
type billLine struct {
Name string
Note string
Amount int64
}
var lines []billLine
var assigned int64
for _, item := range items {
frac := item.Min + rand.Float64()*(item.Max-item.Min)
amount := int64(float64(total) * frac)
if amount < 1 {
amount = 1
}
lines = append(lines, billLine{Name: item.Name, Note: item.Note, Amount: amount})
assigned += amount
}
// Miscellaneous absorbs the remainder
misc := total - assigned
if misc < 0 {
// Over-assigned — scale everything down proportionally
scale := float64(total) / float64(assigned)
assigned = 0
for i := range lines {
lines[i].Amount = int64(float64(lines[i].Amount) * scale)
if lines[i].Amount < 1 {
lines[i].Amount = 1
}
assigned += lines[i].Amount
}
misc = total - assigned
}
if misc > 0 {
lines = append(lines, billLine{
Name: "Miscellaneous",
Note: "",
Amount: misc,
})
}
// Find max name width for alignment
maxName := 0
for _, l := range lines {
if len(l.Name) > maxName {
maxName = len(l.Name)
}
}
if maxName < 35 {
maxName = 35
}
// Format
var sb strings.Builder
sb.WriteString("```\n")
sb.WriteString("ST. GUILDMORE'S MEMORIAL HOSPITAL\n")
sb.WriteString("─────────────────────────────────\n")
for _, l := range lines {
padding := maxName - len(l.Name) + 2
if padding < 2 {
padding = 2
}
sb.WriteString(l.Name)
sb.WriteString(strings.Repeat(" ", padding))
sb.WriteString(formatBillAmount(l.Amount))
sb.WriteByte('\n')
if l.Note != "" {
sb.WriteString(" " + l.Note + "\n")
}
}
sb.WriteString("─────────────────────────────────\n")
totalLabel := "TOTAL (before insurance)"
padding := maxName - len(totalLabel) + 2
if padding < 2 {
padding = 2
}
sb.WriteString(totalLabel)
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()
}
// formatBillAmount formats an int64 as €X,XXX with comma separators.
func formatBillAmount(n int64) string {
if n == 0 {
return "€0"
}
neg := n < 0
if neg {
n = -n
}
// Format with commas
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
if neg {
return "-€" + s
}
return "€" + s
}
// Insert commas from right
var result strings.Builder
remainder := len(s) % 3
if remainder > 0 {
result.WriteString(s[:remainder])
}
for i := remainder; i < len(s); i += 3 {
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3])
}
if neg {
return "-€" + result.String()
}
return "€" + result.String()
}

View File

@@ -0,0 +1,105 @@
package plugin
import "math/rand/v2"
// ── Rival Trash Talk Pools ──────────────────────────────────────────────────
// rivalOpeningTaunts are delivered with the challenge DM (Round 1).
var rivalOpeningTaunts = []string{
"Your strategy is filled with more holes than your boots. This will be like taking candy from a baby. Actually I shouldn't joke about that -- it looks like that's how you sustain yourself.",
"I've seen better odds on a three-legged horse. The horse at least had a plan.",
"I want you to know I considered not crossing the street. I crossed anyway. That tells you everything about how this is going to go.",
"You look nervous. You should look nervous. That's the first sensible thing I've seen from you.",
"I've done this before. You can tell by how little I'm sweating. Look at you. Look at me. See the difference?",
"I'm not saying this will be easy. I'm saying it will be quick. There's a distinction.",
"Take your time. Think it through. It won't help but I want you to feel like you had a fair shot.",
}
// rivalRoundWon are said by the rival after they win a round.
var rivalRoundWon = []string{
"Looks like you won't be buying any tacos today. You don't look like you're used to eating real food anyway.",
"One down. The math is unkind to you right now. As is most things, I suspect.",
"Did that hurt? The losing, I mean. Or is this familiar enough to be comfortable?",
"I knew what you were going to throw before you did. I know what you're going to throw next. I won't tell you.",
"You should have thrown something else. You know that now. I knew it before you threw.",
"_takes a moment to write something down_ Sorry. Just keeping notes. Continue.",
"The expression on your face right now is doing a lot of work. Most of it sad.",
}
// rivalRoundLost are said by the rival after they lose a round.
var rivalRoundLost = []string{
"Even a battered, dirty, _sniffs_ smelly, and broken beyond any hope of repair clock is right twice a day.",
"Fine. You got one. Don't read into it. Actually -- you won't know what to read into. Never mind.",
"I want you to enjoy this moment. Really sit in it. It may be all you have to take home today.",
"I respect the throw. I don't respect the thrower. These are two separate things.",
"Lucky. I've seen luck before and that's what that was. Make peace with it.",
"_pauses_ Okay. _pauses again_ I wasn't expecting that. That changes nothing. Next round.",
"You threw the right thing at the right time. The stopped clock principle applies. Ask someone to explain it to you later.",
}
// rivalTied are said by the rival after a tied round (before re-throw).
var rivalTied = []string{
"Same throw. Neither of us embarrassed ourselves. One of us is about to rectify that.",
"We're going again. That's fine. I have nowhere to be. Do you have somewhere to be? It doesn't matter.",
"You matched me. I want to be clear that this is the ceiling of your performance and we both know it.",
"_narrows eyes_ Interesting. Going again. Don't get comfortable.",
}
// rivalClosingWin are said by the rival when they win the match.
var rivalClosingWin = []string{
"I told you. I always tell people. People don't listen. Then it's over and they know I was right. Every time.",
"I hope the walk home gives you some time to think. Not about what you could have done differently. Just in general. Thinking is good for people.",
"Take care of yourself out there. Eat something. You look like you haven't in a while. _folds hands_ Actually that's your business. Good day.",
"The gold is mine. The record is updated. You may go.",
"I've done my good deed. The world is slightly more balanced now. _nods slowly and walks away_",
"You gave it everything. _looks you over_ Everything wasn't enough. But still. Points for showing up.",
}
// rivalClosingLoss are said by the rival when they lose the match.
var rivalClosingLoss = []string{
"You look like you've won the damn lottery. The sheer excitement on your face from winning a handful of gold is genuinely depressing. _sighs_ I suppose I've done my good deed for today. _checks off an item on a list titled 'Feed a waif today'_",
"Fine. Take it. You've clearly never had this much at one time and I'm not going to be the one to take that from you. Today.",
"I'll be back. Enjoy the gold. Buy yourself something warm to eat. You look cold.",
"_stares at you for a long moment_ No. _turns and walks away_",
"You won. I lost. The sun is back out. These things are unrelated. _looks up at the sky anyway_",
"I'm not angry. I'm not even surprised. I'm something else entirely and I don't have the energy to name it right now. Congratulations.",
"Keep it. Consider it a loan. _has not indicated when or if repayment is expected_ You chuckle to yourself, convinced this was said in jest. You notice them scribbling a collection date.",
}
// rivalRoundOutcomeWin are the player-wins-round outcome lines.
var rivalRoundOutcomeWin = []string{
"%s loses to %s. This is known.",
"You read them perfectly. Or you guessed. Either way.",
"%s takes it. Round to you.",
}
// rivalRoundOutcomeLoss are the player-loses-round outcome lines.
var rivalRoundOutcomeLoss = []string{
"%s beats %s. The math is what it is.",
"Wrong call. It happens.",
"%s takes it. Round to them.",
}
// rivalUnlockDM is sent once when a player reaches Combat Level 5.
const rivalUnlockDM = `You have reached Combat Level 5.
This means something now. Not in the way you think. Somewhere out there, someone else hit level 5 too. They're going about their day. So are you. At some point, one of you will receive a DM.
That DM will not be friendly.
Good luck.`
// rivalForfeitLines are used when a challenge expires without response.
var rivalForfeitLines = []string{
"You didn't respond. The gold has been collected. The rival walked away satisfied. You weren't even there to see it.",
"Time's up. The rival waited. You didn't show. The gold is gone.",
"The 24 hours have passed. The rival has claimed their winnings by default. They seemed disappointed. Not about the gold.",
}
// pickRivalFlavor returns a random entry from the given pool.
func pickRivalFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}

View File

@@ -0,0 +1,54 @@
package plugin
// ── Robbie the Friendly Bandit — Flavor Text ─────────────────────────────────
var robbieOpenings = []string{
"Ello there! Robbie here. I had a little look at your inventory and -- well. " +
"I'm just going to say it. You've been holdin' onto some gear that's well below your station " +
"and I can't in good conscience let that stand. So I've gone ahead and sorted it for ya. You're welcome.",
"_tips hat_ Robbie, at your service. I noticed you had some items in there that were frankly " +
"bringing down the average and I've taken the liberty of relieving you of them. " +
"Left ya somethin' for the trouble. Fair's fair.",
"Right so. I was havin' a look -- as I do -- and I couldn't help but notice the state of your inventory. " +
"No judgment! Well. Some judgment. I've handled it. Check your gold.",
"Hiya! It's Robbie. You had some things you didn't need. I took 'em. " +
"I left ya €%d for the lot. I also donated everything to a good cause " +
"so technically you're a philanthropist now. You're welcome for that too.",
}
var robbieClosings = []string{
"Right then. I'll be off. Don't go hoarding gear again now -- I'll know. _taps nose_",
"Pleasure doing business. As always, Robbie leaves things better than he found them. " +
"Debatable, but that's his position and he's sticking to it.",
"You're all sorted. Don't thank me. Actually -- do thank me. I worked hard today.",
"_already gone by the time you read this_",
}
var robbieMasterworkGotCard = "Now THIS -- _holds it up_ -- this is a lovely piece. " +
"I'm not going to pretend otherwise. I've left you something special for this one. " +
"Consider it a bonus from Robbie. Don't spend it all at once. _winks and disappears_"
var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excellent. " +
"I'll keep this baby for myself then -- in case one of yous wakes up again and gives me a wallopping. o_o"
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
// ── Room Announcements ───────────────────────────────────────────────────────
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
"€%d left behind. Everything donated to the community pot. " +
"Robbie considers this a net positive for all parties."
var robbieRoomMasterworkCard = "🎩 Robbie paid %s a visit and helped himself to a Masterwork piece. " +
"He left a Get Out of Medical Debt Free card and €%d. Robbie's words: \"Fair's fair.\""
var robbieRoomMasterworkAlreadyHas = "🎩 Robbie paid %s a visit, found they already had a " +
"Get Out of Medical Debt Free card, and pocketed the Masterwork's card for himself. " +
"He still left €%d. Robbie is if nothing else consistent."

View File

@@ -0,0 +1,200 @@
package plugin
// ── Luigi's Shop Flavor Text ────────────────────────────────────────────────
var luigiGreetings = []string{
"_leans on the counter_ Whadda y'all have? Menu is right above me. Everything's in stock, made fresh daily. Mostly.",
"Welcome to Luigi's! Everything here is fresh out the oven. What can I get started for you today?",
"_slides menu across the counter_ We've got some great stuff today. The Tier 3 armor just came in. Very fresh. Very fresh.",
"ORDER'S UP for the last customer! _turns to you_ Hi! Welcome! What are we doing today, quick service or are you browsing?",
"You look like someone who needs an upgrade. Good news -- Luigi's has got you covered. Fresh stock, hot and ready.",
"_straightens apron_ We're fully staffed today and the inventory is looking real good. What are you in the market for?",
"Luigi's! Home of the freshest gear in the region. _genuinely means this_ What can I get you?",
}
var luigiWeaponIntros = []string{
"Okay so the weapons -- everything here is made with quality ingredients. The higher the tier, the fresher the materials. You can taste the difference. _pause_ Metaphorically. Don't taste the sword.",
"Weapons are our most popular item. People come in for the sword, stay for the full combo. Can I interest you in the armor to go with that?",
"Fresh blades today. The Tier 4 came in this morning. I'd move on that before the rush.",
}
var luigiArmorIntros = []string{
"Armor is our specialty. Every piece is crafted to order. Well. It was crafted to order at some point. It's here now. Still fresh.",
"We've got full chest protection from Tier 1 to Tier 5. The Tier 5 is premium. Made with only the finest materials. You can tell by the weight of it.",
"The armor today is looking really good. Really fresh. I had the Tier 3 out front for the morning crowd and it moved fast.",
}
var luigiHelmetIntros = []string{
"Helmets. Very important. People forget about the helmet and then they're in here asking why things went wrong. Get the helmet.",
"We've got a full helmet lineup. Everything from the Dented Iron Helm -- which is a great value, honestly, a great value -- up to the Crown of the Fallen. Very premium item. Very fresh.",
}
var luigiBootsIntros = []string{
"Boots are underrated. I always say that. People come in for the sword and walk out in the same boots they came in with and then wonder why their feet hurt. The boots matter.",
"We've got boots across all tiers. The Tier 3 Swift Boots just came in. Fresh. Very fresh.",
}
var luigiToolIntros = []string{
"The tools. Yes. Very important for the miners. We've got everything from the Copper Pickaxe -- which, listen, it does the job, I'm not going to oversell it -- all the way up to the Diamond Pickaxe. Premium tool. Made with the best materials we have.",
}
var luigiCategoryIntros = map[EquipmentSlot][]string{
SlotWeapon: luigiWeaponIntros,
SlotArmor: luigiArmorIntros,
SlotHelmet: luigiHelmetIntros,
SlotBoots: luigiBootsIntros,
SlotTool: luigiToolIntros,
}
// ── Purchase Confirmations ──────────────────────────────────────────────────
var luigiPurchaseConfirm = []string{
"_punches it in_\nORDER UP! %s, one count! _slides it across the counter_\n\n€%d deducted. Good choice. I mean that -- genuinely good choice. Come back and see us.",
"_punches it in_\nORDER UP! %s! _slides it across the counter with both hands_\n\n€%d deducted. You made the right call today. Luigi's guarantee.",
"_punches it in_\nORDER UP! One %s, hot and ready! _nods approvingly_\n\n€%d deducted. That's going to serve you well. I stand behind every item on this floor.",
}
var luigiTier5Confirm = []string{
"_stops what he's doing_ Okay. This is -- I want to put this together myself. Not because the staff can't handle it, they absolutely can, I just -- a Tier 5 order deserves the personal touch. Give me one second.\nORDER UP! _voice cracks slightly_ You made a great call today. A genuinely great call.\n\n€%d deducted for **%s**.",
"_takes off his apron, puts it back on, adjusts it_ This is a Tier 5 order. I need -- I need a moment. _assembles the order with visible care_\nORDER UP! _barely containing himself_ This is what I'm here for. This right here.\n\n€%d deducted for **%s**.",
}
var luigiComboConfirm = []string{
"Okay so we've got the full spread -- that's a combo right there and I love seeing a combo. Let me get all of that together for you.\nORDER UP! Full combo! _beams like he always does on a full combo_\n\n€%d deducted for **%s**.",
"_looks at the order_ That's a combo. A real combo. People don't do combos enough and I've never understood why. This is how you shop.\nORDER UP! Full combo! _genuinely emotional_\n\n€%d deducted for **%s**.",
}
// ── Insufficient Funds ──────────────────────────────────────────────────────
var luigiInsufficientFunds = []string{
"I hear you and I wish I could help but I can't move product at a loss -- that's not how this works and honestly it wouldn't be fair to you either. Come back when you've got the funds and I'll make sure it's here for you. Fresh.",
}
// ── Browsing Without Buying ─────────────────────────────────────────────────
var luigiBrowseTimeout = []string{
"No rush. _glances at the door_ We've got people coming in so whenever you're ready. No pressure at all. Just -- whenever you're ready.",
"_reorganizes the display case_ Take your time. I'm not going anywhere. The stock isn't going anywhere. We're all just... here. Waiting. Ready when you are.",
"I'm going to need you to make a decision, we've got people behind you. _there are no people behind you_",
}
// ── Maxed Out / Fully Kitted ────────────────────────────────────────────────
var luigiMaxedOut = []string{
"_looks you over_ You're fully kitted out. I respect that. I really do. _pause_ Is there anything I can -- no. You've got everything. _nods once_ Come back if anything needs replacing. Or just come by. The door's always open.",
}
// ── Masterwork / Arena Acknowledgement ──────────────────────────────────────
var luigiMasterworkAck = []string{
"I see you've got a Masterwork piece in that slot. I can't beat that. But if you ever need a backup...",
"That's a Masterwork item you've got there. _respectful nod_ I'm not going to pretend I carry anything that competes with that. But the rest of the menu is still worth a look.",
"_notices the Arena gear_ You earned that. I know what that takes. I'm not here to replace it -- just here if you need anything else.",
}
// ── Show All Comment ────────────────────────────────────────────────────────
var luigiShowAllComment = []string{
"Sure, I'll show you everything. No judgment.",
"Full menu? You got it. I respect the thoroughness.",
}
// ── Unprompted Commentary ───────────────────────────────────────────────────
var luigiCommentary = []string{
"_gestures at the Vorpal Sword_ That one's been sitting there for a while but I want to be clear -- it's still fresh. We rotate stock.",
"The Dragonscale armor -- I'll be honest with you, I had a hard time sourcing that one. Supply chain issues. But it's here now and it is fresh.",
"_taps the Dented Iron Helm_ This is a value item. I'm not going to pretend it's premium. But for the price? You're not going to beat it. I stand behind the value items.",
"The Diamond Pickaxe just came in. Very fresh. Miners know. They always know when the fresh stock arrives.",
"_quietly, about the Knobby Boots_ We still carry the Tier 0 boots. For... continuity. I don't recommend them. But they're there.",
}
// ── Cancellation ────────────────────────────────────────────────────────────
var luigiCancellation = []string{
"Understood. No hard feelings. The door's always open and the stock is always fresh.",
"That's fine. Totally fine. I'm not going to pressure you. _moves the item back behind the counter_ Come back anytime.",
}
// ── Item Descriptions ───────────────────────────────────────────────────────
type luigiItemKey struct {
Slot EquipmentSlot
Tier int
}
// luigiItemDescriptions — full multi-sentence descriptions for the confirm view.
var luigiItemDescriptions = map[luigiItemKey]string{
// Weapons
{SlotWeapon, 1}: "I'm not going to dress it up -- it's an entry level blade. Iron. Holds an edge if you treat it right. Good for someone just getting started and I mean that genuinely. Everyone starts somewhere and this is a solid somewhere.",
{SlotWeapon, 2}: "Step up from the iron. You can feel it in the grip -- the steel is fresher, better balance. Not going to win any awards but I've seen people clear serious dungeons with this and come back smiling. Honest work. I stand behind it.",
{SlotWeapon, 3}: "Now we're talking. Silver edge, proper weight, made with quality materials. This is the one people come back for and I'll tell you why -- it performs. Every time. If you're going to make one investment in your kit today, this is the one I'd point you to.",
{SlotWeapon, 4}: "Premium item. Enchanted, fresh, made with the finest materials we source. I'm not going to tell you it's a limited time offer because it isn't -- we keep it stocked. What I will tell you is it moves fast because people who try it don't go back. That's not a sales pitch. That's just what happens.",
{SlotWeapon, 5}: "_lowers voice_ The Vorpal Sword. I want to be straight with you -- I don't know everything that goes into this. The supplier doesn't share the full recipe and I've learned not to push on it. What I know is the quality. I've held one. You feel it immediately. This is the item. If you're here for this, you already know.",
// Armor
{SlotArmor, 1}: "The name. I know. It's the supplier's name, not mine, and I'll be upfront -- there's some wear on it. It's not damage, it's use history, and it's been inspected. For the price it's the best value protection I carry and I wouldn't sell it if I didn't mean that.",
{SlotArmor, 2}: "Okay so -- the name. I hear it every day and I'll tell you what I tell everyone: I questioned it too when it first came in. Had my guy look at every link. Solid. The name came from the original batch which had issues. This isn't that batch. This batch passed everything. I wouldn't have it on the floor otherwise.",
{SlotArmor, 3}: "Full plate. Heavy -- and that's a good thing, that weight is quality steel, you don't get that from cheap materials. This is the real thing and if you're at the point in your adventure where you need real protection, this is what real looks like.",
{SlotArmor, 4}: "My personal recommendation for anyone who's serious about staying alive out there. Enchanted, properly made, and I've had customers come back three times for this. Not because it broke -- because they wanted a second one. That tells you everything.",
{SlotArmor, 5}: "_takes a breath_ I'm going to be straight with you about the Dragonscale. I know what it's made of. I know where it comes from. I just -- I don't ask the details because some things you don't need to know and the quality makes the conversation unnecessary. What I can tell you is it's the finest piece of armor in this shop and I've never had a complaint. Not one. In this business that's the only thing that matters.",
// Helmets
{SlotHelmet, 1}: "It's an iron pot. I know what it looks like. I know the history. But listen -- it stops things from hitting your head and that's what a helmet does. For the price you are not going to find better head protection. I guarantee that personally.",
{SlotHelmet, 2}: "The provenance is questionable. I'll give you that. The scratches were there when we got it and the previous owner didn't leave a forwarding address. But the steel is sound, the fit is decent, and it'll keep your skull in one piece. Nobody will compliment this helmet. Nobody needs to.",
{SlotHelmet, 3}: "Reinforced, fitted properly -- or close enough. This is the helmet you buy when you're serious about not dying from the top down. Doesn't make you look competent but competence is what you do with the gear, not what the gear does to your face.",
{SlotHelmet, 4}: "Guardian-grade. This helm has seen real battles, kept real heads intact, and carries itself with the quiet dignity you are only just beginning to deserve. I don't say that to be harsh. I say it because the helm is that good.",
{SlotHelmet, 5}: "Crown of the Fallen. Every previous owner died in it. None of them died because of it. It will outlast you too. _pauses_ I want to be clear -- that's a selling point. This is the most premium headwear I carry and it's not even close.",
// Boots
{SlotBoots, 1}: "Taken off a corpse. I'm not going to sugarcoat it. The corpse didn't need them anymore and honestly these boots have more life in them than some of the Tier 2 stock. For the price? It's a no-brainer. Don't think about the previous owner.",
{SlotBoots, 2}: "They've been places. Bad places. Places that did things to the leather you'd rather not examine. But they've held together through all of it and that tells you something about the construction. Mild discomfort is a feature. It means they're working.",
{SlotBoots, 3}: "Light enough to move in, grip decent enough to trust. These boots are built for someone who moves with purpose, which -- and I'm being honest with you -- you are in the process of becoming. The boots believe in you. Let them.",
{SlotBoots, 4}: "Ranger's boots. You move quieter. Faster. Longer. The ground cooperates with these in a way I can't fully explain. The forest notices. Something shifts. I've had rangers come in and refuse to take them off to try a different pair. That's how good they are.",
{SlotBoots, 5}: "The wind doesn't slow you. Terrain offers suggestions you are free to decline. These boots are an affront to the concept of obstacles. I had a pair behind the counter once. I don't anymore because someone bought them within an hour. That's the demand we're talking about here.",
// Tools
{SlotTool, 1}: "Copper. Soft. It gets the job done if you hit very hard and the ore is feeling cooperative. I'm not going to oversell it. But for someone who's just getting into mining, it's the right starting point and I mean that.",
{SlotTool, 2}: "Iron. Chipped to hell but bites the rock with something approaching intention. This is a pickaxe that exists and functions. The chip is cosmetic. Mostly. It won't affect performance in any way you'd notice if you weren't looking for it.",
{SlotTool, 3}: "Steel, properly weighted, properly edged. The mountain will acknowledge this pickaxe. Not respect it -- that takes time -- but acknowledge it. This is the tool for someone who's ready to be taken seriously by the ore.",
{SlotTool, 4}: "Mithril. Weighs nothing. Hits like consequence. The ores don't resist this so much as rearrange themselves out of respect. I've had miners come in after using one of these and I can see it in their eyes. Everything changed.",
{SlotTool, 5}: "Diamond. Breaks anything short of fate and occasionally that too. I'm going to be honest -- this is the finest mining tool I have ever carried and I've been doing this long enough to know the difference. The only limits left are your arm, your nerve, and the number of hours in a day.",
}
// luigiItemOneLiners — short quotes for the category listing view.
var luigiItemOneLiners = map[luigiItemKey]string{
// Weapons
{SlotWeapon, 1}: "Entry level. Honest work.",
{SlotWeapon, 2}: "Fresher steel. Better balance. Honest work.",
{SlotWeapon, 3}: "The one people come back for. Every time.",
{SlotWeapon, 4}: "Moves fast. People who try it don't go back.",
{SlotWeapon, 5}: "This is the item. You already know.",
// Armor
{SlotArmor, 1}: "Best value protection I carry. I mean that.",
{SlotArmor, 2}: "The name came from the old batch. This batch is solid.",
{SlotArmor, 3}: "This is what real protection looks like.",
{SlotArmor, 4}: "My personal recommendation. Customers come back three times.",
{SlotArmor, 5}: "The finest piece of armor in this shop. Not one complaint.",
// Helmets
{SlotHelmet, 1}: "Stops things from hitting your head. Great value.",
{SlotHelmet, 2}: "The steel is sound. Nobody will compliment it. Nobody needs to.",
{SlotHelmet, 3}: "For when you're serious about not dying from the top down.",
{SlotHelmet, 4}: "Guardian-grade. Quiet dignity. The real deal.",
{SlotHelmet, 5}: "Every previous owner died in it. None because of it.",
// Boots
{SlotBoots, 1}: "Don't think about the previous owner.",
{SlotBoots, 2}: "Mild discomfort is a feature. Means they're working.",
{SlotBoots, 3}: "Built for someone who moves with purpose.",
{SlotBoots, 4}: "Rangers refuse to take them off. That's how good they are.",
{SlotBoots, 5}: "An affront to the concept of obstacles.",
// Tools
{SlotTool, 1}: "Gets the job done if the ore cooperates.",
{SlotTool, 2}: "Exists and functions. The chip is cosmetic. Mostly.",
{SlotTool, 3}: "The mountain will acknowledge this pickaxe.",
{SlotTool, 4}: "Weighs nothing. Hits like consequence.",
{SlotTool, 5}: "Breaks anything short of fate.",
}

View File

@@ -0,0 +1,276 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Pending Interaction Types ──────────────────────────────────────────────
type advPendingHospitalPay struct {
Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety)
}
// ── Hospital Command ───────────────────────────────────────────────────────
func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "You're not registered for Adventure yet. Type `!adventure` to begin.")
}
// If alive, reject
if char.Alive {
text, _ := advPickFlavor(nurseJoyAlive, ctx.Sender, "hospital_alive")
return p.SendDM(ctx.Sender, text)
}
// On-demand revive if death timer already expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong at the hospital. Try again in a moment.")
}
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
// Compute costs
beforeInsurance := int64(char.CombatLevel) * 125_000
afterInsurance := int64(char.CombatLevel) * 25_000
// Check balance
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(afterInsurance) {
// Can't afford — back to the ditch
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
p.SendDM(ctx.Sender, text)
// Room announcement
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
}
return nil
}
// Check if holiday for bill surcharge
isHol, _ := isHolidayToday()
// Build the full multi-act DM
var sb strings.Builder
// Act 1 — Admission
if char.HospitalVisits == 0 {
sb.WriteString(nurseJoyFirstVisit)
}
admission, _ := advPickFlavor(nurseJoyAdmission, ctx.Sender, "hospital_admit")
sb.WriteString(admission)
sb.WriteString("\n\n")
// Act 2 — The Bill
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol))
sb.WriteString("\n\n")
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
sb.WriteString(delivery)
sb.WriteString("\n\n───\n\n")
// Act 3 — After Insurance
afterText, _ := advPickFlavor(nurseJoyAfterInsurance, ctx.Sender, "hospital_after")
// Some entries have two %d placeholders (she says the number twice)
count := strings.Count(afterText, "%d")
switch count {
case 1:
afterText = fmt.Sprintf(afterText, afterInsurance)
case 2:
afterText = fmt.Sprintf(afterText, afterInsurance, afterInsurance)
}
sb.WriteString(afterText)
sb.WriteString("\n\n")
// Payment prompt
sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **€%d**\n\n", afterInsurance))
sb.WriteString("Pay now? (yes / no)\n\n")
sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " +
"You'll be back tomorrow. The chair in the waiting room is available in the meantime.*")
// Store pending interaction
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "hospital_pay",
Data: &advPendingHospitalPay{Cost: afterInsurance},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, sb.String())
}
// ── Payment Resolution ─────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *advPendingInteraction) error {
reply := strings.TrimSpace(strings.ToLower(ctx.Body))
if reply == "yes" || reply == "y" {
// Acquire lock
mu := p.advUserLock(ctx.Sender)
mu.Lock()
defer mu.Unlock()
// Reload fresh (TOCTOU protection)
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong loading your character.")
}
// Already alive (or timer expired while they were deciding)?
if char.Alive {
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
_ = saveAdvCharacter(char)
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
// Recompute cost from current combat level (don't trust cached value)
cost := int64(char.CombatLevel) * 25_000
// Check balance
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(cost) {
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
p.SendDM(ctx.Sender, text)
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
}
return nil
}
// Debit
if !p.euro.Debit(ctx.Sender, float64(cost), "hospital_revival") {
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
return p.SendDM(ctx.Sender, text)
}
// Revive
char.Alive = true
char.DeadUntil = nil
char.HospitalVisits++
if err := saveAdvCharacter(char); err != nil {
slog.Error("hospital: failed to save character after revival", "user", char.UserID, "err", err)
// Refund on save failure
p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund")
return p.SendDM(ctx.Sender, "Something went wrong during recovery. Your payment has been refunded.")
}
// Discharge DM
p.SendDM(ctx.Sender, fmt.Sprintf(
"✅ **Discharged — you're alive!**\n\n"+
"€%d deducted. Nurse Joy wishes you the best. "+
"She means it. She always means it.\n\n"+
"Go get 'em. Type `!adventure` when you're ready.",
cost))
// Room announcement
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDischargeAnnounce, char.DisplayName))
}
return nil
}
// Declined or anything else — back to the ditch
p.SendDM(ctx.Sender, "Understood. Nurse Joy nods cheerfully and signals the orderlies. "+
"You're wheeled back to where they found you.\n\n"+
"Natural respawn will occur in due time. Rest up.")
char, err := loadAdvCharacter(ctx.Sender)
if err == nil {
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
}
}
return nil
}
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
beforeInsurance := int64(char.CombatLevel) * 125_000
afterInsurance := int64(char.CombatLevel) * 25_000
respawnTime := "unknown"
if char.DeadUntil != nil {
respawnTime = char.DeadUntil.Format("15:04")
}
text := fmt.Sprintf(
"🏥 **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"+
"Estimated bill: €%d (Guild insurance covers €%d → you pay €%d)\n\n"+
"Or rest up — natural respawn at %s UTC.",
beforeInsurance, beforeInsurance-afterInsurance, afterInsurance, respawnTime)
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
}
// Schedule nudge for 2 hours from now — checked by hospitalNudgeTicker
p.hospitalNudges.Store(string(userID), time.Now().UTC().Add(2*time.Hour))
})
}
// ── Hospital Nudge Ticker ──────────────────────────────────────────────────
// Runs every minute, checks for nudges that are due. No long-lived goroutines.
func (p *AdventurePlugin) hospitalNudgeTicker() {
ticker := time.NewTicker(1 * time.Minute)
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)
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

@@ -19,7 +19,7 @@ type MasterworkDef struct {
Tier int // 1-5, matches location tier
Name string
Description string // character sheet / trade listing
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.015
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005
}
var masterworkDefs = []MasterworkDef{
@@ -46,7 +46,7 @@ var masterworkDefs = []MasterworkDef{
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 5,
Name: "The Motherload", DropRate: 0.015,
Name: "The Motherload", DropRate: 0.005,
Description: "There is no good explanation for why this was in the rock. The rock isn't talking. You're not asking. You've decided to stop thinking about it and just use it.",
},
@@ -73,7 +73,7 @@ var masterworkDefs = []MasterworkDef{
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 5,
Name: "The Deepforged Carapace", DropRate: 0.015,
Name: "The Deepforged Carapace", DropRate: 0.005,
Description: "This came up on the line from the Abyssal Trench and the line almost didn't hold. It is not from a fish. It is not from anything you have seen or want to see. It fits perfectly. You find this more unsettling than the alternative.",
},
@@ -100,7 +100,7 @@ var masterworkDefs = []MasterworkDef{
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 5,
Name: "The Last Step", DropRate: 0.015,
Name: "The Last Step", DropRate: 0.005,
Description: "Found at the edge of the Fungal Dark where the light stops. You almost didn't go that far. Something about wearing them suggests you will always go that far, from now on. This is either good news or the other kind.",
},
}
@@ -177,18 +177,19 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC
// Check current equipment in target slot
existing := equip[def.Slot]
// Determine: auto-equip, inventory, or silent discard
// Determine: auto-equip, inventory, or silent discard.
// Compare effective tiers: Masterwork = tier*1.25, Arena = tier*1.5, Shop = tier.
// Only auto-equip if the new Masterwork drop is strictly better.
newEffective := float64(def.Tier) * 1.25 // incoming masterwork
existingEffective := advEffectiveTier(existing)
autoEquip := false
if existing == nil {
autoEquip = true
} else if existing.Masterwork {
if def.Tier > existing.Tier {
autoEquip = true // upgrade over lower-tier masterwork
} else {
return // silent discard: same or higher tier masterwork already equipped
}
} else {
autoEquip = true // any masterwork > shop gear
} else if existing.Masterwork && def.Tier <= existing.Tier {
return // silent discard: same or lower tier masterwork already equipped
} else if newEffective > existingEffective {
autoEquip = true // genuinely better than what they have
}
// First-drop detection

View File

@@ -140,7 +140,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
}
}
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
sb.WriteString(fmt.Sprintf(" Equipment Score: %.1f\n", eqScore))
// Treasures
if len(treasures) > 0 {
@@ -172,6 +172,35 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(fmt.Sprintf("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
// Babysit status
if char.BabysitActive {
remaining := "active"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day left"
} else {
remaining = fmt.Sprintf("%d days left", days)
}
}
sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus))
}
// Rival status
if char.RivalPool == 1 {
records, _ := loadAllRivalRecords(char.UserID)
sb.WriteString("\n⚔ Rivals: Unlocked")
if len(records) > 0 {
totalW, totalL := 0, 0
for _, r := range records {
totalW += r.Wins
totalL += r.Losses
}
sb.WriteString(fmt.Sprintf(" (%dW / %dL)", totalW, totalL))
}
sb.WriteString(" — `!adventure rivals` for details\n")
}
// Today's action
if char.ActionTakenToday {
sb.WriteString("\n📅 Today: Action taken")
@@ -268,7 +297,8 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
}
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6Rest** — skip today, bank your luck\n\n")
sb.WriteString("**6Blacksmith** — repair damaged equipment\n")
sb.WriteString("**7⃣ Rest** — skip today, bank your luck\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
sb.WriteString("You have until midnight UTC to choose.")
@@ -320,7 +350,8 @@ func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentS
}
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6Rest** — skip the second action\n\n")
sb.WriteString("**6Blacksmith** — repair damaged equipment\n")
sb.WriteString("**7⃣ Rest** — skip the second action\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
return sb.String()
@@ -443,20 +474,15 @@ func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string,
// ── Death Status DM ──────────────────────────────────────────────────────────
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
text, _ := advPickFlavor(DeathDM, char.UserID, "death_dm")
remaining := ""
cost := int64(char.CombatLevel) * 25_000
remaining := "unknown"
if char.DeadUntil != nil {
remaining = char.DeadUntil.Format("15:04")
}
location := char.GrudgeLocation
if location == "" {
location = "an unknown location"
}
return advSubstituteFlavor(text, map[string]string{
"{name}": char.DisplayName,
"{time}": remaining,
"{location}": location,
})
return fmt.Sprintf("💀 You're still dead.\n\n"+
"🏥 Type `!hospital` for same-day revival (€%d after insurance)\n"+
"⏳ Or wait — natural respawn at %s UTC",
cost, remaining)
}
// ── Respawn DM ───────────────────────────────────────────────────────────────
@@ -547,12 +573,17 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n")
if tbRewards.Eligible > 0 {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
if tbRewards.GoldShare > 0 {
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
}
if tbRewards.GiftCount > 0 {
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
hasRewards := tbRewards.GoldShare > 0 || tbRewards.GiftCount > 0
if hasRewards {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
if tbRewards.GoldShare > 0 {
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
}
if tbRewards.GiftCount > 0 {
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
}
} else {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
}
}
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")

View File

@@ -0,0 +1,807 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
rivalMinCombatLevel = 5
rivalChallengeWindow = 24 * time.Hour
rivalSamePairCooldown = 7 * 24 * time.Hour
rivalMinIntervalHours = 3 * 24 // 3 days in hours
rivalMaxIntervalHours = 4 * 24 // 4 days in hours
)
// ── Types ────────────────────────────────────────────────────────────────────
type advRivalChallenge struct {
ChallengeID string
ChallengerID id.UserID
ChallengedID id.UserID
Stake int
Round int
PlayerScore int
RivalScore int
ExpiresAt time.Time
CreatedAt time.Time
}
type advRivalRecord struct {
RivalID id.UserID
Wins int
Losses int
LastDuelAt *time.Time
}
type advPendingRivalRPS struct {
ChallengeID string
}
// ── Stake Calculation ────────────────────────────────────────────────────────
func rivalStake(combatLevel int) int {
return (combatLevel / 5) * 1000
}
// ── Rival Pool Unlock ────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
if char.CombatLevel >= rivalMinCombatLevel && char.RivalPool == 0 {
char.RivalPool = 1
if !char.RivalUnlockedNotified {
char.RivalUnlockedNotified = true
p.SendDM(char.UserID, rivalUnlockDM)
}
}
}
// ── DB CRUD ──────────────────────────────────────────────────────────────────
func loadRivalChallengeByID(challengeID string) (*advRivalChallenge, error) {
d := db.Get()
c := &advRivalChallenge{}
err := d.QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake,
round, player_score, rival_score, expires_at, created_at
FROM adventure_rival_challenges WHERE challenge_id = ?`, challengeID).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
func insertRivalChallenge(c *advRivalChallenge) error {
db.Exec("rival: insert challenge",
`INSERT INTO adventure_rival_challenges
(challenge_id, challenger_id, challenged_id, stake, round, player_score, rival_score, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID),
c.Stake, c.Round, c.PlayerScore, c.RivalScore, c.ExpiresAt,
)
return nil
}
func saveRivalChallengeRound(c *advRivalChallenge) {
db.Exec("rival: update round",
`UPDATE adventure_rival_challenges
SET round = ?, player_score = ?, rival_score = ?
WHERE challenge_id = ?`,
c.Round, c.PlayerScore, c.RivalScore, c.ChallengeID,
)
}
func deleteRivalChallenge(challengeID string) {
db.Exec("rival: delete challenge",
`DELETE FROM adventure_rival_challenges WHERE challenge_id = ?`,
challengeID,
)
}
func upsertRivalRecord(userID, rivalID id.UserID, won bool) {
if won {
db.Exec("rival: upsert record win",
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
VALUES (?, ?, 1, 0, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, rival_id) DO UPDATE SET wins = wins + 1, last_duel_at = CURRENT_TIMESTAMP`,
string(userID), string(rivalID),
)
} else {
db.Exec("rival: upsert record loss",
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
VALUES (?, ?, 0, 1, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, rival_id) DO UPDATE SET losses = losses + 1, last_duel_at = CURRENT_TIMESTAMP`,
string(userID), string(rivalID),
)
}
}
func loadAllRivalRecords(userID id.UserID) ([]advRivalRecord, error) {
d := db.Get()
rows, err := d.Query(`
SELECT rival_id, wins, losses, last_duel_at
FROM adventure_rival_records
WHERE user_id = ?
ORDER BY last_duel_at DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var records []advRivalRecord
for rows.Next() {
r := advRivalRecord{}
var lastDuel sql.NullTime
if err := rows.Scan(&r.RivalID, &r.Wins, &r.Losses, &lastDuel); err != nil {
return nil, err
}
if lastDuel.Valid {
r.LastDuelAt = &lastDuel.Time
}
records = append(records, r)
}
return records, rows.Err()
}
func communityPotAdd(amount int) {
db.Exec("rival: community pot add",
`INSERT INTO community_pot (id, balance, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
amount, amount,
)
}
func communityPotBalance() int {
d := db.Get()
var balance int
_ = d.QueryRow(`SELECT COALESCE(balance, 0) FROM community_pot WHERE id = 1`).Scan(&balance)
return balance
}
func communityPotDebit(amount int) bool {
d := db.Get()
res, err := d.Exec(`UPDATE community_pot SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1 AND balance >= ?`, amount, amount)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
func loadExpiredRivalChallenges() ([]advRivalChallenge, error) {
d := db.Get()
rows, err := d.Query(`
SELECT challenge_id, challenger_id, challenged_id, stake,
round, player_score, rival_score, expires_at, created_at
FROM adventure_rival_challenges
WHERE expires_at <= CURRENT_TIMESTAMP`)
if err != nil {
return nil, err
}
defer rows.Close()
var challenges []advRivalChallenge
for rows.Next() {
c := advRivalChallenge{}
if err := rows.Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
); err != nil {
return nil, err
}
challenges = append(challenges, c)
}
return challenges, rows.Err()
}
func lastRivalChallengeTime() time.Time {
d := db.Get()
var t sql.NullTime
_ = d.QueryRow(`SELECT MAX(created_at) FROM adventure_rival_challenges`).Scan(&t)
if t.Valid {
return t.Time
}
return time.Time{}
}
func hasActiveChallenge(userID id.UserID) bool {
d := db.Get()
var count int
_ = d.QueryRow(`
SELECT COUNT(*) FROM adventure_rival_challenges
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
string(userID), string(userID)).Scan(&count)
return count > 0
}
func lastDuelBetween(a, b id.UserID) time.Time {
d := db.Get()
var t sql.NullTime
_ = d.QueryRow(`
SELECT last_duel_at FROM adventure_rival_records
WHERE user_id = ? AND rival_id = ?`, string(a), string(b)).Scan(&t)
if t.Valid {
return t.Time
}
return time.Time{}
}
// ── Pair Selection ───────────────────────────────────────────────────────────
func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureCharacter) {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("rival: load characters", "err", err)
return nil, nil
}
// Filter to eligible pool members.
var pool []AdventureCharacter
for _, c := range chars {
if c.RivalPool == 0 || !c.Alive || c.BabysitActive {
continue
}
if hasActiveChallenge(c.UserID) {
continue
}
stake := rivalStake(c.CombatLevel)
if stake <= 0 {
continue
}
bal := p.euro.GetBalance(c.UserID)
if bal < float64(stake) {
continue
}
pool = append(pool, c)
}
if len(pool) < 2 {
return nil, nil
}
// Shuffle and find first valid pair.
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
now := time.Now().UTC()
for i := 0; i < len(pool); i++ {
for j := i + 1; j < len(pool); j++ {
a, b := &pool[i], &pool[j]
// Check same-pair cooldown (7 days).
last := lastDuelBetween(a.UserID, b.UserID)
if !last.IsZero() && now.Sub(last) < rivalSamePairCooldown {
continue
}
// Randomly assign challenger/challenged.
if rand.IntN(2) == 0 {
return a, b
}
return b, a
}
}
return nil, nil
}
// ── Challenge Issuance ───────────────────────────────────────────────────────
func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) {
stake := rivalStake(challenged.CombatLevel)
challenge := &advRivalChallenge{
ChallengeID: uuid.New().String()[:12],
ChallengerID: challenger.UserID,
ChallengedID: challenged.UserID,
Stake: stake,
Round: 1,
PlayerScore: 0,
RivalScore: 0,
ExpiresAt: time.Now().UTC().Add(rivalChallengeWindow),
}
insertRivalChallenge(challenge)
// Notify the rival (challenger) that a challenge was issued in their name.
p.SendDM(challenger.UserID, fmt.Sprintf(
"⚔️ You've been matched as a rival against **%s**. A challenge has been issued in your name. "+
"Your throws will be generated automatically. Sit back and wait for the result.",
challenged.DisplayName))
// Send the dramatic challenge DM to the challenged player.
openingTaunt := pickRivalFlavor(rivalOpeningTaunts)
dm := fmt.Sprintf(`⚔️ RIVAL CHALLENGE
You're walking along enjoying a nice sunny day when you look across the road and notice someone staring at you intensely -- the look of someone who caught a faint but unmistakable smell at a restaurant and has traced it back to its source.
The person crosses the street. The sun disappears behind dark, angry clouds as if on cue.
*"You're looking at me like you got a problem."*
They look you over slowly. The scowl becomes a smug smile.
*"Your face looks like someone who's fought countless battles and lost just as many. Ah well. I already crossed the street so I guess I'll make this quick."*
They reach to their side. You ready yourself.
The stakes are €%d. Best of 3. Rock Paper Scissors.
*%s*
Reply with **rock**, **paper**, or **scissors** for Round 1.
You have 24 hours.`, stake, openingTaunt)
p.SendDM(challenged.UserID, dm)
// Store pending interaction for the challenged player.
p.pending.Store(string(challenged.UserID), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
slog.Info("rival: challenge issued",
"challenger", challenger.DisplayName,
"challenged", challenged.DisplayName,
"stake", stake)
}
// ── RPS Resolution ───────────────────────────────────────────────────────────
type rpsThrow int
const (
rpsRock rpsThrow = iota
rpsPaper
rpsScissors
)
var rpsNames = map[rpsThrow]string{
rpsRock: "rock",
rpsPaper: "paper",
rpsScissors: "scissors",
}
func parseRPS(s string) (rpsThrow, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "rock", "r":
return rpsRock, true
case "paper", "p":
return rpsPaper, true
case "scissors", "s", "scissor":
return rpsScissors, true
}
return 0, false
}
func randomRPS() rpsThrow {
return rpsThrow(rand.IntN(3))
}
// rpsResult: 1 = player wins, -1 = rival wins, 0 = tie.
func rpsResult(player, rival rpsThrow) int {
if player == rival {
return 0
}
if (player == rpsRock && rival == rpsScissors) ||
(player == rpsPaper && rival == rpsRock) ||
(player == rpsScissors && rival == rpsPaper) {
return 1
}
return -1
}
func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *advPendingInteraction) error {
// Acquire per-user lock to prevent concurrent resolution from rapid messages.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingRivalRPS)
playerThrow, ok := parseRPS(ctx.Body)
if !ok {
// Re-store the pending interaction so the player can try again.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Rock, paper, or scissors. That's it. Those are the options.")
}
challenge, err := loadRivalChallengeByID(data.ChallengeID)
if err != nil || challenge == nil {
// Challenge was expired/deleted by the ticker — don't re-store pending.
return p.SendDM(ctx.Sender, "That challenge is no longer active.")
}
// Resolve throw.
rivalThrow := randomRPS()
result := rpsResult(playerThrow, rivalThrow)
// Tie — both re-throw. Re-prompt the player.
if result == 0 {
tieLine := pickRivalFlavor(rivalTied)
text := fmt.Sprintf("You threw %s. They threw %s. Tie.\n\n*%s*\n\n"+
"Reply with **rock**, **paper**, or **scissors** to re-throw.",
rpsNames[playerThrow], rpsNames[rivalThrow], tieLine)
// Re-store the pending interaction for the same round.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
return p.SendDM(ctx.Sender, text)
}
// Build the round DM.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ ROUND %d RESULT\n\n", challenge.Round))
sb.WriteString(fmt.Sprintf("You threw %s. They threw %s.\n\n", rpsNames[playerThrow], rpsNames[rivalThrow]))
if result == 1 {
// Player won the round.
challenge.PlayerScore++
// Outcome line.
outcomePool := rivalRoundOutcomeWin
outcome := pickRivalFlavor(outcomePool)
if strings.Contains(outcome, "%s") {
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
// Rival reaction (they lost the round).
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundLost)))
} else {
// Rival won the round.
challenge.RivalScore++
outcomePool := rivalRoundOutcomeLoss
outcome := pickRivalFlavor(outcomePool)
if strings.Contains(outcome, "%s") {
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
// Rival reaction (they won the round).
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundWon)))
}
// Check for match end (best of 3 = first to 2).
if challenge.PlayerScore >= 2 || challenge.RivalScore >= 2 {
playerWon := challenge.PlayerScore >= 2
saveRivalChallengeRound(challenge)
p.pending.Delete(string(ctx.Sender))
p.finalizeRivalMatch(challenge, playerWon, &sb)
return p.SendDM(ctx.Sender, sb.String())
}
// Match continues — next round.
challenge.Round++
saveRivalChallengeRound(challenge)
sb.WriteString(fmt.Sprintf("\nReply with **rock**, **paper**, or **scissors** for Round %d.", challenge.Round))
// Update pending interaction for next round.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
return p.SendDM(ctx.Sender, sb.String())
}
// ── Match Finalization ───────────────────────────────────────────────────────
func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playerWon bool, sb *strings.Builder) {
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
var winnerID, loserID id.UserID
var winnerName, loserName string
if playerWon {
winnerID = challenge.ChallengedID
loserID = challenge.ChallengerID
winnerName = ""
loserName = ""
if challengedChar != nil {
winnerName = challengedChar.DisplayName
}
if challengerChar != nil {
loserName = challengerChar.DisplayName
}
} else {
winnerID = challenge.ChallengerID
loserID = challenge.ChallengedID
if challengerChar != nil {
winnerName = challengerChar.DisplayName
}
if challengedChar != nil {
loserName = challengedChar.DisplayName
}
}
// Gold transfer — try full stake, fall back to available balance.
stake := challenge.Stake
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss") {
// Loser can't cover full stake — debit whatever they have.
bal := p.euro.GetBalance(loserID)
stake = int(bal)
if stake > 0 {
// Best-effort debit of remaining balance. If it fails (concurrent drain), stake becomes 0.
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss_partial") {
stake = 0
}
}
}
winnerShare := stake / 2
potShare := stake - winnerShare
if winnerShare > 0 {
p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
}
if potShare > 0 {
communityPotAdd(potShare)
}
// Update rival records (both directions).
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, playerWon)
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, !playerWon)
// Append closing line to the challenged player's DM.
sb.WriteString("\n⚔ DUEL RESOLVED\n\n")
sb.WriteString(fmt.Sprintf("Final score: You %d -- Rival %d\n", challenge.PlayerScore, challenge.RivalScore))
if playerWon {
sb.WriteString(fmt.Sprintf("€%d added to your balance. €%d added to the community pot.\n\n", winnerShare, potShare))
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingLoss)))
} else {
sb.WriteString(fmt.Sprintf("€%d removed from your balance. €%d added to the community pot.\n\n", stake, potShare))
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingWin)))
}
// Load W/L record for display.
records, _ := loadAllRivalRecords(challenge.ChallengedID)
for _, r := range records {
if r.RivalID == challenge.ChallengerID {
rivalName := string(challenge.ChallengerID)
if challengerChar != nil {
rivalName = challengerChar.DisplayName
}
sb.WriteString(fmt.Sprintf("Record vs %s: %dW-%dL\n", rivalName, r.Wins, r.Losses))
break
}
}
// Send closing DM to the rival (challenger).
var rivalDM strings.Builder
rivalDM.WriteString("⚔️ DUEL RESOLVED\n\n")
rivalDM.WriteString(fmt.Sprintf("Your challenge against **%s** has concluded.\n",
func() string {
if challengedChar != nil {
return challengedChar.DisplayName
}
return string(challenge.ChallengedID)
}()))
rivalDM.WriteString(fmt.Sprintf("Final score: %s %d -- %s %d\n",
func() string {
if challengedChar != nil {
return challengedChar.DisplayName
}
return "Them"
}(), challenge.PlayerScore,
func() string {
if challengerChar != nil {
return challengerChar.DisplayName
}
return "You"
}(), challenge.RivalScore))
if !playerWon {
rivalDM.WriteString(fmt.Sprintf("You won! +€%d to your balance. €%d to the community pot.\n", winnerShare, potShare))
} else {
rivalDM.WriteString(fmt.Sprintf("You lost. €%d removed from your balance.\n", stake))
}
p.SendDM(challenge.ChallengerID, rivalDM.String())
// Room announcement.
gr := gamesRoom()
if gr != "" {
var announce string
if challenge.Round >= 3 {
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s** in a nail-biting best of 3!",
winnerName, stake, loserName)
} else {
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**!",
winnerName, stake, loserName)
}
p.SendMessage(gr, announce)
}
// Delete the challenge row.
deleteRivalChallenge(challenge.ChallengeID)
slog.Info("rival: match resolved",
"winner", winnerName, "loser", loserName,
"stake", stake, "rounds", challenge.Round)
}
// ── Expiry Handling ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) expireRivalChallenges() {
expired, err := loadExpiredRivalChallenges()
if err != nil {
slog.Error("rival: load expired challenges", "err", err)
return
}
for _, challenge := range expired {
// Challenged player forfeits — rival wins.
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
stake := challenge.Stake
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit") {
bal := p.euro.GetBalance(challenge.ChallengedID)
stake = int(bal)
if stake > 0 {
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit_partial") {
stake = 0
}
}
}
winnerShare := stake / 2
potShare := stake - winnerShare
if winnerShare > 0 {
p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
}
if potShare > 0 {
communityPotAdd(potShare)
}
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false)
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, true)
// DM the challenged player (forfeit notice).
forfeitLine := pickRivalFlavor(rivalForfeitLines)
p.SendDM(challenge.ChallengedID, fmt.Sprintf("⚔️ DUEL FORFEITED\n\n%s\n\n€%d removed from your balance.",
forfeitLine, stake))
// DM the challenger.
challengedName := string(challenge.ChallengedID)
if challengedChar != nil {
challengedName = challengedChar.DisplayName
}
p.SendDM(challenge.ChallengerID, fmt.Sprintf(
"⚔️ Your rival **%s** didn't respond in time. You win by default. +€%d to your balance.",
challengedName, winnerShare))
// Room announcement.
gr := gamesRoom()
if gr != "" {
winnerName := string(challenge.ChallengerID)
if challengerChar != nil {
winnerName = challengerChar.DisplayName
}
loserName := challengedName
p.SendMessage(gr, fmt.Sprintf(
"⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**, who couldn't even be bothered to show up!",
winnerName, stake, loserName))
}
deleteRivalChallenge(challenge.ChallengeID)
p.pending.Delete(string(challenge.ChallengedID))
slog.Info("rival: challenge expired",
"challenger", challenge.ChallengerID,
"challenged", challenge.ChallengedID,
"stake", stake)
}
}
// ── Challenge Scheduler ──────────────────────────────────────────────────────
func (p *AdventurePlugin) rivalChallengeTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
// Roll the next challenge interval once. Re-roll after each issued challenge.
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
for range ticker.C {
now := time.Now().UTC()
// Only fire on the hour.
if now.Minute() != 0 {
continue
}
// Only issue challenges between 08:00 and 22:00 UTC.
if now.Hour() < 8 || now.Hour() >= 22 {
// Still check for expiry outside challenge hours.
p.expireRivalChallenges()
continue
}
// Expire old challenges first.
p.expireRivalChallenges()
// Check if enough time has passed since last challenge.
last := lastRivalChallengeTime()
if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
continue
}
// Try to issue a challenge.
challenger, challenged := p.selectRivalPair()
if challenger == nil || challenged == nil {
continue
}
p.issueRivalChallenge(challenger, challenged)
// Roll a fresh interval for the next challenge.
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
}
}
// ── Rivals Command ───────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return err
}
if char.RivalPool == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You need Combat Level %d to enter the rival pool. Currently: %d.",
rivalMinCombatLevel, char.CombatLevel))
}
records, err := loadAllRivalRecords(char.UserID)
if err != nil || len(records) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"⚔️ You're in the rival pool but haven't been challenged yet. Your time will come.")
}
var sb strings.Builder
sb.WriteString("⚔️ **Rival Record**\n\n")
for _, r := range records {
rivalChar, _ := loadAdvCharacter(r.RivalID)
name := string(r.RivalID)
if rivalChar != nil {
name = rivalChar.DisplayName
}
daysAgo := ""
if r.LastDuelAt != nil {
days := int(time.Since(*r.LastDuelAt).Hours() / 24)
if days == 0 {
daysAgo = "today"
} else if days == 1 {
daysAgo = "1 day ago"
} else {
daysAgo = fmt.Sprintf("%d days ago", days)
}
}
sb.WriteString(fmt.Sprintf(" %-16s %dW - %dL last duel: %s\n", name, r.Wins, r.Losses, daysAgo))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}

View File

@@ -0,0 +1,290 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Robbie the Friendly Bandit ───────────────────────────────────────────────
//
// Robbie is an automated NPC who visits players at a random hour each day,
// takes sub-tier inventory items, leaves €50 per item, donates everything
// to the community pot, and occasionally drops a Get Out of Medical Debt
// Free card when collecting Masterwork gear.
// In-memory target hour — picked fresh each day, regenerated on restart.
var (
robbieTargetHour int = -1
robbieTargetDay string // "2006-01-02"
)
// ── Ticker ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) robbieTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
dateKey := now.Format("2006-01-02")
// At midnight (or first tick of the day), pick today's target hour.
if robbieTargetDay != dateKey {
robbieTargetHour = 8 + rand.IntN(14) // 821 inclusive
robbieTargetDay = dateKey
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
}
if now.Hour() != robbieTargetHour || now.Minute() != 0 {
continue
}
jobName := "adventure_robbie"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("adventure: robbie sweep starting")
p.robbieVisitAll()
db.MarkJobCompleted(jobName, dateKey)
}
}
// ── Visit All Players ────────────────────────────────────────────────────────
func (p *AdventurePlugin) robbieVisitAll() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: robbie: failed to load characters", "err", err)
return
}
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
for i, char := range chars {
if !char.Alive {
continue
}
// Jitter between players to avoid Matrix rate limits.
if i > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
p.robbieVisitPlayer(char.UserID, char.DisplayName)
}
}
// ── Single Player Visit ──────────────────────────────────────────────────────
func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string) {
mu := p.advUserLock(userID)
mu.Lock()
defer mu.Unlock()
// Load inventory + equipped gear
inv, err := loadAdvInventory(userID)
if err != nil {
return
}
equip, err := loadAdvEquipment(userID)
if err != nil {
return
}
// Find qualifying items
qualifying := robbieQualifyingItems(inv, equip)
if len(qualifying) == 0 {
return
}
// 40% chance of visiting
if rand.Float64() >= 0.40 {
return
}
// Execute the visit — collect items
var totalPayout int64
var communityTotal int64
var masterworkTaken bool
var takenItems []AdvItem
for _, item := range qualifying {
if err := removeAdvInventoryItem(item.ID); err != nil {
slog.Error("adventure: robbie: failed to remove item", "item_id", item.ID, "err", err)
continue
}
takenItems = append(takenItems, item)
totalPayout += 50
communityTotal += item.Value
if item.Type == "MasterworkGear" {
masterworkTaken = true
}
}
if len(takenItems) == 0 {
return
}
// Credit player
p.euro.Credit(userID, float64(totalPayout), "robbie_handling_fee")
// Donate to community pot
if communityTotal > 0 {
communityPotAdd(int(communityTotal))
}
// Handle Get Out of Medical Debt Free card
hasCard := robbiePlayerHasCard(userID)
gaveCard := false
if masterworkTaken && !hasCard {
_ = addAdvInventoryItem(userID, AdvItem{
Name: "Get Out of Medical Debt Free",
Type: "card",
Tier: 0,
Value: 0,
})
gaveCard = true
}
// Update visit count
char, err := loadAdvCharacter(userID)
if err == nil {
char.RobbieVisitCount++
_ = saveAdvCharacter(char)
}
// Send DM
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
if err := p.SendDM(userID, dm); err != nil {
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
}
// Room announcement
gr := gamesRoom()
if gr != "" {
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
p.SendMessage(gr, announcement)
}
}
// ── Qualifying Items ─────────────────────────────────────────────────────────
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
var result []AdvItem
for _, item := range inv {
// Only gear with a slot (skip ores, wood, fruit, gems, fish, etc.)
if item.Slot == "" {
continue
}
// Never touch Arena gear or cards
if item.Type == "ArenaGear" || item.Type == "card" {
continue
}
eq, hasSlot := equip[item.Slot]
if !hasSlot {
continue
}
if item.Type == "MasterworkGear" {
// Take MW items only if equipped piece in same slot is also MW
// and has effective tier >= this item's effective tier.
if eq.Masterwork && advEffectiveTier(eq) >= float64(item.Tier)*1.25 {
result = append(result, item)
}
} else {
// Regular shop gear: take if item tier < equipped tier
if item.Tier < eq.Tier {
result = append(result, item)
}
}
}
return result
}
// ── Card Check ───────────────────────────────────────────────────────────────
func robbiePlayerHasCard(userID id.UserID) bool {
inv, err := loadAdvInventory(userID)
if err != nil {
return false
}
for _, item := range inv {
if item.Type == "card" && item.Name == "Get Out of Medical Debt Free" {
return true
}
}
return false
}
// ── DM Rendering ─────────────────────────────────────────────────────────────
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
var sb strings.Builder
// Opening
opening, _ := advPickFlavor(robbieOpenings, userID, "robbie_opening")
if strings.Contains(opening, "%d") {
opening = fmt.Sprintf(opening, total)
}
sb.WriteString(opening)
sb.WriteString("\n\n")
// Itemized list
sb.WriteString("Items collected:\n\n")
cardShownOnLine := false
for _, item := range items {
emoji := slotEmoji(item.Slot)
if item.Type == "MasterworkGear" {
sb.WriteString(fmt.Sprintf(" %s %s (Masterwork T%d) → €50", emoji, item.Name, item.Tier))
if gaveCard && !cardShownOnLine {
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
cardShownOnLine = true
}
} else {
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €50", emoji, item.Name, item.Tier))
}
sb.WriteByte('\n')
}
sb.WriteString(fmt.Sprintf("\nTotal left for you: €%d\n", total))
sb.WriteString("Everything else donated to the community pot. Good on ya.\n\n")
// Context line
if mwTaken {
if gaveCard {
sb.WriteString(robbieMasterworkGotCard)
} else {
sb.WriteString(robbieMasterworkAlreadyHas)
}
} else {
sb.WriteString(fmt.Sprintf(robbieAllShopGear, total))
}
sb.WriteString("\n\n")
// Closing
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
sb.WriteString(closing)
return sb.String()
}
// ── Room Announcement ────────────────────────────────────────────────────────
func renderRobbieRoomAnnouncement(name string, count int, total int64, mwTaken, gaveCard bool) string {
if mwTaken && gaveCard {
return fmt.Sprintf(robbieRoomMasterworkCard, name, total)
}
if mwTaken && !gaveCard {
return fmt.Sprintf(robbieRoomMasterworkAlreadyHas, name, total)
}
return fmt.Sprintf(robbieRoomStandard, name, count, total)
}

View File

@@ -60,9 +60,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
// Check if dead and ready to respawn
// Check if dead and ready to respawn (before babysit check so
// dead+babysitting characters don't stay stuck dead forever).
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
// Revive
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(&char); err != nil {
@@ -77,6 +77,16 @@ func (p *AdventurePlugin) sendMorningDMs() {
}
}
// Babysitting: auto-resolve daily action, skip DM
if char.BabysitActive {
if !char.Alive {
// Dead and not yet ready to respawn — skip babysit action
continue
}
p.runBabysitDaily(&char)
continue
}
// If still dead, send death status
if !char.Alive {
text := renderAdvDeathStatusDM(&char)
@@ -298,11 +308,19 @@ 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 died today (or yesterday — covering late-night deaths
// that span midnight), grant a grace period: no shame, no streak reset.
if char.LastDeathDate == today ||
char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
continue
}
// Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
@@ -365,6 +383,12 @@ func (p *AdventurePlugin) midnightReset() error {
return true
})
// Expire any rival challenges that went unanswered
p.expireRivalChallenges()
// Check babysitting service expirations
p.checkBabysitExpiry(chars)
return nil
}

View File

@@ -2,7 +2,10 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
@@ -36,66 +39,237 @@ func slotTitle(slot EquipmentSlot) string {
return strings.ToUpper(s[:1]) + s[1:]
}
// advShopOverview shows a compact category menu with current equipment and next upgrade.
func advShopOverview(equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
// ── Pending Interaction Types ───────────────────────────────────────────────
type advPendingShopCategory struct {
ShowAll bool
}
type advPendingShopItem struct {
Slot EquipmentSlot
ShowAll bool
}
type advPendingShopConfirm struct {
Slot EquipmentSlot
Tier int
}
// ── Session Tracking ────────────────────────────────────────────────────────
type advShopSession struct {
StartedAt time.Time
ItemsBought int
}
func (p *AdventurePlugin) shopSessionGet(userID id.UserID) *advShopSession {
if val, ok := p.shopSessions.Load(string(userID)); ok {
return val.(*advShopSession)
}
return nil
}
func (p *AdventurePlugin) shopSessionStart(userID id.UserID) {
if p.shopSessionGet(userID) == nil {
p.shopSessions.Store(string(userID), &advShopSession{
StartedAt: time.Now(),
})
}
}
func (p *AdventurePlugin) shopSessionBump(userID id.UserID) {
sess := p.shopSessionGet(userID)
if sess != nil {
sess.ItemsBought++
}
}
func (p *AdventurePlugin) shopSessionEnd(userID id.UserID) {
p.shopSessions.Delete(string(userID))
}
// ── Browse Timeout ──────────────────────────────────────────────────────────
// shopNudgeGen tracks a generation counter per user so only the latest nudge fires.
// Key: userID string, Value: *int64 (atomic counter)
func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) {
// Bump generation so any prior goroutine becomes stale.
var gen int64
if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); ok {
gen = val.(int64) + 1
}
p.shopSessions.Store(string(userID)+":nudge_gen", gen)
capturedGen := gen
go func() {
time.Sleep(2 * time.Minute)
// Check if this goroutine is still the latest.
if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); !ok || val.(int64) != capturedGen {
return
}
val, ok := p.pending.Load(string(userID))
if !ok {
return
}
pi, ok := val.(*advPendingInteraction)
if !ok || !strings.HasPrefix(pi.Type, "shop_") {
return
}
flavor, _ := advPickFlavor(luigiBrowseTimeout, userID, "luigi_browse")
p.SendDM(userID, fmt.Sprintf("*%s*", flavor))
}()
}
// ── Display: Luigi Greeting + Category Menu ─────────────────────────────────
func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string {
var sb strings.Builder
sb.WriteString("🛒 **Equipment Shop**\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
// Check if fully maxed out.
allMaxed := true
for _, slot := range allSlots {
current := equip[slot]
currentTier := 0
currentName := "None"
if current != nil {
currentTier = current.Tier
currentName = current.Name
eq := equip[slot]
if eq == nil || eq.Tier < 5 {
allMaxed = false
break
}
emoji := slotEmoji(slot)
title := slotTitle(slot)
// Find next upgrade.
defs := equipmentTiers[slot]
nextUpgrade := ""
for _, def := range defs {
if def.Tier > currentTier && def.Price > 0 {
nextUpgrade = fmt.Sprintf("Next: %s — €%.0f", def.Name, def.Price)
break
}
}
sb.WriteString(fmt.Sprintf("%s **%s** — %s (T%d)\n", emoji, title, currentName, currentTier))
if nextUpgrade != "" {
sb.WriteString(fmt.Sprintf(" %s\n", nextUpgrade))
} else {
sb.WriteString(" ✨ Maxed out!\n")
}
sb.WriteString("\n")
}
sb.WriteString("Browse a category: `!adventure shop <category>`\n")
sb.WriteString("Categories: `weapon` · `armor` · `helmet` · `boots` · `tool`")
if allMaxed {
flavor, _ := advPickFlavor(luigiMaxedOut, userID, "luigi_maxed")
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n\n*%s*", flavor))
return sb.String()
}
greet, _ := advPickFlavor(luigiGreetings, userID, "luigi_greet")
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n💰 Balance: €%.0f\n\n", balance))
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
sb.WriteString("🪖 Helmets 👢 Boots\n")
sb.WriteString("⛏️ Tools\n\n")
if showAll {
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
sb.WriteString(fmt.Sprintf("*%s*\n\n", flavor))
}
sb.WriteString("Reply with a category name to browse.")
return sb.String()
}
var shopCategoryAliases = map[string]EquipmentSlot{
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
"armor": SlotArmor, "armour": SlotArmor,
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
"boots": SlotBoots, "boot": SlotBoots,
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
}
// ── Display: Category Item List ─────────────────────────────────────────────
// advParseShopCategory maps user input to an EquipmentSlot.
func advParseShopCategory(input string) EquipmentSlot {
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
}
// advShopCategory shows detailed listings for a single equipment slot.
func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
func luigiCategoryView(userID id.UserID, slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string {
var sb strings.Builder
current := equip[slot]
currentTier := 0
currentName := "None"
isMWOrArena := false
if current != nil {
currentTier = current.Tier
currentName = current.Name
isMWOrArena = current.Masterwork || current.ArenaTier > 0
}
emoji := slotEmoji(slot)
title := strings.ToUpper(string(slot))
// Category intro
if intros, ok := luigiCategoryIntros[slot]; ok && len(intros) > 0 {
intro, _ := advPickFlavor(intros, userID, "luigi_cat_"+string(slot))
sb.WriteString(fmt.Sprintf("*%s*\n\n", intro))
}
sb.WriteString(fmt.Sprintf("%s **%s** — Your current: %s (T%d)", emoji, title, currentName, currentTier))
if isMWOrArena {
sb.WriteString(" ⭐")
}
sb.WriteString("\n\n")
if isMWOrArena {
ack, _ := advPickFlavor(luigiMasterworkAck, userID, "luigi_mw_ack")
sb.WriteString(fmt.Sprintf("*%s*\n\n", ack))
}
defs := equipmentTiers[slot]
hasItems := false
// Show current equipped item first.
if current != nil {
for _, def := range defs {
if def.Tier == currentTier {
sb.WriteString(fmt.Sprintf("🟢 %-28s T%d €%.0f Currently equipped\n",
def.Name, def.Tier, def.Price))
break
}
}
}
for _, def := range defs {
if def.Price == 0 {
continue // skip tier 0
}
if def.Tier == currentTier {
continue // already shown as 🟢
}
if !showAll && def.Tier < currentTier {
continue // skip downgrades unless show all
}
hasItems = true
// Upgrade indicator
var indicator string
switch {
case def.Tier > currentTier:
indicator = "⬆️"
case def.Tier == currentTier:
indicator = "➡️"
default:
indicator = "⬇️"
}
// Affordability
afford := "✅"
if balance < def.Price {
afford = "💸"
}
// One-liner
oneLiner := ""
if line, ok := luigiItemOneLiners[luigiItemKey{slot, def.Tier}]; ok {
oneLiner = fmt.Sprintf(" \"%s\"", line)
}
sb.WriteString(fmt.Sprintf("%s %-28s T%d €%-8.0f %s%s\n",
indicator, def.Name, def.Tier, def.Price, afford, oneLiner))
}
// Occasional unprompted Luigi commentary (~30% chance).
if hasItems && rand.IntN(100) < 30 {
comment, _ := advPickFlavor(luigiCommentary, userID, "luigi_comment")
sb.WriteString(fmt.Sprintf("\n*%s*\n", comment))
}
if !hasItems {
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n")
}
if !showAll && currentTier > 1 {
sb.WriteString(fmt.Sprintf("\nTiers below T%d omitted. Use `!adventure shop show all` to see everything.\n", currentTier))
}
sb.WriteString("\nReply with an item name to buy, or \"back\" to return to categories.")
return sb.String()
}
// ── Display: Item Confirm ───────────────────────────────────────────────────
func luigiItemConfirm(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, current *AdvEquipment, balance float64) string {
var sb strings.Builder
currentTier := 0
currentName := "None"
if current != nil {
@@ -103,42 +277,272 @@ func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment,
currentName = current.Name
}
emoji := slotEmoji(slot)
title := slotTitle(slot)
sb.WriteString(fmt.Sprintf("%s **%s Shop**\n", emoji, title))
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
sb.WriteString(fmt.Sprintf("Equipped: **%s** (Tier %d)\n\n", currentName, currentTier))
defs := equipmentTiers[slot]
hasUpgrades := false
for _, def := range defs {
if def.Tier <= currentTier || def.Price == 0 {
continue
}
hasUpgrades = true
priceTag := fmt.Sprintf("€%.0f", def.Price)
if balance < def.Price {
priceTag += " 💸 can't afford"
} else {
priceTag += " ✅ affordable"
}
sb.WriteString(fmt.Sprintf("**Tier %d: %s** — %s\n", def.Tier, def.Name, priceTag))
sb.WriteString(fmt.Sprintf("%s\n\n", def.Description))
// Upgrade indicator
indicator := "⬆️"
if def.Tier == currentTier {
indicator = "➡️"
} else if def.Tier < currentTier {
indicator = "⬇️"
}
if !hasUpgrades {
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n\n")
sb.WriteString(fmt.Sprintf("%s **%s** — T%d %s — €%.0f\n\n", indicator, def.Name, def.Tier, slotTitle(slot), def.Price))
if def.Tier > currentTier {
sb.WriteString(fmt.Sprintf("An upgrade from your %s (T%d).\n", currentName, currentTier))
} else if def.Tier == currentTier {
sb.WriteString(fmt.Sprintf("Same tier as your current %s.\n", currentName))
} else {
sb.WriteString(fmt.Sprintf("A downgrade from your %s (T%d).\n", currentName, currentTier))
}
sb.WriteString("To buy: `!adventure buy <item name>` or `!adventure buy <tier> <category>`\n")
sb.WriteString("Example: `!adventure buy Enchanted Blade` or `!adventure buy 4 sword`\n")
sb.WriteString("Back to overview: `!adventure shop`")
// Full Luigi description
if desc, ok := luigiItemDescriptions[luigiItemKey{slot, def.Tier}]; ok {
sb.WriteString(fmt.Sprintf("\n\"%s\"\n", desc))
}
afterBalance := balance - def.Price
sb.WriteString(fmt.Sprintf("\nYour balance: €%.0f → €%.0f after purchase\n\n", balance, afterBalance))
sb.WriteString("Confirm? (yes / no)")
return sb.String()
}
// ── Resolver: Category Choice ───────────────────────────────────────────────
func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingShopCategory)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "back" || reply == "exit" || reply == "cancel" {
p.shopSessionEnd(ctx.Sender)
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
}
slot := advParseShopCategory(reply)
if slot == "" {
// Re-store pending and reprompt.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, or tools.")
}
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
text := luigiCategoryView(ctx.Sender, slot, equip, balance, data.ShowAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_item",
Data: &advPendingShopItem{Slot: slot, ShowAll: data.ShowAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
// ── Resolver: Item Choice ───────────────────────────────────────────────────
func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingShopItem)
reply := strings.TrimSpace(ctx.Body)
lower := strings.ToLower(reply)
if lower == "back" {
// Return to category menu.
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
if lower == "exit" || lower == "cancel" {
p.shopSessionEnd(ctx.Sender)
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
}
// Try to find the item scoped to the current slot first, then globally.
slot, def, found := advFindShopItemInSlot(reply, data.Slot)
if !found {
slot, def, found = advFindShopItem(reply)
}
if !found {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("I don't have anything matching \"%s\". Reply with an item name from the list, or \"back\" to return.", reply))
}
// Load fresh data.
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
current := equip[slot]
// Check Arena/Masterwork block — only block if shop item isn't clearly better.
// Arena gear always blocks (1.5x multiplier, earned through combat).
// Masterwork blocks only if the shop item's tier doesn't exceed the MW effective tier.
if current != nil && current.ArenaTier > 0 {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear) in that slot. A shop item can't replace that. You earned it.\n\nPick something else, or \"back\" to return.",
current.Name))
}
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Masterwork ⭐) in that slot. That T%d shop item isn't an upgrade over your T%d Masterwork.\n\nPick something else, or \"back\" to return.",
current.Name, def.Tier, current.Tier))
}
text := luigiItemConfirm(ctx.Sender, slot, def, current, balance)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_confirm",
Data: &advPendingShopConfirm{Slot: slot, Tier: def.Tier},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
// ── Resolver: Purchase Confirm ──────────────────────────────────────────────
func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingShopConfirm)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply != "yes" && reply != "y" && reply != "confirm" {
p.shopSessionEnd(ctx.Sender)
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
}
// Reload fresh equipment and balance (TOCTOU protection).
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
// Find the def fresh.
defs := equipmentTiers[data.Slot]
var def *EquipmentDef
for i := range defs {
if defs[i].Tier == data.Tier {
def = &defs[i]
break
}
}
if def == nil {
return p.SendDM(ctx.Sender, "Something went wrong. Item not found.")
}
current := equip[data.Slot]
// Re-check Arena/Masterwork block.
if current != nil && current.ArenaTier > 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** is Arena gear. Luigi won't overwrite it.", current.Name))
}
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Masterwork ⭐) is better than that T%d shop item.", current.Name, def.Tier))
}
// Affordability.
if balance < def.Price {
flavor, _ := advPickFlavor(luigiInsufficientFunds, ctx.Sender, "luigi_broke")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, def.Price))
}
// Debit.
if !p.euro.Debit(ctx.Sender, def.Price, "adventure_shop_"+string(data.Slot)) {
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
}
// Move old gear to inventory before replacing.
if current != nil && current.Tier > 0 {
itemType := "ShopGear"
var resaleValue int64
if current.Masterwork {
itemType = "MasterworkGear"
// Masterwork has no resale value (it's special, not shop-priced)
} else if current.Tier < len(equipmentTiers[data.Slot]) {
resaleValue = int64(equipmentTiers[data.Slot][current.Tier].Price * 0.3)
}
err := addAdvInventoryItem(ctx.Sender, AdvItem{
Name: current.Name,
Type: itemType,
Tier: current.Tier,
Value: resaleValue,
Slot: data.Slot,
SkillSource: current.SkillSource,
})
if err != nil {
slog.Error("shop: failed to move old gear to inventory", "user", ctx.Sender, "slot", data.Slot, "err", err)
}
}
// Save new equipment.
eq := &AdvEquipment{
Slot: data.Slot,
Tier: def.Tier,
Condition: 100,
Name: def.Name,
ActionsUsed: 0,
}
if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
// Refund on DB error.
p.euro.Credit(ctx.Sender, def.Price, "adventure_shop_refund")
return p.SendDM(ctx.Sender, "Something went wrong saving your equipment. You've been refunded.")
}
// Build confirmation message.
p.shopSessionBump(ctx.Sender)
var confirmText string
sess := p.shopSessionGet(ctx.Sender)
isCombo := sess != nil && sess.ItemsBought >= 3
if isCombo {
flavor, _ := advPickFlavor(luigiComboConfirm, ctx.Sender, "luigi_combo")
confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
} else if def.Tier == 5 {
flavor, _ := advPickFlavor(luigiTier5Confirm, ctx.Sender, "luigi_t5")
confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
} else {
flavor, _ := advPickFlavor(luigiPurchaseConfirm, ctx.Sender, "luigi_buy")
confirmText = fmt.Sprintf(flavor, def.Name, int(def.Price))
}
var sb strings.Builder
sb.WriteString(confirmText)
sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
if current != nil && current.Tier > 0 {
sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
}
sb.WriteString(fmt.Sprintf("\nBalance: €%.0f", balance-def.Price))
return p.SendDM(ctx.Sender, sb.String())
}
// ── Find Shop Item ───────────────────────────────────────────────────────────
// normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents.
@@ -174,6 +578,22 @@ func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
return "", nil, false
}
// advFindShopItemInSlot finds an item scoped to a specific slot.
func advFindShopItemInSlot(name string, slot EquipmentSlot) (EquipmentSlot, *EquipmentDef, bool) {
name = normalizeQuotes(strings.TrimSpace(name))
for i := range equipmentTiers[slot] {
def := &equipmentTiers[slot][i]
if def.Price == 0 {
continue
}
if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) {
return slot, def, true
}
}
return "", nil, false
}
// advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots".
func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
lower := strings.ToLower(input)
@@ -213,7 +633,20 @@ func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
return "", nil, false
}
// ── Buy Equipment ────────────────────────────────────────────────────────────
var shopCategoryAliases = map[string]EquipmentSlot{
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
"armor": SlotArmor, "armour": SlotArmor,
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
"boots": SlotBoots, "boot": SlotBoots,
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
}
// advParseShopCategory maps user input to an EquipmentSlot.
func advParseShopCategory(input string) EquipmentSlot {
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
}
// ── Buy Equipment (legacy !adventure buy path) ──────────────────────────────
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
current := equip[slot]
@@ -221,35 +654,56 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.",
current.Name, current.Tier, def.Name, def.Tier)
}
// Block shop purchases that would overwrite arena or masterwork gear
if current != nil && (current.ArenaTier > 0 || current.Masterwork) {
label := "Masterwork"
if current.ArenaTier > 0 {
label = "Arena"
}
return fmt.Sprintf("You already have **%s** (%s gear). A shop item cannot replace this. "+
// Block shop purchases that would overwrite arena gear (always) or
// masterwork gear (only if shop item isn't clearly better).
if current != nil && current.ArenaTier > 0 {
return fmt.Sprintf("You already have **%s** (Arena gear). A shop item cannot replace this. "+
"You earned that. Don't throw it away.",
current.Name, label)
current.Name)
}
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
return fmt.Sprintf("You already have **%s** (Masterwork ⭐ T%d). That T%d shop item isn't an upgrade.",
current.Name, current.Tier, def.Tier)
}
balance := p.euro.GetBalance(userID)
if balance < def.Price {
return fmt.Sprintf("You cannot afford %s. You have €%.0f. %s costs €%.0f. "+
"The gap between these numbers is both mathematical and deeply personal. "+
"Go do something about it. Not gambling. You know what I mean.",
def.Name, balance, def.Name, def.Price)
flavor, _ := advPickFlavor(luigiInsufficientFunds, userID, "luigi_broke")
return fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, def.Price)
}
if !p.euro.Debit(userID, def.Price, "adventure_shop_"+string(slot)) {
return "Transaction failed. The economy is having a moment."
}
// Move old gear to inventory.
if current != nil && current.Tier > 0 {
itemType := "ShopGear"
var resaleValue int64
if current.Masterwork {
itemType = "MasterworkGear"
} else if current.Tier < len(equipmentTiers[slot]) {
resaleValue = int64(equipmentTiers[slot][current.Tier].Price * 0.3)
}
err := addAdvInventoryItem(userID, AdvItem{
Name: current.Name,
Type: itemType,
Tier: current.Tier,
Value: resaleValue,
Slot: slot,
SkillSource: current.SkillSource,
})
if err != nil {
slog.Error("shop: failed to move old gear to inventory", "user", userID, "slot", slot, "err", err)
}
}
// Update equipment
eq := &AdvEquipment{
Slot: slot,
Tier: def.Tier,
Condition: 100,
Name: def.Name,
Slot: slot,
Tier: def.Tier,
Condition: 100,
Name: def.Name,
ActionsUsed: 0,
}
if err := saveAdvEquipment(userID, eq); err != nil {
@@ -259,10 +713,20 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
}
equip[slot] = eq
return fmt.Sprintf("You have purchased **%s** for €%.0f.\n\n_%s_\n\n"+
"This is an upgrade over what you had. That bar was underground, but you cleared it. "+
"Equip it now before you do something stupid with the money instead.",
def.Name, def.Price, def.Description)
var sb strings.Builder
if def.Tier == 5 {
flavor, _ := advPickFlavor(luigiTier5Confirm, userID, "luigi_t5")
sb.WriteString(fmt.Sprintf(flavor, int(def.Price), def.Name))
} else {
flavor, _ := advPickFlavor(luigiPurchaseConfirm, userID, "luigi_buy")
sb.WriteString(fmt.Sprintf(flavor, def.Name, int(def.Price)))
}
sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
if current != nil && current.Tier > 0 {
sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
}
return sb.String()
}
// ── Sell Items ────────────────────────────────────────────────────────────────
@@ -280,7 +744,7 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
var sold int
var keptSpecial int
for _, item := range items {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
keptSpecial++
continue
}
@@ -318,7 +782,7 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string
// Fuzzy match
for _, item := range items {
if containsFold(item.Name, itemName) {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name)
}
if err := removeAdvInventoryItem(item.ID); err != nil {
@@ -352,6 +816,8 @@ func advInventoryDisplay(userID id.UserID) string {
sb.WriteString(fmt.Sprintf(" ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
} else if item.Type == "ArenaGear" {
sb.WriteString(fmt.Sprintf(" ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
} else if item.Type == "card" {
sb.WriteString(fmt.Sprintf(" 🃏 %s\n", item.Name))
} else {
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
total += item.Value

View File

@@ -0,0 +1,353 @@
package plugin
import (
"strings"
"testing"
)
// ── Display Tests ───────────────────────────────────────────────────────────
func TestLuigiShopGreeting_Basic(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 2, Condition: 100},
}
text := luigiShopGreeting("@user:test", equip, 5000, false)
if !strings.Contains(text, "Luigi's") {
t.Error("greeting should contain Luigi's")
}
if !strings.Contains(text, "€5000") {
t.Error("greeting should show balance")
}
if !strings.Contains(text, "Weapons") {
t.Error("greeting should show category grid")
}
if !strings.Contains(text, "Reply with a category") {
t.Error("greeting should prompt for category")
}
}
func TestLuigiShopGreeting_MaxedOut(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5, Condition: 100},
SlotArmor: {Tier: 5, Condition: 100},
SlotHelmet: {Tier: 5, Condition: 100},
SlotBoots: {Tier: 5, Condition: 100},
SlotTool: {Tier: 5, Condition: 100},
}
text := luigiShopGreeting("@user:test", equip, 99999, false)
// Should NOT show category grid.
if strings.Contains(text, "Reply with a category") {
t.Error("maxed-out greeting should not show category prompt")
}
// Should use maxed-out flavor.
if !strings.Contains(text, "fully kitted") {
t.Error("maxed-out greeting should mention being fully kitted")
}
}
func TestLuigiShopGreeting_ShowAll(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{}
text := luigiShopGreeting("@user:test", equip, 1000, true)
// Should contain a show-all comment.
if !strings.Contains(text, "judgment") && !strings.Contains(text, "thoroughness") {
t.Error("show all greeting should contain a show-all comment")
}
}
func TestLuigiCategoryView_UpgradeIndicators(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 2, Condition: 100, Name: "Dull Steel Sword of Mediocrity"},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
// Should show current equipped.
if !strings.Contains(text, "🟢") {
t.Error("should show 🟢 for currently equipped item")
}
// Should show upgrade indicators.
if !strings.Contains(text, "⬆️") {
t.Error("should show ⬆️ for items above current tier")
}
// Should NOT show T1 (below current tier) by default.
if strings.Contains(text, "Sad Iron Sword") {
t.Error("should not show items below current tier when showAll=false")
}
}
func TestLuigiCategoryView_ShowAll(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3, Condition: 100, Name: "Sword (It's Fine)"},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, true)
// Should show items below current tier.
if !strings.Contains(text, "Sad Iron Sword") {
t.Error("show all should include items below current tier")
}
// Below-tier items should show ⬇️.
if !strings.Contains(text, "⬇️") {
t.Error("show all should show ⬇️ for items below current tier")
}
}
func TestLuigiCategoryView_Affordability(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0, Condition: 100, Name: "Basic Ass Sword"},
}
// Can afford T1 (€100) but not T2 (€450).
text := luigiCategoryView("@user:test", SlotWeapon, equip, 200, false)
if !strings.Contains(text, "✅") {
t.Error("should show ✅ for affordable items")
}
if !strings.Contains(text, "💸") {
t.Error("should show 💸 for unaffordable items")
}
}
func TestLuigiCategoryView_MasterworkAck(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3, Condition: 100, Name: "MW Blade", Masterwork: true},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
if !strings.Contains(text, "⭐") {
t.Error("should show ⭐ for masterwork gear")
}
// The masterwork acknowledgement pool has varied text — just check it's referenced.
hasMWAck := strings.Contains(text, "Masterwork") || strings.Contains(text, "Arena gear") || strings.Contains(text, "can't beat that")
if !hasMWAck {
t.Error("should acknowledge masterwork/arena gear")
}
}
func TestLuigiCategoryView_MaxedSlot(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5, Condition: 100, Name: "Vorpal Sword"},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
if !strings.Contains(text, "max tier") {
t.Error("should show max-tier message when nothing to buy")
}
}
// ── Item Confirm Tests ──────────────────────────────────────────────<E29480><E29480><EFBFBD>───────
func TestLuigiItemConfirm_Upgrade(t *testing.T) {
def := &EquipmentDef{Name: "Silver Blade", Tier: 3, Price: 1500}
current := &AdvEquipment{Tier: 2, Name: "Dull Steel Sword"}
text := luigiItemConfirm("@user:test", SlotWeapon, def, current, 5000)
if !strings.Contains(text, "⬆️") {
t.Error("should show upgrade indicator")
}
if !strings.Contains(text, "upgrade from") {
t.Error("should mention it's an upgrade")
}
if !strings.Contains(text, "€5000") {
t.Error("should show current balance")
}
if !strings.Contains(text, "€3500") {
t.Error("should show balance after purchase")
}
if !strings.Contains(text, "Confirm?") {
t.Error("should prompt for confirmation")
}
}
func TestLuigiItemConfirm_NoCurrent(t *testing.T) {
def := &EquipmentDef{Name: "Sad Iron Sword", Tier: 1, Price: 100}
text := luigiItemConfirm("@user:test", SlotWeapon, def, nil, 1000)
if !strings.Contains(text, "upgrade from") {
t.Error("should say upgrade from None")
}
}
// ── Item Lookup Tests ──────────────────────────────────────────────<E29480><E29480>────────
func TestAdvFindShopItemInSlot_ExactMatch(t *testing.T) {
slot, def, ok := advFindShopItemInSlot("Enchanted Plate", SlotArmor)
if !ok {
t.Fatal("should find Enchanted Plate in armor slot")
}
if slot != SlotArmor {
t.Errorf("slot should be armor, got %s", slot)
}
if def.Tier != 4 {
t.Errorf("Enchanted Plate should be tier 4, got %d", def.Tier)
}
}
func TestAdvFindShopItemInSlot_WrongSlot(t *testing.T) {
_, _, ok := advFindShopItemInSlot("Dragonscale", SlotWeapon)
if ok {
t.Error("should not find armor item in weapon slot")
}
}
func TestAdvFindShopItemInSlot_PartialMatch(t *testing.T) {
slot, def, ok := advFindShopItemInSlot("Enchanted Blade", SlotWeapon)
if !ok {
t.Fatal("should find Enchanted Blade by partial match")
}
if slot != SlotWeapon {
t.Errorf("slot should be weapon, got %s", slot)
}
if def.Tier != 4 {
t.Errorf("Enchanted Blade should be tier 4, got %d", def.Tier)
}
}
func TestAdvFindShopItemInSlot_CaseInsensitive(t *testing.T) {
_, _, ok := advFindShopItemInSlot("enchanted blade", SlotWeapon)
if !ok {
t.Error("should find item case-insensitively")
}
}
func TestAdvFindShopItemInSlot_NoTier0(t *testing.T) {
_, _, ok := advFindShopItemInSlot("Basic Ass Sword", SlotWeapon)
if ok {
t.Error("should not find tier 0 items (price 0)")
}
}
func TestAdvFindShopItem_TierShorthand(t *testing.T) {
slot, def, ok := advFindShopItem("3 sword")
if !ok {
t.Fatal("should find via tier shorthand")
}
if slot != SlotWeapon {
t.Errorf("slot should be weapon, got %s", slot)
}
if def.Tier != 3 {
t.Errorf("tier should be 3, got %d", def.Tier)
}
}
func TestAdvFindShopItem_TierPrefix(t *testing.T) {
_, def, ok := advFindShopItem("tier 5 boots")
if !ok {
t.Fatal("should find via 'tier 5 boots'")
}
if def.Tier != 5 {
t.Errorf("tier should be 5, got %d", def.Tier)
}
}
func TestAdvFindShopItem_TShorthand(t *testing.T) {
_, def, ok := advFindShopItem("t4 armor")
if !ok {
t.Fatal("should find via 't4 armor'")
}
if def.Tier != 4 {
t.Errorf("tier should be 4, got %d", def.Tier)
}
}
// ── Flavor Coverage Tests ───────────────────────────────────────────────────
func TestLuigiItemDescriptions_Coverage(t *testing.T) {
for _, slot := range allSlots {
defs := equipmentTiers[slot]
for _, def := range defs {
if def.Price == 0 {
continue // skip tier 0
}
key := luigiItemKey{slot, def.Tier}
if _, ok := luigiItemDescriptions[key]; !ok {
t.Errorf("missing Luigi item description for %s tier %d (%s)", slot, def.Tier, def.Name)
}
if _, ok := luigiItemOneLiners[key]; !ok {
t.Errorf("missing Luigi one-liner for %s tier %d (%s)", slot, def.Tier, def.Name)
}
}
}
}
func TestLuigiCategoryIntros_Coverage(t *testing.T) {
for _, slot := range allSlots {
intros, ok := luigiCategoryIntros[slot]
if !ok || len(intros) == 0 {
t.Errorf("missing Luigi category intros for slot %s", slot)
}
}
}
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"luigiGreetings": luigiGreetings,
"luigiPurchaseConfirm": luigiPurchaseConfirm,
"luigiTier5Confirm": luigiTier5Confirm,
"luigiComboConfirm": luigiComboConfirm,
"luigiInsufficientFunds": luigiInsufficientFunds,
"luigiBrowseTimeout": luigiBrowseTimeout,
"luigiMaxedOut": luigiMaxedOut,
"luigiMasterworkAck": luigiMasterworkAck,
"luigiShowAllComment": luigiShowAllComment,
"luigiCommentary": luigiCommentary,
"luigiCancellation": luigiCancellation,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty", name)
}
}
}
// ── Format String Safety ────────────────────────────────────────────────────
func TestLuigiPurchaseConfirm_FormatStrings(t *testing.T) {
for i, tmpl := range luigiPurchaseConfirm {
// Should have %s then %d
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
if !result {
t.Errorf("luigiPurchaseConfirm[%d] missing %%s or %%d placeholder", i)
}
}
}
func TestLuigiTier5Confirm_FormatStrings(t *testing.T) {
for i, tmpl := range luigiTier5Confirm {
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
if !result {
t.Errorf("luigiTier5Confirm[%d] missing %%s or %%d placeholder", i)
}
}
}
func TestLuigiComboConfirm_FormatStrings(t *testing.T) {
for i, tmpl := range luigiComboConfirm {
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
if !result {
t.Errorf("luigiComboConfirm[%d] missing %%s or %%d placeholder", i)
}
}
}
// ── Tier Bounds Safety ──────────────────────────────────────────────────────
func TestEquipmentTiers_ConsistentIndexing(t *testing.T) {
for _, slot := range allSlots {
defs := equipmentTiers[slot]
for i, def := range defs {
if def.Tier != i {
t.Errorf("slot %s: tier %d at index %d (should match for safe direct indexing)", slot, def.Tier, i)
}
}
}
}
func TestEquipmentTiers_SixTiersPerSlot(t *testing.T) {
for _, slot := range allSlots {
defs := equipmentTiers[slot]
if len(defs) != 6 {
t.Errorf("slot %s: expected 6 tiers (0-5), got %d", slot, len(defs))
}
}
}

View File

@@ -5,7 +5,7 @@ import "testing"
func TestAdvEquipmentScore_Empty(t *testing.T) {
score := advEquipmentScore(map[EquipmentSlot]*AdvEquipment{})
if score != 0 {
t.Errorf("empty equipment should score 0, got %d", score)
t.Errorf("empty equipment should score 0, got %.2f", score)
}
}
@@ -16,8 +16,8 @@ func TestAdvEquipmentScore_WeaponDoubled(t *testing.T) {
}
score := advEquipmentScore(equip)
// Weapon: 3*2=6, Armor: 3
if score != 9 {
t.Errorf("got %d, want 9 (weapon 6 + armor 3)", score)
if score != 9.0 {
t.Errorf("got %.2f, want 9 (weapon 6 + armor 3)", score)
}
}
@@ -27,8 +27,8 @@ func TestAdvEquipmentScore_LowConditionHalved(t *testing.T) {
}
score := advEquipmentScore(equip)
// Tier 4, halved = 2
if score != 2 {
t.Errorf("got %d, want 2 (tier 4 halved for low condition)", score)
if score != 2.0 {
t.Errorf("got %.2f, want 2 (tier 4 halved for low condition)", score)
}
}
@@ -42,8 +42,8 @@ func TestAdvEquipmentScore_FullLoadout(t *testing.T) {
}
score := advEquipmentScore(equip)
// Weapon: 5*2=10, others: 5*4=20, total=30
if score != 30 {
t.Errorf("got %d, want 30", score)
if score != 30.0 {
t.Errorf("got %.2f, want 30", score)
}
}

View File

@@ -0,0 +1,886 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"math"
"sort"
"strings"
"gogobee/internal/db"
)
// ─── Threshold Constants ────<E29480><E29480>──────────────────────────────────────
const (
// Communication
thNovelistAvgWords = 7
thNovelistMinMsgs = 100
thMinimalistMaxAvgWords = 4
thMinimalistMinMsgs = 50
thInquisitorPct = 8
thInquisitorMinMsgs = 50
thEnthusiastPct = 8
thEnthusiastMinMsgs = 50
thChatterboxMinMsgs = 500
thChatterboxMinAvgWords = 5
thLinkmasterPct = 5
thLinkmasterMinMsgs = 50
// Temporal
thNightOwlPct = 40
thNightOwlMinMsgs = 100
thEarlyBirdPct = 40
thEarlyBirdMinMsgs = 100
// Emotional (LLM-gated)
thEmotionalMinClassified = 100
thCheerleaderPosPct = 50
thPhilosopherNeutPct = 40
thPhilosopherQPct = 10
thPhilosopherAvgWords = 8
thAgitatorNegPct = 30
thAgitatorMinMsgs = 200
thWildcardStdDev = 0.5
thWildcardMinClassified = 150
thHypeMachineExclPct = 20
thHypeMachinePosPct = 60
// Economy
thBrokeSpiritedMaxBalance = 100.0
thDegenerateMinLosses = 10
// Games
thSharkWinRate = 55
thSharkMinGames = 15
thWordleMinPuzzles = 10
thArenaChampMinTier = 4
thArenaChampWinRate = 50
thArenaCowardMinRuns = 3
thArenaCowardMaxAvgTier = 2
thTriviaNerdMinCorrect = 10
// Adventure
thAdvMinDays = 10
thAdvDiverseMinDays = 15
thAdvDiverseMinTypes = 3
thAdvDiverseMaxShare = 40
thGearheadMinMasterwork = 3
// Communication (vocabulary)
thWordsmithMinFancyWords = 10
// Social
thPatronMinRepGiven = 5
thPatronRatioMultiplier = 2
// Display
maxDisplayArchetypes = 6
)
// ─── Flavor Text ─────<E29480><E29480><EFBFBD>─────────────────────────────────────────────
var archetypeFlavors = map[string]string{
// Communication
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
"Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.",
"Linkmaster": "The community's unofficial curator. Their tab count is not your business.",
"Wordsmith": "Uses words most people have to look up. The thesaurus fears them.",
// Temporal
"Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.",
"Early Bird": "Up before anyone else. Has already formed opinions about the day.",
// Emotional
"Cheerleader": "Lifts the room. Probably the reason someone didn't log off that one time.",
"Philosopher": "Thinks out loud. Asks questions that don't have comfortable answers.",
"Agitator": "Keeps things interesting. You don't always agree but you're never bored.",
"Wildcard": "You never quite know what you're getting. That's the charm. Mostly.",
"Hype Machine": "Arrived and immediately made everything louder. The room is better for it.",
// Economy
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
"Broke But Spirited": "Down but not out. The pot fears them anyway.",
// Games
"Shark": "Wins more than they should. GogoBee has opinions about this.",
"Wordle Devotee": "Shows up every day. Rain or shine. Five letters at a time.",
"Arena Champion": "Walks into the arena and the monsters check their insurance.",
"Arena Coward": "Shows up. Wins a little. Leaves immediately. Smart, actually.",
"Trivia Nerd": "Has retained an unreasonable amount of information.",
// Adventure
"Dungeon Crawler": "Always in the dungeon. The dungeon knows their name by now.",
"The Miner": "Steady. Patient. Has more ore than anyone needs. Still mining.",
"The Forager": "Out in the field while everyone else is underground. Knows where the good stuff is.",
"The Angler": "Patient beyond reason. Has caught things nobody else has caught.",
"The Merchant": "Buys. Sells. Optimizes. The economy is a puzzle and they're solving it.",
"Resting Face": "Technically participating. The numbers are still going up. Good enough.",
"The Adventurer": "Does a bit of everything. Refuses to specialize. Somehow works.",
"Gearhead": "Has the good stuff. Spent the time getting it. You can tell.",
// Social
"Patron": "Gives credit generously. Asks for nothing in return. Suspicious, honestly.",
"Reactor": "Expresses everything through emoji. An entire emotional life, no words required.",
// Fallback
"Regular": "Shows up. Participates. The backbone of any community, honestly.",
}
// ─<><E29480>─ Mutual Exclusions ────<E29480><E29480>────────────────────────────────────────
var mutualExclusions = [][2]string{
{"Novelist", "Minimalist"},
{"Early Bird", "Night Owl"},
{"Arena Champion", "Arena Coward"},
{"Whale", "Broke But Spirited"},
}
// ─── Core Types ────────────<E29480><E29480><EFBFBD>───────────────────────────────────────
type archetypeResult struct {
Name string
Category string
SignalScore float64
Flavor string
}
// ─── Community Percentiles ──────────────────────<E29480><E29480><EFBFBD>──────────────────
type communityPercentiles struct {
whaleBalanceThreshold float64 // top 10%
repGivenP75 int // top 25% rep given
reactionsGivenP75 int // top 25% reactions given
medianMsgCount int // median total_messages
}
func computePercentiles(d *sql.DB) communityPercentiles {
var p communityPercentiles
// Whale: top 10% balance
var userCount int
d.QueryRow(`SELECT COUNT(*) FROM euro_balances WHERE balance > 0`).Scan(&userCount)
offset := userCount / 10
if offset < 1 {
offset = 1
}
d.QueryRow(`SELECT balance FROM euro_balances ORDER BY balance DESC LIMIT 1 OFFSET ?`, offset).Scan(&p.whaleBalanceThreshold)
// Rep given: top 25%
repGiven := queryIntList(d, `SELECT COUNT(*) FROM rep_cooldowns GROUP BY giver ORDER BY COUNT(*) DESC`)
p.repGivenP75 = percentile75(repGiven)
// Reactions given: top 25%
reactionsGiven := queryIntList(d, `SELECT COUNT(*) FROM reaction_log GROUP BY sender ORDER BY COUNT(*) DESC`)
p.reactionsGivenP75 = percentile75(reactionsGiven)
// Median message count
msgCounts := queryIntList(d, `SELECT total_messages FROM user_stats ORDER BY total_messages`)
if len(msgCounts) > 0 {
p.medianMsgCount = msgCounts[len(msgCounts)/2]
}
return p
}
func queryIntList(d *sql.DB, query string) []int {
rows, err := d.Query(query)
if err != nil {
return nil
}
defer rows.Close()
var result []int
for rows.Next() {
var v int
if rows.Scan(&v) == nil {
result = append(result, v)
}
}
return result
}
func percentile75(sorted []int) int {
if len(sorted) == 0 {
return 0
}
idx := len(sorted) / 4 // top 25% = first quarter of desc-sorted list
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
// ─── Per-User Data Loaders ──────────────────────<E29480><E29480>──────────────────
type userData struct {
userID string
// user_stats
totalMsgs int
totalWords int
totalLinks int
totalImages int
totalQuestions int
totalExcl int
totalEmojis int
nightMsgs int
morningMsgs int
fancyWords int
// sentiment_stats
sentPositive int
sentNegative int
sentNeutral int
// llm_classifications
classifiedCount int
sentVariance float64
// economy
balance float64
gamblingLosses int
recentGaming bool
// games
bjPlayed, bjWon int
hmPlayed, hmWon int
holdemPlayed int
holdemNetPositive bool
unoSoloPlayed, unoSoloWon int
unoMultiPlayed, unoMultiWon int
wordlePlayed int
triviaCorrect int
// arena
arenaHighestTier int
arenaRuns int
arenaWins int
arenaAvgCashTier float64
// adventure
advDays int
advActivities map[string]int // activity_type -> count
masterworkCount int
// social
repGiven int
repReceived int
reactionsGiven int
}
func loadUserData(d *sql.DB, userID string) userData {
u := userData{userID: userID, advActivities: make(map[string]int)}
// user_stats
d.QueryRow(`SELECT total_messages, total_words, total_links, total_images,
total_questions, total_exclamations, total_emojis, night_messages, morning_messages,
COALESCE(fancy_words, 0)
FROM user_stats WHERE user_id = ?`, userID).Scan(
&u.totalMsgs, &u.totalWords, &u.totalLinks, &u.totalImages,
&u.totalQuestions, &u.totalExcl, &u.totalEmojis, &u.nightMsgs, &u.morningMsgs,
&u.fancyWords)
// sentiment_stats
d.QueryRow(`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0)
FROM sentiment_stats WHERE user_id = ?`, userID).Scan(
&u.sentPositive, &u.sentNegative, &u.sentNeutral)
// llm_classifications count + variance
d.QueryRow(`SELECT COUNT(*) FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.classifiedCount)
if u.classifiedCount > 1 {
d.QueryRow(`SELECT AVG(sentiment_score * sentiment_score) - AVG(sentiment_score) * AVG(sentiment_score)
FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.sentVariance)
if u.sentVariance < 0 {
u.sentVariance = 0
}
}
// economy
d.QueryRow(`SELECT COALESCE(balance, 0) FROM euro_balances WHERE user_id = ?`, userID).Scan(&u.balance)
// Count actual gambling losses (not bets/antes which are always negative)
d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? AND amount < 0
AND reason IN ('holdem_loss')`,
userID).Scan(&u.gamblingLosses)
// Add blackjack losses (games played minus games won)
var bjLosses int
d.QueryRow(`SELECT MAX(COALESCE(games_played,0) - COALESCE(games_won,0), 0) FROM blackjack_scores WHERE user_id = ?`,
userID).Scan(&bjLosses)
u.gamblingLosses += bjLosses
var recentCount int
d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ?
AND created_at > datetime('now', '-30 days')
AND reason IN ('blackjack_bet','blackjack_win','holdem_loss','holdem_win',
'uno_wager','uno_win','uno_multi_ante','uno_multi_win')`,
userID).Scan(&recentCount)
u.recentGaming = recentCount > 0
// blackjack
d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM blackjack_scores WHERE user_id = ?`,
userID).Scan(&u.bjPlayed, &u.bjWon)
// hangman
d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM hangman_scores WHERE user_id = ?`,
userID).Scan(&u.hmPlayed, &u.hmWon)
// holdem
var totalWon, totalLost int
d.QueryRow(`SELECT COALESCE(hands_played,0), COALESCE(total_won,0), COALESCE(total_lost,0) FROM holdem_scores WHERE user_id = ?`,
userID).Scan(&u.holdemPlayed, &totalWon, &totalLost)
u.holdemNetPositive = totalWon > totalLost
// uno solo
d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result='player_win' THEN 1 ELSE 0 END), 0) FROM uno_games WHERE player_id = ?`,
userID).Scan(&u.unoSoloPlayed, &u.unoSoloWon)
// uno multi
d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE player_ids LIKE ?`,
"%"+userID+"%").Scan(&u.unoMultiPlayed)
d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE winner_id = ?`,
userID).Scan(&u.unoMultiWon)
// wordle
d.QueryRow(`SELECT COALESCE(puzzles_played,0) FROM wordle_stats WHERE user_id = ?`,
userID).Scan(&u.wordlePlayed)
// trivia
d.QueryRow(`SELECT COALESCE(SUM(correct),0) FROM trivia_scores WHERE user_id = ?`,
userID).Scan(&u.triviaCorrect)
// arena
d.QueryRow(`SELECT COALESCE(highest_tier,0), COALESCE(total_runs,0) FROM arena_stats WHERE user_id = ?`,
userID).Scan(&u.arenaHighestTier, &u.arenaRuns)
d.QueryRow(`SELECT COUNT(*) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`,
userID).Scan(&u.arenaWins)
d.QueryRow(`SELECT COALESCE(AVG(tier), 0) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`,
userID).Scan(&u.arenaAvgCashTier)
// adventure
d.QueryRow(`SELECT COUNT(DISTINCT DATE(logged_at)) FROM adventure_activity_log WHERE user_id = ?`,
userID).Scan(&u.advDays)
rows, err := d.Query(`SELECT activity_type, COUNT(*) FROM adventure_activity_log WHERE user_id = ? GROUP BY activity_type`, userID)
if err == nil {
defer rows.Close()
for rows.Next() {
var atype string
var cnt int
if rows.Scan(&atype, &cnt) == nil {
u.advActivities[atype] = cnt
}
}
}
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id = ? AND masterwork = 1`,
userID).Scan(&u.masterworkCount)
// social
d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE giver = ?`, userID).Scan(&u.repGiven)
d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE receiver = ?`, userID).Scan(&u.repReceived)
d.QueryRow(`SELECT COUNT(*) FROM reaction_log WHERE sender = ?`, userID).Scan(&u.reactionsGiven)
return u
}
// <20><><EFBFBD>── Archetype Evaluators ────────<E29480><E29480><EFBFBD>─────────────────────────────────
func evaluateArchetypes(u userData, pct communityPercentiles) []archetypeResult {
var results []archetypeResult
m := max1arch(u.totalMsgs)
// ── Communication Style ──
avgWords := u.totalWords / m
if u.totalMsgs >= thNovelistMinMsgs && avgWords >= thNovelistAvgWords {
results = append(results, archetypeResult{
Name: "Novelist", Category: "Communication",
SignalScore: clampSignal(float64(avgWords-thNovelistAvgWords) / float64(thNovelistAvgWords)),
})
}
if u.totalMsgs >= thMinimalistMinMsgs && avgWords <= thMinimalistMaxAvgWords {
results = append(results, archetypeResult{
Name: "Minimalist", Category: "Communication",
SignalScore: clampSignal(float64(thMinimalistMaxAvgWords-avgWords+1) / float64(thMinimalistMaxAvgWords)),
})
}
qPct := u.totalQuestions * 100 / m
if u.totalMsgs >= thInquisitorMinMsgs && qPct >= thInquisitorPct {
results = append(results, archetypeResult{
Name: "Inquisitor", Category: "Communication",
SignalScore: clampSignal(float64(qPct-thInquisitorPct) / float64(thInquisitorPct)),
})
}
ePct := u.totalExcl * 100 / m
if u.totalMsgs >= thEnthusiastMinMsgs && ePct >= thEnthusiastPct {
results = append(results, archetypeResult{
Name: "Enthusiast", Category: "Communication",
SignalScore: clampSignal(float64(ePct-thEnthusiastPct) / float64(thEnthusiastPct)),
})
}
if u.totalMsgs >= thChatterboxMinMsgs && avgWords >= thChatterboxMinAvgWords {
results = append(results, archetypeResult{
Name: "Chatterbox", Category: "Communication",
SignalScore: clampSignal(float64(u.totalMsgs-thChatterboxMinMsgs) / float64(thChatterboxMinMsgs)),
})
}
lPct := u.totalLinks * 100 / m
if u.totalMsgs >= thLinkmasterMinMsgs && lPct >= thLinkmasterPct {
results = append(results, archetypeResult{
Name: "Linkmaster", Category: "Communication",
SignalScore: clampSignal(float64(lPct-thLinkmasterPct) / float64(thLinkmasterPct)),
})
}
if u.fancyWords >= thWordsmithMinFancyWords {
results = append(results, archetypeResult{
Name: "Wordsmith", Category: "Communication",
SignalScore: clampSignal(float64(u.fancyWords) / float64(thWordsmithMinFancyWords*3)),
})
}
// ── Temporal ──
if u.totalMsgs >= thNightOwlMinMsgs {
nightPct := u.nightMsgs * 100 / m
if nightPct >= thNightOwlPct {
results = append(results, archetypeResult{
Name: "Night Owl", Category: "Temporal",
SignalScore: clampSignal(float64(nightPct-thNightOwlPct) / float64(100-thNightOwlPct)),
})
}
}
if u.totalMsgs >= thEarlyBirdMinMsgs {
morningPct := u.morningMsgs * 100 / m
if morningPct >= thEarlyBirdPct {
results = append(results, archetypeResult{
Name: "Early Bird", Category: "Temporal",
SignalScore: clampSignal(float64(morningPct-thEarlyBirdPct) / float64(100-thEarlyBirdPct)),
})
}
}
// ── Emotional Signature (LLM-gated) ──
sentTotal := u.sentPositive + u.sentNegative + u.sentNeutral
if u.classifiedCount >= thEmotionalMinClassified && sentTotal > 0 {
posPct := u.sentPositive * 100 / sentTotal
negPct := u.sentNegative * 100 / sentTotal
neutPct := u.sentNeutral * 100 / sentTotal
// Cheerleader
if posPct >= thCheerleaderPosPct && u.repGiven >= pct.repGivenP75 && u.reactionsGiven >= pct.reactionsGivenP75 {
results = append(results, archetypeResult{
Name: "Cheerleader", Category: "Emotional",
SignalScore: clampSignal(float64(posPct-thCheerleaderPosPct) / float64(100-thCheerleaderPosPct)),
})
}
// Philosopher
if neutPct >= thPhilosopherNeutPct && qPct >= thPhilosopherQPct && avgWords >= thPhilosopherAvgWords {
results = append(results, archetypeResult{
Name: "Philosopher", Category: "Emotional",
SignalScore: clampSignal(float64(neutPct-thPhilosopherNeutPct) / float64(100-thPhilosopherNeutPct)),
})
}
// Agitator
if negPct >= thAgitatorNegPct && u.totalMsgs >= thAgitatorMinMsgs {
results = append(results, archetypeResult{
Name: "Agitator", Category: "Emotional",
SignalScore: clampSignal(float64(negPct-thAgitatorNegPct) / float64(100-thAgitatorNegPct)),
})
}
// Hype Machine
if ePct >= thHypeMachineExclPct && posPct >= thHypeMachinePosPct && u.reactionsGiven >= pct.reactionsGivenP75 {
results = append(results, archetypeResult{
Name: "Hype Machine", Category: "Emotional",
SignalScore: clampSignal((float64(ePct-thHypeMachineExclPct)/float64(thHypeMachineExclPct) +
float64(posPct-thHypeMachinePosPct)/float64(100-thHypeMachinePosPct)) / 2),
})
}
}
// Wildcard (separate min classified threshold)
if u.classifiedCount >= thWildcardMinClassified {
stddev := math.Sqrt(u.sentVariance)
if stddev >= thWildcardStdDev {
results = append(results, archetypeResult{
Name: "Wildcard", Category: "Emotional",
SignalScore: clampSignal((stddev - thWildcardStdDev) / thWildcardStdDev),
})
}
}
// ── Economy ──
if pct.whaleBalanceThreshold > 0 && u.balance >= pct.whaleBalanceThreshold {
results = append(results, archetypeResult{
Name: "Whale", Category: "Economy",
SignalScore: clampSignal(u.balance / pct.whaleBalanceThreshold / 2),
})
}
if u.gamblingLosses >= thDegenerateMinLosses && u.recentGaming {
results = append(results, archetypeResult{
Name: "Degenerate", Category: "Economy",
SignalScore: clampSignal(float64(u.gamblingLosses) / float64(thDegenerateMinLosses*3)),
})
}
if u.balance < thBrokeSpiritedMaxBalance && u.recentGaming {
results = append(results, archetypeResult{
Name: "Broke But Spirited", Category: "Economy",
SignalScore: clampSignal((thBrokeSpiritedMaxBalance - u.balance) / thBrokeSpiritedMaxBalance),
})
}
// ── Games ──
totalGames := u.bjPlayed + u.hmPlayed + u.unoSoloPlayed + u.unoMultiPlayed
totalWins := u.bjWon + u.hmWon + u.unoSoloWon + u.unoMultiWon
if u.holdemPlayed > 0 {
totalGames += u.holdemPlayed
if u.holdemNetPositive {
totalWins += u.holdemPlayed / 2 // approximate: net positive counts as ~50% wins
}
}
if totalGames >= thSharkMinGames {
winRate := totalWins * 100 / totalGames
if winRate >= thSharkWinRate {
results = append(results, archetypeResult{
Name: "Shark", Category: "Games",
SignalScore: clampSignal(float64(winRate-thSharkWinRate) / float64(100-thSharkWinRate)),
})
}
}
if u.wordlePlayed >= thWordleMinPuzzles {
results = append(results, archetypeResult{
Name: "Wordle Devotee", Category: "Games",
SignalScore: clampSignal(float64(u.wordlePlayed-thWordleMinPuzzles) / float64(thWordleMinPuzzles)),
})
}
if u.arenaHighestTier >= thArenaChampMinTier && u.arenaRuns > 0 {
arenaWinRate := u.arenaWins * 100 / max1arch(u.arenaRuns)
if arenaWinRate >= thArenaChampWinRate {
results = append(results, archetypeResult{
Name: "Arena Champion", Category: "Games",
SignalScore: clampSignal(float64(u.arenaHighestTier-thArenaChampMinTier+1) / 2.0),
})
}
}
if u.arenaWins >= thArenaCowardMinRuns && u.arenaAvgCashTier > 0 && u.arenaAvgCashTier <= float64(thArenaCowardMaxAvgTier) {
results = append(results, archetypeResult{
Name: "Arena Coward", Category: "Games",
SignalScore: clampSignal((float64(thArenaCowardMaxAvgTier) - u.arenaAvgCashTier + 1) / float64(thArenaCowardMaxAvgTier)),
})
}
if u.triviaCorrect >= thTriviaNerdMinCorrect {
results = append(results, archetypeResult{
Name: "Trivia Nerd", Category: "Games",
SignalScore: clampSignal(float64(u.triviaCorrect-thTriviaNerdMinCorrect) / float64(thTriviaNerdMinCorrect)),
})
}
// ── Adventure ──
if u.advDays >= thAdvMinDays {
totalAdv := 0
for _, c := range u.advActivities {
totalAdv += c
}
if totalAdv > 0 {
// Find plurality activity
var topActivity string
var topCount int
for act, cnt := range u.advActivities {
if cnt > topCount {
topActivity = act
topCount = cnt
}
}
topShare := topCount * 100 / totalAdv
activityArchetypes := map[string]string{
"dungeon": "Dungeon Crawler",
"mining": "The Miner",
"foraging": "The Forager",
"fishing": "The Angler",
"shop": "The Merchant",
"rest": "Resting Face",
}
// Check for diverse adventurer first
if len(u.advActivities) >= thAdvDiverseMinTypes && u.advDays >= thAdvDiverseMinDays && topShare <= thAdvDiverseMaxShare {
results = append(results, archetypeResult{
Name: "The Adventurer", Category: "Adventure",
SignalScore: clampSignal(float64(len(u.advActivities)) / float64(thAdvDiverseMinTypes+2)),
})
} else if name, ok := activityArchetypes[topActivity]; ok {
results = append(results, archetypeResult{
Name: name, Category: "Adventure",
SignalScore: clampSignal(float64(topShare) / 100.0),
})
}
}
}
if u.masterworkCount >= thGearheadMinMasterwork {
results = append(results, archetypeResult{
Name: "Gearhead", Category: "Adventure",
SignalScore: clampSignal(float64(u.masterworkCount) / float64(thGearheadMinMasterwork*2)),
})
}
// ── Social ──
if u.repGiven >= thPatronMinRepGiven {
repReceived := max1arch(u.repReceived)
if u.repGiven >= repReceived*thPatronRatioMultiplier {
results = append(results, archetypeResult{
Name: "Patron", Category: "Social",
SignalScore: clampSignal(float64(u.repGiven) / float64(thPatronMinRepGiven*3)),
})
}
}
if u.reactionsGiven >= pct.reactionsGivenP75 && pct.reactionsGivenP75 > 0 && u.totalMsgs <= pct.medianMsgCount {
results = append(results, archetypeResult{
Name: "Reactor", Category: "Social",
SignalScore: clampSignal(float64(u.reactionsGiven) / float64(pct.reactionsGivenP75*2)),
})
}
// Fill in flavor text
for i := range results {
results[i].Flavor = archetypeFlavors[results[i].Name]
}
// Apply mutual exclusions
results = applyExclusions(results)
// Sort by signal score desc
sort.Slice(results, func(i, j int) bool {
return results[i].SignalScore > results[j].SignalScore
})
return results
}
func applyExclusions(results []archetypeResult) []archetypeResult {
nameIdx := make(map[string]int)
for i, r := range results {
nameIdx[r.Name] = i
}
remove := make(map[int]bool)
for _, pair := range mutualExclusions {
idxA, hasA := nameIdx[pair[0]]
idxB, hasB := nameIdx[pair[1]]
if hasA && hasB {
if results[idxA].SignalScore >= results[idxB].SignalScore {
remove[idxB] = true
} else {
remove[idxA] = true
}
}
}
if len(remove) == 0 {
return results
}
var filtered []archetypeResult
for i, r := range results {
if !remove[i] {
filtered = append(filtered, r)
}
}
return filtered
}
// ─── Cache Read/Write ────────<E29480><E29480>─────────────────────────────────────
// GetUserArchetypes returns cached archetypes for a user, sorted by signal_score desc.
func GetUserArchetypes(userID string) []archetypeResult {
d := db.Get()
rows, err := d.Query(`SELECT archetype, category, signal_score, flavor
FROM user_archetypes WHERE user_id = ? ORDER BY signal_score DESC`, userID)
if err != nil {
return nil
}
defer rows.Close()
var results []archetypeResult
for rows.Next() {
var r archetypeResult
if rows.Scan(&r.Name, &r.Category, &r.SignalScore, &r.Flavor) == nil {
results = append(results, r)
}
}
return results
}
// GetUserArchetypesLimited returns up to maxDisplayArchetypes for profile cards.
func GetUserArchetypesLimited(userID string) []archetypeResult {
results := GetUserArchetypes(userID)
if len(results) > maxDisplayArchetypes {
results = results[:maxDisplayArchetypes]
}
if len(results) == 0 {
return []archetypeResult{{
Name: "Regular", Category: "Fallback",
Flavor: archetypeFlavors["Regular"],
}}
}
return results
}
// ─── Refresh Engine ─────────────────────<E29480><E29480>──────────────────────────
// RefreshAllArchetypes recalculates archetypes for all users and writes to user_archetypes.
func RefreshAllArchetypes() {
d := db.Get()
// Get all user IDs
rows, err := d.Query(`SELECT user_id FROM user_stats WHERE total_messages > 0`)
if err != nil {
slog.Error("archetype: failed to list users", "err", err)
return
}
var userIDs []string
for rows.Next() {
var uid string
if rows.Scan(&uid) == nil {
userIDs = append(userIDs, uid)
}
}
rows.Close()
if len(userIDs) == 0 {
return
}
// Compute community percentiles once
pct := computePercentiles(d)
// Evaluate each user
type userResult struct {
userID string
archs []archetypeResult
}
var allResults []userResult
for _, uid := range userIDs {
u := loadUserData(d, uid)
archs := evaluateArchetypes(u, pct)
if len(archs) > 0 {
allResults = append(allResults, userResult{uid, archs})
}
}
// Write to cache in a transaction
tx, err := d.Begin()
if err != nil {
slog.Error("archetype: failed to begin tx", "err", err)
return
}
_, err = tx.Exec(`DELETE FROM user_archetypes`)
if err != nil {
tx.Rollback()
slog.Error("archetype: failed to clear cache", "err", err)
return
}
stmt, err := tx.Prepare(`INSERT INTO user_archetypes (user_id, archetype, category, signal_score, flavor)
VALUES (?, ?, ?, ?, ?)`)
if err != nil {
tx.Rollback()
slog.Error("archetype: failed to prepare insert", "err", err)
return
}
defer stmt.Close()
totalAssigned := 0
for _, ur := range allResults {
for _, a := range ur.archs {
if _, err := stmt.Exec(ur.userID, a.Name, a.Category, a.SignalScore, a.Flavor); err != nil {
slog.Error("archetype: insert failed", "user", ur.userID, "arch", a.Name, "err", err)
}
totalAssigned++
}
}
if err := tx.Commit(); err != nil {
slog.Error("archetype: commit failed", "err", err)
return
}
slog.Info("archetype: refresh complete", "users", len(allResults), "archetypes_assigned", totalAssigned)
}
// ─── Helpers ─────────<E29480><E29480><EFBFBD>─────────────────────────────────────────────
func max1arch(n int) int {
if n < 1 {
return 1
}
return n
}
func clampSignal(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// FormatArchetypeNames returns a " · " separated list of archetype names.
func FormatArchetypeNames(archs []archetypeResult) string {
if len(archs) == 0 {
return "Regular"
}
names := make([]string, len(archs))
for i, a := range archs {
names[i] = a.Name
}
return strings.Join(names, " · ")
}
// FormatArchetypesFull returns a full display with names and flavor text.
func FormatArchetypesFull(archs []archetypeResult) string {
if len(archs) == 0 {
return fmt.Sprintf("**Regular**\n_%s_", archetypeFlavors["Regular"])
}
var sb strings.Builder
for i, a := range archs {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("**%s**\n_%s_", a.Name, a.Flavor))
}
return sb.String()
}

View File

@@ -0,0 +1,367 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"gogobee/internal/dreamclient"
"maunium.net/go/mautrix"
)
// DictionaryPlugin provides extended dictionary commands using DreamDict.
type DictionaryPlugin struct {
Base
dict *dreamclient.Client
}
func NewDictionaryPlugin(client *mautrix.Client, dict *dreamclient.Client) *DictionaryPlugin {
return &DictionaryPlugin{
Base: NewBase(client),
dict: dict,
}
}
func (p *DictionaryPlugin) Name() string { return "dictionary" }
func (p *DictionaryPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "antonym", Description: "Look up antonyms for a word", Usage: "!antonym <word> [lang]", Category: "Lookup & Reference"},
{Name: "pronounce", Description: "Look up pronunciation of a word", Usage: "!pronounce <word> [lang]", Category: "Lookup & Reference"},
{Name: "etymology", Description: "Look up the etymology of a word", Usage: "!etymology <word> [lang]", Category: "Lookup & Reference"},
{Name: "difficulty", Description: "Show difficulty score for a word", Usage: "!difficulty <word> [lang]", Category: "Lookup & Reference"},
{Name: "rhyme", Description: "Find rhyming words (English)", Usage: "!rhyme <word>", Category: "Lookup & Reference"},
}
}
func (p *DictionaryPlugin) Init() error { return nil }
func (p *DictionaryPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *DictionaryPlugin) OnMessage(ctx MessageContext) error {
var handler func(MessageContext) error
switch {
case p.IsCommand(ctx.Body, "antonym"):
handler = p.handleAntonym
case p.IsCommand(ctx.Body, "pronounce"):
handler = p.handlePronounce
case p.IsCommand(ctx.Body, "etymology"):
handler = p.handleEtymology
case p.IsCommand(ctx.Body, "difficulty"):
handler = p.handleDifficulty
case p.IsCommand(ctx.Body, "rhyme"):
handler = p.handleRhyme
default:
return nil
}
go func() {
if err := handler(ctx); err != nil {
slog.Error("dictionary: handler error", "err", err)
}
}()
return nil
}
const dictUnavailable = "Dictionary service unavailable. Try again shortly."
const dictSupportedLangs = "en, fr, pt-PT, zh"
// parseWordLang extracts word and language from command args. Returns empty word if missing.
func parseWordLang(args string) (word, lang string) {
parts := strings.Fields(args)
if len(parts) == 0 {
return "", ""
}
word = strings.ToLower(parts[0])
lang = "en"
if len(parts) >= 2 {
if l := normaliseLangExt(parts[len(parts)-1]); l != "" {
lang = l
if len(parts) > 2 {
word = strings.ToLower(strings.Join(parts[:len(parts)-1], " "))
}
}
}
return word, lang
}
// normaliseLangExt normalises a language tag, including zh.
func normaliseLangExt(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
switch s {
case "en":
return "en"
case "fr":
return "fr"
case "pt-pt", "pt":
return "pt-PT"
case "zh":
return "zh"
default:
return ""
}
}
func (p *DictionaryPlugin) handleAntonym(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "antonym"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!antonym <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
// Check word exists.
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
langName := langDisplayName(lang)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langName))
}
ants, err := p.dict.Antonyms(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if len(ants) == 0 {
langName := langDisplayName(lang)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🔄 No antonyms found for \"**%s**\" in %s.", word, langName))
}
display := ants
more := ""
if len(display) > 6 {
more = fmt.Sprintf(" (+%d more)", len(display)-6)
display = display[:6]
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🔄 Antonyms of **%s** (%s): %s%s", word, lang, strings.Join(display, ", "), more))
}
func (p *DictionaryPlugin) handlePronounce(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "pronounce"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!pronounce <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
langName := langDisplayName(lang)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langName))
}
prons, err := p.dict.Pronunciations(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if len(prons) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🔊 No pronunciation data found for \"**%s**\" in %s.", word, langDisplayName(lang)))
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔊 **%s** (%s)\n", word, lang))
for _, pr := range prons {
label := strings.ToUpper(pr.Format)
if lang == "zh" && pr.Format == "ipa" {
label = "Pinyin"
}
sb.WriteString(fmt.Sprintf(" %s: %s\n", label, pr.Value))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *DictionaryPlugin) handleEtymology(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "etymology"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!etymology <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langDisplayName(lang)))
}
etym, err := p.dict.Etymology(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if etym == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📜 No etymology data found for \"**%s**\" in %s.", word, langDisplayName(lang)))
}
// Truncate at 400 chars for standalone command.
if len(etym) > 400 {
// Try to truncate at sentence boundary.
if idx := strings.LastIndex(etym[:400], "."); idx > 200 {
etym = etym[:idx+1] + "..."
} else {
etym = etym[:400] + "..."
}
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📜 Etymology of **%s** (%s)\n %s", word, lang, etym))
}
func (p *DictionaryPlugin) handleDifficulty(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "difficulty"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!difficulty <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langDisplayName(lang)))
}
diff, err := p.dict.Difficulty(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if diff < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📊 No difficulty score available for \"**%s**\" in %s.", word, langDisplayName(lang)))
}
tier := difficultyTier(diff)
// Get frequency for display.
freq, _ := p.dict.Frequency(word, lang)
freqLabel := frequencyLabel(freq)
wordLen := len([]rune(word))
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📊 **%s** (%s) -- %s (%.2f)\n Frequency: %s | Length: %d",
word, lang, tier, diff, freqLabel, wordLen))
}
func (p *DictionaryPlugin) handleRhyme(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "rhyme"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!rhyme <word>`\nNote: rhyme matching is English only.")
}
parts := strings.Fields(args)
word := strings.ToLower(parts[0])
showAll := false
for _, arg := range parts[1:] {
if arg == "--all" {
showAll = true
}
}
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
rhymes, err := p.dict.Rhymes(word, 50)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if len(rhymes) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🎵 No rhymes found for \"**%s**\". (This is not surprising.)", word))
}
display := rhymes
more := ""
if !showAll && len(display) > 7 {
more = fmt.Sprintf(" (+%d more)\n Use `!rhyme %s --all` to see all results", len(display)-7, word)
display = display[:7]
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🎵 Rhymes with **%s**: %s%s", word, strings.Join(display, ", "), more))
}
// difficultyTier maps a 0.0-1.0 score to a human-readable label.
func difficultyTier(score float64) string {
switch {
case score <= 0.25:
return "Easy"
case score <= 0.50:
return "Medium"
case score <= 0.75:
return "Hard"
default:
return "Brutal"
}
}
// frequencyLabel maps a frequency score to a display label.
func frequencyLabel(freq int) string {
switch {
case freq > 500:
return "high"
case freq >= 100:
return "medium"
case freq > 0:
return "low"
default:
return "unknown"
}
}
// langDisplayName returns a human-readable name for a language code.
func langDisplayName(lang string) string {
switch lang {
case "en":
return "English"
case "fr":
return "French"
case "pt-PT":
return "Portuguese"
case "zh":
return "Chinese"
default:
return lang
}
}

View File

@@ -121,15 +121,15 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
var amount float64
switch {
case words >= 51:
amount = 10.00
amount = 20.00
case words >= 26:
amount = 5.00
amount = 10.00
case words >= 11:
amount = 2.50
amount = 5.00
case words >= 4:
amount = 1.25
amount = 2.50
default:
amount = 0.50
amount = 1.00
}
p.ensureBalance(ctx.Sender)

View File

@@ -574,7 +574,7 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
// Pick a word, optionally with a translatable clue
var word, clueWord string
for attempt := 0; attempt < 3; attempt++ {
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
candidate, err := p.dict.RandomWord(lang, "", 4, 12, 0)
if err != nil {
slog.Error("hangman: random word failed", "lang", lang, "err", err)
rollback()
@@ -597,7 +597,7 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
if word == "" {
// Fallback: start without a clue
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
candidate, err := p.dict.RandomWord(lang, "", 4, 12, 0)
if err != nil {
rollback()
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
@@ -615,6 +615,14 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
p.games[ctx.RoomID] = game
p.mu.Unlock()
// Fetch difficulty tier from DreamDict.
diffLabel := ""
if p.dict != nil {
if diff, err := p.dict.Difficulty(strings.ToLower(word), lang); err == nil && diff >= 0 {
diffLabel = " (" + difficultyTier(diff) + ")"
}
}
// Build thread root
var sb strings.Builder
if clueLang != "" {
@@ -622,9 +630,9 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
if clueLangName == "" {
clueLangName = clueLang
}
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s** (clue: %s)\n", langName, clueLangName))
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s** (clue: %s)\n", langName, diffLabel, clueLangName))
} else {
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s**\n", langName))
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s**\n", langName, diffLabel))
}
sb.WriteString(fmt.Sprintf("Word: %s (%d letters)\n", game.displayPhrase(), len([]rune(word))))

View File

@@ -17,6 +17,7 @@ import (
"unicode"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -57,6 +58,7 @@ type queueItem struct {
type LLMPassivePlugin struct {
Base
xp *XPPlugin
dict *dreamclient.Client
ollamaHost string
ollamaModel string
sampleRate float64
@@ -71,7 +73,7 @@ type LLMPassivePlugin struct {
}
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin {
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
enabled := host != "" && model != ""
@@ -86,6 +88,7 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin
p := &LLMPassivePlugin{
Base: NewBase(client),
xp: xp,
dict: dict,
ollamaHost: host,
ollamaModel: model,
sampleRate: sampleRate,
@@ -154,6 +157,18 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
return nil
}
// Check for fancy words on every message (independent of LLM sampling)
if p.dict != nil {
go func() {
if p.hasFancyWord(ctx.Body) {
_ = p.SendReact(ctx.RoomID, ctx.EventID, "\U0001f393") // 🎓 graduation cap
db.Exec("llm: track fancy word",
`UPDATE user_stats SET fancy_words = fancy_words + 1 WHERE user_id = ?`,
string(ctx.Sender))
}
}()
}
// Pre-filter: only classify messages that match certain criteria
var fmtBody string
if ctx.Event != nil {
@@ -309,7 +324,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
mentionHint = "\nMentioned users: " + strings.Join(parts, ", ")
}
result, err := p.callOllama(item.Body + mentionHint)
// Fetch today's WOTD so the LLM can check for correct usage
today := time.Now().UTC().Format("2006-01-02")
var todayWOTD string
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
result, err := p.callOllama(item.Body+mentionHint, todayWOTD)
if err != nil {
return fmt.Errorf("ollama call: %w", err)
}
@@ -424,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
_ = 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
@@ -485,7 +478,12 @@ type ollamaResponse struct {
}
// callOllama sends a classification prompt to Ollama and parses the JSON result.
func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult, error) {
func (p *LLMPassivePlugin) callOllama(messageText, wotd string) (*classificationResult, error) {
wotdInstruction := `"wotd_used": false`
if wotd != "" {
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
}
prompt := fmt.Sprintf(`Classify the following chat message. Respond ONLY with valid JSON (no markdown, no explanation).
JSON schema:
@@ -496,11 +494,11 @@ JSON schema:
"profanity": true | false,
"profanity_severity": 0 | 1 | 2 | 3 (0=none, 1=mild e.g. damn/hell/crap, 2=moderate e.g. shit/ass/bitch, 3=scorching e.g. fuck/cunt and slurs),
"insult_target": "" or "@user:server" if someone is being insulted,
"wotd_used": true | false (if the message uses an unusual/sophisticated word),
%s,
"gratitude_target": "" or "@user:server" if thanking someone
}
Message: %s`, messageText)
Message: %s`, wotdInstruction, messageText)
reqBody := ollamaRequest{
Model: p.ollamaModel,
@@ -540,6 +538,63 @@ Message: %s`, messageText)
}
// parseClassification parses a JSON classification response with repair logic.
// fancyWordMaxFreq is the threshold below which a word is considered rare/sophisticated.
// SCOWL tiers: 10=1000, 20=800, 35=600, 50=400, 60=200, 70=50.
// A frequency of 100 or below corresponds to SCOWL tier 65-70 (rare words).
const fancyWordMaxFreq = 100
// fancyWordMinLength filters out short words that may just be uncommon abbreviations.
const fancyWordMinLength = 6
// fancyWordCache caches frequency lookups to avoid repeated requests.
var fancyWordCache sync.Map // word (string) -> freq (int)
// hasFancyWord checks if any word in the message is a rare/sophisticated English word
// by looking up frequency scores in DreamDict via a single batch request.
func (p *LLMPassivePlugin) hasFancyWord(message string) bool {
// Extract candidate words
var candidates []string
var cached []string
for _, w := range strings.Fields(strings.ToLower(message)) {
w = strings.Trim(w, ".,!?;:\"'()[]{}…—–-")
if len(w) < fancyWordMinLength {
continue
}
if strings.Contains(w, "://") || strings.HasPrefix(w, "@") {
continue
}
// Check cache first
if freq, ok := fancyWordCache.Load(w); ok {
if f := freq.(int); f > 0 && f <= fancyWordMaxFreq {
return true
}
cached = append(cached, w)
continue
}
candidates = append(candidates, w)
}
if len(candidates) == 0 {
return false
}
// Single batch request for all uncached words
freqs, err := p.dict.FrequencyBatch(candidates, "en")
if err != nil {
slog.Debug("hasFancyWord: batch lookup failed", "err", err)
return false
}
for _, w := range candidates {
freq := freqs[w] // 0 if not in result (unknown word)
fancyWordCache.Store(w, freq)
if freq > 0 && freq <= fancyWordMaxFreq {
return true
}
}
return false
}
func parseClassification(raw string) (*classificationResult, error) {
cleaned := raw

View File

@@ -39,7 +39,7 @@ func (p *LookupPlugin) Name() string { return "lookup" }
func (p *LookupPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
{Name: "define", Description: "Look up a word definition", Usage: "!define <word>", Category: "Lookup & Reference"},
{Name: "define", Description: "Look up a word definition", Usage: "!define <word> [lang]", Category: "Lookup & Reference"},
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
{Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate <word> [lang]", Category: "Lookup & Reference"},
}
@@ -129,68 +129,172 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
}
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if word == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
args := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word> [lang]")
}
// Check 24h cache
cacheKey := "define:" + strings.ToLower(word)
parts := strings.Fields(args)
word := parts[0]
var filterLang string
if len(parts) >= 2 {
filterLang = normaliseLangExt(parts[len(parts)-1])
if filterLang != "" && len(parts) > 2 {
word = strings.Join(parts[:len(parts)-1], " ")
} else if filterLang == "" {
// Last token isn't a lang — treat the whole thing as the word.
word = args
}
}
wordLower := strings.ToLower(word)
// Check 24h cache.
cacheKey := "define:" + wordLower
if filterLang != "" {
cacheKey += ":" + filterLang
}
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
resp, err := httpClient.Get(apiURL)
if err != nil {
slog.Error("lookup: define request", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
}
var entries []struct {
Word string `json:"word"`
Meanings []struct {
PartOfSpeech string `json:"partOfSpeech"`
Definitions []struct {
Definition string `json:"definition"`
Example string `json:"example"`
} `json:"definitions"`
} `json:"meanings"`
}
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
entry := entries[0]
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
foundDreamDict := false
for i, meaning := range entry.Meanings {
if i >= 3 { // Limit to 3 meanings
break
// Try DreamDict across languages.
if p.dict != nil {
langs := []string{"en", "fr", "pt-PT", "zh"}
if filterLang != "" {
langs = []string{filterLang}
}
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
for j, def := range meaning.Definitions {
if j >= 2 { // Limit to 2 definitions per meaning
// Fire antonym fetch concurrently (for whichever lang the word is found in).
type antResult struct {
ants []string
}
antCh := make(chan antResult, 1)
antStarted := false
for _, lang := range langs {
defs, err := p.dict.Define(wordLower, lang)
if err != nil {
slog.Error("lookup: dreamdict define", "lang", lang, "err", err)
continue
}
if len(defs) == 0 {
continue
}
if !foundDreamDict {
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n", word))
foundDreamDict = true
}
langName := langDisplayName(lang)
sb.WriteString(fmt.Sprintf("\n🏷 %s\n", langName))
count := 0
for _, def := range defs {
if count >= 4 {
break
}
if def.POS != "" {
sb.WriteString(fmt.Sprintf(" (%s) %s\n", def.POS, def.Gloss))
} else {
sb.WriteString(fmt.Sprintf(" %s\n", def.Gloss))
}
count++
}
// Start antonym fetch for the first language we find a match in.
if !antStarted {
antStarted = true
antLang := lang
go func() {
ants, err := p.dict.Antonyms(wordLower, antLang)
if err != nil {
antCh <- antResult{}
return
}
antCh <- antResult{ants: ants}
}()
}
}
// Collect antonyms (500ms timeout).
if antStarted {
timer := time.NewTimer(500 * time.Millisecond)
select {
case r := <-antCh:
timer.Stop()
if len(r.ants) > 0 {
display := r.ants
if len(display) > 3 {
display = display[:3]
}
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
}
case <-timer.C:
}
}
}
// Fall back to free dictionary API for English if DreamDict had no results.
if !foundDreamDict && (filterLang == "" || filterLang == "en") {
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
resp, err := httpClient.Get(apiURL)
if err != nil {
slog.Error("lookup: define request", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
}
var entries []struct {
Word string `json:"word"`
Meanings []struct {
PartOfSpeech string `json:"partOfSpeech"`
Definitions []struct {
Definition string `json:"definition"`
Example string `json:"example"`
} `json:"definitions"`
} `json:"meanings"`
}
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
entry := entries[0]
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
for i, meaning := range entry.Meanings {
if i >= 3 {
break
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
if def.Example != "" {
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
for j, def := range meaning.Definitions {
if j >= 2 {
break
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
if def.Example != "" {
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
}
}
}
} else if !foundDreamDict {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("No definition found for \"%s\" in %s.", word, langDisplayName(filterLang)))
}
msg := sb.String()

301
internal/plugin/lottery.go Normal file
View File

@@ -0,0 +1,301 @@
package plugin
import (
"fmt"
"math/rand/v2"
"sort"
"strconv"
"strings"
"time"
"maunium.net/go/mautrix"
)
// ── Plugin ──────────────────────────────────────────────────────────────────
type LotteryPlugin struct {
Base
euro *EuroPlugin
}
func NewLotteryPlugin(client *mautrix.Client, euro *EuroPlugin) *LotteryPlugin {
return &LotteryPlugin{
Base: NewBase(client),
euro: euro,
}
}
func (p *LotteryPlugin) Name() string { return "lottery" }
func (p *LotteryPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "lottery", Description: "Community lottery — buy tickets, win big", Usage: "!lottery buy [N]", Category: "Games"},
}
}
func (p *LotteryPlugin) Init() error {
go p.drawTicker()
go p.reminderTicker()
return nil
}
func (p *LotteryPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *LotteryPlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "lottery") {
return nil
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "lottery"))
lower := strings.ToLower(args)
switch {
case lower == "" || lower == "help":
return p.handleLotteryHelp(ctx)
case lower == "buy" || strings.HasPrefix(lower, "buy "):
return p.handleLotteryBuy(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
case lower == "tickets":
return p.handleLotteryTickets(ctx)
case lower == "pot":
return p.handleLotteryPot(ctx)
case lower == "odds":
return p.handleLotteryOdds(ctx)
case lower == "history":
return p.handleLotteryHistory(ctx)
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown lottery command. Try `!lottery help`.")
}
// ── Commands ────────────────────────────────────────────────────────────────
func (p *LotteryPlugin) handleLotteryHelp(ctx MessageContext) error {
text := `🎟️ **Community Lottery**
` + "`!lottery buy [N]`" + ` — Purchase N tickets (default 1, max 100/week). €1 each.
` + "`!lottery tickets`" + ` — View your tickets for this week's draw
` + "`!lottery pot`" + ` — Current pot balance and draw countdown
` + "`!lottery odds`" + ` — Prize tier table and odds
` + "`!lottery history`" + ` — Last 5 draw results
Draw: Every Friday at 23:59 UTC`
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *LotteryPlugin) handleLotteryBuy(ctx MessageContext, args string) error {
n := 1
if args != "" {
parsed, err := strconv.Atoi(args)
if err != nil || parsed < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!lottery buy [N]` where N is 1-100.")
}
n = parsed
}
weekStart := lotteryCurrentWeekStart()
existing := lotteryTicketCount(ctx.Sender, weekStart)
remaining := 100 - existing
if remaining <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the 100 ticket limit for this week's draw. Come back Friday.")
}
adjusted := false
if n > remaining {
n = remaining
adjusted = true
}
cost := float64(n)
if !p.euro.Debit(ctx.Sender, cost, "lottery_tickets") {
balance := p.euro.GetBalance(ctx.Sender)
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tickets cost €1 each. You need €%d but have €%.0f.", n, balance))
}
// Generate tickets.
tickets := make([][]int, n)
for i := range tickets {
tickets[i] = generateLotteryNumbers()
}
if err := lotteryInsertTickets(ctx.Sender, weekStart, tickets); err != nil {
// Refund — tickets didn't persist.
p.euro.Credit(ctx.Sender, cost, "lottery_refund")
return p.SendReply(ctx.RoomID, ctx.EventID, "Something went wrong buying tickets. You've been refunded.")
}
// Build confirmation.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🎟️ **%d ticket(s) purchased** — €%d\n\n", n, n))
displayLimit := n
if displayLimit > 10 {
displayLimit = 10
}
for i := 0; i < displayLimit; i++ {
sb.WriteString(fmt.Sprintf(" #%d — %s\n", existing+i+1, formatLotteryNumbers(tickets[i])))
}
if n > 10 {
sb.WriteString(fmt.Sprintf(" ... and %d more\n", n-10))
}
if adjusted {
sb.WriteString(fmt.Sprintf("\nAdjusted to %d (weekly cap reached).", n))
}
sb.WriteString(fmt.Sprintf("\nTotal tickets this week: %d/100", existing+n))
sb.WriteString(fmt.Sprintf("\nDraw: Friday 23:59 UTC (%s)", lotteryDrawCountdown()))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *LotteryPlugin) handleLotteryTickets(ctx MessageContext) error {
weekStart := lotteryCurrentWeekStart()
tickets, err := lotteryLoadUserTickets(ctx.Sender, weekStart)
if err != nil || len(tickets) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "🎟️ You have no tickets for this week's draw. `!lottery buy` to get started.")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🎟️ **Your tickets this week** (%d):\n\n", len(tickets)))
displayLimit := len(tickets)
if displayLimit > 20 {
displayLimit = 20
}
for i := 0; i < displayLimit; i++ {
sb.WriteString(fmt.Sprintf(" #%d — %s\n", i+1, formatLotteryNumbers(tickets[i].Numbers)))
}
if len(tickets) > 20 {
sb.WriteString(fmt.Sprintf(" ... and %d more\n", len(tickets)-20))
}
pot := communityPotBalance()
sb.WriteString(fmt.Sprintf("\nDraw: Friday 23:59 UTC (%s) | Pot: €%d", lotteryDrawCountdown(), pot))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *LotteryPlugin) handleLotteryPot(ctx MessageContext) error {
weekStart := lotteryCurrentWeekStart()
pot := communityPotBalance()
totalTickets := lotteryTotalTicketCount(weekStart)
countdown := lotteryDrawCountdown()
text := fmt.Sprintf("🎟️ **Lottery Pot**\n\n"+
"Current pot: **€%d**\n"+
"Tickets sold this week: %d\n"+
"Next draw: Friday 23:59 UTC (%s)\n\n"+
"Pot is funded by rival duel shares and ticket sales.",
pot, totalTickets, countdown)
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error {
text := `🎟️ **Lottery Prize Tiers**
| Match | Prize | Odds (approx.) |
|-------|-------|----------------|
| 5 of 5 | Jackpot (split among winners) | 1 in 142,506 |
| 4 of 5 | €1,000 (fixed) | 1 in 3,062 |
| 3 of 5 | €100 (fixed) | 1 in 141 |
| 2 of 5 | €10 (fixed) | 1 in 16 |
| 1 of 5 | €2 (fixed) | 1 in 4 |
| 0 of 5 | Nothing | — |
Tickets: €1 each. Max 100 per week. 5 numbers from 130.
Minimum €500 pot required for jackpot payout.`
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error {
history, err := lotteryLoadHistory(5)
if err != nil || len(history) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "🎟️ No draw history yet.")
}
var sb strings.Builder
sb.WriteString("🎟️ **Recent Draws**\n\n")
for _, h := range history {
sb.WriteString(fmt.Sprintf("**%s** — %s\n", h.DrawDate, formatLotteryNumbers(h.WinningNumbers)))
sb.WriteString(fmt.Sprintf(" Pot: €%d", h.PotTotal))
if h.JackpotWinners > 0 {
sb.WriteString(fmt.Sprintf(" | Jackpot: %d winner(s), €%d each", h.JackpotWinners, h.JackpotAmount))
}
if h.RolledOver > 0 {
sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver))
}
sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d | 1-match: %d\n\n",
h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ── Number Generation ───────────────────────────────────────────────────────
func generateLotteryNumbers() []int {
// Partial Fisher-Yates on [1..30], take first 5, sort.
pool := make([]int, 30)
for i := range pool {
pool[i] = i + 1
}
for i := 0; i < 5; i++ {
j := i + rand.IntN(30-i)
pool[i], pool[j] = pool[j], pool[i]
}
nums := make([]int, 5)
copy(nums, pool[:5])
sort.Ints(nums)
return nums
}
func countMatches(ticket, winning []int) int {
winSet := make(map[int]bool, len(winning))
for _, n := range winning {
winSet[n] = true
}
count := 0
for _, n := range ticket {
if winSet[n] {
count++
}
}
return count
}
// ── Formatting Helpers ──────────────────────────────────────────────────────
func formatLotteryNumbers(nums []int) string {
parts := make([]string, len(nums))
for i, n := range nums {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, " \u00b7 ")
}
func lotteryDrawCountdown() string {
now := time.Now().UTC()
// Find next Friday 23:59.
daysUntilFriday := (int(time.Friday) - int(now.Weekday()) + 7) % 7
if daysUntilFriday == 0 && (now.Hour() > 23 || (now.Hour() == 23 && now.Minute() >= 59)) {
daysUntilFriday = 7
}
nextDraw := time.Date(now.Year(), now.Month(), now.Day()+daysUntilFriday, 23, 59, 0, 0, time.UTC)
d := nextDraw.Sub(now)
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

View File

@@ -0,0 +1,209 @@
package plugin
import (
"encoding/json"
"log/slog"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Types ───────────────────────────────────────────────────────────────────
type lotteryTicket struct {
ID int64
UserID id.UserID
WeekStart string
Numbers []int
MatchCount *int
Prize *int
}
type lotteryHistoryRow struct {
DrawDate string
WinningNumbers []int
JackpotWinners int
JackpotAmount int
Match4Winners int
Match3Winners int
Match2Winners int
Match1Winners int
PotTotal int
RolledOver int
}
// ── Week Helpers ────────────────────────────────────────────────────────────
// lotteryCurrentWeekStart returns Monday of the current week as "2006-01-02".
func lotteryCurrentWeekStart() string {
now := time.Now().UTC()
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7 // Sunday = 7
}
monday := now.AddDate(0, 0, -(weekday - 1))
return monday.Format("2006-01-02")
}
// ── Ticket CRUD ─────────────────────────────────────────────────────────────
func lotteryTicketCount(userID id.UserID, weekStart string) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&count)
return count
}
func lotteryTotalTicketCount(weekStart string) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count)
return count
}
func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) error {
d := db.Get()
tx, err := d.Begin()
if err != nil {
slog.Error("lottery: begin tx", "err", err)
return err
}
defer tx.Rollback()
for _, nums := range tickets {
data, _ := json.Marshal(nums)
_, err := tx.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`,
string(userID), weekStart, string(data))
if err != nil {
slog.Error("lottery: failed to insert ticket", "user", userID, "err", err)
return err
}
}
// Each ticket costs €1 — add to community pot (same transaction).
_, err = tx.Exec(
`INSERT INTO community_pot (id, balance, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
len(tickets), len(tickets))
if err != nil {
slog.Error("lottery: failed to add to community pot", "err", err)
return err
}
return tx.Commit()
}
func lotteryLoadUserTickets(userID id.UserID, weekStart string) ([]lotteryTicket, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, user_id, week_start, numbers, match_count, prize
FROM lottery_tickets WHERE user_id = ? AND week_start = ? ORDER BY id`,
string(userID), weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLotteryTickets(rows)
}
func lotteryLoadAllWeekTickets(weekStart string) ([]lotteryTicket, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, user_id, week_start, numbers, match_count, prize
FROM lottery_tickets WHERE week_start = ? ORDER BY id`, weekStart)
if err != nil {
return nil, err
}
defer rows.Close()
return scanLotteryTickets(rows)
}
func lotteryUpdateTicketResult(ticketID int64, matchCount, prize int) {
d := db.Get()
_, err := d.Exec(`UPDATE lottery_tickets SET match_count = ?, prize = ? WHERE id = ?`,
matchCount, prize, ticketID)
if err != nil {
slog.Error("lottery: failed to update ticket result", "id", ticketID, "err", err)
}
}
// ── History CRUD ────────────────────────────────────────────────────────────
func lotteryInsertHistory(h *lotteryHistoryRow) {
d := db.Get()
winJSON, _ := json.Marshal(h.WinningNumbers)
_, err := d.Exec(`INSERT INTO lottery_history
(draw_date, winning_numbers, jackpot_winners, jackpot_amount,
match4_winners, match3_winners, match2_winners, match1_winners,
pot_total, rolled_over)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
h.DrawDate, string(winJSON), h.JackpotWinners, h.JackpotAmount,
h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners,
h.PotTotal, h.RolledOver)
if err != nil {
slog.Error("lottery: failed to insert history", "err", err)
}
}
func lotteryLoadHistory(limit int) ([]lotteryHistoryRow, error) {
d := db.Get()
rows, err := d.Query(`SELECT draw_date, winning_numbers, jackpot_winners, jackpot_amount,
match4_winners, match3_winners, match2_winners, match1_winners,
pot_total, rolled_over
FROM lottery_history ORDER BY draw_date DESC LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var history []lotteryHistoryRow
for rows.Next() {
var h lotteryHistoryRow
var winJSON string
if err := rows.Scan(&h.DrawDate, &winJSON, &h.JackpotWinners, &h.JackpotAmount,
&h.Match4Winners, &h.Match3Winners, &h.Match2Winners, &h.Match1Winners,
&h.PotTotal, &h.RolledOver); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(winJSON), &h.WinningNumbers)
history = append(history, h)
}
return history, rows.Err()
}
// ── Cleanup ─────────────────────────────────────────────────────────────────
func lotteryCleanupOldTickets() {
d := db.Get()
_, err := d.Exec(`DELETE FROM lottery_tickets WHERE week_start < DATE('now', '-30 days')`)
if err != nil {
slog.Error("lottery: failed to cleanup old tickets", "err", err)
}
}
// ── Scan Helper ─────────────────────────────────────────────────────────────
type lotteryRows interface {
Next() bool
Scan(dest ...interface{}) error
Err() error
}
func scanLotteryTickets(rows lotteryRows) ([]lotteryTicket, error) {
var tickets []lotteryTicket
for rows.Next() {
var t lotteryTicket
var numsJSON string
var matchCount, prize *int
if err := rows.Scan(&t.ID, &t.UserID, &t.WeekStart, &numsJSON, &matchCount, &prize); err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(numsJSON), &t.Numbers)
t.MatchCount = matchCount
t.Prize = prize
tickets = append(tickets, t)
}
return tickets, rows.Err()
}

View File

@@ -0,0 +1,274 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Prize Tiers (fixed payouts) ─────────────────────────────────────────────
var lotteryFixedPrizes = map[int]int{
4: 1000,
3: 100,
2: 10,
1: 2,
}
// ── Draw Ticker ─────────────────────────────────────────────────────────────
func (p *LotteryPlugin) drawTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
if now.Weekday() != time.Friday || now.Hour() != 23 || now.Minute() != 59 {
continue
}
weekKey := lotteryCurrentWeekStart()
jobName := "lottery_draw"
if db.JobCompleted(jobName, weekKey) {
continue
}
slog.Info("lottery: executing weekly draw")
p.executeDraw(weekKey)
db.MarkJobCompleted(jobName, weekKey)
}
}
// ── Reminder Ticker ─────────────────────────────────────────────────────────
func (p *LotteryPlugin) reminderTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
if now.Weekday() != time.Thursday || now.Hour() != 20 || now.Minute() != 0 {
continue
}
weekKey := lotteryCurrentWeekStart()
jobName := "lottery_reminder"
if db.JobCompleted(jobName, weekKey) {
continue
}
gr := gamesRoom()
if gr != "" {
pot := communityPotBalance()
p.SendMessage(gr, fmt.Sprintf(
"🎟️ Lottery draw tomorrow. 23:59 UTC. Current pot: €%d. "+
"Tickets €1 each. Max 100 per player. `!lottery buy [N]` to enter.",
pot))
}
db.MarkJobCompleted(jobName, weekKey)
}
}
// ── Draw Execution ──────────────────────────────────────────────────────────
func (p *LotteryPlugin) executeDraw(weekStart string) {
winning := generateLotteryNumbers()
tickets, err := lotteryLoadAllWeekTickets(weekStart)
if err != nil {
slog.Error("lottery: failed to load tickets for draw", "err", err)
return
}
if len(tickets) == 0 {
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, "🎟️ **LOTTERY DRAW** — No tickets were sold this week. The pot rolls over.")
}
return
}
// Score all tickets.
matchBuckets := make(map[int][]lotteryTicket) // matchCount -> tickets
for i := range tickets {
mc := countMatches(tickets[i].Numbers, winning)
tickets[i].MatchCount = &mc
prize := 0
if mc >= 1 && mc <= 4 {
prize = lotteryFixedPrizes[mc]
}
tickets[i].Prize = &prize
lotteryUpdateTicketResult(tickets[i].ID, mc, prize)
matchBuckets[mc] = append(matchBuckets[mc], tickets[i])
}
pot := communityPotBalance()
initialPot := pot
// Calculate fixed tier payouts.
fixedTotal := 0
for tier := 4; tier >= 1; tier-- {
count := len(matchBuckets[tier])
fixedTotal += count * lotteryFixedPrizes[tier]
}
// If pot can't cover all fixed payouts, prorate top-down.
actualFixed := fixedTotal
if actualFixed > pot {
actualFixed = pot
}
// Debit fixed payouts from pot.
if actualFixed > 0 {
if !communityPotDebit(actualFixed) {
slog.Error("lottery: failed to debit fixed payouts from pot", "amount", actualFixed)
actualFixed = 0
}
pot -= actualFixed
}
// Credit fixed tier winners.
prorateRatio := 1.0
if fixedTotal > 0 && actualFixed < fixedTotal {
prorateRatio = float64(actualFixed) / float64(fixedTotal)
}
if actualFixed > 0 {
for tier := 4; tier >= 1; tier-- {
for _, t := range matchBuckets[tier] {
amount := int(float64(lotteryFixedPrizes[tier]) * prorateRatio)
if amount > 0 {
p.euro.Credit(t.UserID, float64(amount), fmt.Sprintf("lottery_%dmatch", tier))
}
// Update ticket with actual prorated prize (not nominal)
lotteryUpdateTicketResult(t.ID, *t.MatchCount, amount)
}
}
}
// Jackpot.
jackpotWinners := matchBuckets[5]
jackpotAmount := 0
rolledOver := 0
if len(jackpotWinners) > 0 && pot >= 500 {
// Split remaining pot among jackpot winners.
perWinner := pot / len(jackpotWinners)
remainder := pot - (perWinner * len(jackpotWinners))
jackpotAmount = perWinner
totalJackpotDebit := perWinner * len(jackpotWinners)
if !communityPotDebit(totalJackpotDebit) {
slog.Error("lottery: failed to debit jackpot from pot", "amount", totalJackpotDebit)
// Jackpot rolls over — don't credit winners.
jackpotAmount = 0
rolledOver = pot
} else {
for _, t := range jackpotWinners {
p.euro.Credit(t.UserID, float64(perWinner), "lottery_jackpot")
lotteryUpdateTicketResult(t.ID, 5, perWinner)
}
rolledOver = remainder
}
} else {
// Jackpot rolls over.
rolledOver = pot
}
// Insert history.
h := &lotteryHistoryRow{
DrawDate: time.Now().UTC().Format("2006-01-02"),
WinningNumbers: winning,
JackpotWinners: len(jackpotWinners),
JackpotAmount: jackpotAmount,
Match4Winners: len(matchBuckets[4]),
Match3Winners: len(matchBuckets[3]),
Match2Winners: len(matchBuckets[2]),
Match1Winners: len(matchBuckets[1]),
PotTotal: initialPot,
RolledOver: rolledOver,
}
lotteryInsertHistory(h)
// Room announcement.
gr := gamesRoom()
if gr != "" {
announcement := p.buildDrawAnnouncement(winning, h, jackpotWinners)
p.SendMessage(gr, announcement)
}
// Cleanup old tickets.
lotteryCleanupOldTickets()
slog.Info("lottery: draw completed",
"winning", winning,
"tickets", len(tickets),
"pot", initialPot,
"jackpot_winners", len(jackpotWinners))
}
// ── Announcement Builder ────────────────────────────────────────────────────
func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRow, jackpotWinners []lotteryTicket) string {
var sb strings.Builder
sb.WriteString("🎟️ **LOTTERY DRAW** — Friday 23:59 UTC\n\n")
sb.WriteString(fmt.Sprintf("This week's numbers: **%s**\n\n", formatLotteryNumbers(winning)))
// Jackpot line.
if len(jackpotWinners) > 0 && h.JackpotAmount > 0 {
names := p.resolveWinnerNames(jackpotWinners)
if len(jackpotWinners) == 1 {
sb.WriteString(fmt.Sprintf("Jackpot (5 match): **%s** — €%d 🎉\n", names[0], h.JackpotAmount))
} else {
sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. €%d each. 🎉\n",
joinNames(names), h.JackpotAmount))
}
} else if len(jackpotWinners) > 0 && h.JackpotAmount == 0 {
sb.WriteString("Jackpot withheld — pot insufficient. Rolls to next week. Fixed tiers paid as normal.\n")
} else {
sb.WriteString("Jackpot (5 match): No winner this week. Pot rolls over.\n")
}
// Fixed tiers.
sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €1,000 each\n", h.Match4Winners))
sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €100 each\n", h.Match3Winners))
sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €10 each\n", h.Match2Winners))
sb.WriteString(fmt.Sprintf("1 match: %d winner(s) — €2 each\n", h.Match1Winners))
distributed := h.PotTotal - h.RolledOver
sb.WriteString(fmt.Sprintf("\nPot distributed: €%d. Next draw: Friday. Tickets on sale now.", distributed))
return sb.String()
}
func (p *LotteryPlugin) resolveWinnerNames(winners []lotteryTicket) []string {
// Deduplicate by user ID (multiple tickets from same player).
seen := make(map[id.UserID]bool)
var names []string
for _, t := range winners {
if seen[t.UserID] {
continue
}
seen[t.UserID] = true
// Try to get display name from DM room.
name := p.DisplayName(t.UserID)
names = append(names, name)
}
return names
}
func joinNames(names []string) string {
if len(names) <= 2 {
return strings.Join(names, " and ")
}
return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1]
}

View File

@@ -511,70 +511,26 @@ func (p *MilkCartonPlugin) renderCarton(
return buf.Bytes(), nil
}
// deriveCharacteristics generates flavor text from user stats.
// deriveCharacteristics generates flavor text from cached archetypes.
func (p *MilkCartonPlugin) deriveCharacteristics(userID string) []string {
d := db.Get()
archetypes := GetUserArchetypesLimited(userID)
// Pick up to 3 archetype flavor texts (highest signal score first)
var chars []string
var totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks int
err := d.QueryRow(
`SELECT COALESCE(total_messages,0), COALESCE(total_words,0), COALESCE(total_emojis,0),
COALESCE(total_questions,0), COALESCE(total_links,0)
FROM user_stats WHERE user_id = ?`, userID,
).Scan(&totalMsgs, &totalWords, &totalEmojis, &totalQuestions, &totalLinks)
if err != nil {
return []string{"Whereabouts unknown", "Considered a person of interest"}
}
avgWords := 0
if totalMsgs > 0 {
avgWords = totalWords / totalMsgs
}
// Sentiment data
var positive, negative, sarcastic, humorous int
_ = d.QueryRow(
`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(sarcastic,0), COALESCE(humorous,0)
FROM sentiment_stats WHERE user_id = ?`, userID,
).Scan(&positive, &negative, &sarcastic, &humorous)
// Profanity
var profanityCount int
_ = d.QueryRow(`SELECT COALESCE(count,0) FROM potty_mouth WHERE user_id = ?`, userID).Scan(&profanityCount)
// Build characteristics based on stat thresholds
type trait struct {
condition bool
text string
}
traits := []trait{
{totalMsgs > 1000, "Known to be extremely chatty"},
{totalMsgs > 500 && totalMsgs <= 1000, "Considered a regular contributor"},
{totalMsgs < 50 && totalMsgs > 0, "Frequently lurks, rarely commits"},
{avgWords > 12, "Known to post novellas"},
{avgWords > 0 && avgWords <= 3, "A person of few words"},
{totalQuestions > totalMsgs/4 && totalQuestions > 20, "Asks questions compulsively"},
{totalEmojis > totalMsgs/3 && totalEmojis > 30, "Communicates primarily in emoji"},
{totalLinks > totalMsgs/8 && totalLinks > 20, "Prone to sharing unsolicited links"},
{profanityCount > 100, "Has a mouth that could strip paint"},
{profanityCount > 30, "Known to use colorful language"},
{sarcastic > positive && sarcastic > 10, "Armed with weapons-grade sarcasm"},
{humorous > 20, "Considered dangerously funny"},
{negative > positive && negative > 15, "Last seen expressing strong opinions"},
{positive > 50, "Generally considered a ray of sunshine"},
}
for _, t := range traits {
if t.condition {
chars = append(chars, t.text)
for _, a := range archetypes {
if a.Flavor != "" {
chars = append(chars, a.Flavor)
}
if len(chars) >= 3 {
break
}
}
// Fallbacks
if len(chars) > 0 {
return chars
}
// Fallback when no archetypes cached yet
fallbacks := []string{
"Considered armed with strong opinions",
"May be found near a keyboard",

View File

@@ -235,5 +235,11 @@ func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
sb.WriteString(" Status: 🟢 Online\n")
}
// Archetypes
archetypes := GetUserArchetypesLimited(string(targetUser))
if len(archetypes) > 0 && archetypes[0].Name != "Regular" {
sb.WriteString(fmt.Sprintf("\n🎭 %s\n", FormatArchetypeNames(archetypes)))
}
return p.SendMessage(ctx.RoomID, sb.String())
}

View File

@@ -283,41 +283,16 @@ func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
}
func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
d := db.Get()
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
var totalQuestions, totalExclamations, totalEmojis int
archetypes := GetUserArchetypesLimited(string(ctx.Sender))
err := d.QueryRow(
`SELECT total_messages, total_words, total_chars, total_links, total_images,
total_questions, total_exclamations, total_emojis
FROM user_stats WHERE user_id = ?`, string(ctx.Sender),
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
&totalQuestions, &totalExclamations, &totalEmojis)
if err == sql.ErrNoRows {
var totalMsg int
db.Get().QueryRow(`SELECT total_messages FROM user_stats WHERE user_id = ?`, string(ctx.Sender)).Scan(&totalMsg)
if totalMsg == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!")
}
if err != nil {
slog.Error("stats: personality query", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to analyze personality.")
}
msgStats := util.MessageStats{
Words: totalWords,
Chars: totalChars,
Links: totalLinks,
Images: totalImages,
Questions: totalQuestions,
Exclamations: totalExclamations,
Emojis: totalEmojis,
}
archetype := util.DeriveArchetype(msgStats, totalMsg)
msg := fmt.Sprintf(
"🧠 %s, your chat personality is: **%s**\n_%s_\n\nBased on %s messages analyzed.",
string(ctx.Sender), archetype.Name, archetype.Description, formatNumber(totalMsg),
)
msg := fmt.Sprintf("🧠 %s, your chat personality:\n\n%s\n\nBased on %s messages analyzed.",
string(ctx.Sender), FormatArchetypesFull(archetypes), formatNumber(totalMsg))
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
@@ -361,10 +336,9 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalLinks),
formatNumber(totalEmojis), formatNumber(totalQuestions)))
// Personality
msgStats := util.MessageStats{Words: totalWords, Links: totalLinks, Emojis: totalEmojis, Questions: totalQuestions}
archetype := util.DeriveArchetype(msgStats, totalMsg)
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", archetype.Name))
// Personality (from cached archetypes)
archetypes := GetUserArchetypesLimited(uid)
sb.WriteString(fmt.Sprintf(" Personality: **%s**\n", FormatArchetypeNames(archetypes)))
}
// ── Sentiment ──
@@ -399,15 +373,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
sb.WriteString("\n")
// ── Adventure ──
var combatLv, miningLv, forageLv, combatXP, miningXP, forageXP int
var combatLv, miningLv, fishingLv, forageLv, combatXP, miningXP, fishingXP, forageXP int
var alive bool
var streak, bestStreak int
err = d.QueryRow(
`SELECT combat_level, mining_skill, foraging_skill,
combat_xp, mining_xp, foraging_xp,
`SELECT combat_level, mining_skill, fishing_skill, foraging_skill,
combat_xp, mining_xp, fishing_xp, foraging_xp,
alive, current_streak, best_streak
FROM adventure_characters WHERE user_id = ?`, uid,
).Scan(&combatLv, &miningLv, &forageLv, &combatXP, &miningXP, &forageXP,
).Scan(&combatLv, &miningLv, &fishingLv, &forageLv, &combatXP, &miningXP, &fishingXP, &forageXP,
&alive, &streak, &bestStreak)
if err == nil {
status := "Alive"
@@ -415,8 +389,8 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
status = "💀 Dead"
}
sb.WriteString(fmt.Sprintf("⚔️ **Adventure:** %s\n", status))
sb.WriteString(fmt.Sprintf(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n",
combatLv, combatXP, miningLv, miningXP, forageLv, forageXP))
sb.WriteString(fmt.Sprintf(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Fishing Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n",
combatLv, combatXP, miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP))
if streak > 0 || bestStreak > 0 {
sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak))
}
@@ -424,7 +398,7 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error {
// Equipment score (use canonical scoring function)
equip, eqErr := loadAdvEquipment(id.UserID(uid))
if eqErr == nil && len(equip) > 0 {
sb.WriteString(fmt.Sprintf(" Equipment score: %d\n", advEquipmentScore(equip)))
sb.WriteString(fmt.Sprintf(" Equipment score: %.1f\n", advEquipmentScore(equip)))
}
sb.WriteString("\n")
}
@@ -478,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
}
@@ -502,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
}
@@ -513,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) - wager
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

@@ -415,7 +415,7 @@ func (p *UnoPlugin) Name() string { return "uno" }
func (p *UnoPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno [nomercy [7-0]] €amount | !uno start [nomercy [7-0]] €amount | !uno join | !uno go", Category: "Games"},
{Name: "uno", Description: "Solo or multiplayer Uno", Usage: "!uno [nomercy [7-0]] €amount | !uno start [nomercy [7-0]] €amount | !uno join | !uno addbot | !uno removebot | !uno go | !uno leave | !uno cancel", Category: "Games"},
{Name: "uno_pot", Description: "Show the community pot balance", Usage: "!uno_pot", Category: "Games"},
}
}
@@ -447,6 +447,10 @@ func (p *UnoPlugin) OnMessage(ctx MessageContext) error {
return p.handleMultiLeave(ctx)
case lower == "cancel":
return p.handleMultiCancel(ctx)
case lower == "addbot":
return p.handleMultiAddBot(ctx)
case lower == "removebot":
return p.handleMultiRemoveBot(ctx)
}
// Solo challenge: !uno [nomercy [7-0]] €amount

View File

@@ -29,6 +29,7 @@ const (
type unoMultiPlayer struct {
userID id.UserID
displayName string // set at creation; bots use their assigned name
dmRoomID id.RoomID
hand []unoCard
calledUno bool
@@ -67,6 +68,9 @@ type unoMultiGame struct {
stackTotal int // cumulative draw penalty during stacking
stackMinValue int // minimum draw value to stack (0 = not stacking)
// Antes paid by addbot bots (funded from community pot)
botAntes int
// Sudden death (long-game point scoring)
suddenDeath bool
suddenDeathTurn int
@@ -81,16 +85,66 @@ type unoMultiLobby struct {
creator id.UserID
ante float64
players []id.UserID
botNames []string // bots added via !uno addbot
createdAt time.Time
timer *time.Timer
noMercy bool
sevenZeroRule bool
}
// unoExtraBotNames are the names available for !uno addbot (in order).
// The default bot name comes from unoBotName() and is always present.
var unoExtraBotNames = []string{"WinBee", "GwinBee"}
// lobbySlots returns the number of lobby slots taken (humans + addbot bots).
func (l *unoMultiLobby) lobbySlots() int {
return len(l.players) + len(l.botNames)
}
// buildLobbyDisplay renders the lobby message. Caller should hold p.mu or
// have a stable snapshot of the lobby fields.
func (p *UnoPlugin) buildLobbyDisplay(lobby *unoMultiLobby) string {
var sb strings.Builder
modeTag := ""
if lobby.noMercy {
modeTag = " 🔥 NO MERCY"
if lobby.sevenZeroRule {
modeTag += " (7-0)"
}
}
slots := lobby.lobbySlots()
sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", modeTag, int(lobby.ante), slots))
idx := 1
for _, uid := range lobby.players {
name := p.DisplayName(uid)
label := ""
if uid == lobby.creator {
label = " (host)"
}
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", idx, name, label))
idx++
}
for _, bn := range lobby.botNames {
sb.WriteString(fmt.Sprintf(" %d. 🤖 %s\n", idx, bn))
idx++
}
sb.WriteString("\nType `!uno join` to join, `!uno addbot` for a bot, or `!uno go` to start!")
return sb.String()
}
// ---------------------------------------------------------------------------
// Game helpers
// ---------------------------------------------------------------------------
// multiName returns the display name for a multiplayer UNO player.
// Bots use their assigned displayName; humans use the Matrix display name.
func (p *UnoPlugin) multiName(pl *unoMultiPlayer) string {
if pl.isBot {
return pl.displayName
}
return p.DisplayName(pl.userID)
}
func (g *unoMultiGame) currentPlayer() *unoMultiPlayer {
return g.players[g.currentIdx]
}
@@ -281,19 +335,10 @@ func (p *UnoPlugin) handleMultiStart(ctx MessageContext, amountStr string, noMer
})
p.lobbies[ctx.RoomID] = lobby
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
creatorName := p.DisplayName(ctx.Sender)
modeTag := ""
if noMercy {
modeTag = " 🔥 NO MERCY"
if sevenZeroRule {
modeTag += " (7-0)"
}
}
return p.SendMessage(ctx.RoomID, fmt.Sprintf(
"🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (1/4):\n 1. %s (host)\n\nType `!uno join` to join or `!uno go` to start!",
modeTag, int(amount), creatorName))
return p.SendMessage(ctx.RoomID, msg)
}
func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error {
@@ -316,9 +361,9 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error {
}
}
if len(lobby.players) >= 4 {
if lobby.lobbySlots() >= 4 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Lobby is full! (4/4 players)")
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Lobby is full! (%d/4)", lobby.lobbySlots()))
}
// Check player isn't in another game
@@ -344,30 +389,10 @@ func (p *UnoPlugin) handleMultiJoin(ctx MessageContext) error {
}
lobby.players = append(lobby.players, ctx.Sender)
count := len(lobby.players)
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
// Build player list
var sb strings.Builder
lobbyModeTag := ""
if lobby.noMercy {
lobbyModeTag = " 🔥 NO MERCY"
if lobby.sevenZeroRule {
lobbyModeTag += " (7-0)"
}
}
sb.WriteString(fmt.Sprintf("🃏 **UNO Lobby**%s — Ante: €%d\nPlayers (%d/4):\n", lobbyModeTag, int(lobby.ante), count))
for i, uid := range lobby.players {
name := p.DisplayName(uid)
label := ""
if uid == lobby.creator {
label = " (host)"
}
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", i+1, name, label))
}
sb.WriteString("\nType `!uno join` to join or `!uno go` to start!")
return p.SendMessage(ctx.RoomID, sb.String())
return p.SendMessage(ctx.RoomID, msg)
}
func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error {
@@ -408,7 +433,7 @@ func (p *UnoPlugin) handleMultiLeave(ctx MessageContext) error {
// Refund the leaving player
p.euro.Credit(ctx.Sender, lobby.ante, "uno_multi_refund")
name := p.DisplayName(ctx.Sender)
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the lobby. (%d/4 players)", name, len(lobby.players)))
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the lobby. (%d/4)", name, lobby.lobbySlots()))
}
func (p *UnoPlugin) handleMultiCancel(ctx MessageContext) error {
@@ -437,6 +462,63 @@ func (p *UnoPlugin) handleMultiCancel(ctx MessageContext) error {
return p.SendMessage(ctx.RoomID, "🃏 Lobby cancelled — all antes refunded.")
}
func (p *UnoPlugin) handleMultiAddBot(ctx MessageContext) error {
if !isGamesRoom(ctx.RoomID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Use this command in the games channel!")
}
p.mu.Lock()
lobby, exists := p.lobbies[ctx.RoomID]
if !exists {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open. Start one with `!uno start €amount`.")
}
if lobby.lobbySlots() >= 4 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Lobby is full! (%d/4)", lobby.lobbySlots()))
}
botIdx := len(lobby.botNames)
if botIdx >= len(unoExtraBotNames) {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Maximum %d extra bots already added.", len(unoExtraBotNames)))
}
lobby.botNames = append(lobby.botNames, unoExtraBotNames[botIdx])
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
return p.SendMessage(ctx.RoomID, msg)
}
func (p *UnoPlugin) handleMultiRemoveBot(ctx MessageContext) error {
if !isGamesRoom(ctx.RoomID) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Use this command in the games channel!")
}
p.mu.Lock()
lobby, exists := p.lobbies[ctx.RoomID]
if !exists {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No lobby open.")
}
if len(lobby.botNames) == 0 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "No bots to remove.")
}
removed := lobby.botNames[len(lobby.botNames)-1]
lobby.botNames = lobby.botNames[:len(lobby.botNames)-1]
msg := p.buildLobbyDisplay(lobby)
p.mu.Unlock()
p.SendMessage(ctx.RoomID, fmt.Sprintf("🤖 %s removed from the lobby.", removed))
return p.SendMessage(ctx.RoomID, msg)
}
func (p *UnoPlugin) lobbyExpired(roomID id.RoomID) {
p.mu.Lock()
lobby, exists := p.lobbies[roomID]
@@ -469,13 +551,16 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "Only the host can start the game.")
}
if len(lobby.players) < 2 {
// Need at least 2 total participants (humans + lobby bots + default bot)
totalParticipants := len(lobby.players) + len(lobby.botNames) + 1 // +1 for default bot
if totalParticipants < 2 {
p.mu.Unlock()
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start.")
}
lobby.timer.Stop()
players := lobby.players
lobbyBots := lobby.botNames
ante := lobby.ante
roomID := lobby.roomID
noMercy := lobby.noMercy
@@ -498,8 +583,14 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
resolved = append(resolved, playerDMPair{uid, dmRoom})
}
// Debit bot antes from community pot
for range lobbyBots {
p.claimFromPot(ante)
}
// Build game
game := p.initMultiGame(resolved, roomID, ante, noMercy, sevenZeroRule)
game := p.initMultiGame(resolved, lobbyBots, roomID, ante, noMercy, sevenZeroRule)
game.botAntes = len(lobbyBots)
p.mu.Lock()
p.multiGames[game.id] = game
@@ -512,7 +603,6 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
// Announce
var sb strings.Builder
bn := unoBotName()
modeTag := ""
if noMercy {
modeTag = " 🔥 NO MERCY"
@@ -520,12 +610,10 @@ func (p *UnoPlugin) handleMultiGo(ctx MessageContext) error {
modeTag += " (7-0)"
}
}
sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, int(ante)*len(players)))
totalPot := int(ante) * (len(players) + len(lobbyBots))
sb.WriteString(fmt.Sprintf("🃏 **Multiplayer UNO!**%s Pot: €%d\n\nPlayers:\n", modeTag, totalPot))
for i, pl := range game.players {
name := p.DisplayName(pl.userID)
if pl.isBot {
name = bn
}
name := p.multiName(pl)
marker := ""
if i == game.currentIdx {
marker = " ← first turn"
@@ -558,7 +646,7 @@ type playerDMPair struct {
dmRoomID id.RoomID
}
func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante float64, noMercy, sevenZeroRule bool) *unoMultiGame {
func (p *UnoPlugin) initMultiGame(players []playerDMPair, lobbyBots []string, roomID id.RoomID, ante float64, noMercy, sevenZeroRule bool) *unoMultiGame {
var deck []unoCard
if noMercy {
deck = newNoMercyDeck()
@@ -575,23 +663,33 @@ func (p *UnoPlugin) initMultiGame(players []playerDMPair, roomID id.RoomID, ante
copy(hand, deck[cardIdx:cardIdx+cardsPerPlayer])
cardIdx += cardsPerPlayer
unshuffled = append(unshuffled, &unoMultiPlayer{
userID: pd.userID,
dmRoomID: pd.dmRoomID,
hand: hand,
active: true,
userID: pd.userID,
displayName: p.DisplayName(pd.userID),
dmRoomID: pd.dmRoomID,
hand: hand,
active: true,
})
}
// Bot
botHand := make([]unoCard, cardsPerPlayer)
copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer])
cardIdx += cardsPerPlayer
bot := &unoMultiPlayer{
userID: id.UserID(unoBotName()),
hand: botHand,
isBot: true,
active: true,
// Default bot (always present)
addBot := func(name string) {
botHand := make([]unoCard, cardsPerPlayer)
copy(botHand, deck[cardIdx:cardIdx+cardsPerPlayer])
cardIdx += cardsPerPlayer
unshuffled = append(unshuffled, &unoMultiPlayer{
userID: id.UserID(name),
displayName: name,
hand: botHand,
isBot: true,
active: true,
})
}
addBot(unoBotName())
// Additional bots from lobby
for _, bn := range lobbyBots {
addBot(bn)
}
unshuffled = append(unshuffled, bot)
// Shuffle turn order
rand.Shuffle(len(unshuffled), func(i, j int) { unshuffled[i], unshuffled[j] = unshuffled[j], unshuffled[i] })
@@ -856,12 +954,7 @@ func (p *UnoPlugin) advanceAndExecute(game *unoMultiGame) {
// nextTurnLabel returns "It's X's turn." for the current player.
func (p *UnoPlugin) nextTurnLabel(game *unoMultiGame) string {
next := game.currentPlayer()
name := p.DisplayName(next.userID)
if next.isBot {
name = unoBotName()
}
return fmt.Sprintf("It's %s's turn.", name)
return fmt.Sprintf("It's %s's turn.", p.multiName(game.currentPlayer()))
}
// ---------------------------------------------------------------------------
@@ -1087,10 +1180,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
if pl == player || !pl.active {
continue
}
name := p.DisplayName(pl.userID)
if pl.isBot {
name = unoBotName()
}
name := p.multiName(pl)
sb.WriteString(fmt.Sprintf("%d. %s (%d cards)\n", i, name, len(pl.hand)))
i++
}
@@ -1104,10 +1194,7 @@ func (p *UnoPlugin) handleMultiPlayerPlay(game *unoMultiGame, player *unoMultiPl
if pl != player && pl.active {
swapHandsMulti(player, pl)
name := p.DisplayName(player.userID)
otherName := p.DisplayName(pl.userID)
if pl.isBot {
otherName = unoBotName()
}
otherName := p.multiName(pl)
p.SendMessage(game.roomID, fmt.Sprintf("🔄 %s swaps hands with %s! %s",
name, otherName, pickNoMercyCommentary("hand_swap")))
if !pl.isBot {
@@ -1179,10 +1266,7 @@ func (p *UnoPlugin) handleMultiColorChoice(game *unoMultiGame, player *unoMultiP
name := p.DisplayName(player.userID)
nextIdx := game.nextActiveIdx()
target := game.players[nextIdx]
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
targetName := p.multiName(target)
flipped := p.executeColorRouletteMulti(game, target, color)
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s plays: %s (chose %s %s)\n🎰 Color Roulette! %s flips %d cards until finding %s.\n%s",
@@ -1377,10 +1461,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
nextIdx := game.nextActiveIdx()
nextPlayer := game.players[nextIdx]
nextName := p.DisplayName(nextPlayer.userID)
if nextPlayer.isBot {
nextName = unoBotName()
}
nextName := p.multiName(nextPlayer)
switch card.Value {
case unoSkip:
@@ -1395,10 +1476,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
var skippedNames []string
for _, pl := range game.players {
if pl != game.currentPlayer() && pl.active {
name := p.DisplayName(pl.userID)
if pl.isBot {
name = unoBotName()
}
name := p.multiName(pl)
skippedNames = append(skippedNames, name)
}
}
@@ -1473,10 +1551,7 @@ func (p *UnoPlugin) applyCardEffects(game *unoMultiGame, card unoCard) cardEffec
// After reverse, get next player in new direction
nextIdx = game.nextActiveIdx()
nextPlayer = game.players[nextIdx]
nextName = p.DisplayName(nextPlayer.userID)
if nextPlayer.isBot {
nextName = unoBotName()
}
nextName = p.multiName(nextPlayer)
result.skippedName = nextName
result.drawnCount = game.stackTotal
game.currentIdx = game.nextActiveIdx()
@@ -1566,11 +1641,7 @@ func (p *UnoPlugin) applyAndAnnounce(game *unoMultiGame, player *unoMultiPlayer,
}
// Next player
nextUp := game.currentPlayer()
nextUpName := p.DisplayName(nextUp.userID)
if nextUp.isBot {
nextUpName = unoBotName()
}
nextUpName := p.multiName(game.currentPlayer())
roomMsg.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName))
p.SendMessage(game.roomID, roomMsg.String())
@@ -1596,10 +1667,7 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
game.currentIdx = nextIdx
game.turnID++
playerName := p.DisplayName(wd4Player.userID)
if wd4Player.isBot {
playerName = unoBotName()
}
playerName := p.multiName(wd4Player)
if victim.isBot {
// Bot decides whether to challenge
@@ -1617,14 +1685,8 @@ func (p *UnoPlugin) startWD4Challenge(game *unoMultiGame, wd4Player *unoMultiPla
func (p *UnoPlugin) resolveWD4Challenge(game *unoMultiGame, challenged bool) {
wd4Player := game.wd4Player
victim := game.wd4Victim
wd4Name := p.DisplayName(wd4Player.userID)
if wd4Player.isBot {
wd4Name = unoBotName()
}
victimName := p.DisplayName(victim.userID)
if victim.isBot {
victimName = unoBotName()
}
wd4Name := p.multiName(wd4Player)
victimName := p.multiName(victim)
// Clear challenge state
game.wd4Player = nil
@@ -1699,7 +1761,7 @@ func (p *UnoPlugin) botHandleWD4Challenge(game *unoMultiGame) {
threshold = 30
}
challenged := rand.IntN(100) < threshold
bn := unoBotName()
bn := game.currentPlayer().displayName
if challenged {
p.SendMessage(game.roomID, fmt.Sprintf("⚡ %s challenges the Wild Draw Four!", bn))
@@ -1716,7 +1778,7 @@ func (p *UnoPlugin) botHandleWD4Challenge(game *unoMultiGame) {
func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
bot := game.currentPlayer()
bn := unoBotName()
bn := bot.displayName
// No Mercy stacking: bot must stack or absorb
if game.noMercy && game.stackMinValue > 0 {
@@ -1931,7 +1993,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
p.SendMessage(game.roomID, roomBuf.String())
roomBuf.Reset()
}
p.multiBotWins(game)
p.multiBotWins(game, bot)
return
}
@@ -1976,10 +2038,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
// applyCardEffects already advanced currentIdx to the victim
if game.noMercy && card.Value == unoWildColorRoulette {
target := game.players[game.currentIdx]
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = bn
}
targetName := p.multiName(target)
flipped := p.executeColorRouletteMulti(game, target, game.topColor)
roomBuf.WriteString(fmt.Sprintf("\n 🎰 Color Roulette! %s flips %d cards until finding %s %s.",
targetName, len(flipped), game.topColor.Emoji(), game.topColor))
@@ -2013,11 +2072,7 @@ func (p *UnoPlugin) botMultiTurn(game *unoMultiGame, roomBuf *strings.Builder) {
}
// Next turn label (applyCardEffects already advanced currentIdx)
nextUp := game.currentPlayer()
nextUpName := p.DisplayName(nextUp.userID)
if nextUp.isBot {
nextUpName = unoBotName()
}
nextUpName := p.multiName(game.currentPlayer())
roomBuf.WriteString(fmt.Sprintf("\n It's %s's turn.", nextUpName))
game.turns++
@@ -2151,10 +2206,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
if game.noMercy && pendingCard != nil && pendingCard.Value == unoWildColorRoulette {
nextIdx := game.nextActiveIdx()
target := game.players[nextIdx]
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
targetName := p.multiName(target)
flipped := p.executeColorRouletteMulti(game, target, color)
p.SendMessage(game.roomID, fmt.Sprintf("🎰 Color Roulette! %s flips %d cards until finding %s.\n%s",
targetName, len(flipped), color, pickNoMercyCommentary("color_roulette")))
@@ -2234,10 +2286,7 @@ func (p *UnoPlugin) autoPlayMultiTurn(game *unoMultiGame, player *unoMultiPlayer
target := botChooseSwapTarget(game, player)
if target != nil {
swapHandsMulti(player, target)
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
targetName := p.multiName(target)
p.SendMessage(player.dmRoomID, fmt.Sprintf("*Auto-played:* Swapped hands with %s.", targetName))
p.SendMessage(game.roomID, fmt.Sprintf("🔄 *%s was auto-played.* Swaps hands with %s!", name, targetName))
}
@@ -2466,16 +2515,9 @@ func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMult
aScore := scoreHand(a.hand)
bScore := scoreHand(b.hand)
nameOf := func(pl *unoMultiPlayer) string {
if pl.isBot {
return unoBotName()
}
return p.DisplayName(pl.userID)
}
breakdown := fmt.Sprintf("**Final Scores:**\n%s: %s\n%s: %s",
nameOf(a), formatHandScore(a.hand),
nameOf(b), formatHandScore(b.hand))
p.multiName(a), formatHandScore(a.hand),
p.multiName(b), formatHandScore(b.hand))
var winner, loser *unoMultiPlayer
switch {
@@ -2503,7 +2545,7 @@ func (p *UnoPlugin) multiSuddenDeathWinner(game *unoMultiGame, active []*unoMult
p.SendMessage(game.roomID, fmt.Sprintf(
"⏰ **SUDDEN DEATH!** %s wins on points!\n%s\n\n%s",
nameOf(winner), breakdown, pickCommentary("sudden_death_win")))
p.multiName(winner), breakdown, pickCommentary("sudden_death_win")))
if winner.isBot {
p.multiBotWins2(game)
@@ -2521,7 +2563,7 @@ func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer)
humanCount++
}
}
totalPot := game.ante * float64(humanCount)
totalPot := game.ante * float64(humanCount+game.botAntes)
p.euro.Credit(winner.userID, totalPot, "uno_multi_win")
p.recordMultiGame(game, winner.userID, "sudden_death_win")
@@ -2535,7 +2577,7 @@ func (p *UnoPlugin) multiBotWins2(game *unoMultiGame) {
humanCount++
}
}
totalPot := game.ante * float64(humanCount)
totalPot := game.ante * float64(humanCount+game.botAntes)
p.addToPot(totalPot)
for _, pl := range game.players {
@@ -2571,7 +2613,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
humanCount++
}
}
totalPot := game.ante * float64(humanCount)
totalPot := game.ante * float64(humanCount+game.botAntes)
name := p.DisplayName(winner.userID)
p.euro.Credit(winner.userID, totalPot, "uno_multi_win")
@@ -2584,7 +2626,7 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer)
p.cleanupMultiGame(game)
}
func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
func (p *UnoPlugin) multiBotWins(game *unoMultiGame, winner *unoMultiPlayer) {
if game.done {
return
}
@@ -2603,12 +2645,12 @@ func (p *UnoPlugin) multiBotWins(game *unoMultiGame) {
humanCount++
}
}
totalPot := game.ante * float64(humanCount)
totalPot := game.ante * float64(humanCount+game.botAntes)
// Pot goes to community
p.addToPot(totalPot)
bn := unoBotName()
bn := winner.displayName
p.SendMessage(game.roomID, fmt.Sprintf(
"💀 **%s wins Multiplayer UNO!**\n€%d goes to the community pot.\n\n%s",
bn, int(totalPot), pickCommentary("bot_win")))
@@ -2638,7 +2680,7 @@ func (p *UnoPlugin) multiPlayerForfeit(game *unoMultiGame, player *unoMultiPlaye
winner := active[0]
if winner.isBot {
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. All humans out!", name))
p.multiBotWins(game)
p.multiBotWins(game, winner)
} else {
p.SendMessage(game.roomID, fmt.Sprintf("🃏 %s forfeited. Only one player remains!", name))
p.multiPlayerWins(game, winner)
@@ -2682,7 +2724,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu
}
humanCount := len(playerIDs)
totalPot := game.ante * float64(humanCount)
totalPot := game.ante * float64(humanCount+game.botAntes)
db.Exec("uno_multi: record game",
`INSERT INTO uno_multi_games (room_id, ante, pot_total, winner_id, player_ids, result, turns, started_at, ended_at)
@@ -2700,12 +2742,7 @@ func (p *UnoPlugin) recordMultiGame(game *unoMultiGame, winnerID id.UserID, resu
func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer) {
var sb strings.Builder
bn := unoBotName()
current := game.currentPlayer()
currentName := p.DisplayName(current.userID)
if current.isBot {
currentName = bn
}
currentName := p.multiName(game.currentPlayer())
sb.WriteString(fmt.Sprintf("🃏 **UNO Status**\nDiscard pile: %s\nCurrent turn: %s\n\n",
game.discardTop.DisplayWithColor(game.topColor), currentName))
@@ -2725,11 +2762,7 @@ func (p *UnoPlugin) sendMultiStatus(game *unoMultiGame, player *unoMultiPlayer)
if pl == player || !pl.active {
continue
}
name := p.DisplayName(pl.userID)
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand)))
}
sb.WriteString(strings.Join(counts, " | "))
@@ -2752,16 +2785,11 @@ func (p *UnoPlugin) sendMultiHandDisplay(game *unoMultiGame, player *unoMultiPla
// Card counts for all opponents
sb.WriteString("\nCard counts: ")
counts := make([]string, 0)
bn := unoBotName()
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
name := p.DisplayName(pl.userID)
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand)))
}
sb.WriteString(strings.Join(counts, " | "))
@@ -2790,16 +2818,11 @@ func (p *UnoPlugin) sendMultiHandDisplayStacking(game *unoMultiGame, player *uno
sb.WriteString("\nCard counts: ")
counts := make([]string, 0)
bn := unoBotName()
for _, pl := range game.players {
if pl == player || !pl.active {
continue
}
name := p.DisplayName(pl.userID)
if pl.isBot {
name = bn
}
counts = append(counts, fmt.Sprintf("%s (%d)", name, len(pl.hand)))
counts = append(counts, fmt.Sprintf("%s (%d)", p.multiName(pl), len(pl.hand)))
}
sb.WriteString(strings.Join(counts, " | "))
@@ -2840,10 +2863,7 @@ func (p *UnoPlugin) handleMultiSwapChoice(game *unoMultiGame, player *unoMultiPl
game.phase = unoMultiPhasePlay
name := p.DisplayName(player.userID)
targetName := p.DisplayName(target.userID)
if target.isBot {
targetName = unoBotName()
}
targetName := p.multiName(target)
p.SendMessage(player.dmRoomID, fmt.Sprintf("Swapped hands with %s! You now have %d cards.", targetName, len(player.hand)))
if !target.isBot {

View File

@@ -201,10 +201,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu
}
player.active = false
name := p.DisplayName(player.userID)
if player.isBot {
name = unoBotName()
}
name := p.multiName(player)
// Shuffle cards back into draw pile
game.drawPile = append(game.drawPile, player.hand...)
@@ -226,7 +223,7 @@ func (p *UnoPlugin) checkMultiMercyElimination(game *unoMultiGame, player *unoMu
if len(active) == 1 {
winner := active[0]
if winner.isBot {
p.multiBotWins(game)
p.multiBotWins(game, winner)
} else {
p.multiPlayerWins(game, winner)
}

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

@@ -35,7 +35,7 @@ type WordlePlugin struct {
func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient.Client) *WordlePlugin {
length := 5
if v := os.Getenv("WORDLE_DEFAULT_LENGTH"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 7 {
if n, err := strconv.Atoi(v); err == nil && n >= 5 && n <= 20 {
length = n
}
}
@@ -110,7 +110,7 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error {
"`!wordle grid` — Re-post current puzzle grid\n"+
"`!wordle stats` — All-time leaderboard\n"+
"`!wordle new` — Start a new puzzle (admin)\n"+
"`!wordle new <5|6|7>` — New puzzle with specific length (admin)\n"+
"`!wordle new <5-20>` — New puzzle with specific length (admin)\n"+
"`!wordle new pt` — Portuguese puzzle (admin)\n"+
"`!wordle new fr` — French puzzle (admin)\n"+
"`!wordle skip` — Reveal answer and end puzzle (admin)")
@@ -180,15 +180,22 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
}
puzzle.Guesses = append(puzzle.Guesses, g)
// Persist guess to DB so it survives restarts.
db.Exec("wordle: persist guess",
`INSERT INTO wordle_guesses (puzzle_id, room_id, guess_num, word, player_id, player_name, guessed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
puzzle.PuzzleID, string(puzzle.RoomID), len(puzzle.Guesses), guess, string(ctx.Sender), displayName, now,
)
// Check for win.
if guess == puzzle.Answer {
puzzle.Solved = true
puzzle.SolvedAt = &now
definition := p.fetchDefinition(puzzle.Answer)
p.updateStats(puzzle)
p.markPuzzleDone(puzzle)
payouts := p.awardPrize(puzzle)
p.updateStats(puzzle, payouts)
p.markPuzzleDone(puzzle)
return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts))
}
@@ -198,7 +205,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error {
puzzle.Failed = true
definition := p.fetchDefinition(puzzle.Answer)
p.updateStats(puzzle)
p.updateStats(puzzle, nil)
p.markPuzzleDone(puzzle)
return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition))
@@ -235,7 +242,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
category := WordleCategoryEN
parts := strings.Fields(args)
// Parse optional arguments: language (pt/fr) and/or length (5/6/7)
// Parse optional arguments: language (pt/fr) and/or length (5-20)
for _, part := range parts[1:] {
lower := strings.ToLower(part)
switch lower {
@@ -244,7 +251,7 @@ func (p *WordlePlugin) handleNew(ctx MessageContext, args string) error {
case "fr", "french":
category = WordleCategoryFR
default:
if n, err := strconv.Atoi(part); err == nil && n >= 5 && n <= 7 {
if n, err := strconv.Atoi(part); err == nil && n >= 5 && n <= 20 {
wordLength = n
}
}
@@ -318,14 +325,8 @@ func (p *WordlePlugin) handleStats(ctx MessageContext) error {
// createAndPostPuzzle creates a new puzzle, persists it, and posts the announcement.
func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, category WordleCategory) error {
// Pick word from the appropriate pool.
var word string
switch category {
case WordleCategoryPT, WordleCategoryFR:
word = pickLanguageWord(category, wordLength)
default:
word = pickFallbackWord(wordLength)
}
// Pick word from DreamDict, falling back to the local word pool.
word := p.pickWord(wordLength, category)
if word == "" {
return p.SendMessage(roomID, "Failed to select a puzzle word — no words available for that length/language.")
}
@@ -345,7 +346,7 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
RoomID: roomID,
Answer: word,
WordLength: wordLength,
MaxGuesses: 6,
MaxGuesses: wordleMaxGuesses(wordLength),
Category: category,
StartedAt: now,
LetterStates: make(map[rune]LetterResult),
@@ -359,6 +360,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
today, string(roomID), word, wordLength, string(category), now,
word, wordLength, string(category), now,
)
// Clear any stale guesses from a previous puzzle on the same day (e.g. after skip+new).
db.Exec("wordle: clear old guesses",
`DELETE FROM wordle_guesses WHERE puzzle_id = ? AND room_id = ?`,
today, string(roomID),
)
p.mu.Lock()
p.puzzles[roomID] = puzzle
@@ -375,6 +381,56 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat
return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint))
}
// pickWord tries DreamDict's RandomWord API first, falling back to the local
// word pool if DreamDict is unavailable or only returns recently-used words.
func (p *WordlePlugin) pickWord(length int, category WordleCategory) string {
if p.dict != nil {
recent := loadRecentWordleAnswers(500)
lang := categoryLang(category)
// Try a few times to get a word that hasn't been used recently.
for range 10 {
word, err := p.dict.RandomWord(lang, "", length, length, 500)
if err != nil {
slog.Warn("wordle: DreamDict random word failed, falling back", "err", err)
break
}
word = strings.ToUpper(word)
// Verify the word is exactly the right length and contains only letters.
runes := []rune(word)
if len(runes) != length {
continue
}
clean := true
for _, r := range runes {
if r < 'A' || r > 'Z' {
clean = false
break
}
}
if !clean {
continue
}
if !recent[word] {
return word
}
}
}
// Fallback to local word pools.
switch category {
case WordleCategoryPT, WordleCategoryFR:
return pickLanguageWord(category, length)
default:
return pickFallbackWord(length)
}
}
// wordleMaxGuesses returns the number of allowed guesses for a given word length.
func wordleMaxGuesses(length int) int {
return 6
}
// wordleCategoryHint returns the hint string for a puzzle category.
func wordleCategoryHint(category WordleCategory) string {
switch category {
@@ -388,6 +444,29 @@ func wordleCategoryHint(category WordleCategory) string {
return ""
}
// expireUnsolved marks an active puzzle as failed and posts an announcement.
// Must be called without holding p.mu.
func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) {
p.mu.Lock()
existing := p.puzzles[roomID]
if existing == nil || existing.Solved || existing.Failed {
p.mu.Unlock()
return
}
existing.Failed = true
p.markPuzzleDone(existing)
p.updateStats(existing, nil)
p.mu.Unlock()
definition := p.fetchDefinition(existing.Answer)
defLine := ""
if definition != "" {
defLine = fmt.Sprintf("\n📖 *%s*\n", definition)
}
p.SendMessage(roomID,
fmt.Sprintf("⏰ **Time's up!** Yesterday's puzzle expired.\nThe word was **%s**.%s", existing.Answer, defLine))
}
// PostDailyPuzzle is called by the scheduler to post today's puzzle.
func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
today := time.Now().UTC().Format("2006-01-02")
@@ -401,6 +480,9 @@ func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error {
}
p.mu.Unlock()
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(roomID)
return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN)
}
@@ -485,7 +567,7 @@ func (p *WordlePlugin) markPuzzleDone(puzzle *WordlePuzzle) {
)
}
func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle, payouts []WordlePayout) {
d := db.Get()
// Tally per-player contributions.
@@ -542,6 +624,18 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
}
}
// Update total_earned from payouts (same transaction as stats).
for _, po := range payouts {
if po.Amount > 0 {
_, err := tx.Exec(
`UPDATE wordle_stats SET total_earned = total_earned + ? WHERE user_id = ?`,
po.Amount, string(po.UserID))
if err != nil {
slog.Error("wordle: update total_earned", "user", po.UserID, "err", err)
}
}
}
if err := tx.Commit(); err != nil {
slog.Error("wordle: commit stats", "err", err)
}
@@ -549,13 +643,14 @@ func (p *WordlePlugin) updateStats(puzzle *WordlePuzzle) {
// WordlePayout tracks a payout for rendering.
type WordlePayout struct {
UserID id.UserID
Name string
Amount int
Solver bool
}
// wordleBasePots maps guess count to euro prize pot. Fewer guesses = bigger reward.
var wordleBasePots = [7]int{0, 100, 80, 60, 45, 35, 25}
var wordleBasePots = [7]int{0, 500, 350, 250, 150, 100, 75}
// awardPrize credits euros to all contributors when a puzzle is solved.
// The solver who got the final guess gets a 50% bonus on their share.
@@ -589,8 +684,8 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
numPlayers := len(contributors)
share := pot / numPlayers
if share < 5 {
share = 5
if share < 10 {
share = 10
}
solverBonus := share / 2
@@ -602,7 +697,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout {
amount += solverBonus
}
p.euro.Credit(uid, float64(amount), "wordle_win")
payouts = append(payouts, WordlePayout{Name: c.name, Amount: amount, Solver: c.solver})
payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: amount, Solver: c.solver})
}
return payouts
}
@@ -665,7 +760,7 @@ func (p *WordlePlugin) rehydratePuzzles() {
continue
}
if solved == 1 || guessCount >= 6 {
if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) {
continue // already done
}
@@ -689,18 +784,54 @@ func (p *WordlePlugin) rehydratePuzzles() {
RoomID: roomID,
Answer: pr.answer,
WordLength: pr.wordLength,
MaxGuesses: 6,
MaxGuesses: wordleMaxGuesses(pr.wordLength),
Category: WordleCategory(pr.category),
StartedAt: pr.startedAt,
LetterStates: make(map[rune]LetterResult),
}
// Reload persisted guesses.
guessRows, err := d.Query(
`SELECT word, player_id, player_name, guessed_at FROM wordle_guesses
WHERE puzzle_id = ? AND room_id = ? ORDER BY guess_num ASC`,
pr.pid, pr.roomStr,
)
if err == nil {
for guessRows.Next() {
var word, playerID, playerName string
var guessedAt time.Time
if err := guessRows.Scan(&word, &playerID, &playerName, &guessedAt); err != nil {
continue
}
results := scoreGuess(word, puzzle.Answer)
updateLetterStates(puzzle.LetterStates, word, results)
puzzle.Guesses = append(puzzle.Guesses, WordleGuess{
Word: word,
PlayerID: id.UserID(playerID),
PlayerName: playerName,
Results: results,
Timestamp: guessedAt,
})
}
guessRows.Close()
}
// Check if puzzle is actually done (guesses may have filled up).
if len(puzzle.Guesses) >= puzzle.MaxGuesses {
puzzle.Failed = true
continue
}
if len(puzzle.Guesses) > 0 && puzzle.Guesses[len(puzzle.Guesses)-1].Word == puzzle.Answer {
puzzle.Solved = true
continue
}
p.mu.Lock()
p.puzzles[roomID] = puzzle
p.validCache[pr.pid] = make(map[string]bool)
p.mu.Unlock()
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength)
slog.Info("wordle: rehydrated puzzle", "room", roomID, "answer_len", pr.wordLength, "guesses", len(puzzle.Guesses))
}
}
@@ -745,6 +876,9 @@ func (p *WordlePlugin) checkAndPostDaily() {
return
}
// Announce expiry of yesterday's unsolved puzzle before creating the new one.
p.expireUnsolved(gr)
slog.Info("wordle: posting daily puzzle", "room", gr)
if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil {
slog.Error("wordle: daily puzzle failed", "err", err)

View File

@@ -41,7 +41,7 @@ type WordlePuzzle struct {
RoomID id.RoomID
Answer string // uppercased
WordLength int
MaxGuesses int // always 6
MaxGuesses int
Category WordleCategory
Guesses []WordleGuess
Solved bool

View File

@@ -21,9 +21,9 @@ func letterEmoji(r LetterResult) string {
func renderWordleGrid(puzzle *WordlePuzzle) string {
var sb strings.Builder
status := fmt.Sprintf("%d/6", len(puzzle.Guesses))
status := fmt.Sprintf("%d/%d", len(puzzle.Guesses), puzzle.MaxGuesses)
if puzzle.Solved {
status += " 🟩🟩🟩🟩🟩"
status += " " + strings.Repeat("🟩", puzzle.WordLength)
}
sb.WriteString(fmt.Sprintf("🟩 **Wordle #%d** — %s\n\n", puzzle.PuzzleNumber, status))
@@ -74,8 +74,8 @@ func renderKeyboard(states map[rune]LetterResult) string {
// renderWordleStartAnnouncement renders the puzzle start message.
func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string {
base := fmt.Sprintf(
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — 6 guesses shared.",
puzzleNumber, wordLength,
"🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.",
puzzleNumber, wordLength, wordleMaxGuesses(wordLength),
)
if hint != "" {
base += fmt.Sprintf("\n💡 **Hint:** %s", hint)
@@ -91,7 +91,7 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts [
// Find the solver.
lastGuess := puzzle.Guesses[len(puzzle.Guesses)-1]
sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/6!**\n", len(puzzle.Guesses)))
sb.WriteString(fmt.Sprintf("🎉 **Solved in %d/%d!**\n", len(puzzle.Guesses), puzzle.MaxGuesses))
sb.WriteString(fmt.Sprintf("The word was **%s** — solved by %s on guess %d.\n",
puzzle.Answer, lastGuess.PlayerName, len(puzzle.Guesses)))
@@ -144,7 +144,7 @@ func renderSolvedAnnouncement(puzzle *WordlePuzzle, definition string, payouts [
func renderFailedAnnouncement(puzzle *WordlePuzzle, definition string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — 6/6**\nThe word was **%s**.\n", puzzle.Answer))
sb.WriteString(fmt.Sprintf("💀 **Puzzle failed — %d/%d**\nThe word was **%s**.\n", puzzle.MaxGuesses, puzzle.MaxGuesses, puzzle.Answer))
if definition != "" {
sb.WriteString(fmt.Sprintf("\n📖 *%s*\n", definition))

View File

@@ -37,7 +37,7 @@ func (p *WOTDPlugin) Name() string { return "wotd" }
func (p *WOTDPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "wotd", Description: "Show today's Palavra do Dia", Usage: "!wotd [force]", Category: "Lookup & Reference"},
{Name: "wotd", Description: "Show today's Word of the Day", Usage: "!wotd [force]", Category: "Lookup & Reference"},
}
}
@@ -70,8 +70,119 @@ func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
return nil
}
// Prefetch picks today's Palavra do Dia from DreamDict and stores it in the database.
// Prefers pt-PT words with at least one definition and one English translation.
// pickWord attempts to find a suitable word in the given language.
// Returns word, definition, partOfSpeech, translationsJSON.
// For non-English languages, requires English translations and filters cognates.
// For all languages, fetches synonyms and cross-translations.
func (p *WOTDPlugin) pickWord(lang string) (string, string, string, string) {
// Define which other languages to optionally translate into.
// English translation is required for non-English words; others are best-effort.
type transTarget struct {
lang string
key string
required bool
}
var targets []transTarget
switch lang {
case "pt-PT":
targets = []transTarget{{"en", "en", false}, {"fr", "fr", false}}
case "fr":
targets = []transTarget{{"en", "en", false}, {"pt-PT", "pt-PT", false}}
case "en":
targets = []transTarget{{"fr", "fr", false}, {"pt-PT", "pt-PT", false}}
}
for attempt := 0; attempt < 100; attempt++ {
candidate, err := p.dict.RandomWord(lang, "", 4, 14, 0)
if err != nil {
continue
}
defs, err := p.dict.Define(candidate, lang)
if err != nil || len(defs) == 0 {
continue
}
transMap := map[string][]string{}
valid := true
for _, t := range targets {
trans, _ := p.dict.Translate(candidate, lang, t.lang)
if len(trans) == 0 && t.required {
valid = false
break
}
if len(trans) > 0 {
transMap[t.key] = trans
}
}
if !valid {
continue
}
// For non-English words, reject English words that leaked into the
// foreign language database.
if lang != "en" {
// Skip if the word is a valid English word — it's almost certainly
// not a real Portuguese/French word.
if engValid, _ := p.dict.IsValidWord(candidate, "en"); engValid {
continue
}
// If no English translation from DreamDict, ask the LLM.
if _, ok := transMap["en"]; !ok {
if llmTrans := p.llmTranslate(candidate, lang); llmTrans != "" {
transMap["en"] = []string{llmTrans}
} else {
continue // can't provide any English meaning, skip
}
}
// Skip if the English translation is just the word itself
// (cognate with no meaningful translation).
if enTrans, ok := transMap["en"]; ok {
meaningfulEn := false
for _, t := range enTrans {
if !strings.EqualFold(t, candidate) {
meaningfulEn = true
break
}
}
if !meaningfulEn {
continue
}
}
}
// Fetch synonyms in the word's own language
synonyms, _ := p.dict.Synonyms(candidate, lang)
if len(synonyms) > 0 {
transMap["syn"] = synonyms
}
// Fetch etymology
if etym, err := p.dict.Etymology(candidate, lang); err == nil && etym != "" {
// Truncate for WOTD display (max 200 chars).
if len(etym) > 200 {
if idx := strings.LastIndex(etym[:200], "."); idx > 100 {
etym = etym[:idx+1] + "..."
} else {
etym = etym[:200] + "..."
}
}
transMap["_etym"] = []string{etym}
}
var translationsJSON string
if data, err := json.Marshal(transMap); err == nil {
translationsJSON = string(data)
}
return candidate, defs[0].Gloss, defs[0].POS, translationsJSON
}
return "", "", "", ""
}
// Prefetch picks today's Word of the Day from DreamDict and stores it in the database.
func (p *WOTDPlugin) Prefetch() error {
return p.prefetchWord(false)
}
@@ -94,49 +205,41 @@ func (p *WOTDPlugin) prefetchWord(force bool) error {
}
}
// Pick a random pt-PT word with definitions; prefer one with English translations.
var word, definition, partOfSpeech, translationsJSON string
// Rotate language by day: pt-PT, fr, en
langs := []string{"pt-PT", "fr", "en"}
dayOfYear := time.Now().UTC().YearDay()
lang := langs[dayOfYear%len(langs)]
for attempt := 0; attempt < 10; attempt++ {
candidate, err := p.dict.RandomWord("pt-PT", "", 4, 14)
if err != nil {
slog.Warn("wotd: random word attempt failed", "attempt", attempt+1, "err", err)
continue
}
word, definition, partOfSpeech, translationsJSON := p.pickWord(lang)
defs, err := p.dict.Define(candidate, "pt-PT")
if err != nil || len(defs) == 0 {
continue
// Fallback through other languages if primary fails
if word == "" {
for _, fallback := range langs {
if fallback == lang {
continue
}
slog.Warn("wotd: failed to find word", "lang", lang, "fallback", fallback)
word, definition, partOfSpeech, translationsJSON = p.pickWord(fallback)
if word != "" {
lang = fallback
break
}
}
// Check for English translation (preferred but not required after 5 attempts)
enTrans, _ := p.dict.Translate(candidate, "pt-PT", "en")
if len(enTrans) == 0 && attempt < 5 {
continue
}
frTrans, _ := p.dict.Translate(candidate, "pt-PT", "fr")
word = candidate
definition = defs[0].Gloss
partOfSpeech = defs[0].POS
// Store translations as JSON in the example column
transMap := map[string][]string{}
if len(enTrans) > 0 {
transMap["en"] = enTrans
}
if len(frTrans) > 0 {
transMap["fr"] = frTrans
}
if data, err := json.Marshal(transMap); err == nil {
translationsJSON = string(data)
}
break
}
if word == "" {
return fmt.Errorf("wotd: failed to find a pt-PT word with definitions after 10 attempts")
return fmt.Errorf("wotd: failed to find a word in any language")
}
// Store the language in the translations JSON so formatWOTD knows which language was picked
var transMap map[string][]string
json.Unmarshal([]byte(translationsJSON), &transMap)
if transMap == nil {
transMap = map[string][]string{}
}
transMap["_lang"] = []string{lang}
if data, err := json.Marshal(transMap); err == nil {
translationsJSON = string(data)
}
if force {
@@ -231,12 +334,47 @@ func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
var wotdHeaders = map[string]string{
"pt-PT": "📖 **Palavra do Dia** — %s\n\n",
"fr": "📖 **Mot du Jour** — %s\n\n",
"en": "📖 **Word of the Day** — %s\n\n",
}
var wotdFlags = map[string]string{
"pt-PT": "🇵🇹",
"fr": "🇫🇷",
"en": "🇬🇧",
}
var wotdSynonymLabels = map[string]string{
"pt-PT": "Sinónimos",
"fr": "Synonymes",
"en": "Synonyms",
}
func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON string) string {
now := time.Now().UTC()
dateStr := now.Format("Monday, 2 January 2006")
// Determine the source language from stored metadata
lang := "pt-PT" // default for backwards compat with old entries
var transMap map[string][]string
if translationsJSON != "" {
json.Unmarshal([]byte(translationsJSON), &transMap)
}
if transMap != nil {
if l, ok := transMap["_lang"]; ok && len(l) > 0 {
lang = l[0]
}
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📖 **Palavra do Dia** — %s\n\n", dateStr))
header := wotdHeaders[lang]
if header == "" {
header = "📖 **Word of the Day** — %s\n\n"
}
sb.WriteString(fmt.Sprintf(header, dateStr))
if partOfSpeech != "" {
sb.WriteString(fmt.Sprintf("✨ **%s** (%s)\n\n", word, partOfSpeech))
@@ -244,34 +382,60 @@ func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, translationsJSON
sb.WriteString(fmt.Sprintf("✨ **%s**\n\n", word))
}
// Portuguese definition
// Definition in the word's own language
flag := wotdFlags[lang]
if flag == "" {
flag = lang
}
if definition != "" {
sb.WriteString(fmt.Sprintf("🇵🇹 pt-PT\n %s\n\n", definition))
sb.WriteString(fmt.Sprintf("%s %s\n %s\n", flag, lang, definition))
}
// Parse translations from JSON stored in example column
if translationsJSON != "" {
var transMap map[string][]string
if json.Unmarshal([]byte(translationsJSON), &transMap) == nil {
if enTrans, ok := transMap["en"]; ok && len(enTrans) > 0 {
display := enTrans
// Synonyms
if transMap != nil {
if syns, ok := transMap["syn"]; ok && len(syns) > 0 {
display := syns
if len(display) > 5 {
display = display[:5]
}
label := wotdSynonymLabels[lang]
if label == "" {
label = "Synonyms"
}
sb.WriteString(fmt.Sprintf(" %s: %s\n", label, strings.Join(display, ", ")))
}
sb.WriteString("\n")
// Translations into other languages
for _, tLang := range []string{"en", "fr", "pt-PT"} {
if tLang == lang {
continue
}
if trans, ok := transMap[tLang]; ok && len(trans) > 0 {
display := trans
if len(display) > 5 {
display = display[:5]
}
sb.WriteString(fmt.Sprintf("🇬🇧 en\n %s\n\n", strings.Join(display, ", ")))
}
if frTrans, ok := transMap["fr"]; ok && len(frTrans) > 0 {
display := frTrans
if len(display) > 5 {
display = display[:5]
tFlag := wotdFlags[tLang]
if tFlag == "" {
tFlag = tLang
}
sb.WriteString(fmt.Sprintf("🇫🇷 fr\n %s\n\n", strings.Join(display, ", ")))
sb.WriteString(fmt.Sprintf("%s %s\n %s\n\n", tFlag, tLang, strings.Join(display, ", ")))
}
}
} else {
sb.WriteString("\n")
}
// Etymology section (if available).
if transMap != nil {
if etym, ok := transMap["_etym"]; ok && len(etym) > 0 && etym[0] != "" {
sb.WriteString(fmt.Sprintf("Etymology\n %s\n\n", etym[0]))
}
}
sb.WriteString("━━━━━━━━━━━━━━━━━━━━\n")
sb.WriteString(fmt.Sprintf("Learn more: `!define %s pt-PT`\n", word))
sb.WriteString(fmt.Sprintf("Learn more: `!define %s %s`\n", word, lang))
sb.WriteString("Use this word in a message today to earn 25 XP!")
return sb.String()
}
@@ -418,6 +582,74 @@ Respond with ONLY "yes" or "no".`, message, word)
return accepted
}
// llmTranslate asks the LLM for a brief English translation of a foreign word.
// Returns empty string on failure.
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return ""
}
langName := map[string]string{
"pt-PT": "Portuguese",
"fr": "French",
}[lang]
if langName == "" {
langName = lang
}
prompt := fmt.Sprintf(
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
langName, word)
payload := map[string]interface{}{
"model": model,
"prompt": prompt,
"stream": false,
"think": false,
}
data, err := json.Marshal(payload)
if err != nil {
return ""
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil {
slog.Error("wotd: LLM translate request failed", "err", err)
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return ""
}
var result struct {
Response string `json:"response"`
}
if json.Unmarshal(body, &result) != nil {
return ""
}
response := result.Response
if i := strings.Index(response, "<think>"); i != -1 {
if j := strings.Index(response, "</think>"); j != -1 {
response = response[:i] + response[j+len("</think>"):]
}
}
translation := strings.TrimSpace(response)
if translation == "" || len(translation) > 50 {
return ""
}
slog.Debug("wotd: LLM translation", "word", word, "lang", lang, "translation", translation)
return translation
}
// grantWOTDXP inserts XP directly via SQL to avoid cross-plugin dependency.
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
d := db.Get()

View File

@@ -90,43 +90,6 @@ func ProgressBar(current, max, width int) string {
return "[" + strings.Repeat("#", filled) + strings.Repeat("-", empty) + "] " + strconv.Itoa(pct) + "%"
}
// Archetype definitions matching the TS version.
type Archetype struct {
Name string
Description string
}
var archetypes = []struct {
name string
desc string
check func(s MessageStats, totalMessages int) bool
}{
{"Chatterbox", "You talk a LOT", func(s MessageStats, t int) bool { return t > 500 && s.Words > 0 }},
{"Novelist", "Long-form writer", func(s MessageStats, t int) bool { return s.Words > 0 && s.Chars/max1(s.Words) > 8 }},
{"Inquisitor", "Always asking questions", func(s MessageStats, t int) bool { return s.Questions > t/5 && s.Questions > 10 }},
{"Linkmaster", "Shares lots of links", func(s MessageStats, t int) bool { return s.Links > t/10 && s.Links > 5 }},
{"Shutterbug", "Posts lots of images", func(s MessageStats, t int) bool { return s.Images > t/10 && s.Images > 5 }},
{"Enthusiast", "Lots of exclamation marks!", func(s MessageStats, t int) bool { return s.Exclamations > t/4 && s.Exclamations > 10 }},
{"Regular", "A steady community member", func(_ MessageStats, _ int) bool { return true }},
}
// DeriveArchetype picks the best-fitting archetype based on aggregate stats.
func DeriveArchetype(stats MessageStats, totalMessages int) Archetype {
for _, a := range archetypes {
if a.check(stats, totalMessages) {
return Archetype{Name: a.name, Description: a.desc}
}
}
return Archetype{Name: "Regular", Description: "A steady community member"}
}
func max1(n int) int {
if n < 1 {
return 1
}
return n
}
// IsCommand checks if body starts with prefix+command (case-insensitive).
func IsCommand(body, prefix, command string) bool {
cmd := prefix + command

31
main.go
View File

@@ -112,6 +112,7 @@ func main() {
// Entertainment / Lookup
registry.Register(plugin.NewRetroPlugin(client))
registry.Register(plugin.NewLookupPlugin(client, ratePlugin, dictClient))
registry.Register(plugin.NewDictionaryPlugin(client, dictClient))
registry.Register(plugin.NewCountdownPlugin(client))
registry.Register(plugin.NewStocksPlugin(client))
forexPlugin := plugin.NewForexPlugin(client)
@@ -135,6 +136,8 @@ func main() {
registry.Register(adventurePlugin)
wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient)
registry.Register(wordlePlugin)
lotteryPlugin := plugin.NewLotteryPlugin(client, euroPlugin)
registry.Register(lotteryPlugin)
// Community
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))
@@ -155,7 +158,7 @@ func main() {
registry.Register(minifluxPlugin)
// LLM-powered (passive)
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin))
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin, dictClient))
// Scheduled
wotdPlugin := plugin.NewWOTDPlugin(client, dictClient)
@@ -309,6 +312,9 @@ func main() {
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin, animePlugin, moviesPlugin, concertsPlugin, esteemedPlugin, forexPlugin, minifluxPlugin, marketPlugin)
scheduler.Start()
// ---- Initial archetype calculation ----
go plugin.RefreshAllArchetypes()
// ---- Start syncing ----
slog.Info("GogoBee starting sync...")
@@ -366,7 +372,6 @@ func setupScheduledJobs(
// Prefetch at 00:05 — grab data ahead of scheduled posts
c.AddFunc("5 0 * * *", func() {
slog.Info("scheduler: prefetching daily data")
wotd.Prefetch()
holidays.Prefetch()
})
@@ -387,12 +392,16 @@ func setupScheduledJobs(
})
// WOTD post at 08:00
c.AddFunc("0 8 * * *", func() {
slog.Info("scheduler: posting WOTD")
for _, r := range rooms {
wotd.PostWOTD(r)
}
})
if strings.ToLower(os.Getenv("DISABLE_WOTD_POST")) != "true" {
c.AddFunc("0 8 * * *", func() {
slog.Info("scheduler: posting WOTD")
for _, r := range rooms {
wotd.PostWOTD(r)
}
})
} else {
slog.Info("scheduler: WOTD daily post disabled via DISABLE_WOTD_POST")
}
// Game releases Monday 09:00
c.AddFunc("0 9 * * 1", func() {
@@ -461,6 +470,12 @@ func setupScheduledJobs(
plugin.MarkovPurgeExpired()
})
// Archetype refresh at 04:30 daily
c.AddFunc("30 4 * * *", func() {
slog.Info("scheduler: refreshing archetypes")
plugin.RefreshAllArchetypes()
})
// Miniflux RSS polling
if miniflux != nil {
interval := fmt.Sprintf("@every %dm", miniflux.PollInterval())