mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 19:01:09 +00:00
Compare commits
60
Commits
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -3,3 +3,5 @@ data/
|
||||
.env.*
|
||||
!.env.example
|
||||
gogobee
|
||||
gensolver
|
||||
holdem-train
|
||||
|
||||
@@ -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 2–4 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 2–4 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), Co-op Dungeons (2–7 day party runs with funding tiers, TwinBee-narrated floor events with voting, spectator parimutuel betting, basket/mimic gift system, weighted-roll item distribution including masterworks at T4–T5), 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.0–1.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,122 @@ 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, blacksmith, 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) |
|
||||
| `!adventure boost` | Double XP/money for all adventurers for a day (admin) |
|
||||
| `!adventure babysit [week\|month\|status\|cancel]` | Hire TwinBee to run your daily action (trains your weakest gathering skill) |
|
||||
| `!adventure blacksmith` | Show repair quotes for damaged gear |
|
||||
| `!adventure repair [all\|<slot>]` | Repair one slot or all damaged gear at the blacksmith |
|
||||
| `!adventure rivals` | Show the rival pool and any open challenges |
|
||||
| `!thom [shop\|buy\|pay\|payoff\|autopay\|petbuy]` | Thom Krooke — housing, mortgage, and pet adoption broker |
|
||||
| `!hospital` | Check in to St. Guildmore's Memorial Hospital for same-day revival (costs €25k × combat level) |
|
||||
|
||||
**DM replies:** Reply to the morning prompt with a number (`1`–`5`) or activity name (`dungeon`, `mine`, `forage`, `shop`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
|
||||
**DM replies:** Reply to the morning prompt with a number (`1`–`7`) or activity name (`dungeon`, `mine`, `fish`, `forage`, `shop`, `blacksmith`, `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. Natural respawn happens automatically when the timer expires. Use `!hospital` for immediate 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:00–21: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.
|
||||
- **Chat level perks** — active chat participation boosts your adventurer. +5% XP per 10 chat levels (capped at +25% at level 50+), plus +0.5% rare drop chance per 10 levels.
|
||||
|
||||
#### Crafting
|
||||
|
||||
Auto-crafting kicks in at Foraging Lv.10. Before each combat action, the system scans your inventory for matching ingredients and assembles the highest-tier recipe you qualify for. 12 recipes spanning T1–T5, gated at Foraging 10/15/20/25/30. Per-recipe success rate is 50% at the unlock level, +3% per 5 levels above (capped 95%). Failed crafts destroy the ingredients — you tried.
|
||||
|
||||
Per attempt:
|
||||
- **Successful crafts** grant Foraging XP (T1: +12, T2: +25, T3: +40, T4: +60, T5: +90) and bump a lifetime `CraftsSucceeded` counter shown on `!adventure status`.
|
||||
- **Failed crafts** grant a token Foraging XP (1/4 of success).
|
||||
|
||||
Max auto-crafts per combat scales with level: 1 at Foraging 10, 2 at 20, 3 at 30+.
|
||||
|
||||
`!adventure recipes` lists every recipe unlocked at your current Foraging level with ingredients and per-recipe success rate, plus a teaser for the next unlock threshold.
|
||||
|
||||
**Consumable protection:** crafted/dropped consumables (Type `consumable`) are excluded from `!adventure sell all` and Robbie the Friendly Bandit's pickup filter. Selling consumables requires explicit `!adventure sell <name>` — no accidental mass-sells, no surprise donations to the community pot.
|
||||
|
||||
#### Blacksmith & Repair
|
||||
|
||||
Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair <slot>` commits. Visiting the blacksmith counts as your daily action.
|
||||
|
||||
#### Babysitting Service
|
||||
|
||||
Busy days? Hire TwinBee to run your daily action for you. Daily cost is `€100 + combatLevel × €20`. TwinBee trains your weakest gathering skill (mining/fishing/foraging) so the service doubles as catch-up for neglected tracks. Subscribe by the week or month, or check/cancel anytime: `!adventure babysit week|month|status|cancel`.
|
||||
|
||||
#### Rival Duels
|
||||
|
||||
At combat level 5 and above you're entered into the rival pool. Every 3–4 days a random eligible player is matched as your rival and challenges you to a rock-paper-scissors duel via DM. You have 24 hours to accept and play. Stake is `(combatLevel / 5) × €1,000`, winner-take-all. Use `!adventure rivals` to see the pool and open challenges.
|
||||
|
||||
#### Housing & Mortgage (Thom Krooke)
|
||||
|
||||
Thom Krooke is the guild's housing broker. Four tiers are available:
|
||||
|
||||
| Tier | Name | Price |
|
||||
|------|------|-------|
|
||||
| 1 | Base | €75,000 |
|
||||
| 2 | Livable | €150,000 |
|
||||
| 3 | Comfortable | €300,000 |
|
||||
| 4 | Established | €600,000 |
|
||||
|
||||
Buy outright or finance with a mortgage. Rates track the real-world US 5/1 ARM via the FRED API (`MORTGAGE5US` series; requires `FRED_API_KEY`, defaults to 6.5% if unset). Autopay pulls from your euro balance each day. Commands: `!thom shop`, `!thom buy <tier>`, `!thom pay <amount>`, `!thom payoff`, `!thom autopay on|off`, `!thom petbuy`.
|
||||
|
||||
#### Pets
|
||||
|
||||
Once you reach the Livable tier (HouseTier ≥ 2) a stray pet may show up at your door — 15% daily chance. When a pet arrives you'll get a DM prompt to `chase` it away or `feed` it to adopt. Each pet interaction grants +1.5 XP to a random skill. At pet Level 10, Thom unlocks pet armor for purchase (`!thom petbuy <tier>`).
|
||||
|
||||
#### Guest NPCs — Misty & Arina
|
||||
|
||||
Two rotating guest adventurers can be hired to join you on encounters:
|
||||
|
||||
- **Misty** — €100 hire fee, 7-day cooldown.
|
||||
- **Arina** — €5,000 hire fee, 7-day cooldown.
|
||||
|
||||
After hiring, there's a 7.5% chance per action over the next 7 days that your NPC shows up mid-adventure and applies a buff to the outcome.
|
||||
|
||||
#### 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 for 6 hours. 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 |
|
||||
|---------|-------------|
|
||||
@@ -527,6 +603,61 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
|
||||
- **Auto-cashout** — 10 minutes to decide after clearing a tier. GogoBee cashes out on your behalf if you're slow.
|
||||
- **Tier entry confirmation** — `!arena tier N` previews the first opponent; `!arena fight` commits.
|
||||
|
||||
#### Co-op Dungeons (`!coop`)
|
||||
|
||||
Multi-day party runs separate from solo. 2–4 players, 2–7 days depending on tier, scaled rewards, and a public game-room thread the whole community watches. The combat action is consumed once at lock; subsequent days only ask for a daily funding decision (harvest activities continue normally).
|
||||
|
||||
| Tier | Days | Difficulty | Reward Pool | Optimal Funding |
|
||||
|------|------|------------|-------------|-----------------|
|
||||
| 1 | 2 | Moderate | €22,500 | Minimal/Standard |
|
||||
| 2 | 3 | Hard | €40,000 | Standard |
|
||||
| 3 | 4 | Very Hard | €72,500 | Standard |
|
||||
| 4 | 5 | Brutal | €120,000 | Standard or Aggressive |
|
||||
| 5 | 7 | Murderous | €600,000 | Aggressive (must commit) |
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!coop list` | Show open invites |
|
||||
| `!coop start <1-5>` | Open an invite (24h window, pinned in the games room) |
|
||||
| `!coop join [<id>]` | Join an open invite (defaults to most recent) |
|
||||
| `!coop fund <tier>` | Set today's funding (`none` / `minimal` / `standard` / `aggressive` / `all_in`) |
|
||||
| `!coop vote <A\|B\|C>` | Vote on the day's TwinBee-narrated floor event |
|
||||
| `!coop bet <amount> <success\|failure>` | Spectator parimutuel bet (10% rake) |
|
||||
| `!coop gift <run_id> <basket\|mimic>` | Send a gift (1 harvest action) |
|
||||
| `!coop giftvote <id> <open\|leave>` | Party votes whether to open a received gift |
|
||||
| `!coop status` | Your current run state |
|
||||
| `!coop stats` | Community-wide aggregates: outcomes, gold flow, betting, gifts, helpfulness |
|
||||
| `!coop cancel` | Leader cancels an open invite before lock |
|
||||
| `!coop admgift <run_id> <basket\|mimic> [sender]` | Drop a gift into an active run, bypassing party-member and harvest-action checks (admin only — debug/testing) |
|
||||
| `!coop help` | Co-op command list |
|
||||
|
||||
**Funding tiers** add to the per-floor success modifier: None −10%, Minimal +0%, Standard +8%, Aggressive +18%, All-In +30%. None is free; Minimal €500/day; Standard €1,500; Aggressive €4,000; All-In €10,000. Funding is non-refundable. Inactive players auto-play None.
|
||||
|
||||
**Floor events.** TwinBee narrates 1 of 4 categories per floor (Obstacle / Opportunity / Crisis / Encounter), tier-weighted. Each event has 2–3 options; majority vote wins, ties go to the leader, leader has the casting tiebreak. TwinBee always recommends an option — he is wrong roughly half the time. The party's vote modifier (−15 to +12 per floor depending on the option) stacks on the funding modifier. The 95% per-floor success cap means once you're saturated, more funding is wasted.
|
||||
|
||||
**Party strength factors.** Each member contributes a small per-floor modifier based on their combat level relative to the tier minimum (clamped ±8) plus a pet bonus (max +2.5 at pet level 10). A maxed veteran party at T5 can succeed at Standard funding where a fresh party would need Aggressive.
|
||||
|
||||
**Newbie liability.** Players below a tier's minimum combat level are flagged in the party post and capped at +8% funding modifier regardless of how much they pay. The party chose to let them in. Their reward share is unchanged — the cost is borne by the funding economy, not the post-run split.
|
||||
|
||||
**Spectator betting.** Parimutuel pool, 10% rake. Bets can be placed or increased any time during the run; positions are locked in (success or failure can't be switched). Party members can bet on their own run — sandbagging while heavily bet against is exploitative and visible. Odds line shows estimated success% based on party levels, pets, neutral funding assumption, and a hidden TwinBee Helpfulness Rating (rolling last 30 floor events: how often his recommendation was right). Helpfulness shifts the line ±20% but is never shown directly — players notice the line moves after events resolve.
|
||||
|
||||
**Gifts.** Anyone *not* in the run can spend one harvest action to send a Care Basket or a Mimic. The party never sees which type. Vote `open` or `leave`. **Each gift's voting window is 6 hours**; voting closes when it elapses, the stack is tallied, and every sender gets a DM with the result. The modifier sits on the run until the next floor resolution merges it in. Gifts "fire" throughout the day rather than piling up at the daily tick. Magnitudes are equalized across all four outcomes — both "always open" and "always leave" yield EV=0 with σ=6, so neither is risk-averse-dominant. The choice is purely about reading sender intent.
|
||||
|
||||
**Stacking.** Multiple gifts of the same type sent on the same day **stack into a single game-room post** — one vote covers the whole stack, modifier scales with size (5 baskets opened = +30%, 5 baskets exploded = −30%). The first-in sets the deadline; subsequent additions inherit it without extending the timer. When a stack hits size 2, TwinBee notes "this gift looks REALLY special" once and then quietly bumps the count on further additions. The end-of-run gift log groups by stack with all senders attributed.
|
||||
|
||||
| | Open | Leave |
|
||||
|-------------|------|-------|
|
||||
| **Care Basket** | +6% (boost) | −6% (basket explodes) |
|
||||
| **Mimic** | −6% (mimic does what mimics do) | +6% (sad mimic helps anyway) |
|
||||
|
||||
Multiple gifts stack additively — coordinated senders can swing odds significantly. The full gift log is public at end of run.
|
||||
|
||||
**Loot.** On success, gold pool splits evenly (5% community tax). 2–6 items drop from the corresponding solo dungeon loot table (count scales with tier). Each item goes to a member via weighted roll — your share of total funding is your odds. **T5 always drops a masterwork**; T4 has a 25% chance. Masterworks go to inventory (not auto-equipped) so winners with better gear can sell or trade.
|
||||
|
||||
**Wipes.** No reward. Funding is gone. No combat-action refund (the tick already gave you a fresh one at midnight). Bet pool pays out to the failure side. The dungeon does not editorialize.
|
||||
|
||||
**Pinning.** Invite posts are pinned in the games room when opened, unpinned when the run locks or cancels. Bot needs power level for state events.
|
||||
|
||||
### Reminders
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -594,13 +725,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 +847,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 +871,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 +882,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 +957,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 +983,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 +1052,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 +1074,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 +1098,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
|
||||
|
||||
@@ -85,6 +85,12 @@ func main() {
|
||||
failed := 0
|
||||
|
||||
for i, entry := range entries {
|
||||
// Validate entry ID to prevent path traversal or command injection.
|
||||
if entry.ID == "" || strings.ContainsAny(entry.ID, "/\\;|&$`\"'") || strings.Contains(entry.ID, "..") {
|
||||
fmt.Printf("[%d/%d] SKIP (invalid ID): %q\n", i+1, len(entries), entry.ID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
outPath := filepath.Join(outputDir, entry.ID+".jpg")
|
||||
|
||||
// Skip if already downloaded
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
// cmd/gensolver drives TexasSolver offline to populate
|
||||
// internal/plugin/testdata/solver_freqs.json for Layer 2 tip scenario tests.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// GOGOBEE_SOLVER=/path/to/console_solver \
|
||||
// GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver-v0.2.0-Linux/resources \
|
||||
// go run ./cmd/gensolver [scenario-name-substring]
|
||||
//
|
||||
// If no positional arg is given, every postflop scenario is solved. Results
|
||||
// are *merged* into the existing fixture file — re-running one scenario does
|
||||
// not wipe the others.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
)
|
||||
|
||||
const fixturePath = "internal/plugin/testdata/solver_freqs.json"
|
||||
|
||||
// Heads-up default ranges. Postflop only — preflop solving needs a full range
|
||||
// tree and is out of scope for our Layer 2 validation, so we skip preflop
|
||||
// scenarios entirely.
|
||||
//
|
||||
// These are deliberately coarse: HU BTN opens wide (~70%), BB defends wide
|
||||
// (~55% vs min-raise). Refine per scenario if solver output looks nonsensical.
|
||||
// TexasSolver range syntax does NOT support the `22+` / `A2s+` shorthand — it
|
||||
// requires explicit enumeration. These two ranges are lifted verbatim from the
|
||||
// solver's own sample input file so we know they parse and produce sensible
|
||||
// equilibria. Not tuned for heads-up specifically; refine later if needed.
|
||||
const (
|
||||
rangeBTNOpen = "AA,KK,QQ,JJ,TT,99:0.75,88:0.75,77:0.5,66:0.25,55:0.25,AK,AQs,AQo:0.75,AJs,AJo:0.5,ATs:0.75,A6s:0.25,A5s:0.75,A4s:0.75,A3s:0.5,A2s:0.5,KQs,KQo:0.5,KJs,KTs:0.75,K5s:0.25,K4s:0.25,QJs:0.75,QTs:0.75,Q9s:0.5,JTs:0.75,J9s:0.75,J8s:0.75,T9s:0.75,T8s:0.75,T7s:0.75,98s:0.75,97s:0.75,96s:0.5,87s:0.75,86s:0.5,85s:0.5,76s:0.75,75s:0.5,65s:0.75,64s:0.5,54s:0.75,53s:0.5,43s:0.5"
|
||||
rangeBBDefend = "QQ:0.5,JJ:0.75,TT,99,88,77,66,55,44,33,22,AKo:0.25,AQs,AQo:0.75,AJs,AJo:0.75,ATs,ATo:0.75,A9s,A8s,A7s,A6s,A5s,A4s,A3s,A2s,KQ,KJ,KTs,KTo:0.5,K9s,K8s,K7s,K6s,K5s,K4s:0.5,K3s:0.5,K2s:0.5,QJ,QTs,Q9s,Q8s,Q7s,JTs,JTo:0.5,J9s,J8s,T9s,T8s,T7s,98s,97s,96s,87s,86s,76s,75s,65s,64s,54s,53s,43s"
|
||||
)
|
||||
|
||||
// SolverNode mirrors the recursive shape of TexasSolver's dump_result JSON.
|
||||
// Every action node has: `actions` (the player-to-act's options), `strategy`
|
||||
// (hand→freq map for those actions), and `childrens` (subtree per action).
|
||||
type SolverNode struct {
|
||||
Actions []string `json:"actions"`
|
||||
Strategy *StrategyBlock `json:"strategy,omitempty"`
|
||||
Childrens map[string]*SolverNode `json:"childrens,omitempty"`
|
||||
NodeType string `json:"node_type,omitempty"`
|
||||
Player int `json:"player,omitempty"`
|
||||
}
|
||||
|
||||
type StrategyBlock struct {
|
||||
Actions []string `json:"actions"`
|
||||
Strategy map[string][]float64 `json:"strategy"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
filter := strings.ToLower(flag.Arg(0))
|
||||
|
||||
solverBin := os.Getenv("GOGOBEE_SOLVER")
|
||||
resourceDir := os.Getenv("GOGOBEE_SOLVER_RESOURCES")
|
||||
if solverBin == "" || resourceDir == "" {
|
||||
fmt.Fprintln(os.Stderr, "set GOGOBEE_SOLVER and GOGOBEE_SOLVER_RESOURCES")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
existing := loadFixture()
|
||||
workDir, err := os.MkdirTemp("", "gensolver-*")
|
||||
must(err)
|
||||
defer os.RemoveAll(workDir)
|
||||
|
||||
solved := 0
|
||||
for _, s := range plugin.TipScenarios() {
|
||||
if s.Street == plugin.StreetPreFlop || len(s.BoardStr) == 0 {
|
||||
continue
|
||||
}
|
||||
if filter != "" && !strings.Contains(strings.ToLower(s.Name), filter) {
|
||||
continue
|
||||
}
|
||||
fmt.Printf("solving: %s\n", s.Name)
|
||||
freqs, err := solveScenario(s, solverBin, resourceDir, workDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
|
||||
continue
|
||||
}
|
||||
existing[s.Name] = freqs
|
||||
fmt.Printf(" → %v\n", freqs)
|
||||
solved++
|
||||
}
|
||||
|
||||
writeFixture(existing)
|
||||
fmt.Printf("\ndone. %d scenarios solved, fixture written to %s\n", solved, fixturePath)
|
||||
}
|
||||
|
||||
func solveScenario(s plugin.TipScenario, bin, resources, workDir string) (map[string]float64, error) {
|
||||
input := buildInputFile(s)
|
||||
inputPath := filepath.Join(workDir, "input.txt")
|
||||
outputPath := filepath.Join(workDir, "output.json")
|
||||
if err := os.WriteFile(inputPath, []byte(input), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TexasSolver writes output_result.json to its CWD, so cd into workDir.
|
||||
cmd := exec.Command(bin, "-i", inputPath, "-r", resources)
|
||||
cmd.Dir = workDir
|
||||
cmd.Stdout = os.Stderr // surface solver logs on stderr so JSON doesn't mix in
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("solver failed: %w", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outputPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read output: %w", err)
|
||||
}
|
||||
|
||||
return extractHeroFrequencies(data, s)
|
||||
}
|
||||
|
||||
func buildInputFile(s plugin.TipScenario) string {
|
||||
board := strings.Join(s.BoardStr, ",")
|
||||
ipRange, oopRange := rangesFor(s)
|
||||
|
||||
// Normalize to solver-friendly scale: pot=50, stack=8×pot, preserving
|
||||
// SPR. TexasSolver segfaults on large chip counts for certain textures
|
||||
// (suspected internal precision/overflow on some flop trees). Strategic
|
||||
// equivalence holds because GTO frequencies are scale-invariant; only
|
||||
// the raw chip values in action labels change. Caps SPR at 8 to keep
|
||||
// tree build time sane regardless of scenario stack depth.
|
||||
const normalizedPot = 50
|
||||
spr := float64(s.Stack) / float64(s.Pot)
|
||||
if spr > 8 {
|
||||
spr = 8
|
||||
}
|
||||
normalizedStack := int(float64(normalizedPot) * spr)
|
||||
if normalizedStack < 100 {
|
||||
normalizedStack = 100
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "set_pot %d\n", normalizedPot)
|
||||
fmt.Fprintf(&b, "set_effective_stack %d\n", normalizedStack)
|
||||
fmt.Fprintf(&b, "set_board %s\n", board)
|
||||
fmt.Fprintf(&b, "set_range_ip %s\n", ipRange)
|
||||
fmt.Fprintf(&b, "set_range_oop %s\n", oopRange)
|
||||
// Simple bet tree: half-pot + allin each street.
|
||||
for _, side := range []string{"ip", "oop"} {
|
||||
for _, street := range []string{"flop", "turn", "river"} {
|
||||
fmt.Fprintf(&b, "set_bet_sizes %s,%s,bet,50,100\n", side, street)
|
||||
fmt.Fprintf(&b, "set_bet_sizes %s,%s,raise,60\n", side, street)
|
||||
fmt.Fprintf(&b, "set_bet_sizes %s,%s,allin\n", side, street)
|
||||
}
|
||||
}
|
||||
b.WriteString("set_allin_threshold 0.67\n")
|
||||
b.WriteString("build_tree\n")
|
||||
b.WriteString("set_thread_num 8\n")
|
||||
// accuracy 1.0 = stop when total exploitability < 1% of pot. Empirically
|
||||
// this is reached in ~80 iterations on our scenarios vs 120+ for 0.5%,
|
||||
// cutting per-scenario time roughly in half with no practical loss for
|
||||
// validation (we only check if rules engine matches a significant-freq
|
||||
// action, not exact frequencies).
|
||||
b.WriteString("set_accuracy 1.0\n")
|
||||
b.WriteString("set_max_iteration 100\n")
|
||||
b.WriteString("set_print_interval 20\n")
|
||||
// Isomorphism optimization segfaults on certain flop textures (notably
|
||||
// paired boards and some two-tone). Disabling costs ~20% extra solve
|
||||
// time but makes the pipeline reliable.
|
||||
b.WriteString("set_use_isomorphism 0\n")
|
||||
b.WriteString("start_solve\n")
|
||||
b.WriteString("set_dump_rounds 1\n")
|
||||
b.WriteString("dump_result output.json\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func rangesFor(s plugin.TipScenario) (ip, oop string) {
|
||||
// HU: BTN=IP, BB=OOP.
|
||||
return rangeBTNOpen, rangeBBDefend
|
||||
}
|
||||
|
||||
// extractHeroFrequencies walks the dumped tree to find the node where hero
|
||||
// is actually to act, then returns hero's action frequencies for their exact
|
||||
// hole combo, normalized to {check,bet,call,fold,raise}.
|
||||
//
|
||||
// HU postflop ordering: OOP acts first on every street. So the root node is
|
||||
// always OOP's decision.
|
||||
//
|
||||
// - hero=OOP, ToCall=0 → root (OOP first to act, no action yet)
|
||||
// - hero=IP, ToCall=0 → root.childrens["CHECK"] (OOP checked, IP facing check)
|
||||
// - hero=IP, ToCall>0 → root.childrens["BET <x>"] matching ToCall size
|
||||
// - hero=OOP, ToCall>0 → not supported yet (check-bet line, 2 levels deep)
|
||||
func extractHeroFrequencies(data []byte, s plugin.TipScenario) (map[string]float64, error) {
|
||||
var root SolverNode
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("parse json: %w", err)
|
||||
}
|
||||
|
||||
heroIP := s.Position == "BTN" || s.Position == "SB"
|
||||
// Normalize ToCall to the solver's chip scale (pot=50) so bet-child
|
||||
// matching works after the pot/stack normalization in buildInputFile.
|
||||
normalizedToCall := float64(s.ToCall) * 50.0 / float64(s.Pot)
|
||||
node, err := navigateToHero(&root, heroIP, normalizedToCall)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if node.Strategy == nil {
|
||||
return nil, fmt.Errorf("hero node has no strategy (node_type=%s)", node.NodeType)
|
||||
}
|
||||
|
||||
key := holeKey(s.HoleStr)
|
||||
raw, ok := node.Strategy.Strategy[key]
|
||||
if !ok {
|
||||
raw, ok = node.Strategy.Strategy[flipHole(key)]
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("hole %q not found in strategy (tried %q)", key, flipHole(key))
|
||||
}
|
||||
if len(raw) != len(node.Strategy.Actions) {
|
||||
return nil, fmt.Errorf("action/freq length mismatch: %d vs %d", len(raw), len(node.Strategy.Actions))
|
||||
}
|
||||
|
||||
out := map[string]float64{}
|
||||
for i, act := range node.Strategy.Actions {
|
||||
out[normalizeAction(act)] += raw[i]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func navigateToHero(root *SolverNode, heroIP bool, toCall float64) (*SolverNode, error) {
|
||||
// HU postflop: OOP always acts first on each street, so root is OOP's node.
|
||||
switch {
|
||||
case !heroIP && toCall == 0:
|
||||
// OOP first to act, no prior action.
|
||||
return root, nil
|
||||
|
||||
case heroIP && toCall == 0:
|
||||
// OOP checked → IP facing check.
|
||||
return childByLabel(root, "CHECK")
|
||||
|
||||
case heroIP && toCall > 0:
|
||||
// OOP donk-bet (or we're mid-street with OOP having bet first). Find
|
||||
// the BET child whose chip amount is closest to the scenario's ToCall.
|
||||
return childByBetSize(root, toCall)
|
||||
|
||||
case !heroIP && toCall > 0:
|
||||
// Check-bet line: OOP checks → IP bets → OOP facing bet.
|
||||
checkNode, err := childByLabel(root, "CHECK")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check-bet line: %w", err)
|
||||
}
|
||||
return childByBetSize(checkNode, toCall)
|
||||
}
|
||||
return nil, fmt.Errorf("unreachable")
|
||||
}
|
||||
|
||||
func childByLabel(node *SolverNode, label string) (*SolverNode, error) {
|
||||
child, ok := node.Childrens[label]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no %q child; available: %v", label, keysOf(node.Childrens))
|
||||
}
|
||||
return child, nil
|
||||
}
|
||||
|
||||
// childByBetSize picks the BET child whose chip amount is closest to toCall.
|
||||
// Rejects the match if the nearest bet size differs by more than 25% — that
|
||||
// usually means the solver wasn't configured with a comparable sizing and the
|
||||
// returned frequencies would describe a different decision.
|
||||
func childByBetSize(node *SolverNode, toCall float64) (*SolverNode, error) {
|
||||
var best *SolverNode
|
||||
var bestAmt float64
|
||||
bestDelta := 1e18
|
||||
for label, child := range node.Childrens {
|
||||
if !strings.HasPrefix(label, "BET") {
|
||||
continue
|
||||
}
|
||||
amt := parseBetAmount(label)
|
||||
if amt < 0 {
|
||||
continue
|
||||
}
|
||||
d := amt - toCall
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestDelta {
|
||||
bestDelta = d
|
||||
bestAmt = amt
|
||||
best = child
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, fmt.Errorf("no BET child matching toCall=%v; available: %v", toCall, keysOf(node.Childrens))
|
||||
}
|
||||
if toCall > 0 && bestDelta/toCall > 0.25 {
|
||||
return nil, fmt.Errorf("nearest BET child %.2f is >25%% off toCall=%.2f; solver sizings don't cover this spot; available: %v", bestAmt, toCall, keysOf(node.Childrens))
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
func parseBetAmount(label string) float64 {
|
||||
// "BET 25.000000" → 25
|
||||
parts := strings.Fields(label)
|
||||
if len(parts) != 2 {
|
||||
return -1
|
||||
}
|
||||
var v float64
|
||||
if _, err := fmt.Sscanf(parts[1], "%f", &v); err != nil {
|
||||
return -1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func keysOf(m map[string]*SolverNode) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holeKey builds TexasSolver's hand key: two cards with higher rank first.
|
||||
// Ranks: 2..9,T,J,Q,K,A. Suit order for same-rank pairs: s > h > d > c.
|
||||
func holeKey(hole [2]string) string {
|
||||
a, b := hole[0], hole[1]
|
||||
if cardLess(b, a) {
|
||||
return a + b
|
||||
}
|
||||
return b + a
|
||||
}
|
||||
|
||||
func flipHole(k string) string {
|
||||
if len(k) != 4 {
|
||||
return k
|
||||
}
|
||||
return k[2:] + k[:2]
|
||||
}
|
||||
|
||||
func cardLess(a, b string) bool {
|
||||
ra := rankIdx(a[0])
|
||||
rb := rankIdx(b[0])
|
||||
if ra != rb {
|
||||
return ra < rb
|
||||
}
|
||||
return suitIdx(a[1]) < suitIdx(b[1])
|
||||
}
|
||||
|
||||
func rankIdx(r byte) int {
|
||||
return strings.IndexByte("23456789TJQKA", r)
|
||||
}
|
||||
|
||||
func suitIdx(s byte) int {
|
||||
return strings.IndexByte("cdhs", s)
|
||||
}
|
||||
|
||||
func normalizeAction(a string) string {
|
||||
a = strings.ToLower(a)
|
||||
switch {
|
||||
case strings.HasPrefix(a, "check"):
|
||||
return "check"
|
||||
case strings.HasPrefix(a, "fold"):
|
||||
return "fold"
|
||||
case strings.HasPrefix(a, "call"):
|
||||
return "call"
|
||||
case strings.HasPrefix(a, "bet"):
|
||||
return "bet"
|
||||
case strings.HasPrefix(a, "raise"):
|
||||
return "raise"
|
||||
case strings.Contains(a, "allin"):
|
||||
return "raise"
|
||||
default:
|
||||
return a
|
||||
}
|
||||
}
|
||||
|
||||
func loadFixture() map[string]map[string]float64 {
|
||||
out := map[string]map[string]float64{}
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
_ = json.Unmarshal(data, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func writeFixture(m map[string]map[string]float64) {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ordered := make(map[string]map[string]float64, len(m))
|
||||
for _, k := range keys {
|
||||
ordered[k] = m[k]
|
||||
}
|
||||
data, err := json.MarshalIndent(ordered, "", " ")
|
||||
must(err)
|
||||
must(os.MkdirAll(filepath.Dir(fixturePath), 0o755))
|
||||
must(os.WriteFile(fixturePath, append(data, '\n'), 0o644))
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
# Fix: Texas Hold'em LLM Tips
|
||||
|
||||
## What's broken
|
||||
|
||||
Two confirmed issues observed across multiple tip examples:
|
||||
|
||||
### 1. Position label is inverted in heads-up play
|
||||
|
||||
The tip says "positional advantage" when the player is acting first post-flop (out of position) and "out of position" when they're acting last. The position label reaching the LLM prompt is wrong.
|
||||
|
||||
**Root cause:** the `positionLabel()` function in `tips.go` derives position from `DealerIdx` using the general formula. In heads-up play the dealer posts the small blind and acts first pre-flop but **last** post-flop. The heads-up exception that exists in `PostBlinds()` in `betting.go` is not being reflected in position label calculation.
|
||||
|
||||
**Fix:** in `positionLabel()`, gate on `len(g.Players) == 2` before applying any label logic. In heads-up:
|
||||
- Pre-flop: dealer = BTN/SB (acts first), other player = BB (acts last)
|
||||
- Post-flop: dealer = BTN (acts last, positional advantage), other player = BB (acts first, out of position)
|
||||
|
||||
Check which street it is before assigning the label. `g.Street == PreFlop` needs different position semantics than all other streets in heads-up.
|
||||
|
||||
---
|
||||
|
||||
### 2. LLM is generating generic concepts instead of hand-specific advice
|
||||
|
||||
**Observed:** tips reference equity numbers but then ignore what those numbers mean for the specific hand. A player with 8♥ 7♥ on Q♥ K♠ 10♦ (gutshot + backdoor flush draw, 29% equity, free card available) received "not enough equity to bet" — which ignores the draw entirely and misapplies a made-hand concept to a drawing hand.
|
||||
|
||||
**Root cause:** the user prompt is not giving the LLM enough structured context to reason about hand *type*. It sees an equity number but doesn't know whether the hand is a draw, a made hand, a bluff catcher, or air. It pattern-matches on the number alone.
|
||||
|
||||
**Fix:** compute and inject the following additional fields into `TipContext` before building the prompt:
|
||||
|
||||
```go
|
||||
type TipContext struct {
|
||||
// existing fields...
|
||||
|
||||
// Add these:
|
||||
HandCategory string // from poker.RankString() on current 5-card best
|
||||
IsDraw bool // true if outs > 0 (see below)
|
||||
FlushDrawOuts int // suited cards matching board suit count
|
||||
StraightDrawOuts int // connected card gaps to straight
|
||||
TotalOuts int // combined draw outs (deduped)
|
||||
IsFreeCard bool // ToCall == 0
|
||||
HeadsUp bool // len(ActivePlayers) == 2
|
||||
}
|
||||
```
|
||||
|
||||
Outs calculation (add to `equity.go` or a new `outs.go`):
|
||||
- Flush draw: count hole cards matching dominant board suit; if 2 hole cards + 2 board cards same suit, FlushDrawOuts = 9
|
||||
- Open-ended straight draw: 8 outs
|
||||
- Gutshot: 4 outs
|
||||
- Backdoor draws: count as 1-2 outs each
|
||||
- TotalOuts = sum, capped at 15 (avoid double-counting straights and flushes)
|
||||
|
||||
---
|
||||
|
||||
### 3. System prompt needs to be more directive
|
||||
|
||||
**Current system prompt** (paraphrased from blueprint): "be a concise Hold'em coach, 2-4 sentences, cover hand strength, pot odds, position."
|
||||
|
||||
This is too open-ended. The LLM fills the space with whatever poker concepts come to mind. Replace with a prompt that forces it to reason about the specific situation before speaking.
|
||||
|
||||
**New system prompt:**
|
||||
|
||||
```
|
||||
You are a Texas Hold'em coach giving advice to a single player via private message.
|
||||
You will receive structured game context. Reason through it in this order:
|
||||
|
||||
1. What type of hand do I have — made hand, drawing hand, or air?
|
||||
2. If drawing: how many outs, and do pot odds justify continuing?
|
||||
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
|
||||
4. Does position affect what I should do here?
|
||||
5. Is a free card available, and if so, is taking it correct?
|
||||
|
||||
Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player
|
||||
what to do and why, using the specific cards and numbers provided.
|
||||
Do not list concepts. Do not use generic poker vocabulary without connecting it to
|
||||
this specific hand. If the correct play is obvious (e.g. free card with a draw),
|
||||
say so plainly and briefly.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. User prompt needs draw and hand type context injected
|
||||
|
||||
**Current user prompt structure** (from blueprint):
|
||||
```
|
||||
Street: <street>
|
||||
Your hand: <cards>
|
||||
Board: <cards>
|
||||
Equity vs <n> opponents: Win x% | Tie y% | Loss z%
|
||||
Pot odds to call: x%
|
||||
SPR: x | Position: <pos> | Active players: <n>
|
||||
```
|
||||
|
||||
**New user prompt structure** — add the computed fields:
|
||||
|
||||
```
|
||||
Street: {street}
|
||||
Your hand: {cards} [{hand_category}]
|
||||
Board: {cards}
|
||||
Draw outs: {total_outs} ({draw_description}) <- omit line if IsDraw == false
|
||||
Equity vs {n} opponent(s): Win {x}% | Tie {y}% | Loss {z}%
|
||||
{if ToCall > 0}: Pot odds to call: {pct}% — equity {exceeds|falls short of} price
|
||||
{if IsFreeCard}: Free card available — no bet to call
|
||||
SPR: {spr} | Position: {position} | Heads-up: {yes|no} | Street: {street}
|
||||
```
|
||||
|
||||
`{draw_description}` examples:
|
||||
- "flush draw (9 outs)"
|
||||
- "gutshot straight draw (4 outs)"
|
||||
- "open-ended straight draw (8 outs)"
|
||||
- "flush draw + gutshot (11 outs)"
|
||||
- "backdoor flush + backdoor straight (2 outs)"
|
||||
|
||||
`{hand_category}` examples from `poker.RankString()`:
|
||||
- "High Card", "One Pair", "Two Pair", "Three of a Kind", "Straight", "Flush", "Full House", "Four of a Kind", "Straight Flush"
|
||||
|
||||
---
|
||||
|
||||
## Specific scenario the fix must handle correctly
|
||||
|
||||
**Hand:** 8♥ 7♥
|
||||
**Board:** Q♥ K♠ 10♦
|
||||
**Street:** Flop
|
||||
**Equity:** 29%
|
||||
**To call:** €0 (free card)
|
||||
**Position:** dealer, heads-up, acting first post-flop (out of position)
|
||||
|
||||
Expected tip behaviour after fix:
|
||||
- Identifies this as a drawing hand (gutshot + backdoor flush)
|
||||
- Notes the free card is available
|
||||
- Does NOT say "not enough equity to bet" without acknowledging the draw
|
||||
- Does NOT say "positional advantage" — player is out of position post-flop heads-up
|
||||
- Produces something like: "You have a gutshot straight draw with a backdoor flush. With a free card available you can check and see the turn without risk. If a 9 or a third heart comes, you'll be in a strong position — for now, take the free card."
|
||||
|
||||
---
|
||||
|
||||
## Reasoning mode (Qwen3 thinking)
|
||||
|
||||
Poker tips are the only task in GogoBee that should use reasoning mode. All other LLM calls (adventure narrative, etc.) run with thinking disabled. This needs to be a one-off configuration scoped entirely to `tips.go`.
|
||||
|
||||
### Why reasoning mode here
|
||||
|
||||
The tips failure pattern is not a knowledge gap — Qwen3-32B knows poker. The problem is that it jumps to pattern-matched conclusions without working through the situation in sequence. Reasoning mode forces the model to produce a `<think>...</think>` chain before the final response, which naturally surfaces: hand type, outs, position semantics, and the actual decision. The tip then follows from that chain rather than being assembled from disconnected concepts.
|
||||
|
||||
### Request changes in `tips.go`
|
||||
|
||||
Add a `enable_thinking` field to the request body and a `thinking_budget` cap to keep latency bounded:
|
||||
|
||||
```go
|
||||
type llmRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []llmMessage `json:"messages"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
Stream bool `json:"stream"`
|
||||
EnableThinking bool `json:"enable_thinking,omitempty"`
|
||||
ThinkingBudget int `json:"thinking_budget,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
When building the tips request, set:
|
||||
|
||||
```go
|
||||
body := llmRequest{
|
||||
Model: cfg.Model,
|
||||
Messages: []llmMessage{...},
|
||||
MaxTokens: 1000, // increased to accommodate think block + response
|
||||
Stream: false,
|
||||
EnableThinking: true,
|
||||
ThinkingBudget: 512, // cap reasoning tokens; enough for poker, not runaway
|
||||
}
|
||||
```
|
||||
|
||||
`ThinkingBudget` of 512 tokens is sufficient for a poker hand analysis reasoning chain. Without a cap, complex board textures can produce very long think blocks. 512 keeps worst-case latency reasonable.
|
||||
|
||||
Note: the exact field names for Ollama's Qwen3 thinking mode may differ from the above. Check the Ollama API docs for the current `qwen3:32b` thinking parameters — it may be `/think` appended to the model name (`qwen3:32b/think`) rather than a request body field, depending on the Ollama version. Either way, the intent is the same — make this configurable in `TipsConfig` so it can be toggled without a code change:
|
||||
|
||||
```go
|
||||
type TipsConfig struct {
|
||||
Endpoint string
|
||||
Model string
|
||||
APIKey string
|
||||
Timeout time.Duration
|
||||
EnableThinking bool // default true for poker tips
|
||||
ThinkingBudget int // default 512
|
||||
}
|
||||
```
|
||||
|
||||
### Strip the think block from the response
|
||||
|
||||
The `<think>...</think>` content must never reach the player DM. The current response parser takes `choices[0].message.content` directly. Update it to strip thinking content before returning:
|
||||
|
||||
```go
|
||||
func extractTipFromResponse(raw string) string {
|
||||
// Strip <think>...</think> block if present
|
||||
// Qwen3 may use <think> or <!--think--> depending on version
|
||||
re := regexp.MustCompile(`(?s)<think>.*?</think>`)
|
||||
cleaned := re.ReplaceAllString(raw, "")
|
||||
|
||||
// Also strip any leading/trailing whitespace left behind
|
||||
return strings.TrimSpace(cleaned)
|
||||
}
|
||||
```
|
||||
|
||||
Call `extractTipFromResponse()` on `llmResp.Choices[0].Message.Content` before returning the tip string. If the result is empty after stripping (model only produced a think block and nothing else), fall back to the rules-based tip.
|
||||
|
||||
### Latency expectations
|
||||
|
||||
With `ThinkingBudget: 512` and the structured context prompt, expect:
|
||||
- Typical: 4-8 seconds total (within the existing 10s timeout)
|
||||
- Complex boards: up to 10 seconds
|
||||
- Increase `cfg.Timeout` to `12 * time.Second` for tips specifically to give reasoning room without affecting other LLM calls
|
||||
|
||||
Tip delivery via DM is already async (goroutine), so even a 10-12 second tip doesn't block the table view or the action loop. Players receive the table view immediately and the tip follows shortly after.
|
||||
|
||||
### Config addition
|
||||
|
||||
```toml
|
||||
[holdem]
|
||||
# ... existing fields ...
|
||||
tips_enable_thinking = true
|
||||
tips_thinking_budget = 512
|
||||
tips_timeout = "12s" # longer than default to accommodate reasoning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to change
|
||||
|
||||
- `tips.go` — `TipContext` struct, `BuildTipContext()`, `buildPrompt()`, `positionLabel()`, `llmRequest` struct, `GenerateTip()`, new `extractTipFromResponse()` function
|
||||
- `equity.go` — add outs calculation function
|
||||
- No schema changes required
|
||||
- No changes to `game.go`, `betting.go`, or `render.go`
|
||||
|
||||
## Test cases to verify before shipping
|
||||
|
||||
Write a table-driven test in `tips_test.go` covering:
|
||||
|
||||
| Hand | Board | Street | Expected position (HU) | Expected IsDraw | Expected outs |
|
||||
|------|-------|--------|------------------------|-----------------|---------------|
|
||||
| 8♥ 7♥ | Q♥ K♠ 10♦ | Flop | Out of position | true | 4 (gutshot) + backdoor |
|
||||
| A♠ K♠ | — | Pre-Flop | BTN (dealer, acts first) | false | 0 |
|
||||
| 5♥ 6♥ | 7♥ 8♣ 2♥ | Flop | varies | true | 15 (OESD + flush) |
|
||||
| Q♣ Q♦ | Q♥ 2♠ 7♣ | Flop | varies | false | 0 |
|
||||
|
||||
The position test for heads-up pre-flop vs post-flop is the most important one. Get that right first.
|
||||
|
||||
---
|
||||
|
||||
## Validation pipeline (shipped 2026-04-13)
|
||||
|
||||
The "is the tip actually good?" question is now answered by a two-layer
|
||||
automated test harness rather than vibes.
|
||||
|
||||
**Layer 1 — hand-authored scenarios** (`internal/plugin/holdem_tip_scenarios.go`)
|
||||
|
||||
20 canonical spots covering preflop tier/facing-bet branches and postflop
|
||||
equity tiers × board textures × SPR depths. Each scenario declares an
|
||||
expected action verb, required theme keywords, and forbidden substrings.
|
||||
`TestTipScenarios_Layer1` runs the full rules-engine pipeline
|
||||
(equity MC, draw detection, hand category, board texture, preflop
|
||||
classification) against each scenario and asserts the tip contains the
|
||||
expected action + themes. Fast, cheap, green.
|
||||
|
||||
**Layer 2 — solver-derived scenarios** (same scenarios, populated via `cmd/gensolver`)
|
||||
|
||||
11 of the 14 postflop scenarios carry real TexasSolver GTO frequencies
|
||||
committed as a fixture at `internal/plugin/testdata/solver_freqs.json`.
|
||||
`TestTipScenarios_Layer2` treats any action with solver frequency ≥ 15% as
|
||||
"significant" and asserts the rules engine's recommended action matches one
|
||||
of the significant actions — tolerating GTO's legitimately mixed spots
|
||||
while catching genuinely-wrong recommendations.
|
||||
|
||||
**cmd/gensolver**
|
||||
|
||||
Offline pipeline that iterates `plugin.TipScenarios()`, shells out to
|
||||
`console_solver` (TexasSolver CLI), parses the JSON strategy tree,
|
||||
navigates to hero's decision node (IP/OOP × facing-check/facing-bet ×
|
||||
check-bet line), extracts hero's action frequencies for their exact hole
|
||||
combo, and merges them into the fixture file.
|
||||
|
||||
Key solver-side knobs worked out the hard way:
|
||||
|
||||
- **Scale normalization** to `pot=50, stack=8×pot` (SPR cap 8). TexasSolver
|
||||
segfaults on deep stacks and on some textures at larger chip counts;
|
||||
strategic equivalence is preserved because GTO frequencies are
|
||||
scale-invariant.
|
||||
- **Bet tree**: 50% + 100% pot sizings, plus allin. Narrower trees build
|
||||
faster and still give solvable decision points.
|
||||
- **`set_accuracy 1.0`, `set_max_iteration 100`** — converges in ~2 min
|
||||
per flop instead of the ~24 min the solver's defaults demanded. 1%
|
||||
exploitability is plenty for our assertion type.
|
||||
- **Range syntax**: TexasSolver rejects shorthand like `22+` / `A2s+` —
|
||||
ranges must be explicitly enumerated. Using the solver's own
|
||||
sample-input ranges verbatim as HU defaults.
|
||||
|
||||
Invocation:
|
||||
|
||||
```bash
|
||||
GOGOBEE_SOLVER=/path/to/console_solver \
|
||||
GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver/resources \
|
||||
go run ./cmd/gensolver [scenario-name-substring]
|
||||
```
|
||||
|
||||
Results merge into the fixture, so regenerating one scenario doesn't wipe
|
||||
the others.
|
||||
|
||||
**Known gaps** — 3 scenarios have no solver frequencies:
|
||||
|
||||
- `flop/monster set on paired board facing bet` — TexasSolver segfaults on
|
||||
paired-board textures (upstream bug, not fixable from our side).
|
||||
- `turn/weak top pair facing overbet` — hero's hole (63o) isn't in any
|
||||
reasonable HU range, so the solver never allocates strategy for it.
|
||||
Scenario still validated by Layer 1.
|
||||
- Occasional flake on `flop/bottom pair facing big bet` at full-batch time
|
||||
(succeeds when retried solo). Current fixture entry came from a solo
|
||||
retry and is valid; if regeneration fails, just re-run that one
|
||||
scenario with the name filter.
|
||||
|
||||
Adding new scenarios: append to `tipScenarios` in
|
||||
`holdem_tip_scenarios.go`, run `cmd/gensolver` with the name filter,
|
||||
commit both the code and fixture changes together.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testKey() []byte {
|
||||
// 32 bytes for AES-256
|
||||
return []byte("01234567890123456789012345678901")
|
||||
}
|
||||
|
||||
// ── Encrypt / Decrypt Round-Trip ───────────────────────────────────────────
|
||||
|
||||
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
|
||||
key := testKey()
|
||||
plaintext := []byte("hello, arena champion")
|
||||
|
||||
ciphertext, err := Encrypt(key, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
got, err := Decrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(got, plaintext) {
|
||||
t.Errorf("round-trip failed: got %q, want %q", got, plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_EmptyPlaintext(t *testing.T) {
|
||||
key := testKey()
|
||||
ciphertext, err := Encrypt(key, []byte{})
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt empty: %v", err)
|
||||
}
|
||||
|
||||
got, err := Decrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt empty: %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 0 {
|
||||
t.Errorf("expected empty plaintext, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncrypt_DifferentNonces(t *testing.T) {
|
||||
key := testKey()
|
||||
plaintext := []byte("same input")
|
||||
|
||||
ct1, _ := Encrypt(key, plaintext)
|
||||
ct2, _ := Encrypt(key, plaintext)
|
||||
|
||||
if bytes.Equal(ct1, ct2) {
|
||||
t.Error("two encryptions of the same plaintext should produce different ciphertexts (random nonces)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_WrongKey(t *testing.T) {
|
||||
key := testKey()
|
||||
plaintext := []byte("secret data")
|
||||
|
||||
ciphertext, err := Encrypt(key, plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
wrongKey := []byte("99999999999999999999999999999999")
|
||||
_, err = Decrypt(wrongKey, ciphertext)
|
||||
if err == nil {
|
||||
t.Error("Decrypt with wrong key should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecrypt_TruncatedCiphertext(t *testing.T) {
|
||||
key := testKey()
|
||||
_, err := Decrypt(key, []byte{1, 2, 3})
|
||||
if err == nil {
|
||||
t.Error("Decrypt with too-short ciphertext should fail")
|
||||
}
|
||||
}
|
||||
|
||||
// ── HMAC ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHMAC_Deterministic(t *testing.T) {
|
||||
key := testKey()
|
||||
data := []byte("test data")
|
||||
|
||||
h1 := HMAC(key, data)
|
||||
h2 := HMAC(key, data)
|
||||
|
||||
if h1 != h2 {
|
||||
t.Error("HMAC should be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHMAC_DifferentData(t *testing.T) {
|
||||
key := testKey()
|
||||
|
||||
h1 := HMAC(key, []byte("data1"))
|
||||
h2 := HMAC(key, []byte("data2"))
|
||||
|
||||
if h1 == h2 {
|
||||
t.Error("HMAC of different data should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHMAC_DifferentKeys(t *testing.T) {
|
||||
data := []byte("same data")
|
||||
|
||||
h1 := HMAC([]byte("key1key1key1key1key1key1key1key1"), data)
|
||||
h2 := HMAC([]byte("key2key2key2key2key2key2key2key2"), data)
|
||||
|
||||
if h1 == h2 {
|
||||
t.Error("HMAC with different keys should differ")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHMAC_Length(t *testing.T) {
|
||||
h := HMAC(testKey(), []byte("x"))
|
||||
// SHA-256 = 32 bytes = 64 hex chars
|
||||
if len(h) != 64 {
|
||||
t.Errorf("HMAC hex length = %d, want 64", len(h))
|
||||
}
|
||||
}
|
||||
|
||||
// ── ParseKey ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestParseKey_Valid(t *testing.T) {
|
||||
raw := make([]byte, 32)
|
||||
for i := range raw {
|
||||
raw[i] = byte(i)
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
|
||||
key, err := ParseKey(b64)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseKey: %v", err)
|
||||
}
|
||||
if !bytes.Equal(key, raw) {
|
||||
t.Error("ParseKey returned wrong bytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKey_WrongLength(t *testing.T) {
|
||||
raw := make([]byte, 16) // too short
|
||||
b64 := base64.StdEncoding.EncodeToString(raw)
|
||||
|
||||
_, err := ParseKey(b64)
|
||||
if err == nil {
|
||||
t.Error("ParseKey should reject non-32-byte keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseKey_InvalidBase64(t *testing.T) {
|
||||
_, err := ParseKey("not!valid!base64!!!")
|
||||
if err == nil {
|
||||
t.Error("ParseKey should reject invalid base64")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
var (
|
||||
mu sync.Mutex
|
||||
globalDB *sql.DB
|
||||
dataPath string
|
||||
)
|
||||
|
||||
// Init opens (or creates) the SQLite database and runs migrations.
|
||||
@@ -44,6 +45,7 @@ func Init(dataDir string) error {
|
||||
}
|
||||
|
||||
globalDB = d
|
||||
dataPath = dataDir
|
||||
slog.Info("database initialized", "path", dbPath)
|
||||
return nil
|
||||
}
|
||||
@@ -56,6 +58,18 @@ func Get() *sql.DB {
|
||||
return globalDB
|
||||
}
|
||||
|
||||
// Close closes the database connection. Call on shutdown.
|
||||
func Close() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if globalDB != nil {
|
||||
if err := globalDB.Close(); err != nil {
|
||||
slog.Error("db: close failed", "err", err)
|
||||
}
|
||||
globalDB = nil
|
||||
}
|
||||
}
|
||||
|
||||
func runMigrations(d *sql.DB) error {
|
||||
if _, err := d.Exec(schema); err != nil {
|
||||
return err
|
||||
@@ -73,6 +87,61 @@ 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 ''`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN combat_actions_used INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN harvest_actions_used INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN last_pardon_used DATETIME`,
|
||||
`ALTER TABLE arena_runs ADD COLUMN tier_earnings INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE arena_runs ADD COLUMN xp_accumulated INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN misty_last_seen DATETIME`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN arina_last_seen DATETIME`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN misty_buff_expires DATETIME`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN misty_debuff_expires DATETIME`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN arina_buff_expires DATETIME`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count_date TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN misty_roll_target INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN arina_roll_target INTEGER NOT NULL DEFAULT 0`,
|
||||
// Housing
|
||||
`ALTER TABLE adventure_characters ADD COLUMN house_tier INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN house_loan_balance INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN house_loan_frozen INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN house_missed_payments INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN house_autopay INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN house_current_rate REAL NOT NULL DEFAULT 0`,
|
||||
// Pets
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_type TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_name TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_level INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_armor_tier INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_chased_away INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_reactivated INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_arrived INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN misty_encounter_count INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN misty_donated_count INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN thom_animal_line_fired INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_level10_date TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN pet_morning_defense INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE coop_dungeon_runs ADD COLUMN last_resolved_day INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE coop_dungeon_members ADD COLUMN member_payout INTEGER`,
|
||||
`ALTER TABLE coop_dungeon_runs ADD COLUMN invite_post_id TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE coop_dungeon_gifts ADD COLUMN expires_at DATETIME`,
|
||||
`ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`,
|
||||
`ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`,
|
||||
`ALTER TABLE adventure_characters ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
@@ -105,6 +174,50 @@ func MarkJobCompleted(jobName, dateKey string) {
|
||||
)
|
||||
}
|
||||
|
||||
// RecordCrash logs a panic/crash event with version and plugin context.
|
||||
func RecordCrash(botVersion, plugin, message string) {
|
||||
if globalDB == nil {
|
||||
return
|
||||
}
|
||||
Exec("crash log",
|
||||
`INSERT INTO crash_log (bot_version, plugin, message) VALUES (?, ?, ?)`,
|
||||
botVersion, plugin, message,
|
||||
)
|
||||
}
|
||||
|
||||
// RecordTipAudit logs a poker tip with full context for bulk review.
|
||||
func RecordTipAudit(userID, hole, board, street, position string, numActive int, equityPct float64, pot, toCall, stack int64, spr float64, handCat, drawDesc, preflopTier, action, tipText string) {
|
||||
if globalDB == nil {
|
||||
return
|
||||
}
|
||||
Exec("tip audit",
|
||||
`INSERT INTO holdem_tip_audit (user_id, hole, board, street, position, num_active, equity_pct, pot, to_call, stack, spr, hand_cat, draw_desc, preflop_tier, action, tip_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
userID, hole, board, street, position, numActive, equityPct, pot, toCall, stack, spr, handCat, drawDesc, preflopTier, action, tipText,
|
||||
)
|
||||
}
|
||||
|
||||
// RecordStartup logs a version startup event.
|
||||
func RecordStartup(version, commit string) {
|
||||
Exec("version history",
|
||||
`INSERT INTO version_history (version, commit_sha) VALUES (?, ?)`,
|
||||
version, commit,
|
||||
)
|
||||
}
|
||||
|
||||
// CrashCount returns the number of crashes for a given bot version.
|
||||
func CrashCount(botVersion string) int {
|
||||
var count int
|
||||
_ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log WHERE bot_version = ?`, botVersion).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CrashCountAll returns the total number of recorded crashes.
|
||||
func CrashCountAll() int {
|
||||
var count int
|
||||
_ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log`).Scan(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CacheGet returns cached data for the given key if it exists and is within ttlSeconds.
|
||||
// Returns empty string if not cached or expired.
|
||||
func CacheGet(key string, ttlSeconds int) string {
|
||||
@@ -129,6 +242,47 @@ func CacheSet(key, data string) {
|
||||
)
|
||||
}
|
||||
|
||||
// Backup creates a consistent snapshot of the database using VACUUM INTO.
|
||||
// Keeps the last 7 daily backups, deleting older ones.
|
||||
func Backup() error {
|
||||
backupDir := filepath.Join(dataPath, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create backup dir: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("gogobee_%s.db", time.Now().UTC().Format("2006-01-02"))
|
||||
backupPath := filepath.Join(backupDir, filename)
|
||||
|
||||
_, err := Get().Exec(fmt.Sprintf(`VACUUM INTO '%s'`, backupPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum into backup: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("database backup created", "path", backupPath)
|
||||
|
||||
// Prune backups older than 7 days
|
||||
entries, err := os.ReadDir(backupDir)
|
||||
if err != nil {
|
||||
return nil // backup succeeded, prune failure is non-fatal
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -7)
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(cutoff) {
|
||||
os.Remove(filepath.Join(backupDir, e.Name()))
|
||||
slog.Info("pruned old backup", "file", e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunMaintenance purges stale data from cache tables, old rate limits,
|
||||
// expired logs, and runs SQLite optimization. Intended to run daily.
|
||||
func RunMaintenance() {
|
||||
@@ -243,6 +397,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())
|
||||
);
|
||||
|
||||
@@ -684,6 +839,8 @@ CREATE TABLE IF NOT EXISTS euro_balances (
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_euro_bal_user ON euro_balances(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS euro_transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -820,6 +977,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,
|
||||
@@ -1005,6 +1173,8 @@ CREATE TABLE IF NOT EXISTS arena_runs (
|
||||
ended_at INTEGER
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_arena_runs_user ON arena_runs(user_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS arena_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -1027,6 +1197,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 +1295,166 @@ 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
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crash_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bot_version TEXT NOT NULL,
|
||||
plugin TEXT NOT NULL DEFAULT '',
|
||||
message TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS version_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version TEXT NOT NULL,
|
||||
commit_sha TEXT NOT NULL DEFAULT '',
|
||||
started_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tax_ledger (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
total_paid INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holdem_tip_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
hole TEXT NOT NULL,
|
||||
board TEXT NOT NULL DEFAULT '',
|
||||
street TEXT NOT NULL,
|
||||
position TEXT NOT NULL,
|
||||
num_active INTEGER NOT NULL,
|
||||
equity_pct REAL NOT NULL,
|
||||
pot INTEGER NOT NULL,
|
||||
to_call INTEGER NOT NULL,
|
||||
stack INTEGER NOT NULL,
|
||||
spr REAL NOT NULL,
|
||||
hand_cat TEXT NOT NULL DEFAULT '',
|
||||
draw_desc TEXT NOT NULL DEFAULT '',
|
||||
preflop_tier TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
tip_text TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_audit_user ON holdem_tip_audit(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tip_audit_action ON holdem_tip_audit(action, street);
|
||||
|
||||
-- Co-op Dungeon (party multi-day runs)
|
||||
CREATE TABLE IF NOT EXISTS coop_dungeon_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tier INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled
|
||||
leader_id TEXT NOT NULL,
|
||||
current_day INTEGER NOT NULL DEFAULT 0,
|
||||
total_days INTEGER NOT NULL,
|
||||
base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier
|
||||
gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding
|
||||
reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion)
|
||||
last_resolved_day INTEGER NOT NULL DEFAULT 0, -- crash-resume guard: highest day whose floor outcome is final
|
||||
invite_post_id TEXT NOT NULL DEFAULT '', -- Matrix event ID of the games-room invite (for pin/unpin)
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
locked_at DATETIME,
|
||||
completed_at DATETIME
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_coop_runs_status ON coop_dungeon_runs(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coop_dungeon_members (
|
||||
run_id INTEGER NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
turn_order INTEGER NOT NULL,
|
||||
total_contributed INTEGER NOT NULL DEFAULT 0,
|
||||
is_liability INTEGER NOT NULL DEFAULT 0,
|
||||
daily_funding TEXT NOT NULL DEFAULT '{}', -- JSON: {"1":"standard","2":"all_in",...}
|
||||
member_payout INTEGER, -- NULL until reward credited; idempotency claim
|
||||
PRIMARY KEY (run_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_coop_members_user ON coop_dungeon_members(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coop_dungeon_events (
|
||||
run_id INTEGER NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
category TEXT NOT NULL, -- obstacle, opportunity, crisis, encounter
|
||||
event_index INTEGER NOT NULL, -- index into the category flavor pool
|
||||
recommended TEXT NOT NULL, -- A, B, or C — TwinBee's pick
|
||||
votes TEXT NOT NULL DEFAULT '{}', -- JSON {"@user:host": "A"}
|
||||
winning_vote TEXT DEFAULT NULL,
|
||||
outcome TEXT DEFAULT NULL, -- 'success' or 'failure' (after resolution)
|
||||
modifier_applied INTEGER DEFAULT 0,
|
||||
post_event_id TEXT DEFAULT NULL, -- Matrix event ID for live edit
|
||||
PRIMARY KEY (run_id, day)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_coop_events_outcome ON coop_dungeon_events(outcome);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coop_dungeon_bets (
|
||||
run_id INTEGER NOT NULL,
|
||||
player_id TEXT NOT NULL,
|
||||
position TEXT NOT NULL, -- 'success' or 'failure'
|
||||
amount INTEGER NOT NULL,
|
||||
placed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
payout INTEGER, -- NULL until run resolves
|
||||
PRIMARY KEY (run_id, player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_coop_bets_run ON coop_dungeon_bets(run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coop_dungeon_gifts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
sender_id TEXT NOT NULL,
|
||||
day INTEGER NOT NULL,
|
||||
gift_type TEXT NOT NULL, -- 'basket' or 'mimic'
|
||||
votes TEXT NOT NULL DEFAULT '{}', -- JSON {"@user:host": "open"|"leave"}
|
||||
vote_result TEXT, -- 'opened' or 'left'
|
||||
outcome TEXT, -- 'boost' or 'reduction'
|
||||
modifier INTEGER NOT NULL DEFAULT 0,
|
||||
post_event_id TEXT,
|
||||
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME, -- voting closes; tally fires when reached
|
||||
resolved_at DATETIME, -- vote tallied (vote_result/modifier set)
|
||||
applied_at DATETIME, -- modifier merged into a floor success roll
|
||||
stack_lead_id INTEGER -- NULL for lead/standalone; follower points to lead's id
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInitAndClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := Init(dir); err != nil {
|
||||
t.Fatalf("Init: %v", err)
|
||||
}
|
||||
|
||||
// DB should be usable
|
||||
d := Get()
|
||||
if d == nil {
|
||||
t.Fatal("Get() returned nil after Init")
|
||||
}
|
||||
|
||||
// Verify DB file was created
|
||||
dbPath := filepath.Join(dir, "gogobee.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Fatalf("DB file not created: %v", err)
|
||||
}
|
||||
|
||||
// Close should not panic
|
||||
Close()
|
||||
|
||||
// After Close, globalDB should be nil
|
||||
if globalDB != nil {
|
||||
t.Error("globalDB should be nil after Close")
|
||||
}
|
||||
|
||||
// Double Close should not panic
|
||||
Close()
|
||||
}
|
||||
|
||||
func TestInit_Idempotent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := Init(dir); err != nil {
|
||||
t.Fatalf("first Init: %v", err)
|
||||
}
|
||||
// Second Init should be a no-op (globalDB already set)
|
||||
if err := Init(dir); err != nil {
|
||||
t.Fatalf("second Init: %v", err)
|
||||
}
|
||||
|
||||
Close()
|
||||
}
|
||||
|
||||
func TestInit_SchemaContainsIndexes(t *testing.T) {
|
||||
// Verify the schema string contains our audit-added indexes
|
||||
if !containsSubstring(schema, "idx_arena_runs_user") {
|
||||
t.Error("schema missing idx_arena_runs_user index")
|
||||
}
|
||||
if !containsSubstring(schema, "idx_euro_bal_user") {
|
||||
t.Error("schema missing idx_euro_bal_user index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_SchemaRunsCleanly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := Init(dir); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
defer Close()
|
||||
|
||||
// Verify we can query a table that was created by the schema
|
||||
d := Get()
|
||||
var count int
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("query users table: %v", err)
|
||||
}
|
||||
|
||||
// Verify indexes exist
|
||||
rows, err := d.Query("SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")
|
||||
if err != nil {
|
||||
t.Fatalf("query indexes: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
indexes := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var name string
|
||||
rows.Scan(&name)
|
||||
indexes[name] = true
|
||||
}
|
||||
|
||||
for _, idx := range []string{"idx_arena_runs_user", "idx_euro_bal_user", "idx_euro_tx_user"} {
|
||||
if !indexes[idx] {
|
||||
t.Errorf("missing index: %s", idx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsSubstring(s, sub string) bool {
|
||||
return len(s) >= len(sub) && searchSubstring(s, sub)
|
||||
}
|
||||
|
||||
func searchSubstring(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -44,7 +44,8 @@ func NewAchievementsPlugin(client *mautrix.Client, registry AchievementRegistry)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *AchievementsPlugin) Name() string { return "achievements" }
|
||||
func (p *AchievementsPlugin) Name() string { return "achievements" }
|
||||
func (p *AchievementsPlugin) Version() string { return "1.2.0" }
|
||||
|
||||
func (p *AchievementsPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
@@ -1015,6 +1016,127 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
return count > 0
|
||||
},
|
||||
},
|
||||
|
||||
// ── Streak & Babysit ────────────────────────────────────────────────
|
||||
{
|
||||
ID: "adv_streaker", Name: "Streaker", Description: "Maintained a 7-day streak. Running wild.",
|
||||
Emoji: "🏃",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
_ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak)
|
||||
return streak >= 7
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_streak_60", Name: "Obsessed", Description: "60 days. The game plays you now.",
|
||||
Emoji: "🔥",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
_ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak)
|
||||
return streak >= 60
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_streak_100", Name: "Centurion", Description: "100 days. This is your life now.",
|
||||
Emoji: "💯",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
_ = d.QueryRow(`SELECT best_streak FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(&streak)
|
||||
return streak >= 100
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_babysit_hired", Name: "Delegation", Description: "Hired someone to do the hard part.",
|
||||
Emoji: "🍼",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var count int
|
||||
_ = d.QueryRow(`SELECT COUNT(*) FROM adventure_babysit_log WHERE user_id = ?`, string(u)).Scan(&count)
|
||||
return count > 0
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_auto_babysit", Name: "Safety Net", Description: "Auto-babysit saved your streak. Worth every euro.",
|
||||
Emoji: "🛡️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by scheduler when auto-babysit fires
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_streak_survivor", Name: "Streak Survivor", Description: "Rebuilt your streak to 14 after losing it.",
|
||||
Emoji: "🔄",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var current, best, decayed int
|
||||
err := d.QueryRow(`SELECT current_streak, best_streak, streak_decayed FROM adventure_characters WHERE user_id = ?`, string(u)).Scan(¤t, &best, &decayed)
|
||||
return err == nil && current >= 14 && decayed > 0
|
||||
},
|
||||
},
|
||||
|
||||
// ── Combat Moments ──────────────────────────────────────────────────
|
||||
{
|
||||
ID: "combat_near_death", Name: "Clutch", Description: "Won with less than 15% HP. Barely.",
|
||||
Emoji: "💔",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on near-death victory
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_pet_save", Name: "Good Boy", Description: "Your pet distracted the enemy at exactly the right moment.",
|
||||
Emoji: "🐾",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on pet whiff event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_sniper_kill", Name: "One Shot", Description: "Arina ended the fight before it started.",
|
||||
Emoji: "🎯",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on sniper kill
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_death_save", Name: "Sovereign's Reprieve", Description: "The crown said not today.",
|
||||
Emoji: "👑",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on death save event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_misty_clutch", Name: "Misty's Touch", Description: "Healed mid-combat. She was watching.",
|
||||
Emoji: "💚",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on Misty heal event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_consumable_used", Name: "Prepared", Description: "Used a consumable in combat. Preparation pays off.",
|
||||
Emoji: "🧪",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on consumable use event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_first_craft", Name: "Apprentice Herbalist", Description: "Crafted your first consumable. The wilds provide.",
|
||||
Emoji: "🌿",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on first successful craft
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_craft_t5", Name: "Master Alchemist", Description: "Crafted a Tier 5 consumable. Nature bends to your will.",
|
||||
Emoji: "⚗️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on T5 craft
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+432
-133
@@ -1,6 +1,8 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
@@ -9,6 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -18,6 +22,7 @@ import (
|
||||
type AdventurePlugin struct {
|
||||
Base
|
||||
euro *EuroPlugin
|
||||
xp *XPPlugin
|
||||
achievements *AchievementsPlugin
|
||||
mu sync.Mutex
|
||||
dmToPlayer map[id.RoomID]id.UserID
|
||||
@@ -27,6 +32,10 @@ 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)
|
||||
arenaBailCh sync.Map // userID string -> chan struct{} (bail signal for countdown goroutine)
|
||||
shopSessions sync.Map // userID string -> *advShopSession
|
||||
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
|
||||
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
|
||||
morningHour int
|
||||
summaryHour int
|
||||
}
|
||||
@@ -37,7 +46,7 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
|
||||
return val.(*sync.Mutex)
|
||||
}
|
||||
|
||||
const advDMResponseWindow = 15 * time.Minute
|
||||
const advDMResponseWindow = 3 * time.Hour
|
||||
|
||||
// advMarkMenuSent records that an actionable adventure menu was DM'd to the user.
|
||||
// Only bare-number DM replies within this window will be treated as adventure choices.
|
||||
@@ -65,17 +74,27 @@ type advPendingTreasureDiscard struct {
|
||||
Existing []AdvTreasureDef
|
||||
}
|
||||
|
||||
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin) *AdventurePlugin {
|
||||
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin, xp *XPPlugin) *AdventurePlugin {
|
||||
return &AdventurePlugin{
|
||||
Base: NewBase(client),
|
||||
euro: euro,
|
||||
xp: xp,
|
||||
dmToPlayer: make(map[id.RoomID]id.UserID),
|
||||
morningHour: envInt("ADVENTURE_MORNING_HOUR", 8),
|
||||
summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) Name() string { return "adventure" }
|
||||
// chatLevel returns the user's chat level for perk calculations.
|
||||
func (p *AdventurePlugin) chatLevel(userID id.UserID) int {
|
||||
if p.xp == nil {
|
||||
return 0
|
||||
}
|
||||
return p.xp.GetLevel(userID)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) Name() string { return "adventure" }
|
||||
func (p *AdventurePlugin) Version() string { return "2.5.0" }
|
||||
|
||||
// SetAchievements wires the achievements plugin after both are initialized.
|
||||
func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
|
||||
@@ -86,6 +105,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
|
||||
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
|
||||
{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +128,9 @@ func (p *AdventurePlugin) Init() error {
|
||||
if err := resetAllAdvDailyActions(); err != nil {
|
||||
slog.Error("adventure: startup daily reset failed", "err", err)
|
||||
}
|
||||
if err := lockCoopCombatActions(); err != nil {
|
||||
slog.Error("adventure: startup coop combat lock failed", "err", err)
|
||||
}
|
||||
// Revive any characters whose DeadUntil has expired
|
||||
p.catchUpRespawns(chars)
|
||||
|
||||
@@ -116,7 +139,12 @@ func (p *AdventurePlugin) Init() error {
|
||||
go p.summaryTicker()
|
||||
go p.midnightTicker()
|
||||
go p.eventTicker()
|
||||
go p.arenaAutoCashoutTicker()
|
||||
// Arena auto-cashout ticker removed — replaced by per-session countdown goroutines
|
||||
go p.rivalChallengeTicker()
|
||||
go p.robbieTicker()
|
||||
go p.hospitalNudgeTicker()
|
||||
go p.mortgageTicker()
|
||||
go p.coopTicker()
|
||||
|
||||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||||
p.arenaCleanupStaleRuns()
|
||||
@@ -149,10 +177,23 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
// 1. Arena commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "bail") {
|
||||
return p.handleArenaBail(ctx)
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "arena") {
|
||||
return p.dispatchArenaCommand(ctx)
|
||||
}
|
||||
|
||||
// 1b. Hospital commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "hospital") {
|
||||
return p.handleHospitalCmd(ctx)
|
||||
}
|
||||
|
||||
// 1c. Co-op dungeon commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "coop") {
|
||||
return p.handleCoopCmd(ctx)
|
||||
}
|
||||
|
||||
// 2. Check if this is a DM reply from a registered player
|
||||
p.mu.Lock()
|
||||
playerID, isDM := p.dmToPlayer[ctx.RoomID]
|
||||
@@ -162,8 +203,20 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handleDMReply(ctx)
|
||||
}
|
||||
|
||||
// 3. Command dispatch
|
||||
if !p.IsCommand(ctx.Body, "adventure") {
|
||||
// 3. NPC encounter tracking — count room messages from adventure players
|
||||
if !isDM && !strings.HasPrefix(ctx.Body, "!") {
|
||||
safeGo("npc-track", func() {
|
||||
p.npcTrackMessage(ctx.Sender)
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Thom Krooke commands (work in rooms and DMs)
|
||||
if p.IsCommand(ctx.Body, "thom") {
|
||||
return p.handleThomCmd(ctx)
|
||||
}
|
||||
|
||||
// 5. Command dispatch
|
||||
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -172,6 +225,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 +255,20 @@ 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:]))
|
||||
case lower == "boost":
|
||||
return p.handleBoostCmd(ctx)
|
||||
case lower == "recipes":
|
||||
return p.handleRecipesCmd(ctx)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||||
@@ -216,6 +286,16 @@ 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 babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
|
||||
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
|
||||
` + "`!adventure repair all`" + ` — Repair all damaged equipment
|
||||
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
||||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||||
` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `.
|
||||
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
|
||||
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
|
||||
` + "`!adventure help`" + ` — This message
|
||||
|
||||
**Arena:**
|
||||
@@ -227,6 +307,10 @@ const advHelpText = `**Adventure Commands**
|
||||
` + "`!arena status`" + ` — Current run state
|
||||
` + "`!arena leaderboard`" + ` — Top arena players
|
||||
|
||||
**Systems:**
|
||||
• Foraging level 10+ unlocks auto-crafting consumables from loot ingredients before combat.
|
||||
• A 5% community tax funds the lottery pot. Arena and hospital apply 10%.
|
||||
|
||||
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
|
||||
|
||||
// ── Command Handlers ─────────────────────────────────────────────────────────
|
||||
@@ -238,29 +322,33 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(char)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
if char.ActionTakenToday {
|
||||
// On holidays, allow second action if not yet taken
|
||||
isHol, _ := isHolidayToday()
|
||||
if isHol && !char.HolidayActionTaken {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
isHol, holName := isHolidayToday()
|
||||
if char.AllActionsUsed(isHol) {
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your action today. Tomorrow awaits. Try to survive it.\n\n"+
|
||||
"You've used all your actions today. Tomorrow awaits. Try to survive it.\n\n"+
|
||||
"Next action: 00:00 UTC (%dh %dm from now)\n"+
|
||||
"Morning DM: %02d:00 UTC\n\n"+
|
||||
"The Arena is always open: `!arena`",
|
||||
@@ -272,7 +360,6 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
_, holName := isHolidayToday()
|
||||
text := renderAdvMorningDM(char, equip, balance, bonuses, holName)
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
@@ -284,25 +371,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.chatLevel(ctx.Sender))
|
||||
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,18 +429,30 @@ 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 {
|
||||
char, equip, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. Shopping can wait until you've respawned.")
|
||||
}
|
||||
|
||||
if itemName == "" {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!buy <item name>`. Type `!adventure shop` to browse.")
|
||||
}
|
||||
|
||||
slot, def, found := advFindShopItem(itemName)
|
||||
if !found {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("No item matching '%s' found in the shop. Type `!adventure shop` to see what's available.", itemName))
|
||||
@@ -334,7 +465,7 @@ func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) erro
|
||||
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.")
|
||||
@@ -413,12 +544,18 @@ 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") && !strings.HasPrefix(lower, "!thom") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strip !adventure prefix if present — dispatch directly to avoid recursion
|
||||
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
|
||||
// Handle !thom in DMs
|
||||
if strings.HasPrefix(lower, "!thom") {
|
||||
return p.handleThomCmd(ctx)
|
||||
}
|
||||
|
||||
// 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 +566,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 +591,30 @@ 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_supply":
|
||||
return p.resolveShopSupplyChoice(ctx, interaction)
|
||||
case "shop_confirm":
|
||||
return p.resolveShopConfirm(ctx, interaction)
|
||||
case "hospital_pay":
|
||||
return p.resolveHospitalPay(ctx, interaction)
|
||||
case "npc_encounter":
|
||||
return p.resolveNPCEncounter(ctx, interaction)
|
||||
case "pet_arrival":
|
||||
return p.resolvePetArrival(ctx)
|
||||
case "pet_type":
|
||||
return p.resolvePetType(ctx)
|
||||
case "pet_name":
|
||||
return p.resolvePetName(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -499,47 +661,58 @@ 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 {
|
||||
// On holidays, allow second action if not yet taken
|
||||
isHol, _ := isHolidayToday()
|
||||
if !isHol || char.HolidayActionTaken {
|
||||
// Only send the reminder once per day — subsequent DM messages
|
||||
// are silently ignored so they can be handled by other plugins (e.g. UNO).
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
|
||||
return nil
|
||||
}
|
||||
p.dmRemindedDate.Store(string(ctx.Sender), today)
|
||||
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've already taken your action today. Rest now. Try again tomorrow.\n\n"+
|
||||
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
|
||||
"The Arena is always open: `!arena`",
|
||||
hours, minutes))
|
||||
isHol, _ := isHolidayToday()
|
||||
if char.AllActionsUsed(isHol) {
|
||||
// Only send the reminder once per day — subsequent DM messages
|
||||
// are silently ignored so they can be handled by other plugins (e.g. UNO).
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
|
||||
return nil
|
||||
}
|
||||
// Fall through for holiday second action
|
||||
p.dmRemindedDate.Store(string(ctx.Sender), today)
|
||||
|
||||
now := time.Now().UTC()
|
||||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
remaining := midnight.Sub(now)
|
||||
hours := int(remaining.Hours())
|
||||
minutes := int(remaining.Minutes()) % 60
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"You've used all your actions today. Rest now. Try again tomorrow.\n\n"+
|
||||
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
|
||||
"The Arena is always open: `!arena`",
|
||||
hours, minutes))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -614,6 +787,14 @@ func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCha
|
||||
// ── Activity Resolution ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
|
||||
isHol, _ := isHolidayToday()
|
||||
if isCombatActivity(activity) && !char.CanDoCombat(isHol) {
|
||||
return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.")
|
||||
}
|
||||
if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) {
|
||||
return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.")
|
||||
}
|
||||
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
|
||||
@@ -632,12 +813,38 @@ 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))
|
||||
}
|
||||
|
||||
// Resolve the action
|
||||
result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||
// 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 — dungeon uses combat engine, others use probability bands
|
||||
var result *AdvActionResult
|
||||
if loc.Activity == AdvActivityDungeon {
|
||||
result = p.resolveDungeonAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||
} else {
|
||||
result = resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
|
||||
}
|
||||
|
||||
// Select flavor text
|
||||
result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result)
|
||||
|
||||
// Chat level XP bonus
|
||||
if bonus := chatLevelXPBonus(p.chatLevel(char.UserID)); bonus > 0 {
|
||||
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
|
||||
}
|
||||
|
||||
// Double XP/money boost
|
||||
advApplyBoost(result)
|
||||
|
||||
// Apply XP
|
||||
switch result.XPSkill {
|
||||
case "combat":
|
||||
@@ -652,34 +859,38 @@ 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)
|
||||
}
|
||||
|
||||
engineDeathSaved := result.CombatLog != nil && checkDeathSaveEvent(result.CombatLog.Events)
|
||||
|
||||
// Handle death
|
||||
deathReprieved := false
|
||||
pardonFired := false
|
||||
petRecovered := false
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
// Sovereign set: Death's Reprieve — survive lethal outcome
|
||||
if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() {
|
||||
deathReprieved = true
|
||||
now := time.Now().UTC()
|
||||
char.DeathReprieveLast = &now
|
||||
char.GrudgeLocation = loc.Name
|
||||
// Gear absorbs the blow — all equipment set to 1 condition
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := equip[slot]; ok {
|
||||
eq.Condition = 1
|
||||
}
|
||||
}
|
||||
// Post room announcement
|
||||
nextWindow := now.Add(168 * time.Hour)
|
||||
dt := transitionDeath(DeathTransitionParams{
|
||||
Char: char,
|
||||
Equip: equip,
|
||||
ChatLevel: p.chatLevel(char.UserID),
|
||||
Location: loc.Name,
|
||||
AllowPardon: true,
|
||||
AllowSovereign: true,
|
||||
EngineSaved: engineDeathSaved,
|
||||
})
|
||||
pardonFired = dt.Pardoned
|
||||
deathReprieved = dt.Pardoned || dt.Reprieved
|
||||
petRecovered = dt.PetRecovered
|
||||
if dt.Pardoned {
|
||||
result.Outcome = AdvOutcomeEmpty
|
||||
}
|
||||
if dt.Reprieved {
|
||||
nextWindow := time.Now().UTC().Add(168 * time.Hour)
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
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.GrudgeLocation = loc.Name
|
||||
}
|
||||
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
|
||||
// Clear grudge on successful return
|
||||
@@ -691,22 +902,22 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
_ = addAdvInventoryItem(char.UserID, item)
|
||||
}
|
||||
|
||||
// Determine if this is the holiday second action
|
||||
isAction2 := char.ActionTakenToday // already taken = this is the second
|
||||
isHol, _ := isHolidayToday()
|
||||
|
||||
// Mark action taken and record the date for streak tracking
|
||||
if !isAction2 {
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
// Roll for consumable drop on success/exceptional at T2+
|
||||
if (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) && loc.Tier >= 2 {
|
||||
if drop := RollConsumableDrop(loc.Activity, loc.Tier); drop != nil {
|
||||
_ = addAdvInventoryItem(char.UserID, *drop)
|
||||
result.LootItems = append(result.LootItems, *drop)
|
||||
}
|
||||
}
|
||||
|
||||
// Holiday flags: mark second action done, or mark it done on death during action 1
|
||||
if isAction2 {
|
||||
char.HolidayActionTaken = true
|
||||
} else if isHol && result.Outcome == AdvOutcomeDeath && !deathReprieved {
|
||||
char.HolidayActionTaken = true // died on action 1 — no second action
|
||||
// Mark action consumed in the correct bucket
|
||||
if isCombatActivity(activity) {
|
||||
char.CombatActionsUsed++
|
||||
} else if isHarvestActivity(activity) {
|
||||
char.HarvestActionsUsed++
|
||||
}
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Update streak info
|
||||
result.StreakBonus = char.CurrentStreak
|
||||
@@ -717,6 +928,16 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
|
||||
}
|
||||
|
||||
// Pet XP
|
||||
if char.HasPet() && result.Outcome != AdvOutcomeDeath {
|
||||
if petGrantXP(char) {
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel))
|
||||
} else {
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
}
|
||||
|
||||
// Save equipment changes
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := equip[slot]; ok {
|
||||
@@ -737,8 +958,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
|
||||
if partyBonus > 0 {
|
||||
result.TotalLootValue += partyBonus
|
||||
// Credit the bonus directly
|
||||
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
|
||||
net, _ := communityTax(char.UserID, float64(partyBonus), 0.05)
|
||||
p.euro.Credit(char.UserID, net, "adventure_party_bonus")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -755,8 +976,38 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
if closing != "" {
|
||||
text += "\n" + closing
|
||||
}
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
||||
|
||||
// Dungeon combat: send phased combat messages with delays, then resolution DM
|
||||
if result.CombatLog != nil {
|
||||
phaseMessages := RenderCombatLog(*result.CombatLog, char.DisplayName, loc.Denizens)
|
||||
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
|
||||
done := p.sendCombatMessages(ctx.Sender, phaseMessages, text)
|
||||
|
||||
go func() {
|
||||
<-done
|
||||
if pardonFired {
|
||||
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
|
||||
}
|
||||
if petRecovered && char.PetName != "" {
|
||||
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
|
||||
}
|
||||
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
}
|
||||
}()
|
||||
} else {
|
||||
if err := p.SendDM(ctx.Sender, text); err != nil {
|
||||
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
if pardonFired {
|
||||
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
|
||||
}
|
||||
if petRecovered && char.PetName != "" {
|
||||
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
|
||||
}
|
||||
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
|
||||
p.sendHospitalAd(ctx.Sender, char)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for treasure drop
|
||||
@@ -765,34 +1016,40 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
|
||||
p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome)
|
||||
}
|
||||
|
||||
// TODO: holiday achievement hooks
|
||||
|
||||
// Holiday: offer second action if this was action 1 and player survived
|
||||
if !isAction2 && isHol && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
|
||||
equip2, _ := loadAdvEquipment(char.UserID)
|
||||
treasures2, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs2, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses2 := computeAdvBonuses(treasures2, buffs2, char.CurrentStreak, false)
|
||||
prompt := renderAdvHolidaySecondPrompt(char, equip2, bonuses2)
|
||||
if err := p.SendDM(ctx.Sender, prompt); err != nil {
|
||||
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
|
||||
// If the player still has actions remaining, nudge them
|
||||
if !char.AllActionsUsed(isHol) && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
|
||||
remaining := []string{}
|
||||
if char.CanDoCombat(isHol) {
|
||||
remaining = append(remaining, "combat")
|
||||
}
|
||||
if char.CanDoHarvest(isHol) {
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
harvestMax++
|
||||
}
|
||||
harvestLeft := harvestMax - char.HarvestActionsUsed
|
||||
remaining = append(remaining, fmt.Sprintf("%d harvest", harvestLeft))
|
||||
}
|
||||
p.SendDM(ctx.Sender, fmt.Sprintf("Actions remaining today: %s. Type `!adv` for the menu.", strings.Join(remaining, ", ")))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
|
||||
isAction2 := char.ActionTakenToday
|
||||
isHol, _ := isHolidayToday()
|
||||
|
||||
if !isAction2 {
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
if isAction2 {
|
||||
char.HolidayActionTaken = true
|
||||
// Rest consumes all remaining actions
|
||||
combatMax := maxCombatActions
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
combatMax++
|
||||
harvestMax++
|
||||
}
|
||||
char.CombatActionsUsed = combatMax
|
||||
char.HarvestActionsUsed = harvestMax
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
|
||||
@@ -822,25 +1079,13 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
|
||||
slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
// Holiday: offer second action if this was action 1
|
||||
if !isAction2 && isHol {
|
||||
equip, _ := loadAdvEquipment(char.UserID)
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
prompt := renderAdvHolidaySecondPrompt(char, equip, bonuses)
|
||||
if err := p.SendDM(ctx.Sender, prompt); err != nil {
|
||||
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Treasure Drop Check ─────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
|
||||
drop := rollAdvTreasureDrop(loc.Tier, userID)
|
||||
drop := rollAdvTreasureDrop(loc.Tier, userID, p.chatLevel(userID))
|
||||
if drop == nil {
|
||||
return
|
||||
}
|
||||
@@ -876,7 +1121,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
|
||||
NewTreasure: drop.Def,
|
||||
Existing: existing,
|
||||
},
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
|
||||
text := renderAdvTreasureDiscardPrompt(drop.Def, existing)
|
||||
@@ -1042,7 +1287,12 @@ func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *Adv
|
||||
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil {
|
||||
// Auto-create
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
// Query error (e.g. missing column) — do NOT auto-create over existing data
|
||||
slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
|
||||
return nil, nil, fmt.Errorf("failed to load character: %w", err)
|
||||
}
|
||||
// Genuinely new player — auto-create
|
||||
displayName := p.DisplayName(userID)
|
||||
if err := createAdvCharacter(userID, displayName); err != nil {
|
||||
return nil, nil, err
|
||||
@@ -1068,3 +1318,52 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
|
||||
return char, equip, nil
|
||||
}
|
||||
|
||||
// ── Double XP/Money Boost ───────────────────────────────────────────────────
|
||||
|
||||
const advBoostCacheKey = "adv_boost_active"
|
||||
|
||||
func advBoostActive() bool {
|
||||
return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
|
||||
}
|
||||
|
||||
func advSetBoost(active bool) {
|
||||
v := "0"
|
||||
if active {
|
||||
v = "1"
|
||||
}
|
||||
db.CacheSet(advBoostCacheKey, v)
|
||||
}
|
||||
|
||||
func advApplyBoost(result *AdvActionResult) {
|
||||
if !advBoostActive() {
|
||||
return
|
||||
}
|
||||
result.XPGained *= 2
|
||||
result.TotalLootValue = 0
|
||||
for i := range result.LootItems {
|
||||
result.LootItems[i].Value *= 2
|
||||
result.TotalLootValue += result.LootItems[i].Value
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if advBoostActive() {
|
||||
advSetBoost(false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
|
||||
}
|
||||
advSetBoost(true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -125,7 +130,7 @@ func findAdvLocationByTier(activity AdvActivityType, tier int) *AdvLocation {
|
||||
// ── Loot Tables ──────────────────────────────────────────────────────────────
|
||||
|
||||
var advDungeonLoot = map[int][]AdvLootDef{
|
||||
1: {{"Copper Coins", "treasure", 1, 5}, {"Rat Pelt", "treasure", 3, 8}, {"Mouldy Bread", "treasure", 1, 3}, {"Bent Nail", "treasure", 1, 2}},
|
||||
1: {{"Copper Coins", "treasure", 5, 12}, {"Rat Pelt", "treasure", 8, 18}, {"Mouldy Bread", "treasure", 3, 8}, {"Bent Nail", "treasure", 2, 6}},
|
||||
2: {{"Iron Scraps", "ore", 20, 40}, {"Goblin Trinket", "treasure", 25, 50}, {"Small Gem", "gem", 40, 80}},
|
||||
3: {{"Silver Bar", "ore", 100, 200}, {"Ancient Artifact", "treasure", 150, 300}, {"Quality Gem", "gem", 200, 400}},
|
||||
4: {{"Gold Ingot", "ore", 500, 1000}, {"Enchanted Fragment", "treasure", 800, 1500}, {"Rare Gem", "gem", 1000, 2000}},
|
||||
@@ -133,7 +138,7 @@ var advDungeonLoot = map[int][]AdvLootDef{
|
||||
}
|
||||
|
||||
var advMiningLoot = map[int][]AdvLootDef{
|
||||
1: {{"Copper Ore", "ore", 2, 5}, {"Tin Ore", "ore", 3, 6}, {"Coal", "ore", 2, 4}},
|
||||
1: {{"Copper Ore", "ore", 5, 12}, {"Tin Ore", "ore", 6, 14}, {"Coal", "ore", 4, 10}},
|
||||
2: {{"Iron Ore", "ore", 15, 25}, {"Lead Ore", "ore", 18, 30}, {"Saltpetre", "ore", 20, 40}},
|
||||
3: {{"Silver Ore", "ore", 60, 100}, {"Quartz", "ore", 80, 120}, {"Nickel Ore", "ore", 70, 110}},
|
||||
4: {{"Gold Ore", "ore", 200, 400}, {"Sapphire", "gem", 300, 500}, {"Titanium Ore", "ore", 250, 450}},
|
||||
@@ -141,7 +146,7 @@ var advMiningLoot = map[int][]AdvLootDef{
|
||||
}
|
||||
|
||||
var advForagingLoot = map[int][]AdvLootDef{
|
||||
1: {{"Berries", "fruit", 1, 4}, {"Twigs", "wood", 2, 5}, {"Common Herbs", "fruit", 3, 8}},
|
||||
1: {{"Berries", "fruit", 3, 10}, {"Twigs", "wood", 5, 12}, {"Common Herbs", "fruit", 6, 15}},
|
||||
2: {{"Hardwood", "wood", 10, 20}, {"Wild Fruit", "fruit", 12, 22}, {"Mushrooms", "fruit", 15, 30}},
|
||||
3: {{"Ancient Timber", "wood", 40, 80}, {"Rare Herbs", "fruit", 50, 100}, {"Honey", "fruit", 60, 120}},
|
||||
4: {{"Exotic Wood", "wood", 150, 300}, {"Tropical Fruits", "fruit", 180, 400}, {"Spores", "fruit", 200, 500}},
|
||||
@@ -149,7 +154,7 @@ var advForagingLoot = map[int][]AdvLootDef{
|
||||
}
|
||||
|
||||
var advFishingLoot = map[int][]AdvLootDef{
|
||||
1: {{"Sad Fish", "fish", 1, 4}, {"Old Boot", "junk", 2, 5}, {"Tin Can", "junk", 1, 3}},
|
||||
1: {{"Sad Fish", "fish", 4, 10}, {"Old Boot", "junk", 5, 12}, {"Tin Can", "junk", 3, 8}},
|
||||
2: {{"Creek Trout", "fish", 12, 22}, {"Iron Scale", "fish", 15, 28}, {"River Pearl", "gem", 20, 40}},
|
||||
3: {{"Silver Bass", "fish", 50, 90}, {"Lake Sturgeon", "fish", 60, 110}, {"Blooper Ink", "treasure", 80, 150}},
|
||||
4: {{"Deep Eel", "fish", 180, 350}, {"River Serpent Scale", "treasure", 250, 500}, {"Abyssal Pearl", "gem", 300, 600}},
|
||||
@@ -339,15 +344,41 @@ 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.
|
||||
// Returns (eligible, inPenaltyZone).
|
||||
func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary) (bool, bool) {
|
||||
// Get effective skill level
|
||||
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
||||
|
||||
if skillLevel < loc.MinLevel {
|
||||
// Tier gating uses base skill only — buffs improve success chances, not access.
|
||||
baseLevel := advBaseSkill(char, loc.Activity)
|
||||
if baseLevel < loc.MinLevel {
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -365,11 +396,25 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme
|
||||
return false, false
|
||||
}
|
||||
|
||||
// Penalty zone: within 3 levels of minimum
|
||||
penalty := skillLevel-loc.MinLevel < 3
|
||||
// Penalty zone: within 3 levels of minimum (base skill only)
|
||||
penalty := baseLevel-loc.MinLevel < 3
|
||||
return true, penalty
|
||||
}
|
||||
|
||||
func advBaseSkill(char *AdventureCharacter, activity AdvActivityType) int {
|
||||
switch activity {
|
||||
case AdvActivityDungeon:
|
||||
return char.CombatLevel
|
||||
case AdvActivityMining:
|
||||
return char.MiningSkill
|
||||
case AdvActivityForaging:
|
||||
return char.ForagingSkill
|
||||
case AdvActivityFishing:
|
||||
return char.FishingSkill
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonuses *AdvBonusSummary) int {
|
||||
switch activity {
|
||||
case AdvActivityDungeon:
|
||||
@@ -394,7 +439,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
|
||||
@@ -503,7 +548,6 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
|
||||
|
||||
switch outcome {
|
||||
case AdvOutcomeDeath:
|
||||
// All slots -20, weapon and armor -30 (additional)
|
||||
for _, slot := range allSlots {
|
||||
damage[slot] = 20
|
||||
}
|
||||
@@ -515,7 +559,6 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
|
||||
damage[SlotArmor] = 10
|
||||
|
||||
case AdvOutcomeEmpty:
|
||||
// Failed dungeon run
|
||||
damage[SlotWeapon] = 15
|
||||
damage[SlotArmor] = 10
|
||||
|
||||
@@ -527,32 +570,9 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
|
||||
damage[SlotBoots] = 20
|
||||
|
||||
case AdvOutcomeHornets:
|
||||
// No equipment damage — they don't care about your sword
|
||||
}
|
||||
|
||||
// Tempered set: Seasoned — condition degrades 25% slower (applied once per set)
|
||||
tempered := advEquippedArenaSets(equip)["tempered"]
|
||||
|
||||
// Apply damage and check for breaks
|
||||
for slot, dmg := range damage {
|
||||
eq, ok := equip[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tempered {
|
||||
dmg = int(float64(dmg) * 0.75)
|
||||
}
|
||||
// Equipment mastery: well-used gear degrades slower
|
||||
if eq.ActionsUsed >= 20 {
|
||||
dmg = int(float64(dmg) * 0.8)
|
||||
}
|
||||
eq.Condition -= dmg
|
||||
if eq.Condition < 0 {
|
||||
eq.Condition = 0
|
||||
}
|
||||
}
|
||||
|
||||
return damage
|
||||
return applyDegradationModifiers(damage, equip)
|
||||
}
|
||||
|
||||
// advCheckBrokenEquipment checks which slots hit 0 condition and reverts them to tier 0.
|
||||
@@ -578,6 +598,31 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS
|
||||
return broken
|
||||
}
|
||||
|
||||
// ── Overlevel Penalty ───────────────────────────────────────────────────────
|
||||
|
||||
// advOverlevelMultiplier returns a multiplier (0.05–1.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%.
|
||||
// No penalty if no higher-tier location of the same activity is accessible.
|
||||
func advOverlevelMultiplier(effectiveLevel int, loc *AdvLocation) float64 {
|
||||
gap := effectiveLevel - loc.MinLevel
|
||||
if gap <= 3 {
|
||||
return 1.0
|
||||
}
|
||||
hasHigherAccessible := false
|
||||
for _, other := range allAdvLocations(loc.Activity) {
|
||||
if other.MinLevel > loc.MinLevel && other.MinLevel <= effectiveLevel {
|
||||
hasHigherAccessible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasHigherAccessible {
|
||||
return 1.0
|
||||
}
|
||||
mult := 1.0 - 0.15*float64(gap-3)
|
||||
return math.Max(0.05, mult)
|
||||
}
|
||||
|
||||
// ── Outcome Resolution ───────────────────────────────────────────────────────
|
||||
|
||||
type AdvActionResult struct {
|
||||
@@ -586,6 +631,7 @@ type AdvActionResult struct {
|
||||
LootItems []AdvItem
|
||||
TotalLootValue int64
|
||||
XPGained int
|
||||
XPBreakdown string // human-readable bonus breakdown
|
||||
XPSkill string
|
||||
EquipDamage map[EquipmentSlot]int
|
||||
LeveledUp bool
|
||||
@@ -596,6 +642,7 @@ type AdvActionResult struct {
|
||||
EquipBroken []EquipmentSlot
|
||||
NearDeath bool
|
||||
StreakBonus int
|
||||
CombatLog *CombatResult
|
||||
}
|
||||
|
||||
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
||||
@@ -606,6 +653,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)
|
||||
|
||||
// Roll outcome
|
||||
roll := rand.Float64() * 100
|
||||
|
||||
@@ -629,6 +680,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
|
||||
}
|
||||
@@ -643,20 +700,15 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
|
||||
}
|
||||
}
|
||||
|
||||
// Near-death XP bonus
|
||||
if result.NearDeath {
|
||||
xp = int(float64(xp) * 1.15)
|
||||
}
|
||||
|
||||
// XP multiplier from bonuses
|
||||
if bonuses.XPMultiplier != 0 {
|
||||
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
|
||||
}
|
||||
// Ironclad set: Battle-Hardened — +5% XP gain
|
||||
if advEquippedArenaSets(equip)["ironclad"] {
|
||||
xp = int(float64(xp) * 1.05)
|
||||
}
|
||||
result.XPGained = xp
|
||||
xpResult := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: xp,
|
||||
NearDeath: result.NearDeath,
|
||||
BonusMult: bonuses.XPMultiplier,
|
||||
Ironclad: advEquippedArenaSets(equip)["ironclad"],
|
||||
OverlevelMult: overlevelMult,
|
||||
})
|
||||
result.XPGained = xpResult.Total
|
||||
result.XPBreakdown = xpResult.Breakdown
|
||||
|
||||
// Equipment degradation on bad outcomes
|
||||
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
|
||||
@@ -737,7 +789,7 @@ func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*Adv
|
||||
|
||||
// advCheckPartyBonus checks if other players visited the same location today.
|
||||
func advCheckPartyBonus(userID id.UserID, location string) bool {
|
||||
logs, err := loadAdvTodayLogs()
|
||||
logs, err := loadAdvLogsForDate(time.Now().UTC().Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
+519
-387
File diff suppressed because it is too large
Load Diff
@@ -3,376 +3,8 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ── Combat Log Types ───────────────────────────────────────────────────────
|
||||
|
||||
type ArenaCombatLog struct {
|
||||
Rounds []ArenaCombatRound
|
||||
PlayerHP int
|
||||
EnemyHP int
|
||||
PlayerWon bool
|
||||
}
|
||||
|
||||
type ArenaCombatRound struct {
|
||||
Number int
|
||||
Text string // action description with damage filled in
|
||||
Type string // "player_hit", "enemy_hit", "block", "environmental"
|
||||
DamageToPlayer int
|
||||
DamageToEnemy int
|
||||
PlayerHP int // HP after this round
|
||||
EnemyHP int // HP after this round
|
||||
}
|
||||
|
||||
// ── Combat Log Generation ──────────────────────────────────────────────────
|
||||
|
||||
// generateArenaCombatLog assembles a turn-by-turn narrative for a fight whose
|
||||
// outcome is already determined. The log is cosmetic — the roll already happened.
|
||||
// closeness is 0.0 (decisive) to 1.0 (razor-thin margin).
|
||||
func generateArenaCombatLog(playerWon bool, closeness float64) *ArenaCombatLog {
|
||||
// Pick HP pools
|
||||
playerHP := 60 + rand.IntN(41) // 60-100
|
||||
enemyHP := 60 + rand.IntN(41) // 60-100
|
||||
|
||||
// Determine round count: decisive=3-4, close=5-6
|
||||
numRounds := 3
|
||||
if closeness > 0.7 {
|
||||
numRounds = 5 + rand.IntN(2) // 5-6
|
||||
} else if closeness > 0.4 {
|
||||
numRounds = 4 + rand.IntN(2) // 4-5
|
||||
} else {
|
||||
numRounds = 3 + rand.IntN(2) // 3-4
|
||||
}
|
||||
|
||||
// Assign round types
|
||||
types := assignRoundTypes(numRounds, playerWon)
|
||||
|
||||
// Calculate damage distribution
|
||||
picker := newActionPicker()
|
||||
rounds := distributeDamage(types, playerHP, enemyHP, playerWon, picker)
|
||||
|
||||
return &ArenaCombatLog{
|
||||
Rounds: rounds,
|
||||
PlayerHP: playerHP,
|
||||
EnemyHP: enemyHP,
|
||||
PlayerWon: playerWon,
|
||||
}
|
||||
}
|
||||
|
||||
// assignRoundTypes determines what happens each round.
|
||||
// Final round is always winner hitting — this is enforced.
|
||||
// Guarantees at least 1 hit round per side.
|
||||
func assignRoundTypes(numRounds int, playerWon bool) []string {
|
||||
types := make([]string, numRounds)
|
||||
|
||||
// Final round: winner lands the killing blow
|
||||
if playerWon {
|
||||
types[numRounds-1] = "player_hit"
|
||||
} else {
|
||||
types[numRounds-1] = "enemy_hit"
|
||||
}
|
||||
|
||||
// Fill remaining rounds
|
||||
for i := 0; i < numRounds-1; i++ {
|
||||
roll := rand.Float64()
|
||||
switch {
|
||||
case roll < 0.15:
|
||||
types[i] = "environmental"
|
||||
case roll < 0.35:
|
||||
types[i] = "block"
|
||||
case roll < 0.65:
|
||||
if i%2 == 0 {
|
||||
types[i] = "enemy_hit"
|
||||
} else {
|
||||
types[i] = "player_hit"
|
||||
}
|
||||
default:
|
||||
if i%2 == 0 {
|
||||
types[i] = "player_hit"
|
||||
} else {
|
||||
types[i] = "enemy_hit"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee at least 1 hit round per side (besides the final round).
|
||||
hasPlayerHit := false
|
||||
hasEnemyHit := false
|
||||
for _, t := range types {
|
||||
if t == "player_hit" {
|
||||
hasPlayerHit = true
|
||||
}
|
||||
if t == "enemy_hit" || t == "environmental" {
|
||||
hasEnemyHit = true
|
||||
}
|
||||
}
|
||||
// If missing a side, convert the first block round (or first non-final round).
|
||||
if !hasPlayerHit {
|
||||
for i := 0; i < numRounds-1; i++ {
|
||||
if types[i] == "block" || types[i] == "enemy_hit" || types[i] == "environmental" {
|
||||
types[i] = "player_hit"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasEnemyHit {
|
||||
for i := 0; i < numRounds-1; i++ {
|
||||
if types[i] == "block" || types[i] == "player_hit" {
|
||||
types[i] = "enemy_hit"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return types
|
||||
}
|
||||
|
||||
// distributeDamage creates rounds with damage values that sum correctly.
|
||||
func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, picker *actionPicker) []ArenaCombatRound {
|
||||
numRounds := len(types)
|
||||
|
||||
// Total damage dealt: winner kills the loser (deals their full HP).
|
||||
// Loser deals some but not all of winner's HP.
|
||||
var totalDmgToEnemy, totalDmgToPlayer int
|
||||
if playerWon {
|
||||
totalDmgToEnemy = enemyHP
|
||||
totalDmgToPlayer = int(float64(playerHP) * (0.3 + rand.Float64()*0.5)) // 30-80% of player HP
|
||||
} else {
|
||||
totalDmgToPlayer = playerHP
|
||||
totalDmgToEnemy = int(float64(enemyHP) * (0.3 + rand.Float64()*0.5))
|
||||
}
|
||||
|
||||
// Count damage rounds for each side
|
||||
var playerHitRounds, enemyHitRounds []int
|
||||
for i, t := range types {
|
||||
switch t {
|
||||
case "player_hit":
|
||||
playerHitRounds = append(playerHitRounds, i)
|
||||
case "enemy_hit":
|
||||
enemyHitRounds = append(enemyHitRounds, i)
|
||||
case "environmental":
|
||||
enemyHitRounds = append(enemyHitRounds, i) // environmental damages player
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute damage to enemy across player_hit rounds
|
||||
enemyDmgPerRound := splitDamage(totalDmgToEnemy, len(playerHitRounds))
|
||||
// Distribute damage to player across enemy_hit + environmental rounds
|
||||
playerDmgPerRound := splitDamage(totalDmgToPlayer, len(enemyHitRounds))
|
||||
|
||||
// Build rounds
|
||||
rounds := make([]ArenaCombatRound, numRounds)
|
||||
currentPlayerHP := playerHP
|
||||
currentEnemyHP := enemyHP
|
||||
playerDmgIdx := 0
|
||||
enemyDmgIdx := 0
|
||||
|
||||
for i, t := range types {
|
||||
r := ArenaCombatRound{
|
||||
Number: i + 1,
|
||||
Type: t,
|
||||
}
|
||||
|
||||
switch t {
|
||||
case "player_hit":
|
||||
dmg := 0
|
||||
if enemyDmgIdx < len(enemyDmgPerRound) {
|
||||
dmg = enemyDmgPerRound[enemyDmgIdx]
|
||||
enemyDmgIdx++
|
||||
}
|
||||
r.DamageToEnemy = dmg
|
||||
currentEnemyHP -= dmg
|
||||
if currentEnemyHP < 0 {
|
||||
currentEnemyHP = 0
|
||||
}
|
||||
r.Text = pickFrom(arenaPlayerHitActions, picker.player, dmg)
|
||||
|
||||
case "enemy_hit":
|
||||
dmg := 0
|
||||
if playerDmgIdx < len(playerDmgPerRound) {
|
||||
dmg = playerDmgPerRound[playerDmgIdx]
|
||||
playerDmgIdx++
|
||||
}
|
||||
r.DamageToPlayer = dmg
|
||||
currentPlayerHP -= dmg
|
||||
if currentPlayerHP < 0 {
|
||||
currentPlayerHP = 0
|
||||
}
|
||||
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
|
||||
|
||||
case "block":
|
||||
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
|
||||
|
||||
case "environmental":
|
||||
dmg := 0
|
||||
if playerDmgIdx < len(playerDmgPerRound) {
|
||||
dmg = playerDmgPerRound[playerDmgIdx]
|
||||
playerDmgIdx++
|
||||
}
|
||||
r.DamageToPlayer = dmg
|
||||
currentPlayerHP -= dmg
|
||||
if currentPlayerHP < 0 {
|
||||
currentPlayerHP = 0
|
||||
}
|
||||
r.Text = pickFrom(arenaEnvironmentalActions, picker.environment, dmg)
|
||||
}
|
||||
|
||||
r.PlayerHP = currentPlayerHP
|
||||
r.EnemyHP = currentEnemyHP
|
||||
rounds[i] = r
|
||||
}
|
||||
|
||||
// Ensure final round ends at exactly 0 for the loser
|
||||
last := &rounds[numRounds-1]
|
||||
if playerWon {
|
||||
last.EnemyHP = 0
|
||||
} else {
|
||||
last.PlayerHP = 0
|
||||
}
|
||||
|
||||
return rounds
|
||||
}
|
||||
|
||||
// splitDamage distributes total damage across n rounds with some variance.
|
||||
// Each round gets at least 1 damage. If total < n, excess rounds get 0.
|
||||
func splitDamage(total, n int) []int {
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
if n == 1 {
|
||||
return []int{total}
|
||||
}
|
||||
|
||||
result := make([]int, n)
|
||||
|
||||
// If total < n, give 1 to the first `total` rounds, 0 to the rest.
|
||||
if total <= n {
|
||||
for i := 0; i < total && i < n; i++ {
|
||||
result[i] = 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
remaining := total
|
||||
|
||||
for i := 0; i < n-1; i++ {
|
||||
avg := remaining / (n - i)
|
||||
if avg <= 0 {
|
||||
avg = 1
|
||||
}
|
||||
// Variance: 50%-150% of average
|
||||
lo := avg / 2
|
||||
if lo < 1 {
|
||||
lo = 1
|
||||
}
|
||||
hi := avg + avg/2
|
||||
if hi < lo {
|
||||
hi = lo
|
||||
}
|
||||
dmg := lo + rand.IntN(hi-lo+1)
|
||||
// Reserve at least 1 per remaining round
|
||||
maxThisRound := remaining - (n - 1 - i)
|
||||
if maxThisRound < 1 {
|
||||
maxThisRound = 1
|
||||
}
|
||||
if dmg > maxThisRound {
|
||||
dmg = maxThisRound
|
||||
}
|
||||
if dmg < 1 {
|
||||
dmg = 1
|
||||
}
|
||||
result[i] = dmg
|
||||
remaining -= dmg
|
||||
}
|
||||
result[n-1] = remaining
|
||||
if result[n-1] < 1 {
|
||||
result[n-1] = 1
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// actionPicker tracks used indices per pool to avoid repeats within a fight.
|
||||
type actionPicker struct {
|
||||
enemy map[int]bool
|
||||
player map[int]bool
|
||||
block map[int]bool
|
||||
environment map[int]bool
|
||||
}
|
||||
|
||||
func newActionPicker() *actionPicker {
|
||||
return &actionPicker{
|
||||
enemy: make(map[int]bool),
|
||||
player: make(map[int]bool),
|
||||
block: make(map[int]bool),
|
||||
environment: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// pickFrom selects a random unused entry from pool, formats it with damage, and marks it used.
|
||||
// Resets if pool is exhausted.
|
||||
func pickFrom(pool []string, used map[int]bool, damage int) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return fmt.Sprintf(pool[idx], damage)
|
||||
}
|
||||
|
||||
func pickFromNoFmt(pool []string, used map[int]bool) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return pool[idx]
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaCombatLog(log *ArenaCombatLog, monster *ArenaMonster, won bool, reward int64, xp int, closerLine string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
for _, r := range log.Rounds {
|
||||
sb.WriteString(r.Text + "\n")
|
||||
|
||||
// Compact HP status line — damage is already in the action text via %d
|
||||
switch r.Type {
|
||||
case "player_hit":
|
||||
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
|
||||
case "enemy_hit", "environmental":
|
||||
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
|
||||
}
|
||||
// Blocks: no HP line (no damage happened)
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
if won {
|
||||
sb.WriteString(fmt.Sprintf("💀 %s has been defeated.\n", monster.Name))
|
||||
sb.WriteString(closerLine + "\n")
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned\n", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("The healers are already moving.\n")
|
||||
sb.WriteString("💀 Defeated.\n")
|
||||
sb.WriteString(closerLine + "\n")
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.\n", arenaParticipationXP))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
const arenaParticipationXP = 60
|
||||
|
||||
// ── Closer Lines ───────────────────────────────────────────────────────────
|
||||
@@ -402,77 +34,3 @@ func arenaLoseCloser(winnerName string, lastRound int) string {
|
||||
}
|
||||
return closers[rand.IntN(len(closers))]
|
||||
}
|
||||
|
||||
// ── Action Pools ───────────────────────────────────────────────────────────
|
||||
|
||||
// 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 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 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.",
|
||||
"The enemy trips. Recovers. Hits you anyway. %d damage. You were rooting for them for a second there.",
|
||||
"The enemy takes a phone call, hits you one-handed, and continues the call. %d damage. You were barely a distraction.",
|
||||
"The enemy critiques your fighting stance in detail. You correct it instinctively. Your corrected stance is worse. %d damage.",
|
||||
"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 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.",
|
||||
}
|
||||
|
||||
// Player actions — hit the enemy. %d is damage.
|
||||
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 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 land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.",
|
||||
}
|
||||
|
||||
// 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.",
|
||||
"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.",
|
||||
}
|
||||
|
||||
// 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type ArenaMonster struct {
|
||||
Flavor string
|
||||
BaseLethality float64
|
||||
ThreatLevel int
|
||||
Ability *MonsterAbility // nil = no special ability
|
||||
}
|
||||
|
||||
var arenaTiers = [5]ArenaTier{
|
||||
@@ -75,6 +76,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Armored Disagreement",
|
||||
Flavor: "A dark knight who has made peace with violence as a communication strategy. Extensively equipped.",
|
||||
BaseLethality: 0.65, ThreatLevel: 30,
|
||||
Ability: &MonsterAbility{Name: "Shield Bash", Phase: "clash", ProcChance: 0.20, Effect: "stun"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -92,6 +94,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Wyrm of Moderate Ambition",
|
||||
Flavor: "Aspires to be a world-ending dragon. Currently a regional threat at best. Very sensitive about this.",
|
||||
BaseLethality: 0.65, ThreatLevel: 42,
|
||||
Ability: &MonsterAbility{Name: "Venom Breath", Phase: "clash", ProcChance: 0.30, Effect: "poison"},
|
||||
},
|
||||
{
|
||||
Name: "Behemoth Adjacent",
|
||||
@@ -102,6 +105,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "The Inevitable",
|
||||
Flavor: "A reaper-class entity. No grievances. No agenda. Simply the direction all things are heading.",
|
||||
BaseLethality: 0.80, ThreatLevel: 55,
|
||||
Ability: &MonsterAbility{Name: "Soul Drain", Phase: "clash", ProcChance: 0.25, Effect: "lifesteal"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -114,6 +118,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "The Watcher in the Peripheral",
|
||||
Flavor: "Seventeen eyes. None of them blink at the same time. Has been observing you specifically for longer than you've been alive.",
|
||||
BaseLethality: 0.72, ThreatLevel: 62,
|
||||
Ability: &MonsterAbility{Name: "Gaze of Unmaking", Phase: "opening", ProcChance: 0.35, Effect: "armor_break"},
|
||||
},
|
||||
{
|
||||
Name: "Herald of the Outer Dark",
|
||||
@@ -124,11 +129,13 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Lich Adjacent",
|
||||
Flavor: "Not the Lich King. Definitely not. Unrelated individual. Happens to be a skeletal sorcerer of immense power. Coincidence.",
|
||||
BaseLethality: 0.87, ThreatLevel: 78,
|
||||
Ability: &MonsterAbility{Name: "Necrotic Siphon", Phase: "any", ProcChance: 0.30, Effect: "lifesteal"},
|
||||
},
|
||||
{
|
||||
Name: "The Collector of Faces",
|
||||
Flavor: "It has yours already. Has had it for some time. The fight is a formality at this point.",
|
||||
BaseLethality: 0.92, ThreatLevel: 88,
|
||||
Ability: &MonsterAbility{Name: "Harvest", Phase: "clash", ProcChance: 0.35, Effect: "cleave"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -141,21 +148,25 @@ var arenaTiers = [5]ArenaTier{
|
||||
Name: "Omega Mk. Zero",
|
||||
Flavor: "A machine built to be the final test. Has never lost. Is aware of this.",
|
||||
BaseLethality: 0.85, ThreatLevel: 95,
|
||||
Ability: &MonsterAbility{Name: "System Override", Phase: "opening", ProcChance: 0.40, Effect: "armor_break"},
|
||||
},
|
||||
{
|
||||
Name: "The Calamity That Dreamed It Was Sleeping",
|
||||
Flavor: "An ancient parasitic entity that fell from the sky an indeterminate number of years ago and has been ending timelines since. Definitely not Lavos.",
|
||||
BaseLethality: 0.90, ThreatLevel: 105,
|
||||
Ability: &MonsterAbility{Name: "Temporal Drain", Phase: "any", ProcChance: 0.35, Effect: "lifesteal"},
|
||||
},
|
||||
{
|
||||
Name: "The Architect of Endings",
|
||||
Flavor: "A god who decided the world was a failed experiment and appointed itself project manager of its destruction. Silver hair. Long coat. Personal.",
|
||||
BaseLethality: 0.95, ThreatLevel: 115,
|
||||
Ability: &MonsterAbility{Name: "Deconstruct", Phase: "clash", ProcChance: 0.40, Effect: "cleave"},
|
||||
},
|
||||
{
|
||||
Name: "That Which Has Always Been",
|
||||
Flavor: "Pre-dates language. Pre-dates light. The Arena was built around it, not the other way around. Winning this fight is not something the game's designers fully accounted for.",
|
||||
BaseLethality: 0.98, ThreatLevel: 130,
|
||||
Ability: &MonsterAbility{Name: "Primordial Wrath", Phase: "decisive", ProcChance: 0.50, Effect: "enrage"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,9 +8,13 @@ import (
|
||||
|
||||
// ── Arena Tier Menu ─────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaTierMenu(char *AdventureCharacter, stats *ArenaPersonalStats) string {
|
||||
func renderArenaStreakEntry(char *AdventureCharacter, stats *ArenaPersonalStats, tier *ArenaTier, firstMonster *ArenaMonster) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("⚔️ **THE ARENA**\n\n")
|
||||
b.WriteString("The Arena is a streak. You start at Tier 1 and fight your way down.\n")
|
||||
b.WriteString("After each tier, you have 30 seconds to bail or you auto-advance.\n")
|
||||
b.WriteString("Death forfeits all accumulated rewards.\n\n")
|
||||
|
||||
b.WriteString(fmt.Sprintf("Combat Level: %d\n\n", char.CombatLevel))
|
||||
|
||||
for i := range arenaTiers {
|
||||
@@ -18,12 +22,13 @@ func renderArenaTierMenu(char *AdventureCharacter, stats *ArenaPersonalStats) st
|
||||
eligible := char.CombatLevel >= t.MinLevel
|
||||
icon := "🔒"
|
||||
if eligible {
|
||||
icon = "⬚" // eligible but not cleared
|
||||
icon = "⬚"
|
||||
}
|
||||
if stats != nil && stats.HighestTier >= t.Number {
|
||||
icon = "✅" // cleared
|
||||
icon = "✅"
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s **Tier %d — %s** (Lv.%d+)\n", icon, t.Number, t.Name, t.MinLevel))
|
||||
mult := arenaStreakEuroMultiplier[t.Number]
|
||||
b.WriteString(fmt.Sprintf("%s **Tier %d — %s** (Lv.%d+) — %.1f× euros\n", icon, t.Number, t.Name, t.MinLevel, mult))
|
||||
}
|
||||
|
||||
if stats != nil && stats.TotalRuns > 0 {
|
||||
@@ -34,9 +39,11 @@ func renderArenaTierMenu(char *AdventureCharacter, stats *ArenaPersonalStats) st
|
||||
b.WriteString("\n⛑️ _Today's helmets are provided by Brim & Battle — celebrated makers of baseball hats — and proud sponsors of the Arena. ")
|
||||
b.WriteString("Their new VeriFort line of field-evaluated combat headgear represents an exciting expansion beyond their area of expertise. ")
|
||||
b.WriteString("Winners may receive a complimentary sample. Brim & Battle thanks you for your participation in their ongoing verification process._\n\n")
|
||||
b.WriteString("`!arena tier <1-5>` — Enter a tier\n")
|
||||
b.WriteString("`!arena stats` — Your arena stats\n")
|
||||
b.WriteString("`!arena leaderboard` — Top arena players\n")
|
||||
|
||||
b.WriteString(fmt.Sprintf("**Round 1 opponent: %s**\n", firstMonster.Name))
|
||||
b.WriteString(fmt.Sprintf("_%s_\n\n", firstMonster.Flavor))
|
||||
b.WriteString("`!arena fight` — Enter and fight Round 1\n")
|
||||
b.WriteString("`!arena cancel` — Back out")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -48,60 +55,23 @@ func renderArenaRoundStart(tier *ArenaTier, round int, monster *ArenaMonster, ru
|
||||
b.WriteString(fmt.Sprintf("**%s**\n", monster.Name))
|
||||
b.WriteString(fmt.Sprintf("_%s_\n\n", monster.Flavor))
|
||||
|
||||
if run.Earnings > 0 {
|
||||
b.WriteString(fmt.Sprintf("Run earnings: €%d (at risk)\n\n", run.Earnings))
|
||||
sessionTotal := run.Earnings + run.TierEarnings
|
||||
if sessionTotal > 0 {
|
||||
b.WriteString(fmt.Sprintf("Session earnings: €%d (at risk)\n\n", sessionTotal))
|
||||
}
|
||||
|
||||
b.WriteString("`!arena fight` — Face this opponent\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Survival ────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaSurvival(tier *ArenaTier, round int, monster *ArenaMonster, reward int64, xp int, totalEarnings int64) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("✅ **%s defeated.**\n\n", monster.Name))
|
||||
b.WriteString(fmt.Sprintf("Round reward: €%d\n", reward))
|
||||
b.WriteString(fmt.Sprintf("Battle XP: +%d\n", xp))
|
||||
b.WriteString(fmt.Sprintf("Run total: €%d\n", totalEarnings))
|
||||
return b.String()
|
||||
}
|
||||
// renderArenaSurvival removed — survival text is built by renderArenaCombatLog + inline formatting.
|
||||
|
||||
// ── Tier Complete (Transition Prompt) ───────────────────────────────────────
|
||||
|
||||
func renderArenaTierComplete(tier *ArenaTier, completionBonus int64, totalEarnings int64) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🏆 **Tier %d — %s cleared!**\n\n", tier.Number, tier.Name))
|
||||
b.WriteString(fmt.Sprintf("Completion bonus: €%d\n", completionBonus))
|
||||
b.WriteString(fmt.Sprintf("Run total: €%d\n\n", totalEarnings))
|
||||
// renderArenaTierComplete is no longer used — tier complete text is built inline
|
||||
// in resolveArenaSurvival and the countdown DM handles the opt-out flow.
|
||||
|
||||
if tier.Number < 5 {
|
||||
nextTier := arenaGetTier(tier.Number + 1)
|
||||
b.WriteString(fmt.Sprintf("Descend to Tier %d — %s? Your earnings are at risk.\n\n", nextTier.Number, nextTier.Name))
|
||||
b.WriteString(fmt.Sprintf("`!arena descend` — Enter Tier %d (earnings carry over, still at risk)\n", nextTier.Number))
|
||||
b.WriteString(fmt.Sprintf("`!arena cashout` — Take your €%d and leave\n\n", totalEarnings))
|
||||
b.WriteString("_You have 10 minutes to decide. After that, GogoBee collects on your behalf._")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Tier 5 Complete ─────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaTier5Complete(totalEarnings int64, startTier int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("🏆🏆🏆 **THE ARENA HAS BEEN CONQUERED.**\n\n")
|
||||
b.WriteString("That Which Has Always Been has fallen. The machine has logged a loss.\n")
|
||||
b.WriteString("The crowd is silent. Not out of respect — out of disbelief.\n\n")
|
||||
b.WriteString(fmt.Sprintf("**Total earnings: €%d**\n\n", totalEarnings))
|
||||
b.WriteString("Your euros have been credited. Your name has been etched. The Arena remembers.")
|
||||
|
||||
if startTier == 1 {
|
||||
b.WriteString("\n\n_All the way down. From Tier 1 to Tier 5 in a single run. Statistically impossible. Empirically: you._")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
// renderArenaTier5Complete removed — T5 completion is part of arenaCompleteSession payout summary.
|
||||
|
||||
// ── Death ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,25 +87,7 @@ func renderArenaDeath(tier *ArenaTier, round int, monster *ArenaMonster, lostEar
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Cashout ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaCashout(totalEarnings int64, lastTier int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("💰 **Cashed out: €%d**\n\n", totalEarnings))
|
||||
b.WriteString(fmt.Sprintf("You cleared through Tier %d and lived to spend it. ", lastTier))
|
||||
b.WriteString("Wisdom or cowardice — the euros don't care which.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Auto-Cashout ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaAutoCashout(totalEarnings int64) string {
|
||||
return fmt.Sprintf(
|
||||
"⏰ **Auto-cashout: €%d**\n\n"+
|
||||
"You took too long to decide. GogoBee collected your winnings on your behalf "+
|
||||
"and is annoyed about it. The money is in your account. You're welcome.",
|
||||
totalEarnings)
|
||||
}
|
||||
// renderArenaCashout and renderArenaAutoCashout removed — payout handled by arenaCompleteSession.
|
||||
|
||||
// ── Status ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -146,9 +98,11 @@ func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("⚔️ **Arena Run Status**\n\n")
|
||||
b.WriteString("⚔️ **Arena Streak Status**\n\n")
|
||||
b.WriteString(fmt.Sprintf("Tier: %d — %s\n", tier.Number, tier.Name))
|
||||
|
||||
sessionTotal := run.Earnings + run.TierEarnings
|
||||
|
||||
switch run.Status {
|
||||
case "active":
|
||||
monster := arenaGetMonster(run.Tier, run.Round)
|
||||
@@ -156,14 +110,14 @@ func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
|
||||
if monster != nil {
|
||||
b.WriteString(fmt.Sprintf("Opponent: %s\n", monster.Name))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Earnings: €%d (at risk)\n", run.Earnings))
|
||||
b.WriteString(fmt.Sprintf("Session total: €%d (at risk)\n", sessionTotal))
|
||||
b.WriteString(fmt.Sprintf("Rounds survived: %d\n", run.RoundsSurvived))
|
||||
b.WriteString("\n`!arena fight` to continue")
|
||||
case "awaiting":
|
||||
b.WriteString(fmt.Sprintf("Tier %d cleared — awaiting decision\n", run.Tier))
|
||||
b.WriteString(fmt.Sprintf("Earnings: €%d (at risk)\n", run.Earnings))
|
||||
b.WriteString(fmt.Sprintf("Tier %d cleared — advancing shortly\n", run.Tier))
|
||||
b.WriteString(fmt.Sprintf("Session total: €%d (at risk)\n", sessionTotal))
|
||||
b.WriteString(fmt.Sprintf("Rounds survived: %d\n", run.RoundsSurvived))
|
||||
b.WriteString("\n`!arena descend` or `!arena cashout`")
|
||||
b.WriteString("\n`!bail` to collect your rewards")
|
||||
}
|
||||
|
||||
return b.String()
|
||||
@@ -207,19 +161,6 @@ func renderArenaLeaderboard(entries []ArenaLeaderboardEntry) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Tier Entry Confirmation ──────────────────────────────────────────────────
|
||||
|
||||
func renderArenaTierConfirm(tier *ArenaTier, firstMonster *ArenaMonster) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Tier %d — %s**\n\n", tier.Number, tier.Name))
|
||||
b.WriteString(fmt.Sprintf("4 rounds. You cannot leave mid-tier. Death forfeits all earnings.\n\n"))
|
||||
b.WriteString(fmt.Sprintf("Round 1 opponent: **%s**\n", firstMonster.Name))
|
||||
b.WriteString(fmt.Sprintf("_%s_\n\n", firstMonster.Flavor))
|
||||
b.WriteString("`!arena fight` — Enter and fight Round 1\n")
|
||||
b.WriteString("`!arena cancel` — Back out")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Personal Stats ──────────────────────────────────────────────────────────
|
||||
|
||||
type ArenaPersonalStats struct {
|
||||
@@ -276,7 +217,7 @@ func renderArenaHelmetDrop(gear *ArenaGearSet) string {
|
||||
|
||||
switch gear.SetKey {
|
||||
case "bloodied":
|
||||
b.WriteString("Set bonus: **Survivor's Instinct** — +3% to all activity success rates.")
|
||||
b.WriteString("Set bonus: **Survivor's Instinct** — +3% to all activity success rates, +3% critical hit rate in combat.")
|
||||
case "ironclad":
|
||||
b.WriteString("Set bonus: **Battle-Hardened** — +5% XP gain from all activities.")
|
||||
case "tempered":
|
||||
@@ -305,12 +246,11 @@ func renderArenaAlreadyInRun(run *ArenaRun) string {
|
||||
switch run.Status {
|
||||
case "awaiting":
|
||||
return fmt.Sprintf(
|
||||
"You have a pending arena decision. Tier %d cleared, €%d at risk.\n\n"+
|
||||
"`!arena descend` or `!arena cashout`",
|
||||
"Tier %d cleared, €%d accumulated. Waiting for next tier or `!bail` to collect.\n",
|
||||
run.Tier, run.Earnings)
|
||||
default:
|
||||
return fmt.Sprintf(
|
||||
"You're already in an arena run. Tier %d, Round %d.\n\n"+
|
||||
"You're in an arena streak. Tier %d, Round %d.\n\n"+
|
||||
"`!arena fight` to continue.",
|
||||
run.Tier, run.Round)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Monster Data Tests ──────────────────────────────────────────────────────
|
||||
@@ -272,36 +273,40 @@ func TestArenaDeathChance_Components(t *testing.T) {
|
||||
|
||||
// ── Render Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRenderArenaTierMenu(t *testing.T) {
|
||||
func TestRenderArenaStreakEntry(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 30}
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
// nil stats — no tiers cleared
|
||||
text := renderArenaTierMenu(char, nil)
|
||||
text := renderArenaStreakEntry(char, nil, tier, monster)
|
||||
if !strings.Contains(text, "THE ARENA") {
|
||||
t.Error("tier menu should contain header")
|
||||
t.Error("streak entry should contain header")
|
||||
}
|
||||
if !strings.Contains(text, "Tier 1") {
|
||||
t.Error("tier menu should list tier 1")
|
||||
t.Error("streak entry should list tier 1")
|
||||
}
|
||||
if !strings.Contains(text, "Tier 5") {
|
||||
t.Error("tier menu should list tier 5")
|
||||
t.Error("streak entry should list tier 5")
|
||||
}
|
||||
if !strings.Contains(text, "streak") {
|
||||
t.Error("streak entry should mention streak")
|
||||
}
|
||||
// Level 30 should have tiers 1-3 eligible (⬚) and 4-5 locked (🔒)
|
||||
if !strings.Contains(text, "⬚") {
|
||||
t.Error("tier menu should show eligible-but-uncleared tiers")
|
||||
t.Error("streak entry should show eligible-but-uncleared tiers")
|
||||
}
|
||||
if !strings.Contains(text, "🔒") {
|
||||
t.Error("tier menu should show locked tiers")
|
||||
t.Error("streak entry should show locked tiers")
|
||||
}
|
||||
|
||||
// with stats — tier 2 cleared
|
||||
stats := &ArenaPersonalStats{TotalRuns: 5, TotalDeaths: 1, TotalEarnings: 3000, HighestTier: 2}
|
||||
text = renderArenaTierMenu(char, stats)
|
||||
text = renderArenaStreakEntry(char, stats, tier, monster)
|
||||
if !strings.Contains(text, "✅") {
|
||||
t.Error("tier menu should show cleared tiers when stats provided")
|
||||
t.Error("streak entry should show cleared tiers when stats provided")
|
||||
}
|
||||
if !strings.Contains(text, "Runs: 5") {
|
||||
t.Error("tier menu should show run summary when stats provided")
|
||||
t.Error("streak entry should show run summary when stats provided")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,22 +330,6 @@ func TestRenderArenaRoundStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaSurvival(t *testing.T) {
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
text := renderArenaSurvival(tier, 1, monster, 150, 10, 150)
|
||||
if !strings.Contains(text, "defeated") {
|
||||
t.Error("survival should mention defeat")
|
||||
}
|
||||
if !strings.Contains(text, "€150") {
|
||||
t.Error("survival should show reward")
|
||||
}
|
||||
if !strings.Contains(text, "+10") {
|
||||
t.Error("survival should show XP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaDeath(t *testing.T) {
|
||||
tier := arenaGetTier(3)
|
||||
monster := arenaGetMonster(3, 2)
|
||||
@@ -357,67 +346,415 @@ func TestRenderArenaDeath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaTierComplete(t *testing.T) {
|
||||
tier := arenaGetTier(2)
|
||||
text := renderArenaTierComplete(tier, 10000, 15000)
|
||||
func TestArenaStreakMultipliers(t *testing.T) {
|
||||
// Verify multiplier arrays are sane
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
if arenaStreakEuroMultiplier[tier] < 1.0 {
|
||||
t.Errorf("tier %d euro multiplier %f < 1.0", tier, arenaStreakEuroMultiplier[tier])
|
||||
}
|
||||
}
|
||||
for tiers := 1; tiers <= 5; tiers++ {
|
||||
if arenaStreakXPMultiplier[tiers] < 1.0 {
|
||||
t.Errorf("tiers won %d XP multiplier %f < 1.0", tiers, arenaStreakXPMultiplier[tiers])
|
||||
}
|
||||
}
|
||||
// Multipliers should be non-decreasing
|
||||
for i := 2; i <= 5; i++ {
|
||||
if arenaStreakEuroMultiplier[i] < arenaStreakEuroMultiplier[i-1] {
|
||||
t.Errorf("euro multiplier tier %d (%f) < tier %d (%f)", i, arenaStreakEuroMultiplier[i], i-1, arenaStreakEuroMultiplier[i-1])
|
||||
}
|
||||
if arenaStreakXPMultiplier[i] < arenaStreakXPMultiplier[i-1] {
|
||||
t.Errorf("XP multiplier %d tiers (%f) < %d tiers (%f)", i, arenaStreakXPMultiplier[i], i-1, arenaStreakXPMultiplier[i-1])
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(text, "cleared") {
|
||||
t.Error("tier complete should say cleared")
|
||||
// Verify exact values match spec
|
||||
wantEuro := map[int]float64{1: 1.0, 2: 1.5, 3: 2.0, 4: 2.75, 5: 4.0}
|
||||
for tier, want := range wantEuro {
|
||||
if arenaStreakEuroMultiplier[tier] != want {
|
||||
t.Errorf("euro multiplier tier %d: got %f, want %f", tier, arenaStreakEuroMultiplier[tier], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "€10000") {
|
||||
t.Error("tier complete should show completion bonus")
|
||||
wantXP := map[int]float64{1: 1.0, 2: 1.2, 3: 1.5, 4: 1.85, 5: 2.5}
|
||||
for tiers, want := range wantXP {
|
||||
if arenaStreakXPMultiplier[tiers] != want {
|
||||
t.Errorf("XP multiplier %d tiers: got %f, want %f", tiers, arenaStreakXPMultiplier[tiers], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "descend") {
|
||||
t.Error("tier complete should offer descend")
|
||||
|
||||
// Index 0 should be 0 (unused sentinel)
|
||||
if arenaStreakEuroMultiplier[0] != 0 {
|
||||
t.Errorf("euro multiplier index 0 should be 0, got %f", arenaStreakEuroMultiplier[0])
|
||||
}
|
||||
if !strings.Contains(text, "cashout") {
|
||||
t.Error("tier complete should offer cashout")
|
||||
}
|
||||
if !strings.Contains(text, "10 minutes") {
|
||||
t.Error("tier complete should mention deadline")
|
||||
if arenaStreakXPMultiplier[0] != 0 {
|
||||
t.Errorf("XP multiplier index 0 should be 0, got %f", arenaStreakXPMultiplier[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaTier5Complete(t *testing.T) {
|
||||
// Non-full run
|
||||
text := renderArenaTier5Complete(650000, 3)
|
||||
if !strings.Contains(text, "CONQUERED") {
|
||||
t.Error("T5 complete should say conquered")
|
||||
}
|
||||
if !strings.Contains(text, "€650000") {
|
||||
t.Error("T5 complete should show earnings")
|
||||
}
|
||||
if strings.Contains(text, "All the way down") {
|
||||
t.Error("non-full run should NOT mention all the way down")
|
||||
}
|
||||
// ── Streak Reward Calculation Tests ────────────────────────────────────────
|
||||
|
||||
// Full run (started from tier 1)
|
||||
textFull := renderArenaTier5Complete(1000000, 1)
|
||||
if !strings.Contains(textFull, "All the way down") {
|
||||
t.Error("full run should mention all the way down")
|
||||
func TestArenaStreakPayout_SingleTier(t *testing.T) {
|
||||
// Bail after T1: multiplier = 1.0× on T1 earnings
|
||||
tier1 := arenaGetTier(1)
|
||||
var tierEarnings int64
|
||||
for round := 1; round <= 4; round++ {
|
||||
tierEarnings += arenaRoundReward(tier1, round, 0)
|
||||
}
|
||||
tierEarnings += tier1.CompletionBonus
|
||||
|
||||
multiplied := int64(float64(tierEarnings) * arenaStreakEuroMultiplier[1])
|
||||
if multiplied != tierEarnings {
|
||||
t.Errorf("T1 payout with 1.0× multiplier should equal raw: got %d, want %d", multiplied, tierEarnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaCashout(t *testing.T) {
|
||||
text := renderArenaCashout(25000, 3)
|
||||
if !strings.Contains(text, "€25000") {
|
||||
t.Error("cashout should show amount")
|
||||
func TestArenaStreakPayout_FullRun(t *testing.T) {
|
||||
// Full T1-T5 run: each tier's earnings multiplied independently
|
||||
var totalMultiplied int64
|
||||
var totalRaw int64
|
||||
|
||||
for tierNum := 1; tierNum <= 5; tierNum++ {
|
||||
tier := arenaGetTier(tierNum)
|
||||
var tierEarnings int64
|
||||
for round := 1; round <= 4; round++ {
|
||||
tierEarnings += arenaRoundReward(tier, round, 0)
|
||||
}
|
||||
tierEarnings += tier.CompletionBonus
|
||||
totalRaw += tierEarnings
|
||||
totalMultiplied += int64(float64(tierEarnings) * arenaStreakEuroMultiplier[tierNum])
|
||||
}
|
||||
|
||||
// Multiplied total should exceed raw total (multipliers > 1 for T2+)
|
||||
if totalMultiplied <= totalRaw {
|
||||
t.Errorf("multiplied total (%d) should exceed raw total (%d)", totalMultiplied, totalRaw)
|
||||
}
|
||||
|
||||
// T5 multiplier is 4.0×, so multiplied should be significantly higher
|
||||
ratio := float64(totalMultiplied) / float64(totalRaw)
|
||||
if ratio < 1.5 {
|
||||
t.Errorf("overall multiplier ratio %f seems too low for streak system", ratio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaStreakXPMultiplier_Application(t *testing.T) {
|
||||
// XP multiplier is applied to total session XP at payout
|
||||
rawXP := 500
|
||||
|
||||
for tiersWon := 1; tiersWon <= 5; tiersWon++ {
|
||||
mult := arenaStreakXPMultiplier[tiersWon]
|
||||
totalXP := int(float64(rawXP) * mult)
|
||||
|
||||
if tiersWon == 1 && totalXP != rawXP {
|
||||
t.Errorf("1 tier won: XP should be unmodified, got %d want %d", totalXP, rawXP)
|
||||
}
|
||||
if tiersWon > 1 && totalXP <= rawXP {
|
||||
t.Errorf("%d tiers won: XP %d should exceed raw %d", tiersWon, totalXP, rawXP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaStreakPayout_TierEarningsReset(t *testing.T) {
|
||||
// Simulate accumulation: tier earnings should reset per tier
|
||||
run := &ArenaRun{Earnings: 0, TierEarnings: 0}
|
||||
|
||||
// Simulate T1 rounds
|
||||
for round := 1; round <= 4; round++ {
|
||||
tier := arenaGetTier(1)
|
||||
run.TierEarnings += arenaRoundReward(tier, round, 10)
|
||||
}
|
||||
run.TierEarnings += arenaGetTier(1).CompletionBonus
|
||||
|
||||
// Apply T1 multiplier
|
||||
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[1])
|
||||
t1Total := run.Earnings
|
||||
run.TierEarnings = 0
|
||||
|
||||
if t1Total <= 0 {
|
||||
t.Fatal("T1 earnings should be positive")
|
||||
}
|
||||
if run.TierEarnings != 0 {
|
||||
t.Error("TierEarnings should be 0 after reset")
|
||||
}
|
||||
|
||||
// Simulate T2 rounds
|
||||
for round := 1; round <= 4; round++ {
|
||||
tier := arenaGetTier(2)
|
||||
run.TierEarnings += arenaRoundReward(tier, round, 10)
|
||||
}
|
||||
run.TierEarnings += arenaGetTier(2).CompletionBonus
|
||||
|
||||
// Apply T2 multiplier (1.5×)
|
||||
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[2])
|
||||
run.TierEarnings = 0
|
||||
|
||||
if run.Earnings <= t1Total {
|
||||
t.Errorf("session total after T2 (%d) should exceed T1 total (%d)", run.Earnings, t1Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaStreakPayout_DeathForfeitsAll(t *testing.T) {
|
||||
// On death, earnings, tier_earnings, and XP are all zeroed
|
||||
run := &ArenaRun{
|
||||
Earnings: 50000,
|
||||
TierEarnings: 3000,
|
||||
XPAccumulated: 200,
|
||||
}
|
||||
|
||||
// Simulate death
|
||||
run.Earnings = 0
|
||||
run.TierEarnings = 0
|
||||
run.XPAccumulated = 0
|
||||
|
||||
if run.Earnings != 0 || run.TierEarnings != 0 || run.XPAccumulated != 0 {
|
||||
t.Error("death should forfeit all session state")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gladiator's Helm Tests ─────────────────────────────────────────────────
|
||||
|
||||
func TestGladiatorHelmDropRates(t *testing.T) {
|
||||
if gladiatorHelmDropT4 != 0.08 {
|
||||
t.Errorf("T4 drop rate: got %f, want 0.08", gladiatorHelmDropT4)
|
||||
}
|
||||
if gladiatorHelmDropT5 != 0.18 {
|
||||
t.Errorf("T5 drop rate: got %f, want 0.18", gladiatorHelmDropT5)
|
||||
}
|
||||
if gladiatorHelmDropT5 <= gladiatorHelmDropT4 {
|
||||
t.Error("T5 drop rate should exceed T4")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGladiatorHelmTier(t *testing.T) {
|
||||
if gladiatorHelmTier != 5 {
|
||||
t.Errorf("Gladiator's Helm tier: got %d, want 5", gladiatorHelmTier)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Arena Gear Tests ──────────────────────────────────────────────────────
|
||||
|
||||
func TestArenaGearByTier(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
gear := arenaGearByTier(tier)
|
||||
if gear == nil {
|
||||
t.Errorf("arenaGearByTier(%d) returned nil", tier)
|
||||
continue
|
||||
}
|
||||
if gear.Tier != tier {
|
||||
t.Errorf("arenaGearByTier(%d).Tier = %d", tier, gear.Tier)
|
||||
}
|
||||
if gear.SetKey == "" {
|
||||
t.Errorf("tier %d: empty SetKey", tier)
|
||||
}
|
||||
if gear.HelmetName == "" {
|
||||
t.Errorf("tier %d: empty HelmetName", tier)
|
||||
}
|
||||
if gear.DropRate <= 0 || gear.DropRate >= 1 {
|
||||
t.Errorf("tier %d: DropRate %f out of (0, 1)", tier, gear.DropRate)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of bounds
|
||||
if arenaGearByTier(0) != nil {
|
||||
t.Error("arenaGearByTier(0) should return nil")
|
||||
}
|
||||
if arenaGearByTier(6) != nil {
|
||||
t.Error("arenaGearByTier(6) should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaGearDropRates_Decreasing(t *testing.T) {
|
||||
// Higher tiers should have lower (or equal) drop rates
|
||||
for i := 2; i <= 5; i++ {
|
||||
curr := arenaGearByTier(i)
|
||||
prev := arenaGearByTier(i - 1)
|
||||
if curr.DropRate > prev.DropRate {
|
||||
t.Errorf("tier %d drop rate (%f) > tier %d (%f)", i, curr.DropRate, i-1, prev.DropRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render Tests (New/Updated) ─────────────────────────────────────────────
|
||||
|
||||
func TestRenderArenaStreakEntry_ShowsMultipliers(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 50}
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
text := renderArenaStreakEntry(char, nil, tier, monster)
|
||||
// Should show streak multiplier for at least T1
|
||||
if !strings.Contains(text, "1.0×") {
|
||||
t.Error("streak entry should show T1 multiplier")
|
||||
}
|
||||
if !strings.Contains(text, "4.0×") {
|
||||
t.Error("streak entry should show T5 multiplier")
|
||||
}
|
||||
// Should show first monster
|
||||
if !strings.Contains(text, monster.Name) {
|
||||
t.Error("streak entry should show first monster name")
|
||||
}
|
||||
// Should show fight command
|
||||
if !strings.Contains(text, "!arena fight") {
|
||||
t.Error("streak entry should show fight command")
|
||||
}
|
||||
// Should show cancel option
|
||||
if !strings.Contains(text, "!arena cancel") {
|
||||
t.Error("streak entry should show cancel command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaStreakEntry_HighLevel_AllEligible(t *testing.T) {
|
||||
// T5 requires level 70, so use level 70+ to unlock everything
|
||||
char := &AdventureCharacter{CombatLevel: 70}
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
text := renderArenaStreakEntry(char, nil, tier, monster)
|
||||
// Level 70 should make all tiers eligible (no 🔒)
|
||||
if strings.Contains(text, "🔒") {
|
||||
t.Error("level 70 should have all tiers eligible (no locked icons)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaRoundStart_WithTierEarnings(t *testing.T) {
|
||||
tier := arenaGetTier(3)
|
||||
monster := arenaGetMonster(3, 1)
|
||||
run := &ArenaRun{Earnings: 10000, TierEarnings: 2000}
|
||||
|
||||
text := renderArenaRoundStart(tier, 1, monster, run)
|
||||
// Should show combined session total (Earnings + TierEarnings)
|
||||
if !strings.Contains(text, "€12000") {
|
||||
t.Error("round start should show combined session total (10000+2000)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaRoundStart_ZeroEarnings(t *testing.T) {
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
run := &ArenaRun{Earnings: 0, TierEarnings: 0}
|
||||
|
||||
text := renderArenaRoundStart(tier, 1, monster, run)
|
||||
// Should NOT show earnings line when zero
|
||||
if strings.Contains(text, "Session earnings") {
|
||||
t.Error("round start should not show earnings line when zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaStatus_Active(t *testing.T) {
|
||||
run := &ArenaRun{Tier: 2, Round: 3, Status: "active", Earnings: 8000, TierEarnings: 1500, RoundsSurvived: 6}
|
||||
char := &AdventureCharacter{CombatLevel: 20}
|
||||
|
||||
text := renderArenaStatus(run, char)
|
||||
if !strings.Contains(text, "Streak") {
|
||||
t.Error("status should say Arena Streak")
|
||||
}
|
||||
if !strings.Contains(text, "€9500") {
|
||||
t.Error("status should show combined session total (8000+1500)")
|
||||
}
|
||||
if !strings.Contains(text, "Round: 3/4") {
|
||||
t.Error("status should show round")
|
||||
}
|
||||
if !strings.Contains(text, "!arena fight") {
|
||||
t.Error("active status should show fight command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaStatus_Awaiting(t *testing.T) {
|
||||
run := &ArenaRun{Tier: 3, Status: "awaiting", Earnings: 20000, TierEarnings: 0, RoundsSurvived: 12}
|
||||
char := &AdventureCharacter{CombatLevel: 30}
|
||||
|
||||
text := renderArenaStatus(run, char)
|
||||
if !strings.Contains(text, "advancing shortly") {
|
||||
t.Error("awaiting status should mention advancing")
|
||||
}
|
||||
if !strings.Contains(text, "!bail") {
|
||||
t.Error("awaiting status should show bail command")
|
||||
}
|
||||
if !strings.Contains(text, "€20000") {
|
||||
t.Error("awaiting status should show session total")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaAlreadyInRun_Active(t *testing.T) {
|
||||
run := &ArenaRun{Tier: 2, Round: 3, Status: "active"}
|
||||
text := renderArenaAlreadyInRun(run)
|
||||
if !strings.Contains(text, "Tier 2") {
|
||||
t.Error("active run message should show tier")
|
||||
}
|
||||
if !strings.Contains(text, "!arena fight") {
|
||||
t.Error("active run message should show fight command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaAlreadyInRun_Awaiting(t *testing.T) {
|
||||
run := &ArenaRun{Tier: 3, Status: "awaiting", Earnings: 15000}
|
||||
text := renderArenaAlreadyInRun(run)
|
||||
if !strings.Contains(text, "Tier 3") {
|
||||
t.Error("cashout should show last tier")
|
||||
t.Error("awaiting run message should show tier")
|
||||
}
|
||||
if !strings.Contains(text, "!bail") {
|
||||
t.Error("awaiting run message should mention bail")
|
||||
}
|
||||
if !strings.Contains(text, "€15000") {
|
||||
t.Error("awaiting run message should show accumulated euros")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaAutoCashout(t *testing.T) {
|
||||
text := renderArenaAutoCashout(10000)
|
||||
if !strings.Contains(text, "Auto-cashout") {
|
||||
t.Error("auto-cashout should identify itself")
|
||||
func TestRenderArenaHelmetDrop_AllSets(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
gear := arenaGearByTier(tier)
|
||||
text := renderArenaHelmetDrop(gear)
|
||||
if !strings.Contains(text, gear.HelmetName) {
|
||||
t.Errorf("tier %d: helmet drop should show helmet name", tier)
|
||||
}
|
||||
if !strings.Contains(text, "Set bonus") {
|
||||
t.Errorf("tier %d: helmet drop should show set bonus", tier)
|
||||
}
|
||||
if !strings.Contains(text, "Equipped automatically") {
|
||||
t.Errorf("tier %d: helmet drop should mention auto-equip", tier)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "€10000") {
|
||||
t.Error("auto-cashout should show amount")
|
||||
}
|
||||
|
||||
func TestRenderArenaDeathReprieve(t *testing.T) {
|
||||
nextWindow := time.Date(2026, 4, 15, 14, 30, 0, 0, time.UTC)
|
||||
text := renderArenaDeathReprieve("Alice", "Ratticus the Persistent", nextWindow)
|
||||
if !strings.Contains(text, "Alice") {
|
||||
t.Error("reprieve should mention player name")
|
||||
}
|
||||
if !strings.Contains(text, "annoyed") {
|
||||
t.Error("auto-cashout should mention GogoBee is annoyed")
|
||||
if !strings.Contains(text, "Death's Reprieve") {
|
||||
t.Error("reprieve should mention the ability name")
|
||||
}
|
||||
if !strings.Contains(text, "2026-04-15") {
|
||||
t.Error("reprieve should show next window date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaPersonalStats_NoStats(t *testing.T) {
|
||||
char := &AdventureCharacter{DisplayName: "TestPlayer"}
|
||||
text := renderArenaPersonalStats(char, nil)
|
||||
if !strings.Contains(text, "No arena runs") {
|
||||
t.Error("empty stats should say no runs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
|
||||
char := &AdventureCharacter{DisplayName: "Alice", ArenaWins: 8, ArenaLosses: 3}
|
||||
stats := &ArenaPersonalStats{
|
||||
TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3,
|
||||
HighestTier: 5, Tier5Completions: 2,
|
||||
}
|
||||
text := renderArenaPersonalStats(char, stats)
|
||||
if !strings.Contains(text, "Alice") {
|
||||
t.Error("stats should show player name")
|
||||
}
|
||||
if !strings.Contains(text, "€150000") {
|
||||
t.Error("stats should show total earnings")
|
||||
}
|
||||
if !strings.Contains(text, "Tier 5 completions: 2") {
|
||||
t.Error("stats should show T5 completions")
|
||||
}
|
||||
if !strings.Contains(text, "8/3") {
|
||||
t.Error("stats should show W/L record")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,3 +862,255 @@ func TestArenaFullRunRewards_AllTiers(t *testing.T) {
|
||||
t.Errorf("full T1-T5 run (no skill) = €%d, expected > €100,000", grandTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Full Streak Simulation ────────────────────────────────────────────────
|
||||
|
||||
func TestArenaStreakSimulation_FullRun(t *testing.T) {
|
||||
// Simulate a complete T1-T5 streak: accumulate rewards as the real code does
|
||||
run := &ArenaRun{
|
||||
StartTier: 1,
|
||||
Tier: 1,
|
||||
Round: 1,
|
||||
Status: "active",
|
||||
Earnings: 0,
|
||||
TierEarnings: 0,
|
||||
XPAccumulated: 0,
|
||||
}
|
||||
combatLevel := 30
|
||||
|
||||
for tierNum := 1; tierNum <= 5; tierNum++ {
|
||||
tier := arenaGetTier(tierNum)
|
||||
run.Tier = tierNum
|
||||
|
||||
for round := 1; round <= 4; round++ {
|
||||
run.Round = round
|
||||
reward := arenaRoundReward(tier, round, combatLevel)
|
||||
run.TierEarnings += reward
|
||||
run.XPAccumulated += tier.BattleXP
|
||||
run.RoundsSurvived++
|
||||
}
|
||||
|
||||
// Tier complete — add completion bonus
|
||||
run.TierEarnings += tier.CompletionBonus
|
||||
|
||||
// Apply streak multiplier
|
||||
multiplier := arenaStreakEuroMultiplier[tierNum]
|
||||
run.Earnings += int64(float64(run.TierEarnings) * multiplier)
|
||||
run.TierEarnings = 0 // reset for next tier
|
||||
}
|
||||
|
||||
// Verify tier earnings reset
|
||||
if run.TierEarnings != 0 {
|
||||
t.Errorf("TierEarnings should be 0 after all tiers, got %d", run.TierEarnings)
|
||||
}
|
||||
|
||||
// Verify earnings are positive and substantial
|
||||
if run.Earnings <= 0 {
|
||||
t.Fatal("session earnings should be positive")
|
||||
}
|
||||
|
||||
// Calculate what raw (unmultiplied) total would be
|
||||
var rawTotal int64
|
||||
for tierNum := 1; tierNum <= 5; tierNum++ {
|
||||
tier := arenaGetTier(tierNum)
|
||||
for round := 1; round <= 4; round++ {
|
||||
rawTotal += arenaRoundReward(tier, round, combatLevel)
|
||||
}
|
||||
rawTotal += tier.CompletionBonus
|
||||
}
|
||||
|
||||
// Multiplied total should exceed raw
|
||||
if run.Earnings <= rawTotal {
|
||||
t.Errorf("multiplied total (%d) should exceed raw total (%d)", run.Earnings, rawTotal)
|
||||
}
|
||||
|
||||
// Verify XP accumulated across 20 rounds (4 rounds × 5 tiers)
|
||||
if run.RoundsSurvived != 20 {
|
||||
t.Errorf("rounds survived: got %d, want 20", run.RoundsSurvived)
|
||||
}
|
||||
if run.XPAccumulated <= 0 {
|
||||
t.Error("XP should be accumulated")
|
||||
}
|
||||
|
||||
// Apply XP multiplier for 5 tiers won
|
||||
xpMult := arenaStreakXPMultiplier[5]
|
||||
totalXP := int(float64(run.XPAccumulated) * xpMult)
|
||||
if totalXP <= run.XPAccumulated {
|
||||
t.Errorf("multiplied XP (%d) should exceed raw XP (%d) with 5-tier multiplier", totalXP, run.XPAccumulated)
|
||||
}
|
||||
// 2.5× multiplier means totalXP should be 2.5× raw
|
||||
expectedXP := int(float64(run.XPAccumulated) * 2.5)
|
||||
if totalXP != expectedXP {
|
||||
t.Errorf("total XP: got %d, want %d (2.5× raw)", totalXP, expectedXP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaStreakSimulation_BailAfterT3(t *testing.T) {
|
||||
// Simulate bail after clearing T3
|
||||
run := &ArenaRun{Earnings: 0, TierEarnings: 0, XPAccumulated: 0}
|
||||
|
||||
for tierNum := 1; tierNum <= 3; tierNum++ {
|
||||
tier := arenaGetTier(tierNum)
|
||||
for round := 1; round <= 4; round++ {
|
||||
run.TierEarnings += arenaRoundReward(tier, round, 20)
|
||||
run.XPAccumulated += tier.BattleXP
|
||||
}
|
||||
run.TierEarnings += tier.CompletionBonus
|
||||
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[tierNum])
|
||||
run.TierEarnings = 0
|
||||
}
|
||||
|
||||
// XP multiplier for 3 tiers = 1.5×
|
||||
totalXP := int(float64(run.XPAccumulated) * arenaStreakXPMultiplier[3])
|
||||
|
||||
if totalXP <= run.XPAccumulated {
|
||||
t.Error("multiplied XP should exceed raw with 3-tier multiplier")
|
||||
}
|
||||
if run.Earnings <= 0 {
|
||||
t.Error("session earnings should be positive after 3 tiers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaStreakSimulation_DeathMidTier(t *testing.T) {
|
||||
// Simulate death in T2R3 — should have accumulated T1 earnings + partial T2
|
||||
run := &ArenaRun{Earnings: 0, TierEarnings: 0, XPAccumulated: 0}
|
||||
|
||||
// Complete T1
|
||||
tier1 := arenaGetTier(1)
|
||||
for round := 1; round <= 4; round++ {
|
||||
run.TierEarnings += arenaRoundReward(tier1, round, 10)
|
||||
run.XPAccumulated += tier1.BattleXP
|
||||
}
|
||||
run.TierEarnings += tier1.CompletionBonus
|
||||
run.Earnings += int64(float64(run.TierEarnings) * arenaStreakEuroMultiplier[1])
|
||||
t1Earnings := run.Earnings
|
||||
run.TierEarnings = 0
|
||||
|
||||
// Partial T2 — 2 rounds
|
||||
tier2 := arenaGetTier(2)
|
||||
for round := 1; round <= 2; round++ {
|
||||
run.TierEarnings += arenaRoundReward(tier2, round, 10)
|
||||
run.XPAccumulated += tier2.BattleXP
|
||||
}
|
||||
|
||||
// Death — forfeit everything
|
||||
lostTotal := run.Earnings + run.TierEarnings
|
||||
if lostTotal <= t1Earnings {
|
||||
t.Error("lost total should include partial T2 earnings")
|
||||
}
|
||||
|
||||
run.Earnings = 0
|
||||
run.TierEarnings = 0
|
||||
run.XPAccumulated = 0
|
||||
|
||||
if run.Earnings != 0 || run.TierEarnings != 0 || run.XPAccumulated != 0 {
|
||||
t.Error("all session state should be zero after death")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaRunFields_NewFieldsDefault(t *testing.T) {
|
||||
run := ArenaRun{}
|
||||
if run.TierEarnings != 0 {
|
||||
t.Errorf("default TierEarnings should be 0, got %d", run.TierEarnings)
|
||||
}
|
||||
if run.XPAccumulated != 0 {
|
||||
t.Errorf("default XPAccumulated should be 0, got %d", run.XPAccumulated)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streak Euro Multiplier Math (Exact) ───────────────────────────────────
|
||||
|
||||
func TestArenaStreakEuroMultiplier_Exact(t *testing.T) {
|
||||
// T1 raw = 4000 (verified in TestArenaFullRunRewards_Tier1)
|
||||
// T1 multiplied = 4000 * 1.0 = 4000
|
||||
tier1 := arenaGetTier(1)
|
||||
var t1Raw int64
|
||||
for r := 1; r <= 4; r++ {
|
||||
t1Raw += arenaRoundReward(tier1, r, 0)
|
||||
}
|
||||
t1Raw += tier1.CompletionBonus
|
||||
|
||||
t1Mult := int64(float64(t1Raw) * arenaStreakEuroMultiplier[1])
|
||||
if t1Mult != 4000 {
|
||||
t.Errorf("T1 multiplied (1.0×): got %d, want 4000", t1Mult)
|
||||
}
|
||||
|
||||
// T2 raw with skill=0
|
||||
tier2 := arenaGetTier(2)
|
||||
var t2Raw int64
|
||||
for r := 1; r <= 4; r++ {
|
||||
t2Raw += arenaRoundReward(tier2, r, 0)
|
||||
}
|
||||
t2Raw += tier2.CompletionBonus
|
||||
// T2 rounds: 500+1000+1500+2000 = 5000, +10000 bonus = 15000
|
||||
if t2Raw != 15000 {
|
||||
t.Errorf("T2 raw: got %d, want 15000", t2Raw)
|
||||
}
|
||||
// T2 multiplied: 15000 * 1.5 = 22500
|
||||
t2Mult := int64(float64(t2Raw) * arenaStreakEuroMultiplier[2])
|
||||
if t2Mult != 22500 {
|
||||
t.Errorf("T2 multiplied (1.5×): got %d, want 22500", t2Mult)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streak Entry Shows Brim & Battle Sponsorship ──────────────────────────
|
||||
|
||||
func TestArenaStreakEntry_Sponsorship(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 1}
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
text := renderArenaStreakEntry(char, nil, tier, monster)
|
||||
if !strings.Contains(text, "Brim & Battle") {
|
||||
t.Error("arena entry should include Brim & Battle sponsorship")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Help Text Tests ──────────────────────────────────────────────────────
|
||||
|
||||
func TestArenaHelpText_StreakContent(t *testing.T) {
|
||||
if !strings.Contains(arenaHelpText, "!bail") {
|
||||
t.Error("help text should mention !bail")
|
||||
}
|
||||
if !strings.Contains(arenaHelpText, "streak") {
|
||||
t.Error("help text should mention streak")
|
||||
}
|
||||
if strings.Contains(arenaHelpText, "!arena tier") {
|
||||
t.Error("help text should NOT mention !arena tier (removed)")
|
||||
}
|
||||
if strings.Contains(arenaHelpText, "!arena descend") {
|
||||
t.Error("help text should NOT mention !arena descend (removed)")
|
||||
}
|
||||
if strings.Contains(arenaHelpText, "!arena cashout") {
|
||||
t.Error("help text should NOT mention !arena cashout (removed)")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gladiator's Helm Drop Rate Logic ──────────────────────────────────────
|
||||
|
||||
func TestGladiatorHelmDropRate_BelowT4(t *testing.T) {
|
||||
// maxTierCleared < 4 should never produce a helm
|
||||
// (We can't test the actual RNG roll, but we can verify the gate)
|
||||
for tier := 1; tier <= 3; tier++ {
|
||||
// The function gates on maxTierCleared < 4, so these tiers should
|
||||
// never even reach the RNG check
|
||||
if tier >= 4 {
|
||||
t.Error("test logic error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGladiatorHelmDropRate_Selection(t *testing.T) {
|
||||
// At T4, rate should be 0.08; at T5, rate should be 0.18
|
||||
// We test the conditional selection logic
|
||||
if gladiatorHelmDropT4 >= gladiatorHelmDropT5 {
|
||||
t.Error("T5 drop rate should be higher than T4")
|
||||
}
|
||||
// Both should be in (0, 1)
|
||||
if gladiatorHelmDropT4 <= 0 || gladiatorHelmDropT4 >= 1 {
|
||||
t.Errorf("T4 rate %f out of (0, 1)", gladiatorHelmDropT4)
|
||||
}
|
||||
if gladiatorHelmDropT5 <= 0 || gladiatorHelmDropT5 >= 1 {
|
||||
t.Errorf("T5 rate %f out of (0, 1)", gladiatorHelmDropT5)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
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)
|
||||
case lower == "auto":
|
||||
return p.handleBabysitAutoToggle(ctx)
|
||||
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 auto` — toggle auto-babysit on missed days\n"+
|
||||
"`!adventure babysit status` — check service status\n"+
|
||||
"`!adventure babysit cancel` — cancel early (no refund)")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitAutoToggle(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. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
char.AutoBabysit = !char.AutoBabysit
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save auto-babysit toggle", "user", ctx.Sender, "err", err)
|
||||
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
|
||||
}
|
||||
|
||||
daily := babysitDailyCost(char.CombatLevel)
|
||||
if char.AutoBabysit {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 **Auto-babysit: ON**\n\nIf you miss a day, the babysitter steps in automatically (€%d/day). Your streak stays alive. Disable anytime with `!adventure babysit auto`.", daily))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "🍼 **Auto-babysit: OFF**\n\nYou're on your own. Miss a day and your streak takes the hit.")
|
||||
}
|
||||
|
||||
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 %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(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.")
|
||||
}
|
||||
|
||||
// Clear any leftover logs from prior service so this period's summary is clean
|
||||
clearBabysitLogs(char.UserID)
|
||||
|
||||
// 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.")
|
||||
}
|
||||
|
||||
autoLabel := "OFF"
|
||||
if char.AutoBabysit {
|
||||
autoLabel = "ON"
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 No active babysitting service.\nAuto-babysit: **%s** (`!adventure babysit auto` to toggle)\n\nUse `!adventure babysit week` or `!adventure babysit month` to start.", autoLabel))
|
||||
}
|
||||
|
||||
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\n"+
|
||||
"Auto-babysit: %s",
|
||||
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
isHol, _ := isHolidayToday()
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
harvestMax++
|
||||
}
|
||||
|
||||
bonuses := &AdvBonusSummary{}
|
||||
focusActivity := skillToActivity(char.BabysitSkillFocus)
|
||||
|
||||
var totalGold int
|
||||
var totalXP int
|
||||
var allItems []string
|
||||
|
||||
// Use all harvest actions on the focused skill — no combat, too dangerous
|
||||
for char.HarvestActionsUsed < harvestMax {
|
||||
gold, xp, items := p.runBabysitAction(char, equip, focusActivity, bonuses)
|
||||
totalGold += gold
|
||||
totalXP += xp
|
||||
allItems = append(allItems, items...)
|
||||
char.HarvestActionsUsed++
|
||||
}
|
||||
|
||||
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 combined daily totals
|
||||
itemsJSON := ""
|
||||
if len(allItems) > 0 {
|
||||
itemsJSON = strings.Join(allItems, ", ")
|
||||
}
|
||||
logBabysitActivity(char.UserID, string(focusActivity), "babysit_daily",
|
||||
totalGold, totalXP, itemsJSON)
|
||||
}
|
||||
|
||||
// runBabysitAction resolves a single action for the babysitter. Returns gold, xp, item names.
|
||||
func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) (int, int, []string) {
|
||||
eligible := advEligibleLocations(char, equip, activity, bonuses)
|
||||
if len(eligible) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
loc := eligible[len(eligible)-1].Location
|
||||
inPenalty := eligible[len(eligible)-1].InPenaltyZone
|
||||
|
||||
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
|
||||
|
||||
// Babysitter never lets the adventurer die
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
result.Outcome = AdvOutcomeEmpty
|
||||
result.LootItems = nil
|
||||
result.TotalLootValue = 0
|
||||
result.EquipDamage = nil
|
||||
result.EquipBroken = nil
|
||||
}
|
||||
|
||||
// Double XP/money boost
|
||||
advApplyBoost(result)
|
||||
|
||||
// Apply XP
|
||||
switch result.XPSkill {
|
||||
case "combat":
|
||||
char.CombatXP += result.XPGained
|
||||
case "mining":
|
||||
char.MiningXP += result.XPGained
|
||||
case "foraging":
|
||||
char.ForagingXP += result.XPGained
|
||||
case "fishing":
|
||||
char.FishingXP += result.XPGained
|
||||
}
|
||||
checkAdvLevelUp(char, result.XPSkill)
|
||||
|
||||
// Credit gold
|
||||
if result.TotalLootValue > 0 {
|
||||
net, _ := communityTax(char.UserID, float64(result.TotalLootValue), 0.05)
|
||||
p.euro.Credit(char.UserID, net, "babysit_haul")
|
||||
}
|
||||
|
||||
// No treasure drops during babysitting
|
||||
result.TreasureFound = nil
|
||||
|
||||
var items []string
|
||||
for _, item := range result.LootItems {
|
||||
items = append(items, item.Name)
|
||||
}
|
||||
return int(result.TotalLootValue), result.XPGained, items
|
||||
}
|
||||
|
||||
// runAutoBabysitDay runs a single day of babysit actions for auto-babysit.
|
||||
// Called by the scheduler when a player with auto-babysit enabled misses a day.
|
||||
func (p *AdventurePlugin) runAutoBabysitDay(char *AdventureCharacter) {
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("auto-babysit: failed to load equipment", "user", char.UserID, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
isHol, _ := isHolidayToday()
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
harvestMax++
|
||||
}
|
||||
|
||||
bonuses := &AdvBonusSummary{}
|
||||
skill := babysitWeakestSkill(char)
|
||||
activity := skillToActivity(skill)
|
||||
|
||||
var totalGold int
|
||||
var totalXP int
|
||||
var allItems []string
|
||||
for char.HarvestActionsUsed < harvestMax {
|
||||
gold, xp, items := p.runBabysitAction(char, equip, activity, bonuses)
|
||||
totalGold += gold
|
||||
totalXP += xp
|
||||
allItems = append(allItems, items...)
|
||||
char.HarvestActionsUsed++
|
||||
}
|
||||
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
itemsJSON := ""
|
||||
if len(allItems) > 0 {
|
||||
itemsJSON = strings.Join(allItems, ", ")
|
||||
}
|
||||
logBabysitActivity(char.UserID, string(activity), "auto_babysit_daily",
|
||||
totalGold, totalXP, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Pricing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
var blacksmithBaseRates = [6]int{1, 2, 5, 12, 30, 80}
|
||||
|
||||
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/200.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))
|
||||
}
|
||||
@@ -39,6 +39,8 @@ type AdventureCharacter struct {
|
||||
DeadUntil *time.Time
|
||||
ActionTakenToday bool
|
||||
HolidayActionTaken bool
|
||||
CombatActionsUsed int
|
||||
HarvestActionsUsed int
|
||||
ArenaWins int // v2
|
||||
ArenaLosses int // v2
|
||||
InvasionScore int // v2
|
||||
@@ -49,8 +51,51 @@ 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
|
||||
LastPardonUsed *time.Time
|
||||
MistyLastSeen *time.Time
|
||||
ArinaLastSeen *time.Time
|
||||
MistyBuffExpires *time.Time
|
||||
MistyDebuffExpires *time.Time
|
||||
ArinaBuffExpires *time.Time
|
||||
NPCMsgCount int
|
||||
NPCMsgCountDate string
|
||||
MistyRollTarget int
|
||||
ArinaRollTarget int
|
||||
// Housing
|
||||
HouseTier int
|
||||
HouseLoanBalance int
|
||||
HouseLoanFrozen bool
|
||||
HouseMissedPayments int
|
||||
HouseAutopay bool
|
||||
HouseCurrentRate float64
|
||||
// Pets
|
||||
PetType string
|
||||
PetName string
|
||||
PetXP int
|
||||
PetLevel int
|
||||
PetArmorTier int
|
||||
PetChasedAway bool
|
||||
PetReactivated bool
|
||||
PetArrived bool
|
||||
MistyEncounterCount int
|
||||
MistyDonatedCount int
|
||||
ThomAnimalLineFired bool
|
||||
PetSupplyShopUnlocked bool
|
||||
PetLevel10Date string
|
||||
PetMorningDefense bool
|
||||
AutoBabysit bool
|
||||
StreakDecayed bool
|
||||
CraftsSucceeded int
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -195,40 +240,127 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool {
|
||||
return time.Since(*c.DeathReprieveLast) >= 168*time.Hour
|
||||
}
|
||||
|
||||
// PardonAvailable returns true if the chat level death pardon cooldown
|
||||
// has expired (or was never triggered). 7-day rolling cooldown.
|
||||
func (c *AdventureCharacter) PardonAvailable() bool {
|
||||
if c.LastPardonUsed == nil {
|
||||
return true
|
||||
}
|
||||
return time.Since(*c.LastPardonUsed) >= 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")
|
||||
}
|
||||
|
||||
// HasPet returns true if the player has an active pet (not chased away).
|
||||
func (c *AdventureCharacter) HasPet() bool {
|
||||
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
|
||||
}
|
||||
|
||||
// HasHouse returns true if the player has purchased at least a base house.
|
||||
func (c *AdventureCharacter) HasHouse() bool {
|
||||
return c.HouseTier > 0 || c.HouseLoanBalance > 0
|
||||
}
|
||||
|
||||
// HouseHPBonus returns the HP bonus percentage from housing tier.
|
||||
// Tier 1 (Base) = +0%, Tier 2 (Livable) = +5%, Tier 3 (Comfortable) = +12%, Tier 4 (Established) = +20%
|
||||
func (c *AdventureCharacter) HouseHPBonus() float64 {
|
||||
switch c.HouseTier {
|
||||
case 2:
|
||||
return 0.05
|
||||
case 3:
|
||||
return 0.12
|
||||
case 4:
|
||||
return 0.20
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action Economy ──────────────────────────────────────────────────────────
|
||||
|
||||
const maxCombatActions = 1
|
||||
const maxHarvestActions = 3
|
||||
|
||||
func (c *AdventureCharacter) CanDoCombat(isHoliday bool) bool {
|
||||
max := maxCombatActions
|
||||
if isHoliday {
|
||||
max++
|
||||
}
|
||||
return c.CombatActionsUsed < max
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
|
||||
max := maxHarvestActions
|
||||
if isHoliday {
|
||||
max++
|
||||
}
|
||||
return c.HarvestActionsUsed < max
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) HasActedToday() bool {
|
||||
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
|
||||
}
|
||||
|
||||
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
|
||||
return !c.CanDoCombat(isHoliday) && !c.CanDoHarvest(isHoliday)
|
||||
}
|
||||
|
||||
func isCombatActivity(activity AdvActivityType) bool {
|
||||
return activity == AdvActivityDungeon
|
||||
}
|
||||
|
||||
func isHarvestActivity(activity AdvActivityType) bool {
|
||||
return activity == AdvActivityMining || activity == AdvActivityForaging || activity == AdvActivityFishing
|
||||
}
|
||||
|
||||
// ── 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 +414,13 @@ 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, pardonUsed sql.NullTime
|
||||
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
|
||||
|
||||
var houseFrozen, houseAutopay int
|
||||
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
|
||||
var autoBabysit, streakDecayed int
|
||||
|
||||
err := d.QueryRow(`
|
||||
SELECT user_id, display_name,
|
||||
@@ -293,7 +430,23 @@ 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,
|
||||
combat_actions_used, harvest_actions_used,
|
||||
last_pardon_used,
|
||||
misty_last_seen, arina_last_seen,
|
||||
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
|
||||
npc_msg_count, npc_msg_count_date,
|
||||
misty_roll_target, arina_roll_target,
|
||||
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
|
||||
house_autopay, house_current_rate,
|
||||
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
|
||||
pet_chased_away, pet_reactivated, pet_arrived,
|
||||
misty_encounter_count, misty_donated_count,
|
||||
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
|
||||
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded
|
||||
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
@@ -303,6 +456,22 @@ 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,
|
||||
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
||||
&pardonUsed,
|
||||
&mistyLastSeen, &arinaLastSeen,
|
||||
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
||||
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||
&c.MistyRollTarget, &c.ArinaRollTarget,
|
||||
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
|
||||
&houseAutopay, &c.HouseCurrentRate,
|
||||
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
|
||||
&petChasedAway, &petReactivated, &petArrived,
|
||||
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
||||
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
||||
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,12 +479,45 @@ 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
|
||||
c.PetMorningDefense = petMorningDef == 1
|
||||
c.AutoBabysit = autoBabysit == 1
|
||||
c.StreakDecayed = streakDecayed == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
if babysitExp.Valid {
|
||||
c.BabysitExpiresAt = &babysitExp.Time
|
||||
}
|
||||
if pardonUsed.Valid {
|
||||
c.LastPardonUsed = &pardonUsed.Time
|
||||
}
|
||||
if mistyLastSeen.Valid {
|
||||
c.MistyLastSeen = &mistyLastSeen.Time
|
||||
}
|
||||
if arinaLastSeen.Valid {
|
||||
c.ArinaLastSeen = &arinaLastSeen.Time
|
||||
}
|
||||
if mistyBuffExp.Valid {
|
||||
c.MistyBuffExpires = &mistyBuffExp.Time
|
||||
}
|
||||
if mistyDebuffExp.Valid {
|
||||
c.MistyDebuffExpires = &mistyDebuffExp.Time
|
||||
}
|
||||
if arinaBuffExp.Valid {
|
||||
c.ArinaBuffExpires = &arinaBuffExp.Time
|
||||
}
|
||||
c.HouseLoanFrozen = houseFrozen == 1
|
||||
c.HouseAutopay = houseAutopay == 1
|
||||
c.PetChasedAway = petChasedAway == 1
|
||||
c.PetReactivated = petReactivated == 1
|
||||
c.PetArrived = petArrived == 1
|
||||
c.ThomAnimalLineFired = thomAnimalLine == 1
|
||||
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -362,6 +564,54 @@ func saveAdvCharacter(char *AdventureCharacter) error {
|
||||
if char.HolidayActionTaken {
|
||||
holidayTaken = 1
|
||||
}
|
||||
rivalUnlocked := 0
|
||||
if char.RivalUnlockedNotified {
|
||||
rivalUnlocked = 1
|
||||
}
|
||||
babysitAct := 0
|
||||
if char.BabysitActive {
|
||||
babysitAct = 1
|
||||
}
|
||||
houseFrozen := 0
|
||||
if char.HouseLoanFrozen {
|
||||
houseFrozen = 1
|
||||
}
|
||||
houseAutopay := 0
|
||||
if char.HouseAutopay {
|
||||
houseAutopay = 1
|
||||
}
|
||||
petChasedAway := 0
|
||||
if char.PetChasedAway {
|
||||
petChasedAway = 1
|
||||
}
|
||||
petReactivated := 0
|
||||
if char.PetReactivated {
|
||||
petReactivated = 1
|
||||
}
|
||||
petArrived := 0
|
||||
if char.PetArrived {
|
||||
petArrived = 1
|
||||
}
|
||||
thomAnimalLine := 0
|
||||
if char.ThomAnimalLineFired {
|
||||
thomAnimalLine = 1
|
||||
}
|
||||
petSupplyUnlocked := 0
|
||||
if char.PetSupplyShopUnlocked {
|
||||
petSupplyUnlocked = 1
|
||||
}
|
||||
petMorningDef := 0
|
||||
if char.PetMorningDefense {
|
||||
petMorningDef = 1
|
||||
}
|
||||
autoBabysit := 0
|
||||
if char.AutoBabysit {
|
||||
autoBabysit = 1
|
||||
}
|
||||
streakDecayed := 0
|
||||
if char.StreakDecayed {
|
||||
streakDecayed = 1
|
||||
}
|
||||
|
||||
_, err := d.Exec(`
|
||||
UPDATE adventure_characters SET
|
||||
@@ -371,14 +621,53 @@ 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 = ?,
|
||||
combat_actions_used = ?, harvest_actions_used = ?,
|
||||
last_pardon_used = ?,
|
||||
misty_last_seen = ?, arina_last_seen = ?,
|
||||
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
|
||||
npc_msg_count = ?, npc_msg_count_date = ?,
|
||||
misty_roll_target = ?, arina_roll_target = ?,
|
||||
house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, house_missed_payments = ?,
|
||||
house_autopay = ?, house_current_rate = ?,
|
||||
pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, pet_armor_tier = ?,
|
||||
pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?,
|
||||
misty_encounter_count = ?, misty_donated_count = ?,
|
||||
thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?,
|
||||
pet_morning_defense = ?,
|
||||
auto_babysit = ?,
|
||||
streak_decayed = ?,
|
||||
crafts_succeeded = ?
|
||||
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,
|
||||
char.CombatActionsUsed, char.HarvestActionsUsed,
|
||||
char.LastPardonUsed,
|
||||
char.MistyLastSeen, char.ArinaLastSeen,
|
||||
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
|
||||
char.NPCMsgCount, char.NPCMsgCountDate,
|
||||
char.MistyRollTarget, char.ArinaRollTarget,
|
||||
char.HouseTier, char.HouseLoanBalance, houseFrozen, char.HouseMissedPayments,
|
||||
houseAutopay, char.HouseCurrentRate,
|
||||
char.PetType, char.PetName, char.PetXP, char.PetLevel, char.PetArmorTier,
|
||||
petChasedAway, petReactivated, petArrived,
|
||||
char.MistyEncounterCount, char.MistyDonatedCount,
|
||||
thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date,
|
||||
petMorningDef,
|
||||
autoBabysit,
|
||||
streakDecayed,
|
||||
char.CraftsSucceeded,
|
||||
string(char.UserID),
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -492,7 +781,23 @@ 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,
|
||||
hospital_visits, robbie_visit_count, last_death_date,
|
||||
combat_actions_used, harvest_actions_used,
|
||||
last_pardon_used,
|
||||
misty_last_seen, arina_last_seen,
|
||||
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
|
||||
npc_msg_count, npc_msg_count_date,
|
||||
misty_roll_target, arina_roll_target,
|
||||
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
|
||||
house_autopay, house_current_rate,
|
||||
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
|
||||
pet_chased_away, pet_reactivated, pet_arrived,
|
||||
misty_encounter_count, misty_donated_count,
|
||||
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
|
||||
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded
|
||||
FROM adventure_characters`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -502,8 +807,12 @@ 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, pardonUsed sql.NullTime
|
||||
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
|
||||
var houseFrozen, houseAutopay int
|
||||
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
|
||||
var autoBabysit, streakDecayed int
|
||||
if err := rows.Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
@@ -513,18 +822,67 @@ 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,
|
||||
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
|
||||
&c.CombatActionsUsed, &c.HarvestActionsUsed,
|
||||
&pardonUsed,
|
||||
&mistyLastSeen, &arinaLastSeen,
|
||||
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
|
||||
&c.NPCMsgCount, &c.NPCMsgCountDate,
|
||||
&c.MistyRollTarget, &c.ArinaRollTarget,
|
||||
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
|
||||
&houseAutopay, &c.HouseCurrentRate,
|
||||
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
|
||||
&petChasedAway, &petReactivated, &petArrived,
|
||||
&c.MistyEncounterCount, &c.MistyDonatedCount,
|
||||
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
|
||||
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
|
||||
); 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
|
||||
c.PetMorningDefense = petMorningDef == 1
|
||||
c.AutoBabysit = autoBabysit == 1
|
||||
c.StreakDecayed = streakDecayed == 1
|
||||
c.HouseLoanFrozen = houseFrozen == 1
|
||||
c.HouseAutopay = houseAutopay == 1
|
||||
c.PetChasedAway = petChasedAway == 1
|
||||
c.PetReactivated = petReactivated == 1
|
||||
c.PetArrived = petArrived == 1
|
||||
c.ThomAnimalLineFired = thomAnimalLine == 1
|
||||
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
if babysitExp.Valid {
|
||||
c.BabysitExpiresAt = &babysitExp.Time
|
||||
}
|
||||
if pardonUsed.Valid {
|
||||
c.LastPardonUsed = &pardonUsed.Time
|
||||
}
|
||||
if mistyLastSeen.Valid {
|
||||
c.MistyLastSeen = &mistyLastSeen.Time
|
||||
}
|
||||
if arinaLastSeen.Valid {
|
||||
c.ArinaLastSeen = &arinaLastSeen.Time
|
||||
}
|
||||
if mistyBuffExp.Valid {
|
||||
c.MistyBuffExpires = &mistyBuffExp.Time
|
||||
}
|
||||
if mistyDebuffExp.Valid {
|
||||
c.MistyDebuffExpires = &mistyDebuffExp.Time
|
||||
}
|
||||
if arinaBuffExp.Valid {
|
||||
c.ArinaBuffExpires = &arinaBuffExp.Time
|
||||
}
|
||||
chars = append(chars, c)
|
||||
}
|
||||
return chars, rows.Err()
|
||||
@@ -535,7 +893,7 @@ func resetAllAdvDailyActions() error {
|
||||
// Only reset actions taken before today — protects against race if a player
|
||||
// resolves their action at exactly midnight.
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
|
||||
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0, pet_morning_defense = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -596,14 +954,13 @@ type AdvDayLog struct {
|
||||
XPGained int
|
||||
}
|
||||
|
||||
func loadAdvTodayLogs() ([]AdvDayLog, error) {
|
||||
func loadAdvLogsForDate(date string) ([]AdvDayLog, error) {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
|
||||
FROM adventure_activity_log
|
||||
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
|
||||
ORDER BY logged_at`, today, today)
|
||||
ORDER BY logged_at`, date, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package plugin
|
||||
|
||||
// ── Chat Level Perks ────────────────────────────────────────────────────────
|
||||
// Passive bonuses based on community chat level. All perks are automatic.
|
||||
|
||||
// chatLevelXPBonus returns the fractional XP bonus for the given chat level.
|
||||
// +5% per 10 levels, capped at +25% at level 50+.
|
||||
func chatLevelXPBonus(chatLevel int) float64 {
|
||||
tier := chatLevel / 10
|
||||
if tier > 5 {
|
||||
tier = 5
|
||||
}
|
||||
return float64(tier) * 0.05
|
||||
}
|
||||
|
||||
// chatLevelRareBonus returns the additive rare drop rate bonus.
|
||||
// +0.5% per 10 levels, capped at +2.5% at level 50+.
|
||||
func chatLevelRareBonus(chatLevel int) float64 {
|
||||
tier := chatLevel / 10
|
||||
if tier > 5 {
|
||||
tier = 5
|
||||
}
|
||||
return float64(tier) * 0.005
|
||||
}
|
||||
|
||||
// shopGreeting returns the shopkeeper's greeting based on chat level.
|
||||
func shopGreeting(chatLevel int) string {
|
||||
switch {
|
||||
case chatLevel >= 50:
|
||||
return "I saw you coming from down the street. Your usual is already on the counter. The shop is open."
|
||||
case chatLevel >= 30:
|
||||
return "Back again. I've started keeping your usual in the back. The shop is open."
|
||||
case chatLevel >= 10:
|
||||
return "Ah. You again. The shop is open."
|
||||
default:
|
||||
return "The shop is open. What do you need?"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Consumable Definitions ───────────────────────────────────────────────────
|
||||
// Items that are auto-consumed during combat. Players don't choose when to use
|
||||
// them — the engine selects up to 2 (1 offensive + 1 defensive) based on threat
|
||||
// assessment to avoid wasting items on trivial fights.
|
||||
|
||||
type ConsumableEffect string
|
||||
|
||||
const (
|
||||
EffectHeal ConsumableEffect = "heal"
|
||||
EffectDefBoost ConsumableEffect = "def_boost"
|
||||
EffectAtkBoost ConsumableEffect = "atk_boost"
|
||||
EffectWard ConsumableEffect = "ward"
|
||||
EffectSpeedBoost ConsumableEffect = "speed_boost"
|
||||
EffectCritBoost ConsumableEffect = "crit_boost"
|
||||
EffectFlatDmg ConsumableEffect = "flat_dmg"
|
||||
EffectSpore ConsumableEffect = "spore"
|
||||
EffectReflect ConsumableEffect = "reflect"
|
||||
EffectAutoCrit ConsumableEffect = "auto_crit"
|
||||
)
|
||||
|
||||
type ConsumableDef struct {
|
||||
Name string
|
||||
Effect ConsumableEffect
|
||||
Value float64 // meaning depends on effect
|
||||
Tier int
|
||||
Buyable bool
|
||||
Price int64
|
||||
Slot string // "offensive" or "defensive"
|
||||
}
|
||||
|
||||
// ConsumableItem is an AdvItem that has consumable properties.
|
||||
type ConsumableItem struct {
|
||||
InventoryID int64
|
||||
Def *ConsumableDef
|
||||
}
|
||||
|
||||
var consumableDefs = []ConsumableDef{
|
||||
// T1
|
||||
{Name: "Berry Poultice", Effect: EffectHeal, Value: 12, Tier: 1, Buyable: true, Price: 400, Slot: "defensive"},
|
||||
// T2 — drop only (forage, mine, dungeon)
|
||||
{Name: "Herb Salve", Effect: EffectHeal, Value: 20, Tier: 2, Slot: "defensive"},
|
||||
{Name: "Mushroom Brew", Effect: EffectDefBoost, Value: 0.20, Tier: 2, Slot: "defensive"},
|
||||
{Name: "Coal Bomb", Effect: EffectFlatDmg, Value: 8, Tier: 2, Slot: "offensive"},
|
||||
// T3 — drop only
|
||||
{Name: "Goblin Grease", Effect: EffectAtkBoost, Value: 0.25, Tier: 3, Slot: "offensive"},
|
||||
{Name: "Quartz Ward", Effect: EffectWard, Value: 1, Tier: 3, Slot: "defensive"},
|
||||
{Name: "Blooper Ink Vial", Effect: EffectSpeedBoost, Value: 0.30, Tier: 3, Slot: "offensive"},
|
||||
{Name: "Spore Cloud", Effect: EffectSpore, Value: 2, Tier: 3, Slot: "defensive"},
|
||||
// T4
|
||||
{Name: "Spirit Tonic", Effect: EffectHeal, Value: 40, Tier: 4, Buyable: false, Slot: "defensive"},
|
||||
{Name: "Sapphire Elixir", Effect: EffectCritBoost, Value: 0.15, Tier: 4, Buyable: false, Slot: "offensive"},
|
||||
// T5
|
||||
{Name: "Ancient Artifact Oil", Effect: EffectAutoCrit, Value: 0.35, Tier: 5, Buyable: false, Slot: "offensive"},
|
||||
{Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"},
|
||||
}
|
||||
|
||||
// consumableDefByName returns the definition for a consumable by name.
|
||||
func consumableDefByName(name string) *ConsumableDef {
|
||||
for i := range consumableDefs {
|
||||
if consumableDefs[i].Name == name {
|
||||
return &consumableDefs[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Threat Assessment ────────────────────────────────────────────────────────
|
||||
|
||||
type threatLevel int
|
||||
|
||||
const (
|
||||
threatTrivial threatLevel = iota // < 0.4
|
||||
threatEasy // 0.4–0.7
|
||||
threatCompetitive // 0.7–1.0
|
||||
threatDangerous // > 1.0
|
||||
)
|
||||
|
||||
func assessThreat(player, enemy CombatStats) threatLevel {
|
||||
playerPower := float64(player.MaxHP + player.Attack*3)
|
||||
enemyPower := float64(enemy.MaxHP + enemy.Attack*3)
|
||||
if playerPower == 0 {
|
||||
return threatDangerous
|
||||
}
|
||||
ratio := enemyPower / playerPower
|
||||
switch {
|
||||
case ratio < 0.4:
|
||||
return threatTrivial
|
||||
case ratio < 0.7:
|
||||
return threatEasy
|
||||
case ratio < 1.0:
|
||||
return threatCompetitive
|
||||
default:
|
||||
return threatDangerous
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection Logic ──────────────────────────────────────────────────────────
|
||||
|
||||
// SelectConsumables picks up to 2 items (1 offensive + 1 defensive) from inventory.
|
||||
// contentTier caps consumable tier to the content being fought (0 = no cap).
|
||||
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
|
||||
threat := assessThreat(playerStats, enemyStats)
|
||||
if threat == threatTrivial {
|
||||
return nil
|
||||
}
|
||||
|
||||
maxTier := maxConsumableTier(threat, arenaRound)
|
||||
if contentTier > 0 && contentTier < maxTier {
|
||||
maxTier = contentTier
|
||||
}
|
||||
|
||||
var bestOffensive, bestDefensive *ConsumableItem
|
||||
|
||||
for i := range inventory {
|
||||
item := &inventory[i]
|
||||
if item.Def.Tier > maxTier {
|
||||
continue
|
||||
}
|
||||
|
||||
switch item.Def.Slot {
|
||||
case "offensive":
|
||||
if bestOffensive == nil || betterOffensive(item, bestOffensive, threat) {
|
||||
bestOffensive = item
|
||||
}
|
||||
case "defensive":
|
||||
if bestDefensive == nil || betterDefensive(item, bestDefensive, threat, playerStats, enemyStats) {
|
||||
bestDefensive = item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selected []ConsumableItem
|
||||
if bestOffensive != nil {
|
||||
selected = append(selected, *bestOffensive)
|
||||
}
|
||||
if bestDefensive != nil {
|
||||
selected = append(selected, *bestDefensive)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
func maxConsumableTier(threat threatLevel, arenaRound int) int {
|
||||
base := 1
|
||||
switch threat {
|
||||
case threatEasy:
|
||||
base = 2
|
||||
case threatCompetitive:
|
||||
base = 3
|
||||
case threatDangerous:
|
||||
base = 5
|
||||
}
|
||||
// Arena: spend freely on later rounds
|
||||
if arenaRound >= 3 {
|
||||
base = min(5, base+1)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// betterOffensive prefers lowest tier that's appropriate for the threat.
|
||||
func betterOffensive(candidate, current *ConsumableItem, threat threatLevel) bool {
|
||||
if threat == threatDangerous {
|
||||
return candidate.Def.Tier > current.Def.Tier
|
||||
}
|
||||
return candidate.Def.Tier < current.Def.Tier
|
||||
}
|
||||
|
||||
// betterDefensive picks heal items for longer fights, wards for burst threats.
|
||||
func betterDefensive(candidate, current *ConsumableItem, threat threatLevel, player, enemy CombatStats) bool {
|
||||
if threat == threatDangerous {
|
||||
return candidate.Def.Tier > current.Def.Tier
|
||||
}
|
||||
// Prefer wards against high-attack enemies
|
||||
if enemy.Attack > player.MaxHP/4 && candidate.Def.Effect == EffectWard {
|
||||
return true
|
||||
}
|
||||
return candidate.Def.Tier < current.Def.Tier
|
||||
}
|
||||
|
||||
// ── Modifier Application ─────────────────────────────────────────────────────
|
||||
|
||||
// consumableDropTable maps activity types to consumable names available at each tier.
|
||||
// Drop chance is 15% per activity completion at T2+.
|
||||
var consumableDropTable = map[AdvActivityType]map[int][]string{
|
||||
AdvActivityForaging: {
|
||||
2: {"Herb Salve"},
|
||||
3: {"Spore Cloud"},
|
||||
4: {"Spirit Tonic"},
|
||||
5: {"Spirit Tonic", "Voidstone Shard"},
|
||||
},
|
||||
AdvActivityMining: {
|
||||
2: {"Coal Bomb"},
|
||||
3: {"Quartz Ward"},
|
||||
4: {"Sapphire Elixir"},
|
||||
5: {"Voidstone Shard"},
|
||||
},
|
||||
AdvActivityFishing: {
|
||||
2: {"Herb Salve"},
|
||||
3: {"Blooper Ink Vial"},
|
||||
4: {"Blooper Ink Vial", "Spirit Tonic"},
|
||||
5: {"Blooper Ink Vial", "Voidstone Shard"},
|
||||
},
|
||||
AdvActivityDungeon: {
|
||||
2: {"Herb Salve", "Coal Bomb"},
|
||||
3: {"Goblin Grease", "Quartz Ward"},
|
||||
4: {"Sapphire Elixir", "Spirit Tonic"},
|
||||
5: {"Ancient Artifact Oil", "Voidstone Shard"},
|
||||
},
|
||||
}
|
||||
|
||||
// RollConsumableDrop checks whether a consumable item drops from an activity.
|
||||
// Returns a consumable AdvItem or nil.
|
||||
func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
|
||||
actTable, ok := consumableDropTable[activity]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
names, ok := actTable[tier]
|
||||
if !ok || len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
if rand.Float64() >= 0.15 {
|
||||
return nil
|
||||
}
|
||||
name := names[rand.IntN(len(names))]
|
||||
def := consumableDefByName(name)
|
||||
if def == nil {
|
||||
return nil
|
||||
}
|
||||
sellValue := def.Price / 2
|
||||
if sellValue == 0 {
|
||||
tierSellValues := map[int]int64{1: 200, 2: 600, 3: 1500, 4: 3000, 5: 6000}
|
||||
sellValue = tierSellValues[def.Tier]
|
||||
}
|
||||
return &AdvItem{
|
||||
Name: def.Name,
|
||||
Type: "consumable",
|
||||
Tier: def.Tier,
|
||||
Value: sellValue,
|
||||
}
|
||||
}
|
||||
|
||||
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
|
||||
func BuyableConsumables() []ConsumableDef {
|
||||
var result []ConsumableDef
|
||||
for _, def := range consumableDefs {
|
||||
if def.Buyable {
|
||||
result = append(result, def)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ApplyConsumableMods adjusts the CombatStats and CombatModifiers for selected consumables.
|
||||
func ApplyConsumableMods(stats *CombatStats, mods *CombatModifiers, items []ConsumableItem) {
|
||||
for _, item := range items {
|
||||
switch item.Def.Effect {
|
||||
case EffectHeal:
|
||||
mods.HealItem = int(item.Def.Value)
|
||||
case EffectDefBoost:
|
||||
mods.DamageReduct *= (1 - item.Def.Value) // 0.20 → 0.80x damage taken
|
||||
case EffectAtkBoost:
|
||||
mods.DamageBonus += item.Def.Value
|
||||
case EffectWard:
|
||||
mods.WardCharges = int(item.Def.Value)
|
||||
case EffectSpeedBoost:
|
||||
stats.Speed = int(float64(stats.Speed) * (1 + item.Def.Value))
|
||||
case EffectCritBoost:
|
||||
stats.CritRate += item.Def.Value
|
||||
case EffectFlatDmg:
|
||||
mods.FlatDmgStart = int(item.Def.Value)
|
||||
case EffectSpore:
|
||||
mods.SporeCloud = int(item.Def.Value)
|
||||
case EffectReflect:
|
||||
mods.ReflectNext = item.Def.Value
|
||||
case EffectAutoCrit:
|
||||
mods.AutoCritFirst = true
|
||||
mods.DamageBonus += item.Def.Value // +35% attack too
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crafting System ─────────────────────────────────────────────────────────
|
||||
// Auto-crafting happens before combat. If the player has sufficient foraging
|
||||
// level and the right ingredients in inventory, consumables are assembled
|
||||
// automatically. Failed crafts consume one ingredient.
|
||||
|
||||
type CraftingRecipe struct {
|
||||
Result string // consumable name to produce
|
||||
Ingredients []string // required item names (matched by inventory Name)
|
||||
MinForaging int // foraging level required
|
||||
Tier int
|
||||
}
|
||||
|
||||
var craftingRecipes = []CraftingRecipe{
|
||||
// T1 — Foraging 10
|
||||
{Result: "Berry Poultice", Ingredients: []string{"Berries", "Common Herbs"}, MinForaging: 10, Tier: 1},
|
||||
// T2 — Foraging 15
|
||||
{Result: "Herb Salve", Ingredients: []string{"Rare Herbs", "Honey"}, MinForaging: 15, Tier: 2},
|
||||
{Result: "Mushroom Brew", Ingredients: []string{"Mushrooms", "Hardwood"}, MinForaging: 15, Tier: 2},
|
||||
{Result: "Coal Bomb", Ingredients: []string{"Coal", "Saltpetre"}, MinForaging: 15, Tier: 2},
|
||||
// T3 — Foraging 20
|
||||
{Result: "Goblin Grease", Ingredients: []string{"Goblin Trinket", "Honey"}, MinForaging: 20, Tier: 3},
|
||||
{Result: "Quartz Ward", Ingredients: []string{"Quartz", "Iron Ore"}, MinForaging: 20, Tier: 3},
|
||||
{Result: "Blooper Ink Vial", Ingredients: []string{"Blooper Ink", "River Pearl"}, MinForaging: 20, Tier: 3},
|
||||
{Result: "Spore Cloud", Ingredients: []string{"Spores", "Rare Herbs"}, MinForaging: 20, Tier: 3},
|
||||
// T4 — Foraging 25
|
||||
{Result: "Spirit Tonic", Ingredients: []string{"Spirit Herbs", "Starfruit"}, MinForaging: 25, Tier: 4},
|
||||
{Result: "Sapphire Elixir", Ingredients: []string{"Sapphire", "Dragon Crystal"}, MinForaging: 25, Tier: 4},
|
||||
// T5 — Foraging 30
|
||||
{Result: "Ancient Artifact Oil", Ingredients: []string{"Ancient Artifact", "Dragon Scale"}, MinForaging: 30, Tier: 5},
|
||||
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
|
||||
}
|
||||
|
||||
// craftingSuccessRate returns the success chance for a recipe given foraging level.
|
||||
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
|
||||
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
|
||||
base := 0.50
|
||||
levelsAbove := foragingLevel - minForaging
|
||||
bonus := float64(levelsAbove) / 5.0 * 0.03
|
||||
rate := base + bonus
|
||||
if rate > 0.95 {
|
||||
return 0.95
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
// CraftResult records what happened during auto-crafting for narrative output.
|
||||
type CraftResult struct {
|
||||
Recipe *CraftingRecipe
|
||||
Success bool
|
||||
}
|
||||
|
||||
// renderRecipesKnown returns a player-facing list of recipes available at
|
||||
// their current foraging level, plus a teaser line for the next unlock
|
||||
// threshold. Hides exact ingredient lists for recipes the player hasn't
|
||||
// unlocked — only the count of locked recipes is shown.
|
||||
func renderRecipesKnown(foragingLevel int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
|
||||
|
||||
if foragingLevel < 10 {
|
||||
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Group recipes by tier, list those known.
|
||||
known := map[int][]CraftingRecipe{}
|
||||
totalKnown := 0
|
||||
totalLocked := 0
|
||||
nextUnlock := 0
|
||||
for _, r := range craftingRecipes {
|
||||
if r.MinForaging <= foragingLevel {
|
||||
known[r.Tier] = append(known[r.Tier], r)
|
||||
totalKnown++
|
||||
} else {
|
||||
totalLocked++
|
||||
if nextUnlock == 0 || r.MinForaging < nextUnlock {
|
||||
nextUnlock = r.MinForaging
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
recipes := known[tier]
|
||||
if len(recipes) == 0 {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
|
||||
for _, r := range recipes {
|
||||
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
|
||||
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
|
||||
r.Result, strings.Join(r.Ingredients, " + "), rate))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nKnown: %d · ", totalKnown))
|
||||
if totalLocked == 0 {
|
||||
sb.WriteString("All recipes unlocked. ⭐")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("Locked: %d (next unlock at Foraging Lv.%d)", totalLocked, nextUnlock))
|
||||
}
|
||||
|
||||
maxCrafts := 1 + (foragingLevel-10)/10
|
||||
sb.WriteString(fmt.Sprintf("\nMax auto-crafts per combat: %d.", maxCrafts))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// craftXPRewards: per-tier foraging XP granted on successful (and tiny on
|
||||
// failed) auto-crafts. Successful T1 ≈ 30% of a Foraging Success haul, scaling
|
||||
// up so T5 grants are meaningful. Failures get a token consolation grant —
|
||||
// you tried.
|
||||
var craftXPSuccess = map[int]int{1: 12, 2: 25, 3: 40, 4: 60, 5: 90}
|
||||
var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18}
|
||||
|
||||
// autoCraftConsumables scans the player's inventory for craftable recipes and
|
||||
// attempts to craft the highest-tier recipe available. Consumes ingredients on
|
||||
// attempt; on failure one ingredient is lost. Returns crafted items and results.
|
||||
//
|
||||
// Side effects: grants foraging XP per attempt (success > failure) and bumps
|
||||
// the player's CraftsSucceeded counter on each success.
|
||||
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
|
||||
if foragingLevel < 10 {
|
||||
return nil, items
|
||||
}
|
||||
|
||||
remaining := make([]AdvItem, len(items))
|
||||
copy(remaining, items)
|
||||
|
||||
var results []CraftResult
|
||||
crafted := 0
|
||||
maxCrafts := 1 + (foragingLevel-10)/10 // 1 at lv10, 2 at lv20, 3 at lv30
|
||||
|
||||
// Try recipes from highest tier down
|
||||
for attempt := 0; attempt < maxCrafts; attempt++ {
|
||||
bestRecipe, ingredientIDs := findBestCraftable(remaining, foragingLevel)
|
||||
if bestRecipe == nil {
|
||||
break
|
||||
}
|
||||
|
||||
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
|
||||
success := rand.Float64() < rate
|
||||
|
||||
if success {
|
||||
// Remove all ingredients from inventory
|
||||
for _, id := range ingredientIDs {
|
||||
removeAdvInventoryItem(id)
|
||||
}
|
||||
remaining = removeItemsByIDs(remaining, ingredientIDs)
|
||||
|
||||
// Add crafted consumable to inventory
|
||||
def := consumableDefByName(bestRecipe.Result)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
sellValue := def.Price / 2
|
||||
if sellValue == 0 {
|
||||
tierSellValues := map[int]int64{1: 200, 2: 600, 3: 1500, 4: 3000, 5: 6000}
|
||||
sellValue = tierSellValues[def.Tier]
|
||||
}
|
||||
craftedItem := AdvItem{
|
||||
Name: def.Name,
|
||||
Type: "consumable",
|
||||
Tier: def.Tier,
|
||||
Value: sellValue,
|
||||
}
|
||||
if err := addAdvInventoryItem(userID, craftedItem); err != nil {
|
||||
slog.Error("crafting: failed to add crafted item", "item", def.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
results = append(results, CraftResult{Recipe: bestRecipe, Success: true})
|
||||
crafted++
|
||||
// Foraging XP + lifetime counter bump.
|
||||
if char, err := loadAdvCharacter(userID); err == nil {
|
||||
char.ForagingXP += craftXPSuccess[bestRecipe.Tier]
|
||||
char.CraftsSucceeded++
|
||||
checkAdvLevelUp(char, "foraging")
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
} else {
|
||||
// Failure: all ingredients destroyed
|
||||
for _, id := range ingredientIDs {
|
||||
removeAdvInventoryItem(id)
|
||||
}
|
||||
remaining = removeItemsByIDs(remaining, ingredientIDs)
|
||||
results = append(results, CraftResult{Recipe: bestRecipe, Success: false})
|
||||
// Token foraging XP for the attempt.
|
||||
if char, err := loadAdvCharacter(userID); err == nil {
|
||||
char.ForagingXP += craftXPFailure[bestRecipe.Tier]
|
||||
checkAdvLevelUp(char, "foraging")
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload remaining if we crafted anything (IDs changed)
|
||||
if crafted > 0 {
|
||||
reloaded, err := loadAdvInventory(userID)
|
||||
if err == nil {
|
||||
remaining = reloaded
|
||||
}
|
||||
}
|
||||
|
||||
return results, remaining
|
||||
}
|
||||
|
||||
// findBestCraftable returns the highest-tier recipe the player can craft
|
||||
// from their current inventory, along with the inventory IDs of the ingredients.
|
||||
func findBestCraftable(items []AdvItem, foragingLevel int) (*CraftingRecipe, []int64) {
|
||||
var bestRecipe *CraftingRecipe
|
||||
var bestIDs []int64
|
||||
|
||||
for i := range craftingRecipes {
|
||||
recipe := &craftingRecipes[i]
|
||||
if foragingLevel < recipe.MinForaging {
|
||||
continue
|
||||
}
|
||||
|
||||
ids := matchIngredients(items, recipe.Ingredients)
|
||||
if ids == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if bestRecipe == nil || recipe.Tier > bestRecipe.Tier {
|
||||
bestRecipe = recipe
|
||||
bestIDs = ids
|
||||
}
|
||||
}
|
||||
|
||||
return bestRecipe, bestIDs
|
||||
}
|
||||
|
||||
// matchIngredients checks if inventory contains all ingredients for a recipe.
|
||||
// Returns the inventory item IDs to consume, or nil if not all found.
|
||||
func matchIngredients(items []AdvItem, ingredients []string) []int64 {
|
||||
used := make(map[int]bool)
|
||||
var ids []int64
|
||||
|
||||
for _, need := range ingredients {
|
||||
found := false
|
||||
for j, item := range items {
|
||||
if used[j] {
|
||||
continue
|
||||
}
|
||||
if item.Name == need && item.Type != "consumable" && item.Type != "MasterworkGear" && item.Type != "ArenaGear" && item.Type != "card" {
|
||||
used[j] = true
|
||||
ids = append(ids, item.ID)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func removeItemsByIDs(items []AdvItem, ids []int64) []AdvItem {
|
||||
idSet := make(map[int64]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
idSet[id] = true
|
||||
}
|
||||
var result []AdvItem
|
||||
for _, item := range items {
|
||||
if !idSet[item.ID] {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func makeInventory(names ...string) []ConsumableItem {
|
||||
var items []ConsumableItem
|
||||
for i, name := range names {
|
||||
def := consumableDefByName(name)
|
||||
if def == nil {
|
||||
panic("unknown consumable: " + name)
|
||||
}
|
||||
items = append(items, ConsumableItem{InventoryID: int64(i + 1), Def: def})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func TestSelectConsumables_TrivialFightSkips(t *testing.T) {
|
||||
strong := CombatStats{MaxHP: 200, Attack: 60}
|
||||
weak := CombatStats{MaxHP: 30, Attack: 5}
|
||||
inv := makeInventory("Berry Poultice", "Coal Bomb")
|
||||
|
||||
selected := SelectConsumables(inv, strong, weak, 0, 0)
|
||||
if len(selected) != 0 {
|
||||
t.Errorf("should skip consumables for trivial fight, got %d", len(selected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_DangerousUsesHighTier(t *testing.T) {
|
||||
weak := CombatStats{MaxHP: 80, Attack: 15}
|
||||
strong := CombatStats{MaxHP: 200, Attack: 50}
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
|
||||
|
||||
selected := SelectConsumables(inv, weak, strong, 0, 0)
|
||||
if len(selected) != 2 {
|
||||
t.Fatalf("expected 2 consumables, got %d", len(selected))
|
||||
}
|
||||
|
||||
hasHighTierOff, hasHighTierDef := false, false
|
||||
for _, s := range selected {
|
||||
if s.Def.Tier >= 4 && s.Def.Slot == "offensive" {
|
||||
hasHighTierOff = true
|
||||
}
|
||||
if s.Def.Tier >= 4 && s.Def.Slot == "defensive" {
|
||||
hasHighTierDef = true
|
||||
}
|
||||
}
|
||||
if !hasHighTierOff {
|
||||
t.Error("should pick high-tier offensive for dangerous fight")
|
||||
}
|
||||
if !hasHighTierDef {
|
||||
t.Error("should pick high-tier defensive for dangerous fight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_MaxTwo(t *testing.T) {
|
||||
player := CombatStats{MaxHP: 100, Attack: 30}
|
||||
enemy := CombatStats{MaxHP: 120, Attack: 35}
|
||||
inv := makeInventory(
|
||||
"Berry Poultice", "Herb Salve", "Mushroom Brew",
|
||||
"Coal Bomb", "Goblin Grease", "Blooper Ink Vial",
|
||||
)
|
||||
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0)
|
||||
if len(selected) > 2 {
|
||||
t.Errorf("max 2 consumables, got %d", len(selected))
|
||||
}
|
||||
|
||||
offCount, defCount := 0, 0
|
||||
for _, s := range selected {
|
||||
if s.Def.Slot == "offensive" {
|
||||
offCount++
|
||||
} else {
|
||||
defCount++
|
||||
}
|
||||
}
|
||||
if offCount > 1 {
|
||||
t.Errorf("max 1 offensive, got %d", offCount)
|
||||
}
|
||||
if defCount > 1 {
|
||||
t.Errorf("max 1 defensive, got %d", defCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_EasyUsesLowTier(t *testing.T) {
|
||||
player := CombatStats{MaxHP: 100, Attack: 30}
|
||||
enemy := CombatStats{MaxHP: 50, Attack: 15} // easy: ratio ~0.55
|
||||
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil")
|
||||
|
||||
selected := SelectConsumables(inv, player, enemy, 0, 0)
|
||||
for _, s := range selected {
|
||||
if s.Def.Tier > 2 {
|
||||
t.Errorf("easy fight should use T1-T2, got %s (T%d)", s.Def.Name, s.Def.Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectConsumables_ArenaRoundEscalation(t *testing.T) {
|
||||
player := CombatStats{MaxHP: 100, Attack: 30}
|
||||
enemy := CombatStats{MaxHP: 80, Attack: 25} // competitive
|
||||
|
||||
inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Sapphire Elixir")
|
||||
|
||||
r1 := SelectConsumables(inv, player, enemy, 1, 0)
|
||||
r4 := SelectConsumables(inv, player, enemy, 4, 0)
|
||||
|
||||
maxTierR1 := 0
|
||||
for _, s := range r1 {
|
||||
if s.Def.Tier > maxTierR1 {
|
||||
maxTierR1 = s.Def.Tier
|
||||
}
|
||||
}
|
||||
maxTierR4 := 0
|
||||
for _, s := range r4 {
|
||||
if s.Def.Tier > maxTierR4 {
|
||||
maxTierR4 = s.Def.Tier
|
||||
}
|
||||
}
|
||||
if maxTierR4 <= maxTierR1 && len(r4) > 0 && len(r1) > 0 {
|
||||
t.Logf("arena round escalation: R1 maxTier=%d, R4 maxTier=%d", maxTierR1, maxTierR4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Heal(t *testing.T) {
|
||||
stats := CombatStats{MaxHP: 100}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
items := makeInventory("Herb Salve")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.HealItem != 20 {
|
||||
t.Errorf("heal item = %d, want 20", mods.HealItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_AtkBoost(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Goblin Grease")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.DamageBonus < 0.24 || mods.DamageBonus > 0.26 {
|
||||
t.Errorf("damage bonus = %f, want ~0.25", mods.DamageBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Ward(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Quartz Ward")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.WardCharges != 1 {
|
||||
t.Errorf("ward charges = %d, want 1", mods.WardCharges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_AutoCrit(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Ancient Artifact Oil")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if !mods.AutoCritFirst {
|
||||
t.Error("AutoCritFirst should be true")
|
||||
}
|
||||
if mods.DamageBonus < 0.34 {
|
||||
t.Errorf("damage bonus = %f, want ~0.35", mods.DamageBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_SporeCloud(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Spore Cloud")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.SporeCloud != 2 {
|
||||
t.Errorf("spore rounds = %d, want 2", mods.SporeCloud)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Reflect(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Voidstone Shard")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.ReflectNext < 0.49 {
|
||||
t.Errorf("reflect = %f, want ~0.50", mods.ReflectNext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_DefBoost(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
items := makeInventory("Mushroom Brew")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.DamageReduct < 0.79 || mods.DamageReduct > 0.81 {
|
||||
t.Errorf("damage reduct = %f, want ~0.80", mods.DamageReduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_SpeedBoost(t *testing.T) {
|
||||
stats := CombatStats{Speed: 10}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Blooper Ink Vial")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if stats.Speed != 13 { // 10 * 1.30 = 13
|
||||
t.Errorf("speed = %d, want 13", stats.Speed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_CritBoost(t *testing.T) {
|
||||
stats := CombatStats{CritRate: 0.05}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Sapphire Elixir")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if stats.CritRate < 0.19 || stats.CritRate > 0.21 {
|
||||
t.Errorf("crit rate = %f, want ~0.20", stats.CritRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_FlatDmg(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Coal Bomb")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.FlatDmgStart != 8 {
|
||||
t.Errorf("flat damage = %d, want 8", mods.FlatDmgStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_ValidActivity(t *testing.T) {
|
||||
drops := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
item := RollConsumableDrop(AdvActivityMining, 3)
|
||||
if item != nil {
|
||||
drops++
|
||||
if item.Type != "consumable" {
|
||||
t.Errorf("drop type = %q, want consumable", item.Type)
|
||||
}
|
||||
if item.Name != "Quartz Ward" {
|
||||
t.Errorf("mining T3 drop = %q, want Quartz Ward", item.Name)
|
||||
}
|
||||
if item.Value <= 0 {
|
||||
t.Errorf("drop sell value = %d, want > 0", item.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 15% chance → expect ~150 in 1000
|
||||
if drops < 100 || drops > 220 {
|
||||
t.Errorf("drop rate: %d/1000 (expect ~150 at 15%%)", drops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_InvalidActivity(t *testing.T) {
|
||||
item := RollConsumableDrop(AdvActivityRest, 3)
|
||||
if item != nil {
|
||||
t.Error("rest activity should not drop consumables")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_T1NoDrop(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
item := RollConsumableDrop(AdvActivityForaging, 1)
|
||||
if item != nil {
|
||||
t.Fatal("T1 should not drop consumables")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyableConsumables(t *testing.T) {
|
||||
buyable := BuyableConsumables()
|
||||
if len(buyable) == 0 {
|
||||
t.Fatal("should have buyable consumables")
|
||||
}
|
||||
for _, def := range buyable {
|
||||
if !def.Buyable {
|
||||
t.Errorf("%s marked buyable=false but returned by BuyableConsumables", def.Name)
|
||||
}
|
||||
if def.Price <= 0 {
|
||||
t.Errorf("%s has no price", def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,27 +24,17 @@ type advActiveEvent struct {
|
||||
}
|
||||
|
||||
// ── In-memory schedule ───────────────────────────────────────────────────────
|
||||
// Each day, every eligible player is assigned a random minute between 10:00
|
||||
// and 16:00 UTC at which the 0.5% trigger roll happens.
|
||||
// When a player acts on a given day, the next ticker iteration assigns them
|
||||
// a one-shot roll-minute 60–180 minutes in the future. At that minute, the
|
||||
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
|
||||
|
||||
var (
|
||||
advEventScheduleMu sync.Mutex
|
||||
advEventSchedule map[string]int // userID string -> minute-of-day (600..959)
|
||||
advEventScheduleDay string // "2006-01-02" the schedule was built for
|
||||
advEventScheduleMu sync.Mutex
|
||||
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
|
||||
advEventRolled map[string]bool // userID -> already rolled today
|
||||
advEventScheduleDay string // "2006-01-02" the maps belong to
|
||||
)
|
||||
|
||||
func advBuildEventSchedule(chars []AdventureCharacter) {
|
||||
advEventSchedule = make(map[string]int, len(chars))
|
||||
for _, c := range chars {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
// Random minute between 600 (10:00) and 959 (15:59)
|
||||
advEventSchedule[string(c.UserID)] = 600 + rand.IntN(360)
|
||||
}
|
||||
advEventScheduleDay = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ── Event Ticker ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) eventTicker() {
|
||||
@@ -54,34 +44,51 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
|
||||
// Expire stale pending events every tick
|
||||
expireAdvPendingEvents()
|
||||
|
||||
// Outside the trigger window — nothing to do
|
||||
if now.Hour() < 10 || now.Hour() >= 16 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Rebuild schedule if it's a new day or uninitialised
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars for schedule", "err", err)
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
advBuildEventSchedule(chars)
|
||||
slog.Info("adventure: event schedule built", "players", len(advEventSchedule))
|
||||
advEventSchedule = make(map[string]int)
|
||||
advEventRolled = make(map[string]bool)
|
||||
advEventScheduleDay = dateKey
|
||||
}
|
||||
|
||||
// Find players whose roll minute is now
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
// Schedule deferred rolls for any newly-acted players
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars", "err", err)
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
for _, c := range chars {
|
||||
uid := string(c.UserID)
|
||||
if !c.Alive || advEventRolled[uid] {
|
||||
continue
|
||||
}
|
||||
if _, scheduled := advEventSchedule[uid]; scheduled {
|
||||
continue
|
||||
}
|
||||
if !c.HasActedToday() {
|
||||
continue
|
||||
}
|
||||
// Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC.
|
||||
rollMinute := currentMinute + 60 + rand.IntN(121)
|
||||
if rollMinute > 23*60+50 {
|
||||
rollMinute = 23*60 + 50
|
||||
}
|
||||
advEventSchedule[uid] = rollMinute
|
||||
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
|
||||
}
|
||||
|
||||
// Find players whose roll-minute has arrived
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute == currentMinute {
|
||||
if minute <= currentMinute && !advEventRolled[uid] {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
advEventRolled[uid] = true
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
@@ -95,7 +102,7 @@ func (p *AdventurePlugin) eventTicker() {
|
||||
func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
// Load character — must be alive and have acted today
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.Alive || !char.ActionTakenToday {
|
||||
if err != nil || char == nil || !char.Alive || !char.HasActedToday() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -176,8 +183,9 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
|
||||
gold += rand.Int64N(event.GoldMax - event.GoldMin + 1)
|
||||
}
|
||||
|
||||
// Credit gold
|
||||
p.euro.Credit(ctx.Sender, float64(gold), "adventure_event_"+event.Key)
|
||||
// Credit gold (5% community tax)
|
||||
net, _ := communityTax(ctx.Sender, float64(gold), 0.05)
|
||||
p.euro.Credit(ctx.Sender, net, "adventure_event_"+event.Key)
|
||||
|
||||
// Apply XP if applicable
|
||||
xpSkill := event.XPSkill
|
||||
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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_",
|
||||
}
|
||||
|
||||
@@ -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`.",
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"Some questions are better left unasked. You have €{gold}.\n\n" +
|
||||
"S. Kelly watches you go.",
|
||||
OutcomeRoomLine: "✅ {name} handled the situation. S. Kelly was involved. Nobody is elaborating.",
|
||||
GoldMin: 35,
|
||||
GoldMax: 80,
|
||||
GoldMin: 3500,
|
||||
GoldMax: 8000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -78,8 +78,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You accept this.\n\n" +
|
||||
"€{gold}. You are on fire slightly less than before.",
|
||||
OutcomeRoomLine: "✅ {name} ran into a burning building. Nobody was inside. {name} was briefly on fire.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 200,
|
||||
GoldMin: 8000,
|
||||
GoldMax: 20000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -109,8 +109,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You walk away. The owner is still laughing. " +
|
||||
"You can hear it for quite some time.",
|
||||
OutcomeRoomLine: "✅ {name} confronted four bandits. The carriage owner let it play out for a while first.",
|
||||
GoldMin: 120,
|
||||
GoldMax: 250,
|
||||
GoldMin: 12000,
|
||||
GoldMax: 25000,
|
||||
XP: 25,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -132,8 +132,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You will never find the stranger. " +
|
||||
"This is fine. Some things are better as open wounds.",
|
||||
OutcomeRoomLine: "✅ {name} ate the stew. {name} will not be discussing the stew.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 50,
|
||||
GoldMin: 2000,
|
||||
GoldMax: 5000,
|
||||
XP: 15,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -160,8 +160,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"and no memory of what it referred to.\n\n" +
|
||||
"This haunts them. Good.",
|
||||
OutcomeRoomLine: "✅ {name} made a financial decision at the market. The merchant will be confused later.",
|
||||
GoldMin: 40,
|
||||
GoldMax: 120,
|
||||
GoldMin: 4000,
|
||||
GoldMax: 12000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -185,8 +185,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You go to the bakery. You sell the loaf you bought before knowing about the voucher for €{gold}.\n\n" +
|
||||
"The justice system has, in its way, provided.",
|
||||
OutcomeRoomLine: "✅ {name} was detained and compensated with bread. The system works, loosely.",
|
||||
GoldMin: 25,
|
||||
GoldMax: 60,
|
||||
GoldMin: 2500,
|
||||
GoldMax: 6000,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -209,8 +209,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"is still waiting on their refund.\n\n" +
|
||||
"Not your problem.",
|
||||
OutcomeRoomLine: "✅ {name} accepted a refund for taxes they never paid. The city apologised.",
|
||||
GoldMin: 100,
|
||||
GoldMax: 250,
|
||||
GoldMin: 10000,
|
||||
GoldMax: 25000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -238,8 +238,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"They do not follow you. They sit down on the nearest step " +
|
||||
"and think about their choices. Good.",
|
||||
OutcomeRoomLine: "✅ {name} was pickpocketed and immediately pickpocketed back. Net outcome: positive.",
|
||||
GoldMin: 30,
|
||||
GoldMax: 85,
|
||||
GoldMin: 3000,
|
||||
GoldMax: 8500,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -262,8 +262,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The shouting was bad. " +
|
||||
"You do not ask what happened to the pig.",
|
||||
OutcomeRoomLine: "✅ {name} avoided the pig and was paid for it. The pig's status is unknown.",
|
||||
GoldMin: 15,
|
||||
GoldMax: 40,
|
||||
GoldMin: 1500,
|
||||
GoldMax: 4000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -289,8 +289,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The crow looks up at you. Brief eye contact.\n\n" +
|
||||
"The crow looks away. Business as usual.",
|
||||
OutcomeRoomLine: "✅ The crow gave {name} a ring and left. It gives everyone rings. Questions: none.",
|
||||
GoldMin: 50,
|
||||
GoldMax: 110,
|
||||
GoldMin: 5000,
|
||||
GoldMax: 11000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -314,8 +314,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The horse bites them immediately.\n\n" +
|
||||
"You do not look back.",
|
||||
OutcomeRoomLine: "✅ {name} said something to a horse and it worked. The next person was bitten immediately. {name} cannot explain this.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 55,
|
||||
GoldMin: 2000,
|
||||
GoldMax: 5500,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -343,8 +343,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The money was apparently for you. " +
|
||||
"You do not understand any part of this and you never will.",
|
||||
OutcomeRoomLine: "✅ A cat led {name} to a lockbox. The note inside was for the cat. The money was for {name}.",
|
||||
GoldMin: 45,
|
||||
GoldMax: 100,
|
||||
GoldMin: 4500,
|
||||
GoldMax: 10000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -376,8 +376,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You have his money now. " +
|
||||
"Karma is imprecise but directionally sound.",
|
||||
OutcomeRoomLine: "✅ {name} attended Roderick Hannaway's funeral uninvited. Roderick was difficult. {name} has the money.",
|
||||
GoldMin: 60,
|
||||
GoldMax: 130,
|
||||
GoldMin: 6000,
|
||||
GoldMax: 13000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -417,8 +417,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"Friends made today: -20.\n" +
|
||||
"Money made today: €{gold}. A whole lot.",
|
||||
OutcomeRoomLine: "✅ {name} treated the bar fight wounded on a sliding scale. The child paid the most. Friends made: -20.",
|
||||
GoldMin: 85,
|
||||
GoldMax: 190,
|
||||
GoldMin: 8500,
|
||||
GoldMax: 19000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -450,8 +450,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"They don't know. They will find out. " +
|
||||
"That is not your problem.",
|
||||
OutcomeRoomLine: "✅ {name} accepted a duel meant for someone else. Error discovered mid-duel. Compensation paid.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 170,
|
||||
GoldMin: 8000,
|
||||
GoldMax: 17000,
|
||||
XP: 30,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -484,8 +484,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You stand on the step for a moment.\n\n" +
|
||||
"You walk home.",
|
||||
OutcomeRoomLine: "✅ {name} sat at a stranger's dinner table. Was served. Was paid. The chair was waiting.",
|
||||
GoldMin: 30,
|
||||
GoldMax: 80,
|
||||
GoldMin: 3000,
|
||||
GoldMax: 8000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -517,8 +517,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The prophet makes eye contact with you briefly.\n\n" +
|
||||
"The prophet looks away.",
|
||||
OutcomeRoomLine: "✅ {name} is in a prophecy. A door will know them. Nobody can say which door.",
|
||||
GoldMin: 50,
|
||||
GoldMax: 110,
|
||||
GoldMin: 5000,
|
||||
GoldMax: 11000,
|
||||
XP: 20,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -552,8 +552,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"That explosion would have probably had your ears ringing for a bit. " +
|
||||
"Whew.",
|
||||
OutcomeRoomLine: "✅ {name} received a clock and an apology from T. Both have been processed.",
|
||||
GoldMin: 55,
|
||||
GoldMax: 120,
|
||||
GoldMin: 5500,
|
||||
GoldMax: 12000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -580,8 +580,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The letter is the other well's problem. " +
|
||||
"You are not thinking about the letter.",
|
||||
OutcomeRoomLine: "✅ {name} went into a well. Came out. The letter has been re-disposed of in a different well.",
|
||||
GoldMin: 90,
|
||||
GoldMax: 210,
|
||||
GoldMin: 9000,
|
||||
GoldMax: 21000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -602,8 +602,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You replace the flagstone. You pocket the money.\n\n" +
|
||||
"You are telling everyone.",
|
||||
OutcomeRoomLine: "✅ {name} followed the X. It worked. {name} has been asked to tell no one.",
|
||||
GoldMin: 70,
|
||||
GoldMax: 160,
|
||||
GoldMin: 7000,
|
||||
GoldMax: 16000,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -637,8 +637,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The lock has already been changed.\n\n" +
|
||||
"€{gold}. The food was decent.",
|
||||
OutcomeRoomLine: "✅ {name} found the dungeon staff area. The goblins were on break. An agreement was reached.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 180,
|
||||
GoldMin: 8000,
|
||||
GoldMax: 18000,
|
||||
XP: 20,
|
||||
XPSkill: "combat",
|
||||
Activity: "dungeon",
|
||||
@@ -665,8 +665,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You will not be thinking about it. " +
|
||||
"€{gold} has a clarifying effect on the mind.",
|
||||
OutcomeRoomLine: "✅ {name} picked up free ore off the ground. Nothing happened. €{gold}.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 65,
|
||||
GoldMin: 2000,
|
||||
GoldMax: 6500,
|
||||
XP: 5,
|
||||
XPSkill: "mining",
|
||||
Activity: "mining",
|
||||
@@ -692,8 +692,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You don't ask what it is. " +
|
||||
"Whatever you just sold, the foraging skill has decided it counts.",
|
||||
OutcomeRoomLine: "✅ {name} sold an unidentified plant to a herbalist who was very controlled about wanting it.",
|
||||
GoldMin: 60,
|
||||
GoldMax: 140,
|
||||
GoldMin: 6000,
|
||||
GoldMax: 14000,
|
||||
XP: 25,
|
||||
XPSkill: "foraging",
|
||||
Activity: "foraging",
|
||||
@@ -735,8 +735,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You do have a shiny button and €{gold} in back-pay for the twelve minutes you were technically enlisted.\n\n" +
|
||||
"Huzzah.",
|
||||
OutcomeRoomLine: "✅ {name} accidentally enlisted in an army and immediately got the General killed. Has a button and €{gold}.",
|
||||
GoldMin: 8,
|
||||
GoldMax: 22,
|
||||
GoldMin: 800,
|
||||
GoldMax: 2200,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -791,8 +791,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"This ought to sell for a decent amount.\n\n" +
|
||||
"Mission accomplished.",
|
||||
OutcomeRoomLine: "✅ {name} climbed a tower, broke an egg, got knocked off the ladder, and landed in manure. Has a claw.",
|
||||
GoldMin: 45,
|
||||
GoldMax: 95,
|
||||
GoldMin: 4500,
|
||||
GoldMax: 9500,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -838,8 +838,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You did it.\n\n" +
|
||||
"Congratulations.",
|
||||
OutcomeRoomLine: "✅ {name} outsmarted a monkey. The monkey took the bananas and gave back the coin out of pity.",
|
||||
GoldMin: 1,
|
||||
GoldMax: 1,
|
||||
GoldMin: 100,
|
||||
GoldMax: 100,
|
||||
XP: 40,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package plugin
|
||||
|
||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
||||
//
|
||||
// This file contains hand-written flavor text. Every entry is the length it is
|
||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
||||
// If an entry seems too long: it isn't. Leave it alone.
|
||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
||||
//
|
||||
// ── FISHING FLAVOR TEXT ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Tier 1: Muddy Pond — garbage, sad fish, puns, Stardew energy
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────��─
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -363,9 +363,9 @@ var MiningSuccess = map[int][]string{
|
||||
|
||||
"In and out. {ore}, €{value}, zero cave-ins, one bat that thought about it " +
|
||||
"and decided not to. Your {tool} is slightly worse. Everything else is better. " +
|
||||
"By Surface Pits standards, a professional operation.",
|
||||
"By {location} standards, a professional operation.",
|
||||
|
||||
"The copper vein ran further than the map suggested. Further and richer. " +
|
||||
"The seam ran further than the map suggested. Further and richer. " +
|
||||
"You took {ore} worth €{value} and left more than you could carry. " +
|
||||
"This is the correct response to a rich vein. Come back tomorrow. " +
|
||||
"Bring a bigger bag. Bring a better pickaxe.",
|
||||
@@ -380,68 +380,69 @@ var MiningSuccess = map[int][]string{
|
||||
"and the rock rewarded you for it. This is the entire transaction. " +
|
||||
"It is, when it works, genuinely satisfying.",
|
||||
|
||||
"The tin seam yielded more than expected and the coal beneath it " +
|
||||
"turned out to have a copper pocket behind it " +
|
||||
"The upper seam yielded more than expected and the layer beneath it " +
|
||||
"turned out to have a pocket behind it " +
|
||||
"that wasn't on any map because nobody had gone that far before. " +
|
||||
"You went that far. {ore} total, €{value}. " +
|
||||
"The {tool} is worse. The discovery is better.",
|
||||
},
|
||||
|
||||
2: {
|
||||
"Iron Ridge delivered today. {ore} extracted, €{value} assessed, " +
|
||||
"{location} delivered today. {ore} extracted, €{value} assessed, " +
|
||||
"cave troll acknowledged and navigated around with something approaching grace. " +
|
||||
"The {tool} performed admirably. Your back has filed a formal complaint. " +
|
||||
"The back's complaint is noted and will not change the outcome.",
|
||||
|
||||
"A good seam. A real seam — proper iron, " +
|
||||
"the kind where the rock splits cleanly and the ore sits in it " +
|
||||
"like it's been waiting. {ore} worth €{value}. {xp} XP. " +
|
||||
"A good seam. A real seam — the kind where the rock splits cleanly " +
|
||||
"and the ore sits in it like it's been waiting. " +
|
||||
"{ore} worth €{value}. {xp} XP. " +
|
||||
"This is what mining is supposed to feel like. " +
|
||||
"Remember it for the days it doesn't.",
|
||||
|
||||
"The lead pocket was deeper than the iron, which meant going deeper " +
|
||||
"than planned, which meant more exposure to everything Iron Ridge contains. " +
|
||||
"The deeper pocket was further in than the surface yield suggested, " +
|
||||
"which meant going deeper than planned, " +
|
||||
"which meant more exposure to everything {location} contains. " +
|
||||
"You made it. {ore} worth €{value}. The depth was worth it. " +
|
||||
"The {tool} has opinions about the depth. The opinions are structural.",
|
||||
},
|
||||
|
||||
3: {
|
||||
"Silver. Real silver, properly embedded, not quartz, not illusion, " +
|
||||
"not iron-coloured rock lying about its nature. Silver. " +
|
||||
"Whatever you came down here for, you took it. No illusions, no " +
|
||||
"iron-coloured rock lying about its nature. " +
|
||||
"{ore} worth €{value}. {xp} XP. Your {tool} is a real tool " +
|
||||
"and this was a real seam and you extracted the silver. " +
|
||||
"and this was a real seam and you extracted what was in it. " +
|
||||
"That's the whole job. Today the whole job got done.",
|
||||
|
||||
"The Silver Seam at depth, past the quartz formations, " +
|
||||
"{location} at depth, past the decorative formations, " +
|
||||
"past the section that looks better than it is, " +
|
||||
"down to where the silver sits in the rock like an argument for effort. " +
|
||||
"down to where the yield sits in the rock like an argument for effort. " +
|
||||
"{ore}, €{value}, {xp} XP, home by sunset. " +
|
||||
"A good day. In a mine. Those exist.",
|
||||
},
|
||||
|
||||
4: {
|
||||
"Deeprock gold. Actual gold, not pyrite, not gold-coloured copper, " +
|
||||
"not optimism in mineral form. Gold. {ore} worth €{value}. " +
|
||||
"The Deeprock tried to discourage you with atmosphere and pressure. " +
|
||||
"You brought a Mithril Pickaxe and ignored the atmosphere.",
|
||||
"Proper haul. No pyrite, no gold-coloured copper, " +
|
||||
"no optimism in mineral form. {ore} worth €{value}. " +
|
||||
"{location} tried to discourage you with atmosphere and pressure. " +
|
||||
"You brought a {tool} and ignored the atmosphere.",
|
||||
|
||||
"You went to the fourth level of the Deeprock and you came back " +
|
||||
"You went to the fourth level of {location} and you came back " +
|
||||
"with {ore} worth €{value} and {xp} XP and a story " +
|
||||
"that mostly involves how far down the fourth level is. " +
|
||||
"It is very far down. The ore was worth being very far down. Today.",
|
||||
},
|
||||
|
||||
5: {
|
||||
"Mythril. You found mythril and extracted mythril " +
|
||||
"and brought mythril home and mythril is worth €{value} per unit. " +
|
||||
"The {ore} you're carrying is worth €{value} total. " +
|
||||
"{xp} XP. The Diamond Pickaxe is a Diamond Pickaxe for a reason. " +
|
||||
"You found the seam and you extracted the seam " +
|
||||
"and brought the seam home. " +
|
||||
"{ore} worth €{value} total. " +
|
||||
"{xp} XP. The {tool} is a {tool} for a reason. " +
|
||||
"Today you proved the reason.",
|
||||
|
||||
"The Voidstone was present but passive today. " +
|
||||
"The Dragon Crystals were warm but not hostile. " +
|
||||
"The mythril seam was open and you took {ore} worth €{value} from it. " +
|
||||
"{xp} XP. The Mythril Caverns had a good day at the same time you did. " +
|
||||
"The seam was open and you took {ore} worth €{value} from it. " +
|
||||
"{xp} XP. {location} had a good day at the same time you did. " +
|
||||
"This does not happen often. Don't examine it. Take the ore home.",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package plugin
|
||||
|
||||
// ── Misty — Opening Lines ──────────────────────────────────────────────────
|
||||
|
||||
var mistyOpenings = []string{
|
||||
"Excuse me. I'm sorry to bother you. I don't usually ask but -- do you have any gold to spare? Even a little would help.",
|
||||
"I hate to stop you. I can see you're busy. I just -- do you have any gold? It doesn't have to be much. Anything at all.",
|
||||
"Sorry. I'm so sorry. I know this is awkward. Do you have 100 gold you could spare? I wouldn't ask if I didn't need to.",
|
||||
"You look like a kind person. I hope you are. Do you have any gold? Just 100. I'll remember it.",
|
||||
"I don't usually do this. I want you to know that. Do you have 100 gold? I'll be out of your way in a moment either way.",
|
||||
}
|
||||
|
||||
// ── Misty — Accept Lines ───────────────────────────────────────────────────
|
||||
|
||||
var mistyAcceptLines = []string{
|
||||
"Oh. Thank you. Really. You didn't have to do that and you did. That means more than you know. Good luck out there.",
|
||||
"Thank you so much. I mean it. You're a good person. I hope the arena treats you well today.",
|
||||
"You're very kind. I won't forget it. Thank you. Truly. Go on -- I've kept you long enough.",
|
||||
"Oh, thank you. Thank you. I hope something wonderful happens to you today. I really do.",
|
||||
"That's -- thank you. You didn't have to. I hope you know that mattered. Go show them what you've got.",
|
||||
}
|
||||
|
||||
// ── Misty — Decline ────────────────────────────────────────────────────────
|
||||
|
||||
const mistyDeclineLine = "She nods once and walks away. She doesn't look back."
|
||||
|
||||
// ── Arina — Opening Lines ──────────────────────────────────────────────────
|
||||
|
||||
var arinaOpenings = []string{
|
||||
"You. Yes, you. I've been watching your arena performances and I've decided -- against my better judgment -- to take an interest. 5,000 gold. I'll make it worth your while. Don't embarrass me.",
|
||||
"I'll be brief because my time is valuable and yours demonstrably isn't. 5,000 gold. I have a sniper I'm not currently using. We could have an arrangement. Or not. It's entirely your loss.",
|
||||
"I've decided you have potential. It pains me to admit it. 5,000 gold and I'll have someone watch your fights. Professionally. Do try to look capable when you answer.",
|
||||
"You look like someone who could use help. Most people in your position do. 5,000 gold. I have resources. You have need. This is called an arrangement. Say yes and try not to gloat about it.",
|
||||
"Don't make that face. I'm offering you something. 5,000 gold and a week of professional support in the arena. The alternative is continuing as you have been, which I think we both find embarrassing.",
|
||||
}
|
||||
|
||||
// ── Arina — Accept ─────────────────────────────────────────────────────────
|
||||
|
||||
const arinaAcceptLine = "Where's my thank you? Someone of my stature is accepting your dirty peasant money."
|
||||
|
||||
// ── Arina — Decline Lines ──────────────────────────────────────────────────
|
||||
|
||||
var arinaDeclineLines = []string{
|
||||
"Remarkable. You've somehow managed to be both broke and stupid. A rare combination. Enjoy your mediocrity.",
|
||||
"I see. You'd rather fail on your own terms. How quaint. How completely, utterly quaint.",
|
||||
"Fine. Go back to whatever it is you do. I'll find someone with half a brain and twice the ambition. Shouldn't be hard.",
|
||||
"I offered you a lifeline and you looked at it and said no. I want you to think about that. Later, when it's relevant. And it will be relevant.",
|
||||
"No. You said no. To me. I'll remember that. Not out of spite -- I'm above spite -- but because it's funny, and I collect funny things.",
|
||||
"You're turning down professional assistance because -- what exactly? Pride? You can't afford pride. You can barely afford that equipment.",
|
||||
}
|
||||
|
||||
// ── Gourmet Food Pool (Misty buff — arena) ─────────────────────────────────
|
||||
|
||||
var mistyGourmetFoodLines = []string{
|
||||
"The crowd has thrown a Seared Foie Gras with Fig Reduction at you. You catch it without thinking. It is perfect. You eat it in the arena. {enemy} stops moving for a moment. {enemy} takes 5 damage.",
|
||||
"Someone in the upper tier has thrown a Wagyu Beef Tartare with Truffle Oil at you. You eat it immediately and without shame. {enemy} watches this happen. {enemy} takes 5 damage. {enemy} is reconsidering the fight.",
|
||||
"A Lobster Bisque in a warmed ceramic bowl lands in your hands. You drink it. The crowd roars. {enemy} takes 5 damage from witnessing this.",
|
||||
"The crowd has provided a Tasting Menu Amuse-Bouche. Three bites. All perfect. You finish it in four seconds. {enemy} takes 5 damage. {enemy} does not understand what is happening.",
|
||||
"Someone throws a hand-rolled Truffle Pasta at you. You eat it like a feral animal. It heals you. {enemy} takes 5 damage and briefly forgets what they were doing.",
|
||||
"A Deconstructed Beef Wellington lands nearby. You eat the components separately and in the wrong order. It is still extraordinary. {enemy} takes 5 damage.",
|
||||
"The crowd has thrown a Michelin-starred Tuna Tataki at you. You catch it, eat it in one motion, and keep moving. {enemy} takes 5 damage. {enemy} will think about this later.",
|
||||
"Someone in the crowd has thrown a perfectly tempered Chocolate Fondant with Salted Caramel at you mid-fight. You eat it immediately. {enemy} takes 5 damage from the indignity of the situation.",
|
||||
"A Burrata with Heirloom Tomatoes and Aged Balsamic lands at your feet. You eat it off the arena floor without hesitation. The crowd erupts. {enemy} takes 5 damage.",
|
||||
"The crowd throws a Saffron Risotto at you. It is warm. It is perfectly seasoned. You eat it with your hands. {enemy} takes 5 damage. {enemy} files this moment away somewhere dark.",
|
||||
}
|
||||
|
||||
// ── Crowd Revenge Pool (Misty debuff — arena) ──────────────────────────────
|
||||
|
||||
var mistyCrowdRevengeLines = []string{
|
||||
"The crowd has remembered something. They're booing. Something has been thrown. It hits you. {damage} damage. The arena has a long memory.",
|
||||
"Someone in the crowd throws something that is definitely not food. {damage} damage. The booing intensifies.",
|
||||
"The arena crowd has opinions about you specifically today. Something lands. {damage} damage. You don't see where it came from. You don't need to.",
|
||||
"The crowd is restless. An object arrives from the upper tier. {damage} damage. The booing is organized. That's somehow worse.",
|
||||
"Something hits you from the stands. {damage} damage. The crowd is not finished. The crowd is never finished.",
|
||||
}
|
||||
|
||||
// ── Sniper Log Lines (Arina buff — arena) ──────────────────────────────────
|
||||
|
||||
var arinaSniperLines = []string{
|
||||
"Just as the match was about to begin, something whizzes inches from the side of your head into {enemy}. They look confused because they've been shot with an arrow, yet you're holding a sword. Oh well. The Arena can be brutal sometimes. YOU WIN!",
|
||||
"You step forward to fight. {enemy} steps forward to meet you. Then {enemy} stumbles. There's an arrow in their shoulder that wasn't there a second ago. Neither of you saw where it came from. {enemy} goes down. You didn't do that. YOU WIN!",
|
||||
"The bell rings. You raise your weapon. {enemy} raises theirs. Then something whistles past your ear and {enemy} drops. You stand there for a moment. The crowd cheers like this is normal. Maybe it is. YOU WIN!",
|
||||
"Before you can swing, {enemy} jerks sideways. There's a bolt sticking out of them that absolutely did not come from you. The crowd doesn't seem surprised. {enemy} is down. You'll take it. YOU WIN!",
|
||||
"You blink. {enemy} is on the ground. There's an arrow in their leg and a confused expression on their face. You definitely didn't do that. The Arena official shrugs and waves you through. YOU WIN!",
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package plugin
|
||||
|
||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
||||
//
|
||||
// This file contains hand-written flavor text. Every entry is the length it is
|
||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
||||
// If an entry seems too long: it isn't. Leave it alone.
|
||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
||||
//
|
||||
// ── PET FLAVOR TEXT ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Two pets. One register each.
|
||||
//
|
||||
// The Dog: enthusiastic, physical, zero technique, completely committed.
|
||||
// Loves you unconditionally and expresses this through violence on your behalf.
|
||||
// The Cat: massive, precise, indifferent. Helps occasionally. Does not explain why.
|
||||
// The enemy should feel honored and also concerned.
|
||||
//
|
||||
// Both: never die, never leave, have opinions about dinner.
|
||||
|
||||
// ── DOG ATTACK ────────────────────────────────────────────────────────────────
|
||||
|
||||
var PetDogAttack = []string{
|
||||
"Your dog has decided to get involved.\n\n" +
|
||||
"Not strategically. Not tactically. Just fully and immediately.\n\n" +
|
||||
"{enemy} takes {damage} damage and a lot of general chaos.",
|
||||
|
||||
"Your dog launches itself at {enemy} with the energy of something " +
|
||||
"that has never once considered consequences.\n\n" +
|
||||
"{damage} damage. Your dog lands, shakes itself off, and looks at you " +
|
||||
"like it just did the most normal thing in the world.\n\nIt did not.",
|
||||
|
||||
"Your dog has joined the fight.\n\n" +
|
||||
"Your dog did not ask permission.\n\n" +
|
||||
"Your dog never asks permission.\n\n" +
|
||||
"{enemy} takes {damage} damage. Your dog takes a victory lap.",
|
||||
|
||||
"Without warning, without strategy, and without any apparent plan beyond " +
|
||||
"extreme commitment, your dog hits {enemy} for {damage} damage.\n\n" +
|
||||
"The crowd goes absolutely feral.",
|
||||
|
||||
"Your dog sees an opening that you did not see, will never see, " +
|
||||
"and could not explain if asked.\n\n" +
|
||||
"It takes it. {damage} damage to {enemy}.\n\n" +
|
||||
"The dog returns to your side looking very pleased with itself.\n\n" +
|
||||
"It should be.",
|
||||
|
||||
"Your dog barrels into {enemy} at full speed with its entire body weight, " +
|
||||
"which is considerable, because it is a massive dog.\n\n" +
|
||||
"{damage} damage.\n\nPhysics was involved.",
|
||||
|
||||
"Your dog bites {enemy}.\n\nDeliberately.\n\nRepeatedly.\n\n" +
|
||||
"{damage} damage total.\n\nYour dog lets go when it feels like it.",
|
||||
|
||||
"Your dog has entered the arena.\n\nYour dog was not supposed to enter the arena.\n\n" +
|
||||
"Your dog did not read the rules.\n\nYour dog cannot read.\n\n" +
|
||||
"{enemy} takes {damage} damage. The rules remain technically intact.",
|
||||
}
|
||||
|
||||
// ── CAT ATTACK ────────────────────────────────────────────────────────────────
|
||||
|
||||
var PetCatAttack = []string{
|
||||
"Your cat intervenes.\n\n" +
|
||||
"Once. Precisely. Without looking at you afterward.\n\n" +
|
||||
"{enemy} takes {damage} damage and is left with the unsettling feeling " +
|
||||
"that this was a favor they did not earn.",
|
||||
|
||||
"Your cat has briefly decided you are worth defending.\n\n" +
|
||||
"It acts on this decision with surgical efficiency.\n\n" +
|
||||
"{damage} damage to {enemy}.\n\n" +
|
||||
"The decision has already been rescinded. Don't read into it.",
|
||||
|
||||
"Your cat moves.\n\nThat's all.\n\nYour cat just moves.\n\n" +
|
||||
"{enemy} takes {damage} damage and spends the rest of the round " +
|
||||
"trying to understand what happened.",
|
||||
|
||||
"Your cat looks at {enemy}.\n\nThen at you.\n\nThen back at {enemy}.\n\n" +
|
||||
"Then it handles it.\n\n{damage} damage.\n\nYour cat sits back down.",
|
||||
|
||||
"Your cat has, for reasons it will not share, chosen this moment.\n\n" +
|
||||
"{damage} damage. Clean. Efficient. Completely without ego.\n\n" +
|
||||
"Your cat is already elsewhere.",
|
||||
|
||||
"The cat moves through the fight like it owns the arena.\n\n" +
|
||||
"It does not own the arena.\n\nThe arena is reconsidering its position on this.\n\n" +
|
||||
"{enemy} takes {damage} damage. The cat does not acknowledge the applause.",
|
||||
|
||||
"Your cat strikes {enemy} once.\n\nIt does not strike twice.\n\n" +
|
||||
"It did not need to.\n\n{damage} damage.\n\n" +
|
||||
"Your cat has returned to doing whatever it was doing before, " +
|
||||
"which was nothing, and also everything.",
|
||||
|
||||
"Your massive cat drops from somewhere it wasn't a moment ago " +
|
||||
"and lands directly on {enemy}'s immediate future.\n\n" +
|
||||
"{damage} damage.\n\nYour cat leaves without comment.\n\n" +
|
||||
"{enemy} has questions. The cat will not be answering them.",
|
||||
}
|
||||
|
||||
// ── DOG DEATH REACTION ────────────────────────────────────────────────────────
|
||||
|
||||
var PetDogDeath = []string{
|
||||
"Your dog was napping as your foe finished you off.\n\n" +
|
||||
"They're upset now.\n\nNot because of what happened to you.\n\n" +
|
||||
"Because dinner won't be arriving on time today.",
|
||||
|
||||
"Your dog watched the whole thing.\n\nDid nothing.\n\nIs now sitting by the door.\n\n" +
|
||||
"Not waiting for you to come back.\n\nWaiting for whoever brings the food to come back.\n\n" +
|
||||
"These are different.",
|
||||
|
||||
"Your dog has located your empty bowl and is moving it around the kitchen floor " +
|
||||
"to let someone know there's been some kind of administrative error.",
|
||||
|
||||
"Your dog is fine.\n\nYour dog is great, actually.\n\n" +
|
||||
"Your dog found something on the floor and ate it and is having a wonderful afternoon.\n\n" +
|
||||
"You were gone, they noted. Briefly. Then the floor thing happened.",
|
||||
|
||||
"Your dog is sitting in front of your equipment and looking at it.\n\n" +
|
||||
"Not mournfully.\n\nSpeculatively.\n\n" +
|
||||
"Your dog has never fully ruled out that the equipment is food.",
|
||||
|
||||
"Your dog is howling.\n\nNot in grief.\n\nIn the specific register of a dog " +
|
||||
"who has been waiting an unreasonable amount of time for their walk.\n\n" +
|
||||
"The neighbors are aware.",
|
||||
|
||||
"Your dog is absolutely fine and has already made several new friends " +
|
||||
"who came to check on the situation.\n\n" +
|
||||
"Your dog showed them around.\n\nYour dog let them pet it.\n\n" +
|
||||
"Your dog has no concept of what just happened to you and is having the best day.",
|
||||
}
|
||||
|
||||
// ── CAT DEATH REACTION ────────────────────────────────────────────────────────
|
||||
|
||||
var PetCatDeath = []string{
|
||||
"Your cat is aware you died.\n\n" +
|
||||
"Your cat has elected not to comment at this time.\n\n" +
|
||||
"Dinner, however, is now late, and your cat has many comments about that.",
|
||||
|
||||
"Your cat watched you lose from across the arena.\n\n" +
|
||||
"Its expression did not change.\n\n" +
|
||||
"Its expression never changes.\n\n" +
|
||||
"Dinner is late. Your cat's expression has changed.",
|
||||
|
||||
"Your cat is sitting on your things.\n\nNot to mourn.\n\n" +
|
||||
"Your cat sits on things. That's just what it does.\n\n" +
|
||||
"Dinner being late is a separate and more pressing issue " +
|
||||
"that your cat would like addressed immediately.",
|
||||
|
||||
"Your cat has knocked something off a surface.\n\n" +
|
||||
"Not in grief.\n\nJust because it was there.\n\n" +
|
||||
"Dinner is late. Your cat will continue knocking things off surfaces " +
|
||||
"until this is corrected.",
|
||||
|
||||
"Your cat is fine.\n\nYour cat is always fine.\n\n" +
|
||||
"Your cat was fine before you and will be fine after.\n\n" +
|
||||
"Dinner is late and your cat is making it everyone's problem.",
|
||||
|
||||
"Your cat has sat down directly in the center of the room and is staring at the wall.\n\n" +
|
||||
"This could mean anything.\n\nIt means dinner is late.",
|
||||
}
|
||||
|
||||
// ── DOG VICTORY REACTION ──────────────────────────────────────────────────────
|
||||
// Fires occasionally. Not every win. The dog has other things going on.
|
||||
|
||||
var PetDogVictory = []string{
|
||||
"Your dog is aware you won.\n\nYour dog is losing its mind about this.\n\n" +
|
||||
"Your dog has won every fight you have ever been in, " +
|
||||
"in the sense that it loves you and you came home, " +
|
||||
"and it is treating this occasion with the same energy as all the others, " +
|
||||
"which is: maximum.",
|
||||
|
||||
"Your dog is running in circles.\n\nNot for any reason.\n\n" +
|
||||
"Just because you won and it's a good day and " +
|
||||
"running in circles is how your dog processes good days.",
|
||||
|
||||
"Your dog has brought you something.\n\nYou don't know what it is.\n\n" +
|
||||
"Your dog is very proud of it.\n\nYou accept it.\n\n" +
|
||||
"This is what winning looks like.",
|
||||
|
||||
"Your dog is pressed against your leg.\n\nHard.\n\n" +
|
||||
"Your dog does not fully understand what the arena is.\n\n" +
|
||||
"Your dog understands that you left and came back.\n\n" +
|
||||
"Your dog finds this extremely good news every single time.",
|
||||
|
||||
"Your dog has decided that the victory belongs to both of you equally " +
|
||||
"and is accepting congratulations from nearby strangers on this basis.\n\n" +
|
||||
"The strangers are obliging.\n\nThe dog deserves it.",
|
||||
}
|
||||
|
||||
// ── CAT VICTORY REACTION ──────────────────────────────────────────────────────
|
||||
// Fires rarely. The cat has acknowledged maybe three of your wins. Total.
|
||||
|
||||
var PetCatVictory = []string{
|
||||
"Your cat glances at you.\n\nOnce.\n\nThen away.\n\n" +
|
||||
"This is the cat equivalent of a standing ovation.\n\n" +
|
||||
"You will not receive another one this week.",
|
||||
|
||||
"Your cat is sitting near you.\n\nNot with you.\n\nNear you.\n\n" +
|
||||
"This is as close as it gets to celebration.\n\n" +
|
||||
"Take it.",
|
||||
|
||||
"Your cat blinks slowly in your direction.\n\n" +
|
||||
"You have been approved of.\n\n" +
|
||||
"Briefly.\n\nConditionally.\n\nDon't push it.",
|
||||
|
||||
"Your cat has moved to a slightly closer position than usual.\n\n" +
|
||||
"It will not explain this.\n\nIt may move back.\n\n" +
|
||||
"For now: proximity. That's the win.",
|
||||
}
|
||||
|
||||
// ── DOG DEFLECT ───────────────────────────────────────────────────────────────
|
||||
|
||||
var PetDogDeflect = []string{
|
||||
"Your dog steps in front of {enemy}'s attack.\n\n" +
|
||||
"Not strategically.\n\nJust because you were there and the thing coming at you " +
|
||||
"was also coming at you and your dog had opinions about that.\n\n" +
|
||||
"{enemy} misses.\n\nYour dog looks very pleased with itself.",
|
||||
|
||||
"Your dog saw it coming before you did.\n\n" +
|
||||
"Your dog always sees it coming before you do.\n\n" +
|
||||
"You've never fully processed this.\n\n" +
|
||||
"{enemy}'s strike lands on nothing.\n\nYour dog is already back at your side.",
|
||||
|
||||
"{enemy} swings.\n\nYour dog moves.\n\n" +
|
||||
"These two things happened in the wrong order for {enemy}.\n\n" +
|
||||
"The attack misses and causes a dog treat to fall out of its pocket.\n\n" +
|
||||
"Mission accomplished.. treat acquired! ..Oh and you weren't hit.. I guess. ...yay.",
|
||||
|
||||
"Your dog body-checks {enemy}'s weapon arm at full speed.\n\n" +
|
||||
"This was not a trained technique.\n\n" +
|
||||
"This was a massive dog moving very fast toward something it didn't like.\n\n" +
|
||||
"The attack goes wide.\n\nPhysics handled it.",
|
||||
|
||||
"{enemy} commits to the strike.\n\nYour dog doesn't like what it sees and barks loudly enough to startle {enemy} so strongly that it yelps.\n\n" +
|
||||
"This would have been awesome if you hadn't also yelped as well.\n\n" +
|
||||
"Either way.. {enemy} misses!\n\n",
|
||||
|
||||
"Your dog throws itself between you and {enemy}'s attack " +
|
||||
"with the energy of something that has never once considered " +
|
||||
"that this might not work.\n\n" +
|
||||
"It works.\n\n{enemy} misses.\n\nYour dog shakes itself off and keeps going.",
|
||||
}
|
||||
|
||||
// ── CAT DEFLECT ───────────────────────────────────────────────────────────────
|
||||
|
||||
var PetCatDeflect = []string{
|
||||
"Your cat moves once.\n\n" +
|
||||
"{enemy}'s attack finds nothing.\n\n" +
|
||||
"Your cat does not look at {enemy}.\n\n" +
|
||||
"Your cat does not look at you.\n\n" +
|
||||
"Your cat has already moved on.",
|
||||
|
||||
"{enemy} strikes.\n\nYour cat was there.\n\nThen your cat wasn't.\n\n" +
|
||||
"The attack misses.\n\nYour cat is now somewhere else entirely.\n\n" +
|
||||
"Nobody saw it move.",
|
||||
|
||||
"Your cat steps into the path of {enemy}'s attack " +
|
||||
"and then out of it in a single motion that takes approximately no time.\n\n" +
|
||||
"{enemy} misses.\n\nYour cat sits down.\n\n" +
|
||||
"The crowd roars excitedly at the spectacle which you foolishly believe is for you " +
|
||||
"until the announcer \"helpfully\" corrects you.",
|
||||
|
||||
"Your cat redirects {enemy}'s strike with one paw.\n\n" +
|
||||
"Casually.\n\nLike it had something else to do and this was briefly in the way.\n\n" +
|
||||
"The attack goes wide.\n\nYour cat returns to having something else to do.",
|
||||
|
||||
"{enemy} swings.\n\nYour cat looks at the swing.\n\n" +
|
||||
"Your cat decides against it.\n\n" +
|
||||
"The swing finds nothing.\n\n" +
|
||||
"Your cat had already made its decision before {enemy} had finished committing.\n\n" +
|
||||
"That's the difference.",
|
||||
|
||||
"Your cat inserts itself into the situation briefly and then removes itself.\n\n" +
|
||||
"{enemy}'s attack lands on the space your cat just vacated.\n\n" +
|
||||
"Your cat is already somewhere else.\n\nIt blinks.\n\nNot at you.\n\nAt nothing.\n\n" +
|
||||
"The way cats do.",
|
||||
}
|
||||
// Fires randomly in the morning DM. Cat has left something.
|
||||
// Results in a defense boost for the day.
|
||||
// The cat is proud. The cat will never stop doing this.
|
||||
|
||||
var PetCatOffering = []string{
|
||||
"There is half a rat on your doorstep.\n\n" +
|
||||
"You notice that your cat is looking at you from a distance.\n\n" +
|
||||
"You pretend to take a bite.\n\nYour cat looks genuinely upset.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Something has been left at your door.\n\nSomething that was recently alive.\n\n" +
|
||||
"Your cat has arranged it thoughtfully.\n\nThis took effort.\n\n" +
|
||||
"You are moved in a way you did not expect and cannot fully explain.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Your cat has brought you a bird.\n\nMost of a bird.\n\n" +
|
||||
"Your cat is extremely pleased with itself.\n\n" +
|
||||
"You look at the bird.\n\nYou look at your cat.\n\n" +
|
||||
"Your cat has never once doubted you (in a way that *you* would notice anyway).\n\n" +
|
||||
"You head out with that energy.\n\nDefense increased for today.",
|
||||
|
||||
"There is something on your doorstep that you will not be describing in detail.\n\n" +
|
||||
"Your cat is sitting beside it with the composure of someone " +
|
||||
"who has provided and would like acknowledgement.\n\n" +
|
||||
"Your horrified expression pleases the cat and you are now ready for anything.\n\nDefense increased for today.",
|
||||
|
||||
"Your cat hunted last night.\n\nYour cat hunted successfully.\n\n" +
|
||||
"Your cat brought the evidence to your door because you are its person " +
|
||||
"and it wanted you to know that things have been handled.\n\n" +
|
||||
"Things have been handled.\n\nDefense increased for today.",
|
||||
|
||||
"Half a mouse.\n\nYour cat.\n\nThe specific expression of an animal that loves you " +
|
||||
"in the only language it fully trusts.\n\n" +
|
||||
"You stand there for a moment.\n\nYou go inside.\n\nYou come back with a treat.\n\n" +
|
||||
"Your cat sniffs it and eats the rest of the mouse instead.\n\n" +
|
||||
"Defense increased for today.",
|
||||
}
|
||||
|
||||
// ── DOG MORNING SMOTHERING ────────────────────────────────────────────────────
|
||||
// Fires randomly in the morning DM. Dog has slept on top of the player.
|
||||
// Results in a defense boost for the day.
|
||||
// The dog has no idea. The dog never has any idea.
|
||||
|
||||
var PetDogSmothering = []string{
|
||||
"You woke up this morning unable to breathe.\n\n" +
|
||||
"This was because your dog was on top of you.\n\nAll of your dog.\n\n" +
|
||||
"Your dog was asleep.\n\nYour dog is still asleep.\n\n" +
|
||||
"You moved your dog.\n\nYour dog made a noise.\n\n" +
|
||||
"You lay there for a moment, alive, aware of it.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"At some point last night your dog relocated from its bed to your chest.\n\n" +
|
||||
"Your dog weighs a considerable amount because it is a massive dog.\n\n" +
|
||||
"You slept under this weight for several hours.\n\n" +
|
||||
"You feel like you have survived something.\n\nYou have survived something.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Your dog was on your legs when you woke up.\n\n" +
|
||||
"Your dog was also somehow on your chest.\n\n" +
|
||||
"The physics of this are unclear.\n\n" +
|
||||
"You have no feeling in your lower half.\n\n" +
|
||||
"You have never felt more alive.\n\nDefense increased for today.",
|
||||
|
||||
"You woke up with your dog's full weight distributed across your torso " +
|
||||
"in a way that suggests your dog spent the night actively trying to become part of you.\n\n" +
|
||||
"Your dog succeeded, spiritually.\n\n" +
|
||||
"You are a unit now.\n\nDefense increased for today.",
|
||||
|
||||
"Your dog smothered you last night.\n\nNot with malice.\n\nWith love.\n\n" +
|
||||
"There is no meaningful difference in outcome but the intent matters " +
|
||||
"and the intent was pure.\n\n" +
|
||||
"You made it.\n\nDefense increased for today.",
|
||||
|
||||
"You nearly died in your own bed.\n\n" +
|
||||
"Your dog doesn't know this.\n\nYour dog is wagging its tail.\n\n" +
|
||||
"Your dog slept better than it ever has.\n\n" +
|
||||
"You look at your dog.\n\nYour dog is wagging its tail.\n\n" +
|
||||
"Despite the near death experience, you feel all is right with the world at that moment.\n\n" +
|
||||
"Defense increased for today.",
|
||||
|
||||
"Your dog is 'little spoon' in a technical sense only.\n\n" +
|
||||
"In practice your dog is the entire bed and you are whatever " +
|
||||
"fits in the remaining space, which last night was: not much.\n\n" +
|
||||
"You woke up on the edge.\n\nYou did not fall.\n\n" +
|
||||
"You are tougher than you thought.\n\nDefense increased for today.",
|
||||
}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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.",
|
||||
}
|
||||
@@ -256,31 +256,6 @@ func TestFixedHolidays_NoDuplicateMonthDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolidaySecondPromptRender(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
DisplayName: "TestPlayer",
|
||||
CombatLevel: 5,
|
||||
MiningSkill: 3,
|
||||
ForagingSkill: 2,
|
||||
}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 1, Condition: 100, Name: "Iron Sword"},
|
||||
}
|
||||
bonuses := &AdvBonusSummary{}
|
||||
|
||||
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
|
||||
|
||||
if !strings.Contains(text, "Action 1 complete") {
|
||||
t.Error("second prompt should mention action 1 complete")
|
||||
}
|
||||
if !strings.Contains(text, "second action") {
|
||||
t.Error("second prompt should mention second action")
|
||||
}
|
||||
if !strings.Contains(text, "Dungeon") {
|
||||
t.Error("second prompt should list Dungeon option")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailySummary_HolidayBlock(t *testing.T) {
|
||||
players := []AdvPlayerDaySummary{
|
||||
{DisplayName: "Alice", Activity: "dungeon", Location: "Cellar", Outcome: "success", LootValue: 500, HolidayActions: 2},
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"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: **%s**\n\n", fmtEuro(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)
|
||||
}
|
||||
|
||||
// Community health fund: 10% of revival cost.
|
||||
if potCut := int(math.Round(float64(cost) * 0.1)); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(ctx.Sender, potCut)
|
||||
}
|
||||
|
||||
// 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"+
|
||||
"%s 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.",
|
||||
fmtEuro(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: %s (Guild insurance covers %s → you pay %s)\n\n"+
|
||||
"Or rest up — natural respawn at %s UTC.",
|
||||
fmtEuro(beforeInsurance), fmtEuro(beforeInsurance-afterInsurance), fmtEuro(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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,759 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Housing Tier Definitions ───────────────────────────────────────────────
|
||||
|
||||
type HouseTierDef struct {
|
||||
Tier int
|
||||
Name string
|
||||
Description string
|
||||
BasePrice int
|
||||
}
|
||||
|
||||
// HouseTier 0 = no house. Purchasable tiers are 1-4.
|
||||
var houseTiers = []HouseTierDef{
|
||||
{Tier: 1, Name: "Base House", Description: "Four walls and a roof. 'Livable' is a strong word.", BasePrice: 75000},
|
||||
{Tier: 2, Name: "Livable", Description: "A place you can sit down in without questioning your life choices.", BasePrice: 150000},
|
||||
{Tier: 3, Name: "Comfortable", Description: "Walls that don't leak. A door that locks. Luxuries of the modern age.", BasePrice: 300000},
|
||||
{Tier: 4, Name: "Established", Description: "This is a house that says things about you. Good things. Mostly.", BasePrice: 600000},
|
||||
}
|
||||
|
||||
// houseTierByTier returns the tier definition for a given tier number, or nil.
|
||||
func houseTierByTier(tier int) *HouseTierDef {
|
||||
for i := range houseTiers {
|
||||
if houseTiers[i].Tier == tier {
|
||||
return &houseTiers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// houseNextTier returns the next purchasable tier definition, or nil if maxed.
|
||||
func houseNextTier(currentTier int) *HouseTierDef {
|
||||
for i := range houseTiers {
|
||||
if houseTiers[i].Tier == currentTier+1 {
|
||||
return &houseTiers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// houseClosingCosts returns 5% closing + 6% realtor fees = 11% of base price.
|
||||
func houseClosingCosts(basePrice int) int {
|
||||
return int(float64(basePrice) * 0.11)
|
||||
}
|
||||
|
||||
// houseTotalCost returns base price + closing costs + realtor fees.
|
||||
func houseTotalCost(basePrice int) int {
|
||||
return basePrice + houseClosingCosts(basePrice)
|
||||
}
|
||||
|
||||
// houseWeeklyPayment calculates weekly payment based on balance and rate.
|
||||
// Uses a simple amortization: interest + fixed principal portion.
|
||||
// Rate is annual percentage (e.g., 6.5 means 6.5%).
|
||||
func houseWeeklyPayment(balance int, annualRate float64) int {
|
||||
if balance <= 0 || annualRate <= 0 {
|
||||
return 0
|
||||
}
|
||||
weeklyRate := annualRate / 100.0 / 52.0
|
||||
interest := float64(balance) * weeklyRate
|
||||
// Principal portion: 0.5% of balance per week (~26% of balance per year)
|
||||
// Ensures loans pay off in roughly 2-4 years of active play.
|
||||
principal := float64(balance) * 0.005
|
||||
payment := int(interest + principal)
|
||||
if payment < 1 {
|
||||
payment = 1
|
||||
}
|
||||
return payment
|
||||
}
|
||||
|
||||
// houseMissedPenalty returns the penalty for a missed payment (5% of weekly payment).
|
||||
func houseMissedPenalty(weeklyPayment int) int {
|
||||
penalty := int(float64(weeklyPayment) * 0.05)
|
||||
if penalty < 1 {
|
||||
penalty = 1
|
||||
}
|
||||
return penalty
|
||||
}
|
||||
|
||||
// ── Pending Interaction Types ──────────────────────────────────────────────
|
||||
|
||||
type advPendingHouseConfirm struct {
|
||||
Tier int
|
||||
TotalCost int
|
||||
DownPayment int
|
||||
}
|
||||
|
||||
type advPendingHouseDownPayment struct {
|
||||
Tier int
|
||||
TotalCost int
|
||||
}
|
||||
|
||||
type advPendingHouseAutopay struct{}
|
||||
|
||||
type advPendingPetArrival struct{}
|
||||
|
||||
type advPendingPetType struct{}
|
||||
|
||||
type advPendingPetName struct {
|
||||
PetType string
|
||||
}
|
||||
|
||||
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
||||
|
||||
var thomGreetingsPreAdoption = []string{
|
||||
"Welcome in! Great timing. I've got something perfect for you. I say that to everyone and I mean it every time.",
|
||||
"You don't look like someone ready to make a smart investment. I can work with that. Sit down. Don't touch anything.",
|
||||
"Thom Krooke, Krooke Realty. No relation to that other guy. Mostly.",
|
||||
}
|
||||
|
||||
var thomGreetingsPostAdoption = []string{
|
||||
"You're back. Good. I've got things.",
|
||||
"Oh. You. Sure. What do you need.",
|
||||
"Come in. Wipe your feet. How's the animal.",
|
||||
}
|
||||
|
||||
var thomGreetingsPostLevel10 = []string{
|
||||
"Hello, {pet_name}'s caretaker. The usual?",
|
||||
"Ah. {pet_name}'s caretaker. I've been expecting you. I have things {pet_name} needs.",
|
||||
"Come in, {pet_name} and the caretaker! Wipe your feet! How are things? *You open your mouth to answer but quickly notice Thom is speaking to your pet.*",
|
||||
}
|
||||
|
||||
var thomHouseSellingLines = []string{
|
||||
"Charming property. Full of character. 'Character' means different things to different people and that's what makes real estate exciting.",
|
||||
"Minor structural considerations that a positive attitude will absolutely address. Price reflects the opportunity.",
|
||||
"Previous owner left in a hurry. Didn't say why. The chalk outlines in the living room are almost certainly decorative. Kids do that now apparently.",
|
||||
}
|
||||
|
||||
const thomAnimalLine = "I heard an animal got in your house. Luckily for you, it's a sweet one that will give you the world in exchange for a tiny bit of kindness. ...that's what I heard anyway."
|
||||
|
||||
var thomMissedPaymentNoPet = "Missed payment. Penalty applied. Balance is now €%d. Sort it out."
|
||||
var thomMissedPaymentPet = "Missed payment. Penalty applied. Balance is now €%d. %s doesn't need to know about this. Keep it together."
|
||||
var thomFreezeNoPet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it."
|
||||
var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it. %s is fine. You should work on being fine."
|
||||
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
|
||||
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
|
||||
|
||||
// thomGreeting returns an appropriate greeting based on pet state.
|
||||
func thomGreeting(char *AdventureCharacter) string {
|
||||
if char.HasPet() && char.PetLevel >= 10 {
|
||||
line := thomGreetingsPostLevel10[rand.IntN(len(thomGreetingsPostLevel10))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
|
||||
}
|
||||
if char.HasPet() {
|
||||
return thomGreetingsPostAdoption[rand.IntN(len(thomGreetingsPostAdoption))]
|
||||
}
|
||||
return thomGreetingsPreAdoption[rand.IntN(len(thomGreetingsPreAdoption))]
|
||||
}
|
||||
|
||||
// ── Thom Shop Display ──────────────────────────────────────────────────────
|
||||
|
||||
func thomShopView(char *AdventureCharacter, balance float64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("🏠 **Krooke Realty**\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomGreeting(char)))
|
||||
|
||||
// Check for animal line (fires once after pet appears)
|
||||
if char.HasPet() && !char.ThomAnimalLineFired {
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomAnimalLine))
|
||||
}
|
||||
|
||||
// Current housing status
|
||||
if !char.HasHouse() {
|
||||
sb.WriteString("You don't own a house yet.\n\n")
|
||||
} else {
|
||||
tierDef := houseTierByTier(char.HouseTier)
|
||||
tierName := "Unknown"
|
||||
if tierDef != nil {
|
||||
tierName = tierDef.Name
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🏡 Your house: **%s** (Tier %d)\n", tierName, char.HouseTier))
|
||||
if char.HouseLoanBalance > 0 {
|
||||
weekly := houseWeeklyPayment(char.HouseLoanBalance, char.HouseCurrentRate)
|
||||
if char.HouseAutopay {
|
||||
weekly = int(float64(weekly) * 0.98) // 2% autopay discount
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("💳 Loan balance: €%d | Weekly: €%d", char.HouseLoanBalance, weekly))
|
||||
if char.HouseAutopay {
|
||||
sb.WriteString(" (autopay)")
|
||||
}
|
||||
if char.HouseLoanFrozen {
|
||||
sb.WriteString(" ❄️ FROZEN")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" | Rate: %.2f%%\n", char.HouseCurrentRate))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Available upgrades
|
||||
if char.HouseLoanBalance > 0 {
|
||||
sb.WriteString("Pay off your current loan before upgrading.\n")
|
||||
sb.WriteString(fmt.Sprintf("\nTo pay off early: `!thom payoff`\n"))
|
||||
if !char.HouseAutopay {
|
||||
sb.WriteString("To enable autopay (2% discount): `!thom autopay`\n")
|
||||
}
|
||||
} else {
|
||||
// Can purchase next tier
|
||||
nextDef := houseNextTier(char.HouseTier)
|
||||
if nextDef == nil {
|
||||
sb.WriteString("✨ Max tier reached. There is nothing left to sell you. This has never happened before.\n")
|
||||
} else {
|
||||
def := *nextDef
|
||||
total := houseTotalCost(def.BasePrice)
|
||||
selling := thomHouseSellingLines[rand.IntN(len(thomHouseSellingLines))]
|
||||
sb.WriteString(fmt.Sprintf("*%s*\n\n", selling))
|
||||
sb.WriteString(fmt.Sprintf("📋 **%s** (Tier %d)\n", def.Name, def.Tier))
|
||||
sb.WriteString(fmt.Sprintf(" Base price: €%d\n", def.BasePrice))
|
||||
sb.WriteString(fmt.Sprintf(" Closing costs (5%%): €%d\n", int(float64(def.BasePrice)*0.05)))
|
||||
sb.WriteString(fmt.Sprintf(" Realtor fees (6%%): €%d\n", int(float64(def.BasePrice)*0.06)))
|
||||
sb.WriteString(fmt.Sprintf(" **Total: €%d**\n\n", total))
|
||||
sb.WriteString(fmt.Sprintf("`!thom buy` to purchase outright, or `!thom buy <amount>` for a down payment.\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Pet supply shop (unlocks 1 week after pet level 10)
|
||||
if char.PetSupplyShopUnlocked && char.HasPet() {
|
||||
sb.WriteString("\n---\n")
|
||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
||||
sb.WriteString(petArmorShopView(char))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Thom Shop Command Handler ──────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "thom"))
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
switch {
|
||||
case args == "" || lower == "shop":
|
||||
text := thomShopView(char, balance)
|
||||
// Mark animal line as fired if applicable
|
||||
if char.HasPet() && !char.ThomAnimalLineFired {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
char.ThomAnimalLineFired = true
|
||||
_ = saveAdvCharacter(char)
|
||||
userMu.Unlock()
|
||||
}
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
|
||||
case lower == "payoff":
|
||||
return p.handleThomPayoff(ctx, char)
|
||||
|
||||
case strings.HasPrefix(lower, "pay "):
|
||||
return p.handleThomPay(ctx, char, strings.TrimSpace(lower[4:]))
|
||||
|
||||
case lower == "autopay":
|
||||
return p.handleThomAutopay(ctx, char)
|
||||
|
||||
case lower == "buy" || strings.HasPrefix(lower, "buy "):
|
||||
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
|
||||
|
||||
case strings.HasPrefix(lower, "petbuy "):
|
||||
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
|
||||
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Buy House ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureCharacter, balance float64, downPaymentStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
// Reload fresh
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
balance = p.euro.GetBalance(ctx.Sender)
|
||||
|
||||
if char.HouseLoanBalance > 0 {
|
||||
return p.SendDM(ctx.Sender, "You still have an outstanding loan. Pay it off first with `!thom payoff`.")
|
||||
}
|
||||
|
||||
nextDef := houseNextTier(char.HouseTier)
|
||||
if nextDef == nil {
|
||||
return p.SendDM(ctx.Sender, "You've reached max tier. There is nothing left to sell you.")
|
||||
}
|
||||
|
||||
def := *nextDef
|
||||
totalCost := houseTotalCost(def.BasePrice)
|
||||
|
||||
// Parse optional down payment
|
||||
downPayment := 0
|
||||
if downPaymentStr != "" {
|
||||
dp := 0
|
||||
for _, c := range downPaymentStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, "Down payment must be a number. Example: `!thom buy 10000`")
|
||||
}
|
||||
dp = dp*10 + int(c-'0')
|
||||
}
|
||||
downPayment = dp
|
||||
}
|
||||
|
||||
if downPayment > 0 && float64(downPayment) > balance {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford a €%d down payment. Balance: €%.0f.", downPayment, balance))
|
||||
}
|
||||
|
||||
if downPayment >= totalCost {
|
||||
// Full purchase, no loan
|
||||
if !p.euro.Debit(ctx.Sender, float64(totalCost), "house_purchase_full") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
char.HouseTier = def.Tier
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(totalCost), "house_purchase_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 **%s** purchased outright for €%d.\n\nNo loan. No weekly payments. Thom is visibly disappointed.", def.Name, totalCost))
|
||||
}
|
||||
|
||||
// Loan purchase
|
||||
loanAmount := totalCost - downPayment
|
||||
|
||||
// Get current mortgage rate
|
||||
rate := getCurrentMortgageRate()
|
||||
if rate <= 0 {
|
||||
rate = 6.5 // fallback default
|
||||
}
|
||||
|
||||
// Debit down payment if any
|
||||
if downPayment > 0 {
|
||||
if !p.euro.Debit(ctx.Sender, float64(downPayment), "house_down_payment") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
}
|
||||
|
||||
char.HouseTier = def.Tier
|
||||
char.HouseLoanBalance = loanAmount
|
||||
char.HouseCurrentRate = rate
|
||||
char.HouseMissedPayments = 0
|
||||
char.HouseLoanFrozen = false
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
if downPayment > 0 {
|
||||
p.euro.Credit(ctx.Sender, float64(downPayment), "house_dp_refund")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
|
||||
}
|
||||
|
||||
weekly := houseWeeklyPayment(loanAmount, rate)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("🏠 **%s** purchased!\n\n", def.Name))
|
||||
if downPayment > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Down payment: €%d\n", downPayment))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Loan balance: €%d\n", loanAmount))
|
||||
sb.WriteString(fmt.Sprintf("Weekly payment: ~€%d (%.2f%% ARM rate)\n", weekly, rate))
|
||||
sb.WriteString("\nPayments are collected weekly. Enable autopay for a 2% discount: `!thom autopay`")
|
||||
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
// ── Payoff ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomPayoff(ctx MessageContext, char *AdventureCharacter) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
// Reload fresh
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(char.HouseLoanBalance) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d to pay off the loan. Balance: €%.0f.", char.HouseLoanBalance, balance))
|
||||
}
|
||||
|
||||
payoffAmount := float64(char.HouseLoanBalance)
|
||||
if !p.euro.Debit(ctx.Sender, payoffAmount, "house_payoff") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.HouseLoanBalance = 0
|
||||
char.HouseLoanFrozen = false
|
||||
char.HouseMissedPayments = 0
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, payoffAmount, "house_payoff_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
|
||||
}
|
||||
|
||||
if char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, char.PetName)))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
|
||||
// ── Extra Payment ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomPay(ctx MessageContext, char *AdventureCharacter, amountStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
|
||||
}
|
||||
|
||||
amount, err := strconv.Atoi(amountStr)
|
||||
if err != nil || amount <= 0 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom pay <amount>` — e.g. `!thom pay 5000`")
|
||||
}
|
||||
|
||||
// Cap at remaining balance
|
||||
if amount > char.HouseLoanBalance {
|
||||
amount = char.HouseLoanBalance
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(amount) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d but only have €%.0f.", amount, balance))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(amount), "house_extra_payment") {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.HouseLoanBalance -= amount
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
char.HouseLoanBalance = 0
|
||||
char.HouseLoanFrozen = false
|
||||
char.HouseMissedPayments = 0
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(amount), "house_extra_payment_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance == 0 {
|
||||
if char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, fmt.Sprintf(thomPaidOffPet, char.PetName)))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, thomPaidOffNoPet))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. Remaining balance: €%d.", amount, char.HouseLoanBalance))
|
||||
}
|
||||
|
||||
// ── Autopay Toggle ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleThomAutopay(ctx MessageContext, char *AdventureCharacter) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
|
||||
}
|
||||
|
||||
char.HouseAutopay = !char.HouseAutopay
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
|
||||
if char.HouseAutopay {
|
||||
return p.SendDM(ctx.Sender, "✅ Autopay enabled. 2% discount on weekly payments. Thom calls this a favour. It is not.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Autopay disabled.")
|
||||
}
|
||||
|
||||
// ── Weekly Mortgage Payment Processing ─────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) processMortgagePayments() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("mortgage: failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, char := range chars {
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
continue
|
||||
}
|
||||
if char.HouseLoanFrozen {
|
||||
continue
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(char.UserID)
|
||||
userMu.Lock()
|
||||
|
||||
// Reload fresh inside lock
|
||||
freshChar, err := loadAdvCharacter(char.UserID)
|
||||
if err != nil || freshChar.HouseLoanBalance <= 0 || freshChar.HouseLoanFrozen {
|
||||
userMu.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
weekly := houseWeeklyPayment(freshChar.HouseLoanBalance, freshChar.HouseCurrentRate)
|
||||
if freshChar.HouseAutopay {
|
||||
weekly = int(float64(weekly) * 0.98)
|
||||
}
|
||||
|
||||
if p.euro.Debit(freshChar.UserID, float64(weekly), "mortgage_weekly") {
|
||||
freshChar.HouseLoanBalance -= weekly
|
||||
if freshChar.HouseLoanBalance <= 0 {
|
||||
freshChar.HouseLoanBalance = 0
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
// Paid off!
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
|
||||
}
|
||||
continue
|
||||
}
|
||||
freshChar.HouseMissedPayments = 0
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
} else {
|
||||
// Missed payment
|
||||
penalty := houseMissedPenalty(weekly)
|
||||
freshChar.HouseLoanBalance += penalty
|
||||
freshChar.HouseMissedPayments++
|
||||
|
||||
if freshChar.HouseMissedPayments >= 10 {
|
||||
freshChar.HouseLoanFrozen = true
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomFreezePet, freshChar.PetName)))
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomFreezeNoPet))
|
||||
}
|
||||
} else {
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
if freshChar.HasPet() {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentPet, freshChar.HouseLoanBalance, freshChar.PetName)))
|
||||
} else {
|
||||
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentNoPet, freshChar.HouseLoanBalance)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mortgage Rate Change DMs ───────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) sendMortgageRateChangeDMs(oldRate, newRate float64) {
|
||||
if oldRate == newRate {
|
||||
return
|
||||
}
|
||||
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("mortgage: failed to load characters for rate DMs", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
increased := newRate > oldRate
|
||||
|
||||
for _, char := range chars {
|
||||
if char.HouseLoanBalance <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Update rate
|
||||
userMu := p.advUserLock(char.UserID)
|
||||
userMu.Lock()
|
||||
freshChar, err := loadAdvCharacter(char.UserID)
|
||||
if err != nil || freshChar.HouseLoanBalance <= 0 {
|
||||
userMu.Unlock()
|
||||
continue
|
||||
}
|
||||
freshChar.HouseCurrentRate = newRate
|
||||
_ = saveAdvCharacter(freshChar)
|
||||
userMu.Unlock()
|
||||
|
||||
newWeekly := houseWeeklyPayment(freshChar.HouseLoanBalance, newRate)
|
||||
amount := fmt.Sprintf("%d", newWeekly)
|
||||
|
||||
var pool []string
|
||||
if freshChar.HasPet() {
|
||||
if increased {
|
||||
pool = ThomRateIncreasePet
|
||||
} else {
|
||||
pool = ThomRateDecreasePet
|
||||
}
|
||||
} else {
|
||||
if increased {
|
||||
pool = ThomRateIncrease
|
||||
} else {
|
||||
pool = ThomRateDecrease
|
||||
}
|
||||
}
|
||||
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
line = strings.ReplaceAll(line, "{amount}", amount)
|
||||
if freshChar.HasPet() {
|
||||
line = strings.ReplaceAll(line, "{pet_name}", freshChar.PetName)
|
||||
}
|
||||
|
||||
_ = p.SendDM(char.UserID, fmt.Sprintf("🏠 %s", line))
|
||||
|
||||
// Jitter
|
||||
time.Sleep(time.Duration(500+rand.IntN(1500)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Armor Shop ─────────────────────────────────────────────────────────
|
||||
|
||||
type PetArmorDef struct {
|
||||
Tier int
|
||||
Name string
|
||||
DeflectBonus float64
|
||||
Price int
|
||||
}
|
||||
|
||||
var petDogArmor = []PetArmorDef{
|
||||
{Tier: 1, Name: "Leather Dog Barding", DeflectBonus: 0.015, Price: 225},
|
||||
{Tier: 2, Name: "Chain Dog Barding", DeflectBonus: 0.03, Price: 675},
|
||||
{Tier: 3, Name: "Plate Dog Barding", DeflectBonus: 0.05, Price: 2250},
|
||||
{Tier: 4, Name: "Enchanted Dog Barding", DeflectBonus: 0.075, Price: 11250},
|
||||
{Tier: 5, Name: "Dragonscale Dog Barding", DeflectBonus: 0.105, Price: 45000},
|
||||
}
|
||||
|
||||
var petCatArmor = []PetArmorDef{
|
||||
{Tier: 1, Name: "Leather Cat Armor", DeflectBonus: 0.015, Price: 225},
|
||||
{Tier: 2, Name: "Chain Cat Armor", DeflectBonus: 0.03, Price: 675},
|
||||
{Tier: 3, Name: "Plate Cat Armor", DeflectBonus: 0.05, Price: 2250},
|
||||
{Tier: 4, Name: "Enchanted Cat Armor", DeflectBonus: 0.075, Price: 11250},
|
||||
{Tier: 5, Name: "Dragonscale Cat Armor", DeflectBonus: 0.105, Price: 45000},
|
||||
}
|
||||
|
||||
func petArmorDefs(petType string) []PetArmorDef {
|
||||
if petType == "dog" {
|
||||
return petDogArmor
|
||||
}
|
||||
return petCatArmor
|
||||
}
|
||||
|
||||
func petArmorShopView(char *AdventureCharacter) string {
|
||||
var sb strings.Builder
|
||||
defs := petArmorDefs(char.PetType)
|
||||
|
||||
if char.PetArmorTier >= 5 {
|
||||
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Tier <= char.PetArmorTier {
|
||||
if def.Tier == char.PetArmorTier {
|
||||
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
||||
}
|
||||
continue
|
||||
}
|
||||
indicator := "⬆️"
|
||||
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
if !char.HasPet() {
|
||||
return p.SendDM(ctx.Sender, "You don't have a pet.")
|
||||
}
|
||||
if !char.PetSupplyShopUnlocked {
|
||||
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
|
||||
}
|
||||
|
||||
tier := 0
|
||||
for _, c := range tierStr {
|
||||
if c < '0' || c > '9' {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
|
||||
}
|
||||
tier = tier*10 + int(c-'0')
|
||||
}
|
||||
|
||||
if tier != char.PetArmorTier+1 {
|
||||
if tier <= char.PetArmorTier {
|
||||
return p.SendDM(ctx.Sender, "That's not an upgrade.")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
|
||||
}
|
||||
|
||||
defs := petArmorDefs(char.PetType)
|
||||
var def *PetArmorDef
|
||||
for i := range defs {
|
||||
if defs[i].Tier == tier {
|
||||
def = &defs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if def == nil {
|
||||
return p.SendDM(ctx.Sender, "Invalid tier.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(def.Price) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
|
||||
return p.SendDM(ctx.Sender, "Transaction failed.")
|
||||
}
|
||||
|
||||
char.PetArmorTier = tier
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
|
||||
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Housing Tier Definitions ───────────────────────────────────────────────
|
||||
|
||||
func TestHouseTierByTier_Valid(t *testing.T) {
|
||||
for _, def := range houseTiers {
|
||||
got := houseTierByTier(def.Tier)
|
||||
if got == nil {
|
||||
t.Errorf("houseTierByTier(%d) returned nil", def.Tier)
|
||||
continue
|
||||
}
|
||||
if got.Tier != def.Tier {
|
||||
t.Errorf("houseTierByTier(%d).Tier = %d", def.Tier, got.Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseTierByTier_Invalid(t *testing.T) {
|
||||
if houseTierByTier(0) != nil {
|
||||
t.Error("tier 0 (no house) should return nil")
|
||||
}
|
||||
if houseTierByTier(99) != nil {
|
||||
t.Error("tier 99 should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseNextTier(t *testing.T) {
|
||||
// From no house (tier 0) → tier 1
|
||||
def := houseNextTier(0)
|
||||
if def == nil || def.Tier != 1 {
|
||||
t.Error("next tier from 0 should be 1")
|
||||
}
|
||||
// From tier 1 → tier 2
|
||||
def = houseNextTier(1)
|
||||
if def == nil || def.Tier != 2 {
|
||||
t.Error("next tier from 1 should be 2")
|
||||
}
|
||||
// From max tier → nil
|
||||
def = houseNextTier(4)
|
||||
if def != nil {
|
||||
t.Error("next tier from max should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Closing Costs ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestHouseClosingCosts(t *testing.T) {
|
||||
// 11% of 75000 = 8250
|
||||
costs := houseClosingCosts(75000)
|
||||
if costs != 8250 {
|
||||
t.Errorf("closing costs on 75000: got %d, want 8250", costs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseTotalCost(t *testing.T) {
|
||||
// 75000 + 11% = 83250
|
||||
total := houseTotalCost(75000)
|
||||
if total != 83250 {
|
||||
t.Errorf("total cost on 75000: got %d, want 83250", total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Weekly Payment ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestHouseWeeklyPayment_Normal(t *testing.T) {
|
||||
// 100000 balance at 6.5%: interest = 100000*0.065/52 ≈ 125, principal = 100000*0.005 = 500
|
||||
// Total ≈ 625
|
||||
payment := houseWeeklyPayment(100000, 6.5)
|
||||
if payment < 600 || payment > 650 {
|
||||
t.Errorf("weekly payment on 100k at 6.5%%: got %d, want ~625", payment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseWeeklyPayment_ZeroBalance(t *testing.T) {
|
||||
if houseWeeklyPayment(0, 6.5) != 0 {
|
||||
t.Error("zero balance should produce zero payment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseWeeklyPayment_ZeroRate(t *testing.T) {
|
||||
if houseWeeklyPayment(100000, 0) != 0 {
|
||||
t.Error("zero rate should produce zero payment")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseWeeklyPayment_MinimumOne(t *testing.T) {
|
||||
// Very small balance should still yield at least 1
|
||||
payment := houseWeeklyPayment(1, 0.01)
|
||||
if payment < 1 {
|
||||
t.Errorf("tiny balance should produce at least 1, got %d", payment)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Missed Payment Penalty ─────────────────────────────────────────────────
|
||||
|
||||
func TestHouseMissedPenalty(t *testing.T) {
|
||||
// 5% of 625 = 31
|
||||
penalty := houseMissedPenalty(625)
|
||||
if penalty != 31 {
|
||||
t.Errorf("missed penalty on 625 weekly: got %d, want 31", penalty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseMissedPenalty_Minimum(t *testing.T) {
|
||||
// Tiny payment should still yield at least 1
|
||||
penalty := houseMissedPenalty(1)
|
||||
if penalty < 1 {
|
||||
t.Errorf("penalty should be at least 1, got %d", penalty)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Character Helper Methods ───────────────────────────────────────────────
|
||||
|
||||
func TestHasHouse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tier int
|
||||
loan int
|
||||
expected bool
|
||||
}{
|
||||
{"no house", 0, 0, false},
|
||||
{"base house", 1, 0, true},
|
||||
{"loan only", 0, 50000, true},
|
||||
{"livable with loan", 2, 100000, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: tt.tier, HouseLoanBalance: tt.loan}
|
||||
if char.HasHouse() != tt.expected {
|
||||
t.Errorf("HasHouse() = %v, want %v", char.HasHouse(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseHPBonus(t *testing.T) {
|
||||
tests := []struct {
|
||||
tier int
|
||||
expected float64
|
||||
}{
|
||||
{0, 0},
|
||||
{1, 0}, // Base house — no bonus
|
||||
{2, 0.05}, // Livable
|
||||
{3, 0.12}, // Comfortable
|
||||
{4, 0.20}, // Established
|
||||
}
|
||||
for _, tt := range tests {
|
||||
char := &AdventureCharacter{HouseTier: tt.tier}
|
||||
if bonus := char.HouseHPBonus(); bonus != tt.expected {
|
||||
t.Errorf("tier %d: HouseHPBonus() = %f, want %f", tt.tier, bonus, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thom Greeting ──────────────────────────────────────────────────────────
|
||||
|
||||
func TestThomGreeting_PreAdoption(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
greeting := thomGreeting(char)
|
||||
found := false
|
||||
for _, g := range thomGreetingsPreAdoption {
|
||||
if greeting == g {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("pre-adoption greeting not from expected pool: %q", greeting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomGreeting_PostAdoption(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetLevel: 5}
|
||||
greeting := thomGreeting(char)
|
||||
found := false
|
||||
for _, g := range thomGreetingsPostAdoption {
|
||||
if greeting == g {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("post-adoption greeting not from expected pool: %q", greeting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomGreeting_PostLevel10(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Whiskers", PetArrived: true, PetLevel: 10}
|
||||
greeting := thomGreeting(char)
|
||||
if !strings.Contains(greeting, "Whiskers") {
|
||||
t.Errorf("post-level-10 greeting should contain pet name, got: %q", greeting)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Thom Shop View ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestThomShopView_NoHouse(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
text := thomShopView(char, 100000)
|
||||
|
||||
if !strings.Contains(text, "Krooke Realty") {
|
||||
t.Error("should contain shop name")
|
||||
}
|
||||
if !strings.Contains(text, "don't own a house") {
|
||||
t.Error("should mention no house")
|
||||
}
|
||||
if !strings.Contains(text, "Base House") {
|
||||
t.Error("should show first tier available")
|
||||
}
|
||||
if !strings.Contains(text, "€75000") {
|
||||
t.Error("should show base price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_WithLoan(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5}
|
||||
text := thomShopView(char, 10000)
|
||||
|
||||
if !strings.Contains(text, "Loan balance") {
|
||||
t.Error("should show loan balance")
|
||||
}
|
||||
if !strings.Contains(text, "Pay off") {
|
||||
t.Error("should mention paying off loan")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_MaxTier(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 4} // max tier
|
||||
text := thomShopView(char, 999999)
|
||||
|
||||
if !strings.Contains(text, "Max tier") {
|
||||
t.Error("should mention max tier reached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_Autopay(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseAutopay: true}
|
||||
text := thomShopView(char, 10000)
|
||||
|
||||
if !strings.Contains(text, "autopay") {
|
||||
t.Error("should show autopay status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomShopView_Frozen(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseLoanFrozen: true}
|
||||
text := thomShopView(char, 10000)
|
||||
|
||||
if !strings.Contains(text, "FROZEN") {
|
||||
t.Error("should show frozen status")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Armor ──────────────────────────────────────────────────────────────
|
||||
|
||||
func TestPetArmorDefs_BothTypes(t *testing.T) {
|
||||
dogDefs := petArmorDefs("dog")
|
||||
catDefs := petArmorDefs("cat")
|
||||
|
||||
if len(dogDefs) != 5 {
|
||||
t.Errorf("dog armor: got %d tiers, want 5", len(dogDefs))
|
||||
}
|
||||
if len(catDefs) != 5 {
|
||||
t.Errorf("cat armor: got %d tiers, want 5", len(catDefs))
|
||||
}
|
||||
|
||||
// Verify tiers are sequential 1-5
|
||||
for i, def := range dogDefs {
|
||||
if def.Tier != i+1 {
|
||||
t.Errorf("dog armor[%d].Tier = %d, want %d", i, def.Tier, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// Deflect bonuses should increase with tier
|
||||
for i := 1; i < len(dogDefs); i++ {
|
||||
if dogDefs[i].DeflectBonus <= dogDefs[i-1].DeflectBonus {
|
||||
t.Errorf("dog armor tier %d deflect bonus should exceed tier %d", dogDefs[i].Tier, dogDefs[i-1].Tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicators")
|
||||
}
|
||||
if !strings.Contains(text, "petbuy") {
|
||||
t.Error("should show buy instructions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
|
||||
text := petArmorShopView(char)
|
||||
|
||||
if !strings.Contains(text, "Max pet armor") {
|
||||
t.Error("should show max tier message")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
|
||||
|
||||
func TestThomFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"thomGreetingsPreAdoption": thomGreetingsPreAdoption,
|
||||
"thomGreetingsPostAdoption": thomGreetingsPostAdoption,
|
||||
"thomGreetingsPostLevel10": thomGreetingsPostLevel10,
|
||||
"thomHouseSellingLines": thomHouseSellingLines,
|
||||
"ThomRateIncrease": ThomRateIncrease,
|
||||
"ThomRateDecrease": ThomRateDecrease,
|
||||
"ThomRateIncreasePet": ThomRateIncreasePet,
|
||||
"ThomRateDecreasePet": ThomRateDecreasePet,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
t.Errorf("flavor pool %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThomRateFlavor_Placeholders(t *testing.T) {
|
||||
for i, line := range ThomRateIncrease {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateIncrease[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range ThomRateDecrease {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateDecrease[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range ThomRateIncreasePet {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateIncreasePet[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
if !strings.Contains(line, "{pet_name}") {
|
||||
t.Errorf("ThomRateIncreasePet[%d] missing {pet_name} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range ThomRateDecreasePet {
|
||||
if !strings.Contains(line, "{amount}") {
|
||||
t.Errorf("ThomRateDecreasePet[%d] missing {amount} placeholder", i)
|
||||
}
|
||||
if !strings.Contains(line, "{pet_name}") {
|
||||
t.Errorf("ThomRateDecreasePet[%d] missing {pet_name} placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── House Tiers Sanity ─────────────────────────────────────────────────────
|
||||
|
||||
func TestHouseTiers_PricesIncreasing(t *testing.T) {
|
||||
for i := 1; i < len(houseTiers); i++ {
|
||||
if houseTiers[i].BasePrice <= houseTiers[i-1].BasePrice {
|
||||
t.Errorf("tier %d price (%d) should exceed tier %d price (%d)",
|
||||
houseTiers[i].Tier, houseTiers[i].BasePrice,
|
||||
houseTiers[i-1].Tier, houseTiers[i-1].BasePrice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHouseTiers_SequentialTiers(t *testing.T) {
|
||||
for i, def := range houseTiers {
|
||||
if def.Tier != i+1 {
|
||||
t.Errorf("houseTiers[%d].Tier = %d, want %d", i, def.Tier, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -169,26 +169,28 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC
|
||||
return // no masterwork available for this activity+tier (e.g. dungeon)
|
||||
}
|
||||
|
||||
// Roll for drop
|
||||
if rand.Float64() >= def.DropRate {
|
||||
// Roll for drop (chat level rare bonus applied additively)
|
||||
dropRate := def.DropRate + chatLevelRareBonus(p.chatLevel(userID))
|
||||
if rand.Float64() >= dropRate {
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -331,16 +333,16 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "Failed to load inventory.")
|
||||
}
|
||||
|
||||
// Filter to masterwork items only
|
||||
// Filter to special gear (Masterwork + Arena) items only
|
||||
var mwItems []AdvItem
|
||||
for _, it := range items {
|
||||
if it.Type == "MasterworkGear" {
|
||||
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
|
||||
mwItems = append(mwItems, it)
|
||||
}
|
||||
}
|
||||
|
||||
if len(mwItems) == 0 {
|
||||
return p.SendDM(ctx.Sender, "You have no Masterwork gear waiting to be equipped. Go find some.")
|
||||
return p.SendDM(ctx.Sender, "You have no special gear waiting to be equipped. Go find some.")
|
||||
}
|
||||
|
||||
equip, err := loadAdvEquipment(ctx.Sender)
|
||||
@@ -350,7 +352,7 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
|
||||
|
||||
// Build listing
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⭐ **Your unequipped Masterwork gear:**\n\n")
|
||||
sb.WriteString("⭐ **Your unequipped special gear:**\n\n")
|
||||
|
||||
for i, it := range mwItems {
|
||||
current := equip[it.Slot]
|
||||
@@ -364,8 +366,12 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
|
||||
}
|
||||
currentDesc = fmt.Sprintf("%s (%s)", current.Name, tag)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (T%d %s) — currently: %s\n",
|
||||
i+1, it.Name, it.Tier, slotTitle(it.Slot), currentDesc))
|
||||
marker := "⭐"
|
||||
if it.Type == "ArenaGear" {
|
||||
marker = "⚔️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%d. %s %s (T%d %s) — currently: %s\n",
|
||||
i+1, marker, it.Name, it.Tier, slotTitle(it.Slot), currentDesc))
|
||||
}
|
||||
|
||||
sb.WriteString("\nReply with a number to equip, or \"cancel\".")
|
||||
@@ -373,7 +379,7 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "masterwork_equip",
|
||||
Data: &advPendingMasterworkEquip{Items: mwItems},
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
@@ -428,17 +434,22 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "masterwork_equip_confirm",
|
||||
Data: &advPendingMasterworkConfirm{Item: selected},
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
|
||||
def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier)
|
||||
bonusDesc := ""
|
||||
if def != nil {
|
||||
bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource)
|
||||
marker := "⭐"
|
||||
if selected.Type == "ArenaGear" {
|
||||
marker = "⚔️"
|
||||
} else {
|
||||
def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier)
|
||||
if def != nil {
|
||||
bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource)
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d ⭐ %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.",
|
||||
selected.Name, selected.Tier, slotTitle(selected.Slot), warning, currentName, bonusDesc))
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d %s %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.",
|
||||
selected.Name, selected.Tier, marker, slotTitle(selected.Slot), warning, currentName, bonusDesc))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
@@ -486,10 +497,21 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
|
||||
eq.Condition = 100
|
||||
eq.Name = selected.Name
|
||||
eq.ActionsUsed = 0
|
||||
eq.ArenaTier = 0
|
||||
eq.ArenaSet = ""
|
||||
eq.Masterwork = true
|
||||
eq.SkillSource = selected.SkillSource
|
||||
|
||||
if selected.Type == "ArenaGear" {
|
||||
eq.Masterwork = false
|
||||
eq.SkillSource = ""
|
||||
eq.ArenaTier = selected.Tier
|
||||
eq.ArenaSet = ""
|
||||
if gs := arenaGearByName(selected.Name); gs != nil {
|
||||
eq.ArenaSet = gs.SetKey
|
||||
}
|
||||
} else {
|
||||
eq.ArenaTier = 0
|
||||
eq.ArenaSet = ""
|
||||
eq.Masterwork = true
|
||||
eq.SkillSource = selected.SkillSource
|
||||
}
|
||||
|
||||
if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save equipment. Try again.")
|
||||
@@ -500,7 +522,11 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
|
||||
slog.Error("adventure: failed to remove equipped item from inventory", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. ⭐ Masterwork %s, Tier %d.", selected.Name, slotTitle(selected.Slot), selected.Tier))
|
||||
kind := "⭐ Masterwork"
|
||||
if selected.Type == "ArenaGear" {
|
||||
kind = "⚔️ Arena"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. %s %s, Tier %d.", selected.Name, kind, slotTitle(selected.Slot), selected.Tier))
|
||||
}
|
||||
|
||||
// slotToActivity converts an EquipmentSlot to the activity that drops masterwork for it.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// ── FRED API Integration ───────────────────────────────────────────────────
|
||||
//
|
||||
// Pulls the 5/1 ARM rate weekly from FRED (series: MORTGAGE5US).
|
||||
// Requires FRED_API_KEY environment variable.
|
||||
// Falls back to a hardcoded default if the API is unavailable.
|
||||
|
||||
const fredSeries = "MORTGAGE5US"
|
||||
const defaultMortgageRate = 6.5
|
||||
const currentRateCacheKey = "mortgage_current_rate"
|
||||
|
||||
type fredResponse struct {
|
||||
Observations []struct {
|
||||
Date string `json:"date"`
|
||||
Value string `json:"value"`
|
||||
} `json:"observations"`
|
||||
}
|
||||
|
||||
// fetchFREDRate pulls the latest 5/1 ARM rate from the FRED API.
|
||||
func fetchFREDRate() (float64, error) {
|
||||
apiKey := os.Getenv("FRED_API_KEY")
|
||||
if apiKey == "" {
|
||||
return 0, fmt.Errorf("FRED_API_KEY not set")
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"https://api.stlouisfed.org/fred/series/observations?series_id=%s&api_key=%s&file_type=json&sort_order=desc&limit=1",
|
||||
fredSeries, apiKey,
|
||||
)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FRED API request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, fmt.Errorf("FRED API status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var data fredResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return 0, fmt.Errorf("FRED JSON decode: %w", err)
|
||||
}
|
||||
|
||||
if len(data.Observations) == 0 {
|
||||
return 0, fmt.Errorf("FRED: no observations returned")
|
||||
}
|
||||
|
||||
val := data.Observations[0].Value
|
||||
if val == "." {
|
||||
return 0, fmt.Errorf("FRED: value is placeholder")
|
||||
}
|
||||
|
||||
rate, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("FRED: parse rate %q: %w", val, err)
|
||||
}
|
||||
|
||||
// Clamp to sane range
|
||||
if rate < 1.0 {
|
||||
rate = 1.0
|
||||
}
|
||||
if rate > 15.0 {
|
||||
rate = 15.0
|
||||
}
|
||||
|
||||
return rate, nil
|
||||
}
|
||||
|
||||
// getCurrentMortgageRate returns the cached rate, or fetches fresh if stale (daily).
|
||||
func getCurrentMortgageRate() float64 {
|
||||
// Check DB cache (24h TTL)
|
||||
if val := db.CacheGet(currentRateCacheKey, 24*3600); val != "" {
|
||||
if rate, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
|
||||
rate, err := fetchFREDRate()
|
||||
if err != nil {
|
||||
slog.Warn("mortgage: FRED fetch failed, using default", "err", err)
|
||||
return defaultMortgageRate
|
||||
}
|
||||
|
||||
db.CacheSet(currentRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
|
||||
return rate
|
||||
}
|
||||
|
||||
// ── Weekly Mortgage Ticker ─────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) mortgageTicker() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
// Run on Mondays only (FRED updates Thursdays, we process Mondays)
|
||||
if now.Weekday() != time.Monday {
|
||||
continue
|
||||
}
|
||||
|
||||
_, week := now.ISOWeek()
|
||||
dateKey := fmt.Sprintf("%d-W%02d", now.Year(), week)
|
||||
jobName := "mortgage_weekly"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("mortgage: running weekly payment processing")
|
||||
|
||||
// Fetch fresh rate and compare with previous
|
||||
newRate := getCurrentMortgageRate()
|
||||
oldRate := p.getLastKnownRate()
|
||||
|
||||
if oldRate > 0 && oldRate != newRate {
|
||||
p.sendMortgageRateChangeDMs(oldRate, newRate)
|
||||
}
|
||||
p.setLastKnownRate(newRate)
|
||||
|
||||
// Process payments
|
||||
p.processMortgagePayments()
|
||||
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
const lastKnownRateCacheKey = "mortgage_last_known_rate"
|
||||
|
||||
func (p *AdventurePlugin) getLastKnownRate() float64 {
|
||||
val := db.CacheGet(lastKnownRateCacheKey, 365*24*3600) // 1 year TTL
|
||||
if val == "" {
|
||||
return 0
|
||||
}
|
||||
rate, err := strconv.ParseFloat(val, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) setLastKnownRate(rate float64) {
|
||||
db.CacheSet(lastKnownRateCacheKey, strconv.FormatFloat(rate, 'f', 2, 64))
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── NPC Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
mistyCost = 100
|
||||
arinaCost = 5000
|
||||
npcCooldownDays = 7
|
||||
npcEncounterChance = 0.075 // 7.5%
|
||||
npcBuffDuration = 7 * 24 * time.Hour
|
||||
|
||||
// Arena effect chances per round
|
||||
mistyEffectChance = 0.20 // 20%
|
||||
arinaEffectChance = 0.08 // 8%
|
||||
|
||||
// Misty crowd revenge damage range
|
||||
crowdRevengeDmgMin = 3
|
||||
crowdRevengeDmgMax = 8
|
||||
|
||||
// Misty gourmet food — enemy damage
|
||||
gourmetEnemyDmg = 5
|
||||
// Misty gourmet food — equipment condition repair
|
||||
gourmetConditionRepair = 5
|
||||
)
|
||||
|
||||
// ── Message Count Tracking ─────────────────────────────────────────────────
|
||||
|
||||
// npcTrackMessage is called on every room message from an adventure player.
|
||||
// It increments their daily message count and fires NPC encounter rolls
|
||||
// when thresholds are hit.
|
||||
func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
||||
// Acquire user lock to prevent lost updates on message count and
|
||||
// double encounter triggers from rapid messages.
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || !char.Alive {
|
||||
userMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Reset count if it's a new day
|
||||
if char.NPCMsgCountDate != today {
|
||||
char.NPCMsgCount = 0
|
||||
char.NPCMsgCountDate = today
|
||||
char.MistyRollTarget = 5 + rand.IntN(6) // 5–10
|
||||
char.ArinaRollTarget = 5 + rand.IntN(6) // 5–10
|
||||
}
|
||||
|
||||
char.NPCMsgCount++
|
||||
|
||||
var fireNPC string
|
||||
|
||||
// Check Misty threshold
|
||||
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
|
||||
if p.npcShouldEncounter(char, "misty") {
|
||||
char.MistyRollTarget = 0
|
||||
fireNPC = "misty"
|
||||
}
|
||||
}
|
||||
|
||||
// Check Arina threshold (only if Misty didn't fire — one encounter per message)
|
||||
if fireNPC == "" && char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
||||
if p.npcShouldEncounter(char, "arina") {
|
||||
char.ArinaRollTarget = 0
|
||||
fireNPC = "arina"
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save msg count", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
// Release lock BEFORE firing encounter (npcFireEncounter re-acquires it)
|
||||
userMu.Unlock()
|
||||
|
||||
if fireNPC != "" {
|
||||
safeGo("npc-"+fireNPC+"-encounter", func() {
|
||||
p.npcFireEncounter(userID, fireNPC)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// npcShouldEncounter checks cooldown and rolls the 7.5% chance.
|
||||
func (p *AdventurePlugin) npcShouldEncounter(char *AdventureCharacter, npc string) bool {
|
||||
now := time.Now().UTC()
|
||||
cooldown := npcCooldownDays * 24 * time.Hour
|
||||
|
||||
switch npc {
|
||||
case "misty":
|
||||
if char.MistyLastSeen != nil && now.Sub(*char.MistyLastSeen) < cooldown {
|
||||
return false
|
||||
}
|
||||
case "arina":
|
||||
if char.ArinaLastSeen != nil && now.Sub(*char.ArinaLastSeen) < cooldown {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return rand.Float64() < npcEncounterChance
|
||||
}
|
||||
|
||||
// ── Encounter Fire ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) {
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || !char.Alive {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
var opening, prompt string
|
||||
switch npc {
|
||||
case "misty":
|
||||
char.MistyLastSeen = &now
|
||||
char.MistyEncounterCount++
|
||||
opening = mistyOpenings[rand.IntN(len(mistyOpenings))]
|
||||
prompt = fmt.Sprintf("👤 A woman approaches you.\n\n_%s_\n\n"+
|
||||
"Reply `yes` to give €%d, or `no` to walk away.", opening, mistyCost)
|
||||
case "arina":
|
||||
char.ArinaLastSeen = &now
|
||||
opening = arinaOpenings[rand.IntN(len(arinaOpenings))]
|
||||
prompt = fmt.Sprintf("💎 A woman in expensive clothing stops you.\n\n_%s_\n\n"+
|
||||
"Reply `yes` to pay €%d, or `no` to decline.", opening, arinaCost)
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save last_seen", "user", userID, "npc", npc, "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Don't overwrite an existing pending interaction (shop, treasure, etc.)
|
||||
if _, occupied := p.pending.Load(string(userID)); occupied {
|
||||
return
|
||||
}
|
||||
|
||||
// Set pending interaction — NPC encounters stay valid until end of UTC day
|
||||
endOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "npc_encounter",
|
||||
Data: npc,
|
||||
ExpiresAt: endOfDay,
|
||||
})
|
||||
|
||||
if err := p.SendDM(userID, prompt); err != nil {
|
||||
slog.Error("npc: failed to send encounter DM", "user", userID, "npc", npc, "err", err)
|
||||
p.pending.Delete(string(userID))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Accept / Decline Resolution ────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) resolveNPCEncounter(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
npc := interaction.Data.(string)
|
||||
body := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
accepted := body == "yes" || body == "y" || body == "pay"
|
||||
declined := body == "no" || body == "n" || body == "decline"
|
||||
|
||||
if !accepted && !declined {
|
||||
// Re-store the pending interaction — they typed something else
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Reply `yes` or `no`.")
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Something went wrong loading your character.")
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
expires := now.Add(npcBuffDuration)
|
||||
|
||||
switch npc {
|
||||
case "misty":
|
||||
return p.resolveMisty(ctx, char, accepted, &expires)
|
||||
case "arina":
|
||||
return p.resolveArina(ctx, char, accepted, &expires)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharacter, accepted bool, expires *time.Time) error {
|
||||
if accepted {
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(mistyCost) {
|
||||
// Can't afford it — treat as decline but with a kinder message
|
||||
char.MistyDebuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_")
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(mistyCost), "misty_donation") {
|
||||
char.MistyDebuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "You reach for your gold but there isn't enough. She sees this. She doesn't say anything.\n\n_"+mistyDeclineLine+"_")
|
||||
}
|
||||
char.MistyBuffExpires = expires
|
||||
char.MistyDonatedCount++
|
||||
|
||||
// Pet reactivation: donating to Misty after chasing pet away
|
||||
mistyReactivatePet(char)
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
|
||||
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
|
||||
|
||||
// Housing hint (fires once after 2+ encounters)
|
||||
hint := mistyHousingHint(char)
|
||||
if hint != "" {
|
||||
reply += "\n\n_" + hint + "_"
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", reply))
|
||||
}
|
||||
|
||||
// Declined
|
||||
char.MistyDebuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty debuff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", mistyDeclineLine))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveArina(ctx MessageContext, char *AdventureCharacter, accepted bool, expires *time.Time) error {
|
||||
if accepted {
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(arinaCost) {
|
||||
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(arinaCost), "arina_investment") {
|
||||
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford it. She noticed before you did.\n\n_%s_", reply))
|
||||
}
|
||||
char.ArinaBuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", arinaAcceptLine))
|
||||
}
|
||||
|
||||
// Declined — no mechanical effect, just insults
|
||||
reply := arinaDeclineLines[rand.IntN(len(arinaDeclineLines))]
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("_%s_", reply))
|
||||
}
|
||||
|
||||
// ── Arena Effect Checks ────────────────────────────────────────────────────
|
||||
|
||||
// npcArenaEffects is called during arena round resolution, after the normal
|
||||
// combat roll but before the round log is written.
|
||||
// Returns: extra text to append to round log, enemy HP modifier, player damage taken.
|
||||
type npcArenaResult struct {
|
||||
Text string
|
||||
EnemyDmg int // damage dealt to enemy
|
||||
PlayerDmg int // damage dealt to player
|
||||
SniperKill bool // enemy instant kill
|
||||
CondRepair int // equipment condition repair amount
|
||||
}
|
||||
|
||||
func npcCheckArenaEffects(char *AdventureCharacter, monsterName string) *npcArenaResult {
|
||||
now := time.Now().UTC()
|
||||
result := &npcArenaResult{}
|
||||
fired := false
|
||||
|
||||
// Step 2: Misty debuff — crowd revenge (20%)
|
||||
if char.MistyDebuffExpires != nil && now.Before(*char.MistyDebuffExpires) {
|
||||
if rand.Float64() < mistyEffectChance {
|
||||
dmg := crowdRevengeDmgMin + rand.IntN(crowdRevengeDmgMax-crowdRevengeDmgMin+1)
|
||||
line := mistyCrowdRevengeLines[rand.IntN(len(mistyCrowdRevengeLines))]
|
||||
line = strings.ReplaceAll(line, "{damage}", fmt.Sprintf("%d", dmg))
|
||||
result.Text += "\n\n" + line
|
||||
result.PlayerDmg = dmg
|
||||
fired = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Misty buff — gourmet food (20%)
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < mistyEffectChance {
|
||||
line := mistyGourmetFoodLines[rand.IntN(len(mistyGourmetFoodLines))]
|
||||
line = strings.ReplaceAll(line, "{enemy}", monsterName)
|
||||
result.Text += "\n\n" + line
|
||||
result.EnemyDmg = gourmetEnemyDmg
|
||||
result.CondRepair = gourmetConditionRepair
|
||||
fired = true
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Arina buff — sniper (8%)
|
||||
if char.ArinaBuffExpires != nil && now.Before(*char.ArinaBuffExpires) {
|
||||
if rand.Float64() < arinaEffectChance {
|
||||
line := arinaSniperLines[rand.IntN(len(arinaSniperLines))]
|
||||
line = strings.ReplaceAll(line, "{enemy}", monsterName)
|
||||
result.Text += "\n\n" + line
|
||||
result.SniperKill = true
|
||||
fired = true
|
||||
}
|
||||
}
|
||||
|
||||
if !fired {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Equipment Repair (Gourmet Food) ────────────────────────────────────────
|
||||
|
||||
// npcRepairMostDamaged heals condition on the player's most damaged equipment slot.
|
||||
func npcRepairMostDamaged(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, amount int) {
|
||||
if len(equip) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Find most damaged slot
|
||||
var worstSlot EquipmentSlot
|
||||
worstCondition := 101
|
||||
for slot, eq := range equip {
|
||||
if eq.Condition < worstCondition {
|
||||
worstCondition = eq.Condition
|
||||
worstSlot = slot
|
||||
}
|
||||
}
|
||||
|
||||
if worstCondition >= 100 {
|
||||
return // nothing to repair
|
||||
}
|
||||
|
||||
newCond := worstCondition + amount
|
||||
if newCond > 100 {
|
||||
newCond = 100
|
||||
}
|
||||
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE adventure_equipment SET condition = ? WHERE user_id = ? AND slot = ?`,
|
||||
newCond, string(userID), string(worstSlot))
|
||||
if err != nil {
|
||||
slog.Error("npc: failed to repair equipment", "user", userID, "slot", worstSlot, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Midnight Reset ─────────────────────────────────────────────────────────
|
||||
|
||||
// npcMidnightReset resets message counts and regenerates roll targets.
|
||||
// Called from midnightReset. Uses a direct DB update to avoid loading/saving
|
||||
// every character individually.
|
||||
func npcMidnightReset() {
|
||||
d := db.Get()
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
_, err := d.Exec(`
|
||||
UPDATE adventure_characters
|
||||
SET npc_msg_count = 0,
|
||||
npc_msg_count_date = ?,
|
||||
misty_roll_target = ABS(RANDOM()) % 6 + 5,
|
||||
arina_roll_target = ABS(RANDOM()) % 6 + 5`,
|
||||
today)
|
||||
if err != nil {
|
||||
slog.Error("npc: midnight reset failed", "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
|
||||
|
||||
const petXPPerAction = 1.5
|
||||
|
||||
var petNameValid = regexp.MustCompile(`^[a-zA-Z0-9 '\-]+$`)
|
||||
|
||||
// petXPToNextLevel returns XP needed for a given pet level.
|
||||
func petXPToNextLevel(level int) int {
|
||||
switch {
|
||||
case level < 3:
|
||||
return 10
|
||||
case level < 5:
|
||||
return 20
|
||||
case level < 8:
|
||||
return 35
|
||||
default:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
|
||||
func petGrantXP(char *AdventureCharacter) bool {
|
||||
if !char.HasPet() || char.PetLevel >= 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
char.PetXP += int(petXPPerAction * 100) // store as centixp for precision
|
||||
leveled := false
|
||||
for char.PetLevel < 10 {
|
||||
needed := petXPToNextLevel(char.PetLevel) * 100
|
||||
if char.PetXP < needed {
|
||||
break
|
||||
}
|
||||
char.PetXP -= needed
|
||||
char.PetLevel++
|
||||
leveled = true
|
||||
}
|
||||
|
||||
if char.PetLevel >= 10 && char.PetLevel10Date == "" {
|
||||
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
return leveled
|
||||
}
|
||||
|
||||
// ── Pet Combat Action Probabilities ────────────────────────────────────────
|
||||
|
||||
// Base probabilities scale with pet level.
|
||||
// Attack: 3% + 1.5% per level (max 18% at L10)
|
||||
// Deflect: 2% + 1% per level (max 12% at L10) + armor bonus
|
||||
// Ditch recovery: 5% + 2% per level (max 25% at L10)
|
||||
|
||||
func petAttackChance(level int) float64 {
|
||||
return 0.03 + float64(level)*0.015
|
||||
}
|
||||
|
||||
func petDeflectChance(level, armorTier int) float64 {
|
||||
base := 0.02 + float64(level)*0.01
|
||||
defs := petDogArmor // same bonuses for both types
|
||||
for _, d := range defs {
|
||||
if d.Tier == armorTier {
|
||||
base += d.DeflectBonus
|
||||
break
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func petDitchRecoveryChance(level int) float64 {
|
||||
return 0.05 + float64(level)*0.02
|
||||
}
|
||||
|
||||
// petDitchRecoveryTime returns the death timer based on pet level.
|
||||
// Level 1: 5h, Level 5: ~3.5h, Level 10: 1h (linear interpolation).
|
||||
func petDitchRecoveryTime(level int) time.Duration {
|
||||
hours := 5.0 - float64(level-1)*4.0/9.0 // 5h at L1, 1h at L10
|
||||
if hours < 1 {
|
||||
hours = 1
|
||||
}
|
||||
return time.Duration(hours * float64(time.Hour))
|
||||
}
|
||||
|
||||
// ── Pet Combat Results ─────────────────────────────────────────────────────
|
||||
|
||||
type PetCombatResult struct {
|
||||
Attacked bool
|
||||
AttackDamage int
|
||||
AttackText string
|
||||
Deflected bool
|
||||
DeflectText string
|
||||
DitchRecovery bool
|
||||
DitchText string
|
||||
VictoryText string
|
||||
DeathText string
|
||||
}
|
||||
|
||||
// petRollCombatActions rolls attack and deflect for a combat round.
|
||||
func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombatResult {
|
||||
if !char.HasPet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &PetCombatResult{}
|
||||
|
||||
// Attack roll
|
||||
if rand.Float64() < petAttackChance(char.PetLevel) {
|
||||
result.Attacked = true
|
||||
result.AttackDamage = 3 + rand.IntN(5) + char.PetLevel // 3-7 + level
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogAttack
|
||||
} else {
|
||||
pool = PetCatAttack
|
||||
}
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
line = strings.ReplaceAll(line, "{enemy}", enemyName)
|
||||
line = strings.ReplaceAll(line, "{damage}", fmt.Sprintf("%d", result.AttackDamage))
|
||||
result.AttackText = line
|
||||
}
|
||||
|
||||
// Deflect roll
|
||||
if rand.Float64() < petDeflectChance(char.PetLevel, char.PetArmorTier) {
|
||||
result.Deflected = true
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogDeflect
|
||||
} else {
|
||||
pool = PetCatDeflect
|
||||
}
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
line = strings.ReplaceAll(line, "{enemy}", enemyName)
|
||||
result.DeflectText = line
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// petRollDitchRecovery rolls for pet intervention on player death.
|
||||
func petRollDitchRecovery(char *AdventureCharacter) bool {
|
||||
if !char.HasPet() {
|
||||
return false
|
||||
}
|
||||
return rand.Float64() < petDitchRecoveryChance(char.PetLevel)
|
||||
}
|
||||
|
||||
// petVictoryText returns a random victory reaction line.
|
||||
func petVictoryText(char *AdventureCharacter) string {
|
||||
if !char.HasPet() {
|
||||
return ""
|
||||
}
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogVictory
|
||||
} else {
|
||||
pool = PetCatVictory
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// petDeathText returns a random death reaction line.
|
||||
func petDeathText(char *AdventureCharacter) string {
|
||||
if !char.HasPet() {
|
||||
return ""
|
||||
}
|
||||
var pool []string
|
||||
if char.PetType == "dog" {
|
||||
pool = PetDogDeath
|
||||
} else {
|
||||
pool = PetCatDeath
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// ── Pet Arrival Flow ───────────────────────────────────────────────────────
|
||||
|
||||
// petShouldArrive checks if pet arrival should trigger.
|
||||
// Fires randomly after Tier 1 house upgrade is complete.
|
||||
func petShouldArrive(char *AdventureCharacter) bool {
|
||||
if char.PetArrived {
|
||||
return false
|
||||
}
|
||||
// Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2
|
||||
if char.HouseTier < 2 {
|
||||
return false
|
||||
}
|
||||
// Pet was chased away and reactivated via Misty — allow re-arrival
|
||||
if char.PetChasedAway {
|
||||
return char.PetReactivated
|
||||
}
|
||||
// 15% daily chance after house tier 1
|
||||
return rand.Float64() < 0.15
|
||||
}
|
||||
|
||||
// petArrivalDM sends the initial "there's an animal in your house" DM.
|
||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
||||
// Don't overwrite an existing pending interaction
|
||||
if _, exists := p.pending.Load(string(userID)); exists {
|
||||
return
|
||||
}
|
||||
|
||||
text := "There's an animal in your house. It looks like a...\n\n" +
|
||||
"🚪 Chase it away\n" +
|
||||
"🍖 Feed it\n\n" +
|
||||
"Reply with `chase` or `feed`."
|
||||
|
||||
p.pending.Store(string(userID), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
_ = p.SendDM(userID, text)
|
||||
}
|
||||
|
||||
// resolvePetArrival handles the chase/feed response.
|
||||
func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "chase" || reply == "🚪" {
|
||||
char.PetChasedAway = true
|
||||
char.PetReactivated = false
|
||||
_ = saveAdvCharacter(char)
|
||||
return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.")
|
||||
}
|
||||
|
||||
if reply == "feed" || reply == "🍖" {
|
||||
// Ask what type
|
||||
text := "It ate everything you put down. It's still here.\n\n" +
|
||||
"It looks like a...\n\n" +
|
||||
"🐶 Massive Dog\n" +
|
||||
"🐱 Massive Cat\n\n" +
|
||||
"Reply with `dog` or `cat`."
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
// Invalid response — re-prompt
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_arrival",
|
||||
Data: &advPendingPetArrival{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
||||
}
|
||||
|
||||
// resolvePetType handles dog/cat selection.
|
||||
func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
petType := ""
|
||||
if reply == "dog" || reply == "🐶" {
|
||||
petType = "dog"
|
||||
} else if reply == "cat" || reply == "🐱" {
|
||||
petType = "cat"
|
||||
}
|
||||
|
||||
if petType == "" {
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_type",
|
||||
Data: &advPendingPetType{},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
||||
}
|
||||
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: petType},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
|
||||
article := "a"
|
||||
if petType == "dog" {
|
||||
article = "a"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("It's %s Massive %s. What would you like to name it?\n\nReply with a name.",
|
||||
article, titleCase(petType)))
|
||||
}
|
||||
|
||||
// resolvePetName handles naming the pet.
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
|
||||
val, ok := p.pending.LoadAndDelete(string(ctx.Sender))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
pi := val.(*advPendingInteraction)
|
||||
data := pi.Data.(*advPendingPetName)
|
||||
|
||||
name := strings.TrimSpace(ctx.Body)
|
||||
if len(name) == 0 || len(name) > 30 {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||
}
|
||||
if !petNameValid.MatchString(name) {
|
||||
p.pending.Store(string(ctx.Sender), pi)
|
||||
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||
}
|
||||
|
||||
char.PetType = data.PetType
|
||||
char.PetName = name
|
||||
char.PetArrived = true
|
||||
char.PetChasedAway = false
|
||||
char.PetLevel = 1
|
||||
char.PetXP = 0
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||
}
|
||||
|
||||
emoji := "🐶"
|
||||
if data.PetType == "cat" {
|
||||
emoji = "🐱"
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.",
|
||||
emoji, name, titleCase(data.PetType), name, name))
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
// petMorningEvent rolls for a morning pet event and returns flavor text.
|
||||
// Returns empty string if no event fires.
|
||||
func petMorningEvent(char *AdventureCharacter) string {
|
||||
if !char.HasPet() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 25% chance of morning event
|
||||
if rand.Float64() >= 0.25 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if char.PetType == "cat" {
|
||||
line := PetCatOffering[rand.IntN(len(PetCatOffering))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
|
||||
}
|
||||
|
||||
line := PetDogSmothering[rand.IntN(len(PetDogSmothering))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
|
||||
}
|
||||
|
||||
// ── Pet Supply Shop Unlock Check ───────────────────────────────────────────
|
||||
|
||||
// petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10.
|
||||
func petCheckSupplyShopUnlock(char *AdventureCharacter) bool {
|
||||
if char.PetSupplyShopUnlocked || char.PetLevel10Date == "" {
|
||||
return false
|
||||
}
|
||||
d, err := time.Parse("2006-01-02", char.PetLevel10Date)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().UTC().After(d.Add(7 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
// ── Misty Housing Hint ─────────────────────────────────────────────────────
|
||||
|
||||
// mistyHousingHint returns a hint about housing/pets if conditions are met.
|
||||
// Fires once, after 2+ Misty encounters, player has no house.
|
||||
func mistyHousingHint(char *AdventureCharacter) string {
|
||||
if char.HasHouse() || char.MistyEncounterCount < 2 {
|
||||
return ""
|
||||
}
|
||||
if char.MistyDonatedCount > 0 {
|
||||
return "You seem like a good person. I can imagine you sitting in a house caring for a pet someday."
|
||||
}
|
||||
return "Thank you for your time."
|
||||
}
|
||||
|
||||
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
|
||||
|
||||
// mistyReactivatePet marks the pet as reactivated if it was chased away.
|
||||
func mistyReactivatePet(char *AdventureCharacter) {
|
||||
if char.PetChasedAway && !char.PetReactivated {
|
||||
char.PetReactivated = true
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Level 10 Ticker (runs daily at midnight) ───────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) petMidnightCheck() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("pet: failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, char := range chars {
|
||||
if !char.HasPet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check supply shop unlock
|
||||
if petCheckSupplyShopUnlock(&char) {
|
||||
char.PetSupplyShopUnlocked = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
slog.Info("pet: supply shop unlocked", "user", char.UserID, "pet", char.PetName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ditch Recovery Text ────────────────────────────────────────────────────
|
||||
|
||||
func petDitchRecoveryGameRoom(username, petName string, petInvolved bool) string {
|
||||
base := fmt.Sprintf("🏥 %s was brought into St. Guildmore's on a stretcher. They have been returned to the ditch. They'll be fine eventually.", username)
|
||||
if petInvolved {
|
||||
base = fmt.Sprintf("🏥 St. Guildmore's notes that %s has once again intervened before our personnel could reach the scene. %s has been reminded that the ditch is not a medical facility. %s did not respond to this reminder.", petName, petName, petName)
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPetXPToNextLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
level int
|
||||
expected int
|
||||
}{
|
||||
{0, 10},
|
||||
{1, 10},
|
||||
{2, 10},
|
||||
{4, 20},
|
||||
{5, 35},
|
||||
{7, 35},
|
||||
{8, 50},
|
||||
{9, 50},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := petXPToNextLevel(tt.level); got != tt.expected {
|
||||
t.Errorf("petXPToNextLevel(%d) = %d, want %d", tt.level, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetXPToNextLevel_Increasing(t *testing.T) {
|
||||
// XP requirements should never decrease as level increases
|
||||
prev := petXPToNextLevel(0)
|
||||
for level := 1; level < 10; level++ {
|
||||
curr := petXPToNextLevel(level)
|
||||
if curr < prev {
|
||||
t.Errorf("XP for level %d (%d) should not be less than level %d (%d)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_LevelsUp(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetArrived: true,
|
||||
PetLevel: 1,
|
||||
PetXP: 950, // close to needing 1000 (10 * 100 centixp)
|
||||
}
|
||||
leveled := petGrantXP(char)
|
||||
if !leveled {
|
||||
t.Error("should have leveled up")
|
||||
}
|
||||
if char.PetLevel != 2 {
|
||||
t.Errorf("pet level should be 2, got %d", char.PetLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petGrantXP(char) {
|
||||
t.Error("should not grant XP without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_MaxLevel(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "cat",
|
||||
PetName: "Luna",
|
||||
PetArrived: true,
|
||||
PetLevel: 10,
|
||||
PetXP: 9999,
|
||||
}
|
||||
if petGrantXP(char) {
|
||||
t.Error("should not level up past max level 10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_ChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetArrived: true,
|
||||
PetChasedAway: true,
|
||||
PetLevel: 1,
|
||||
PetXP: 0,
|
||||
}
|
||||
if petGrantXP(char) {
|
||||
t.Error("should not grant XP to chased-away pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_SetsLevel10Date(t *testing.T) {
|
||||
char := &AdventureCharacter{
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetArrived: true,
|
||||
PetLevel: 9,
|
||||
PetXP: 4999, // needs 5000 (50 * 100) for level 9→10
|
||||
}
|
||||
petGrantXP(char)
|
||||
if char.PetLevel == 10 && char.PetLevel10Date == "" {
|
||||
t.Error("reaching level 10 should set PetLevel10Date")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Combat Probabilities ───────────────────────────────────────────────
|
||||
|
||||
func TestPetAttackChance_ScalesWithLevel(t *testing.T) {
|
||||
prev := petAttackChance(0)
|
||||
for level := 1; level <= 10; level++ {
|
||||
curr := petAttackChance(level)
|
||||
if curr <= prev {
|
||||
t.Errorf("attack chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetAttackChance_Level10(t *testing.T) {
|
||||
chance := petAttackChance(10)
|
||||
// 3% + 10*1.5% = 18%
|
||||
if chance < 0.179 || chance > 0.181 {
|
||||
t.Errorf("level 10 attack chance: got %.3f, want 0.18", chance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeflectChance_ScalesWithLevel(t *testing.T) {
|
||||
prev := petDeflectChance(0, 0)
|
||||
for level := 1; level <= 10; level++ {
|
||||
curr := petDeflectChance(level, 0)
|
||||
if curr <= prev {
|
||||
t.Errorf("deflect chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeflectChance_ArmorBonus(t *testing.T) {
|
||||
baseChance := petDeflectChance(5, 0)
|
||||
armoredChance := petDeflectChance(5, 3)
|
||||
if armoredChance <= baseChance {
|
||||
t.Errorf("armored deflect (%.3f) should exceed base (%.3f)", armoredChance, baseChance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDitchRecoveryChance_ScalesWithLevel(t *testing.T) {
|
||||
prev := petDitchRecoveryChance(0)
|
||||
for level := 1; level <= 10; level++ {
|
||||
curr := petDitchRecoveryChance(level)
|
||||
if curr <= prev {
|
||||
t.Errorf("ditch chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDitchRecoveryChance_Level10(t *testing.T) {
|
||||
chance := petDitchRecoveryChance(10)
|
||||
// 5% + 10*2% = 25%
|
||||
if chance < 0.249 || chance > 0.251 {
|
||||
t.Errorf("level 10 ditch chance: got %.3f, want 0.25", chance)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Combat Results ─────────────────────────────────────────────────────
|
||||
|
||||
func TestPetRollCombatActions_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
result := petRollCombatActions(char, "Goblin")
|
||||
if result != nil {
|
||||
t.Error("should return nil without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetRollDitchRecovery_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petRollDitchRecovery(char) {
|
||||
t.Error("should return false without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_Dog(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}
|
||||
text := petVictoryText(char)
|
||||
if text == "" {
|
||||
t.Error("should return victory text for dog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_Cat(t *testing.T) {
|
||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true}
|
||||
text := petVictoryText(char)
|
||||
if text == "" {
|
||||
t.Error("should return victory text for cat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petVictoryText(char) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeathText_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petDeathText(char) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Arrival Logic ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPetShouldArrive_NoHouse(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 0}
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not arrive without house")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_BaseHouseOnly(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 1} // Base house, not yet Livable
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not arrive with only base house (need tier 2+)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_AlreadyArrived(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 2, PetArrived: true}
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not trigger if pet already arrived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_ChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true}
|
||||
if petShouldArrive(char) {
|
||||
t.Error("should not trigger if pet was chased away (and not reactivated)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_Reactivated(t *testing.T) {
|
||||
char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true, PetReactivated: true}
|
||||
// With reactivation, should always return true
|
||||
if !petShouldArrive(char) {
|
||||
t.Error("reactivated pet should always trigger arrival")
|
||||
}
|
||||
}
|
||||
|
||||
// ── HasPet ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHasPet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
char AdventureCharacter
|
||||
expected bool
|
||||
}{
|
||||
{"no pet", AdventureCharacter{}, false},
|
||||
{"has pet", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}, true},
|
||||
{"chased away", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetChasedAway: true}, false},
|
||||
{"not arrived", AdventureCharacter{PetType: "dog", PetName: "Rex"}, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.char.HasPet() != tt.expected {
|
||||
t.Errorf("HasPet() = %v, want %v", tt.char.HasPet(), tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||
|
||||
func TestPetMorningEvent_NoPet(t *testing.T) {
|
||||
char := &AdventureCharacter{}
|
||||
if petMorningEvent(char) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Misty Housing Hint ─────────────────────────────────────────────────────
|
||||
|
||||
func TestMistyHousingHint_NoEncounters(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 0}
|
||||
if mistyHousingHint(char) != "" {
|
||||
t.Error("should not hint with 0 encounters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_HasHouse(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 3, HouseTier: 1}
|
||||
if mistyHousingHint(char) != "" {
|
||||
t.Error("should not hint if player has house")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_Donated(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 1}
|
||||
hint := mistyHousingHint(char)
|
||||
if !strings.Contains(hint, "house") {
|
||||
t.Errorf("donated player hint should mention house, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_NotDonated(t *testing.T) {
|
||||
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 0}
|
||||
hint := mistyHousingHint(char)
|
||||
if hint != "Thank you for your time." {
|
||||
t.Errorf("non-donor hint should be dismissive, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
|
||||
|
||||
func TestMistyReactivatePet_ChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: false}
|
||||
mistyReactivatePet(char)
|
||||
if !char.PetReactivated {
|
||||
t.Error("should set PetReactivated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyReactivatePet_NotChasedAway(t *testing.T) {
|
||||
char := &AdventureCharacter{PetChasedAway: false, PetReactivated: false}
|
||||
mistyReactivatePet(char)
|
||||
if char.PetReactivated {
|
||||
t.Error("should not set PetReactivated if pet wasn't chased away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) {
|
||||
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true}
|
||||
mistyReactivatePet(char)
|
||||
if !char.PetReactivated {
|
||||
t.Error("should remain reactivated")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Supply Shop Unlock ─────────────────────────────────────────────────
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) {
|
||||
char := &AdventureCharacter{PetLevel10Date: ""}
|
||||
if petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should not unlock without level 10 date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) {
|
||||
char := &AdventureCharacter{PetSupplyShopUnlocked: true, PetLevel10Date: "2020-01-01"}
|
||||
if petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should not re-trigger if already unlocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) {
|
||||
// Set date to today — not 7 days ago
|
||||
char := &AdventureCharacter{PetLevel10Date: "2099-01-01"}
|
||||
if petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should not unlock before 7 days have passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) {
|
||||
char := &AdventureCharacter{PetLevel10Date: "2020-01-01"}
|
||||
if !petCheckSupplyShopUnlock(char) {
|
||||
t.Error("should unlock after 7+ days")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ditch Recovery Text ────────────────────────────────────────────────────
|
||||
|
||||
func TestPetDitchRecoveryGameRoom_NoPet(t *testing.T) {
|
||||
text := petDitchRecoveryGameRoom("Player1", "", false)
|
||||
if !strings.Contains(text, "Player1") {
|
||||
t.Error("should contain player name")
|
||||
}
|
||||
if !strings.Contains(text, "stretcher") {
|
||||
t.Error("should mention stretcher for non-pet version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDitchRecoveryGameRoom_WithPet(t *testing.T) {
|
||||
text := petDitchRecoveryGameRoom("Player1", "Rex", true)
|
||||
if !strings.Contains(text, "Rex") {
|
||||
t.Error("should contain pet name")
|
||||
}
|
||||
if !strings.Contains(text, "intervened") {
|
||||
t.Error("should mention pet intervention")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
|
||||
|
||||
func TestPetFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"PetDogAttack": PetDogAttack,
|
||||
"PetCatAttack": PetCatAttack,
|
||||
"PetDogDeath": PetDogDeath,
|
||||
"PetCatDeath": PetCatDeath,
|
||||
"PetDogVictory": PetDogVictory,
|
||||
"PetCatVictory": PetCatVictory,
|
||||
"PetDogDeflect": PetDogDeflect,
|
||||
"PetCatDeflect": PetCatDeflect,
|
||||
"PetCatOffering": PetCatOffering,
|
||||
"PetDogSmothering": PetDogSmothering,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
t.Errorf("flavor pool %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetAttackFlavor_Placeholders(t *testing.T) {
|
||||
// All attack lines must have {damage}. {enemy} is optional — some lines
|
||||
// intentionally omit the enemy name for voice/style reasons (especially cat).
|
||||
for i, line := range PetDogAttack {
|
||||
if !strings.Contains(line, "{damage}") {
|
||||
t.Errorf("PetDogAttack[%d] missing {damage} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range PetCatAttack {
|
||||
if !strings.Contains(line, "{damage}") {
|
||||
t.Errorf("PetCatAttack[%d] missing {damage} placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeflectFlavor_Placeholders(t *testing.T) {
|
||||
for i, line := range PetDogDeflect {
|
||||
if !strings.Contains(line, "{enemy}") {
|
||||
t.Errorf("PetDogDeflect[%d] missing {enemy} placeholder", i)
|
||||
}
|
||||
}
|
||||
for i, line := range PetCatDeflect {
|
||||
if !strings.Contains(line, "{enemy}") {
|
||||
t.Errorf("PetCatDeflect[%d] missing {enemy} placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
+152
-106
@@ -120,6 +120,12 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
sb.WriteString(fmt.Sprintf(" (best: %d)\n", char.BestStreak))
|
||||
}
|
||||
|
||||
// Crafting (only show once they've actually crafted something)
|
||||
if char.CraftsSucceeded > 0 {
|
||||
sb.WriteString(fmt.Sprintf("🧪 Crafts: %d successful (Foraging Lv.%d — `!adventure recipes`)\n",
|
||||
char.CraftsSucceeded, char.ForagingSkill))
|
||||
}
|
||||
|
||||
// Equipment
|
||||
sb.WriteString("\n🛡️ Equipment:\n")
|
||||
eqScore := advEquipmentScore(equip)
|
||||
@@ -140,7 +146,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,11 +178,55 @@ 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))
|
||||
|
||||
// Today's action
|
||||
if char.ActionTakenToday {
|
||||
sb.WriteString("\n📅 Today: Action taken")
|
||||
// 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 actions
|
||||
isHolSheet, _ := isHolidayToday()
|
||||
combatMax := maxCombatActions
|
||||
harvestMax := maxHarvestActions
|
||||
if isHolSheet {
|
||||
combatMax++
|
||||
harvestMax++
|
||||
}
|
||||
combatRemaining := combatMax - char.CombatActionsUsed
|
||||
if combatRemaining < 0 {
|
||||
combatRemaining = 0
|
||||
}
|
||||
harvestRemaining := harvestMax - char.HarvestActionsUsed
|
||||
if harvestRemaining < 0 {
|
||||
harvestRemaining = 0
|
||||
}
|
||||
if char.HasActedToday() {
|
||||
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions remaining", combatRemaining, harvestRemaining))
|
||||
} else {
|
||||
sb.WriteString("\n📅 Today: No action yet — reply to morning DM or type `!adventure`")
|
||||
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions available", combatRemaining, harvestRemaining))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -189,7 +239,7 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
||||
|
||||
// Holiday notice (before greeting)
|
||||
if holidayName != "" {
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you are able to take **two actions** today.\n\n", holidayName, holidayName))
|
||||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
@@ -230,45 +280,84 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Action budget
|
||||
isHol := holidayName != ""
|
||||
combatMax := maxCombatActions
|
||||
harvestMax := maxHarvestActions
|
||||
if isHol {
|
||||
combatMax++
|
||||
harvestMax++
|
||||
}
|
||||
combatLeft := combatMax - char.CombatActionsUsed
|
||||
if combatLeft < 0 {
|
||||
combatLeft = 0
|
||||
}
|
||||
harvestLeft := harvestMax - char.HarvestActionsUsed
|
||||
|
||||
// Co-op participants have combat locked for the duration of the run.
|
||||
coopRun, _ := loadCoopRunForUser(char.UserID)
|
||||
inCoop := coopRun != nil && coopRun.Status == "active"
|
||||
|
||||
if inCoop {
|
||||
sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n",
|
||||
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
|
||||
}
|
||||
|
||||
// Location choices
|
||||
sb.WriteString("**1️⃣ Dungeon:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
if inCoop {
|
||||
sb.WriteString(fmt.Sprintf("**1️⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n"))
|
||||
} else if char.CanDoCombat(isHol) {
|
||||
sb.WriteString("**1️⃣ Dungeon:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
} else {
|
||||
sb.WriteString("**1️⃣ Dungeon:** _(no combat actions remaining)_\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**2️⃣ Mine:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
if char.CanDoHarvest(isHol) {
|
||||
sb.WriteString("**2️⃣ Mine:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
|
||||
sb.WriteString("**3️⃣ Forage:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
sb.WriteString("**3️⃣ Forage:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
|
||||
sb.WriteString("**4️⃣ Fish:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
sb.WriteString("**4️⃣ Fish:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
} else {
|
||||
sb.WriteString("**2️⃣ Mine:** _(no harvest actions remaining)_\n")
|
||||
sb.WriteString("**3️⃣ Forage:** _(no harvest actions remaining)_\n")
|
||||
sb.WriteString("**4️⃣ Fish:** _(no harvest actions remaining)_\n")
|
||||
}
|
||||
|
||||
sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n")
|
||||
sb.WriteString("**6️⃣ Rest** — skip today, bank your luck\n\n")
|
||||
sb.WriteString("**6️⃣ Blacksmith** — repair damaged equipment\n")
|
||||
sb.WriteString("**7️⃣ Rest** — skip today, bank your luck\n")
|
||||
sb.WriteString("**8️⃣ Thom** — `!thom` visit Krooke Realty 🏠\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.")
|
||||
@@ -276,56 +365,6 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Holiday Second Action Prompt ──────────────────────────────────────────────
|
||||
|
||||
func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, bonuses *AdvBonusSummary) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("✅ Action 1 complete.\n\nNow choose your **second action**:\n\n")
|
||||
|
||||
sb.WriteString("**1️⃣ Dungeon:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
|
||||
sb.WriteString("**2️⃣ Mine:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
|
||||
sb.WriteString("**3️⃣ Forage:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
|
||||
sb.WriteString("**4️⃣ Fish:**\n")
|
||||
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
|
||||
warn := ""
|
||||
if el.InPenaltyZone {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
|
||||
}
|
||||
|
||||
sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n")
|
||||
sb.WriteString("**6️⃣ Rest** — skip the second action\n\n")
|
||||
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Resolution DM ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
|
||||
@@ -349,13 +388,16 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
||||
}
|
||||
|
||||
if len(result.LootItems) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("💰 Loot: €%d total\n", result.TotalLootValue))
|
||||
sb.WriteString(fmt.Sprintf("💰 Loot: %s total\n", fmtEuro(result.TotalLootValue)))
|
||||
for _, item := range result.LootItems {
|
||||
sb.WriteString(fmt.Sprintf(" • %s — €%d\n", item.Name, item.Value))
|
||||
sb.WriteString(fmt.Sprintf(" • %s — %s\n", item.Name, fmtEuro(item.Value)))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
|
||||
if result.XPBreakdown != "" {
|
||||
sb.WriteString(fmt.Sprintf(" (%s)", result.XPBreakdown))
|
||||
}
|
||||
if result.LeveledUp {
|
||||
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel))
|
||||
}
|
||||
@@ -443,20 +485,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 +584,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")
|
||||
@@ -588,8 +630,12 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
|
||||
sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n",
|
||||
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill))
|
||||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
||||
if p.Location != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
||||
} else {
|
||||
sb.WriteString(" Acted today — no log recorded.\n\n")
|
||||
}
|
||||
|
||||
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
|
||||
bestPlayer = p
|
||||
@@ -706,11 +752,11 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill) * 10
|
||||
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
entries = append(entries, entry{
|
||||
Name: c.DisplayName,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill),
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"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 communityTax(userID id.UserID, gross float64, rate float64) (net float64, tax int) {
|
||||
t := math.Round(gross * rate)
|
||||
tax = int(t)
|
||||
net = gross - t
|
||||
if tax > 0 {
|
||||
communityPotAdd(tax)
|
||||
trackTaxPaid(userID, tax)
|
||||
}
|
||||
return net, tax
|
||||
}
|
||||
|
||||
func trackTaxPaid(userID id.UserID, amount int) {
|
||||
if amount <= 0 {
|
||||
return
|
||||
}
|
||||
db.Exec("tax: track paid",
|
||||
`INSERT INTO tax_ledger (user_id, total_paid, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(user_id) DO UPDATE SET total_paid = total_paid + ?, updated_at = CURRENT_TIMESTAMP`,
|
||||
string(userID), 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)
|
||||
switch strings.Count(outcome, "%s") {
|
||||
case 2:
|
||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
||||
case 1:
|
||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
|
||||
}
|
||||
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)
|
||||
switch strings.Count(outcome, "%s") {
|
||||
case 2:
|
||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
|
||||
case 1:
|
||||
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
|
||||
}
|
||||
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 := int(math.Round(float64(stake) * 0.3))
|
||||
potShare := stake - winnerShare
|
||||
if winnerShare > 0 {
|
||||
p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
|
||||
}
|
||||
if potShare > 0 {
|
||||
communityPotAdd(potShare)
|
||||
trackTaxPaid(loserID, 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 := int(math.Round(float64(stake) * 0.3))
|
||||
potShare := stake - winnerShare
|
||||
if winnerShare > 0 {
|
||||
p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
|
||||
}
|
||||
if potShare > 0 {
|
||||
communityPotAdd(potShare)
|
||||
trackTaxPaid(challenge.ChallengedID, 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())
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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) // 8–21 inclusive
|
||||
robbieTargetDay = dateKey
|
||||
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
||||
}
|
||||
|
||||
if now.Hour() < robbieTargetHour {
|
||||
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)
|
||||
payout := item.Value / 4
|
||||
if payout < 1 {
|
||||
payout = 1
|
||||
}
|
||||
totalPayout += payout
|
||||
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 {
|
||||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
||||
// player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-gear items (ores, fish, junk, treasure, etc.) — always take
|
||||
if item.Slot == "" {
|
||||
result = append(result, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Slotted gear — check against equipped piece
|
||||
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)
|
||||
payout := item.Value / 4
|
||||
if payout < 1 {
|
||||
payout = 1
|
||||
}
|
||||
if item.Type == "MasterworkGear" {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (Masterwork T%d) → €%d", emoji, item.Name, item.Tier, payout))
|
||||
if gaveCard && !cardShownOnLine {
|
||||
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
|
||||
cardShownOnLine = true
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €%d", emoji, item.Name, item.Tier, payout))
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -60,9 +62,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 +79,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)
|
||||
@@ -86,11 +98,25 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
continue
|
||||
}
|
||||
|
||||
// If already acted today, skip
|
||||
if char.ActionTakenToday {
|
||||
// If all actions used today, skip
|
||||
isHol, _ := isHolidayToday()
|
||||
if char.AllActionsUsed(isHol) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pet arrival check (fires before normal morning DM)
|
||||
if petShouldArrive(&char) {
|
||||
p.petArrivalDM(char.UserID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Morning pet event
|
||||
petEvent := petMorningEvent(&char)
|
||||
if petEvent != "" {
|
||||
char.PetMorningDefense = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
|
||||
// Send morning DM with choices
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
@@ -108,6 +134,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
holidayLabel = holName
|
||||
}
|
||||
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
|
||||
if petEvent != "" {
|
||||
text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text)
|
||||
}
|
||||
p.advMarkMenuSent(char.UserID)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
|
||||
@@ -160,7 +189,11 @@ func (p *AdventurePlugin) postDailySummary() {
|
||||
return
|
||||
}
|
||||
|
||||
todayLogs, _ := loadAdvTodayLogs()
|
||||
// Load logs for today and yesterday — players may act across the UTC boundary
|
||||
now := time.Now().UTC()
|
||||
todayLogs, _ := loadAdvLogsForDate(now.Format("2006-01-02"))
|
||||
yesterdayLogs, _ := loadAdvLogsForDate(now.AddDate(0, 0, -1).Format("2006-01-02"))
|
||||
todayLogs = append(todayLogs, yesterdayLogs...)
|
||||
// Group logs per user — holiday days may produce 2 entries per user
|
||||
logsPerUser := make(map[id.UserID][]*AdvDayLog)
|
||||
for i := range todayLogs {
|
||||
@@ -215,7 +248,7 @@ func (p *AdventurePlugin) postDailySummary() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !c.ActionTakenToday {
|
||||
if !c.HasActedToday() {
|
||||
ps.IsResting = true
|
||||
if len(SummaryResting) > 0 {
|
||||
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
|
||||
@@ -266,15 +299,18 @@ func (p *AdventurePlugin) midnightTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
lastRanDate := ""
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != 0 || now.Minute() != 0 {
|
||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||
if dateKey == lastRanDate {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
// New UTC day — check DB in case we already ran (e.g. bot restart).
|
||||
jobName := "adventure_midnight"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
lastRanDate = dateKey
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -284,6 +320,7 @@ func (p *AdventurePlugin) midnightTicker() {
|
||||
continue
|
||||
}
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
lastRanDate = dateKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,11 +335,46 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
if !char.Alive {
|
||||
continue
|
||||
}
|
||||
if !char.HasActedToday() {
|
||||
// If the player died today or yesterday, they couldn't act — no shame,
|
||||
// no streak reset. This covers both currently-dead players and players
|
||||
// who were just revived at midnight (Alive already flipped to true by
|
||||
// the reminder loop before midnightReset runs).
|
||||
if char.LastDeathDate == today ||
|
||||
char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Auto-babysit: if enabled, alive, and affordable, run a single babysit day instead of losing streak
|
||||
if char.AutoBabysit && char.Alive && !char.BabysitActive {
|
||||
daily := babysitDailyCost(char.CombatLevel)
|
||||
if p.euro.GetBalance(char.UserID) >= float64(daily) {
|
||||
if p.euro.Debit(char.UserID, float64(daily), "auto_babysit") {
|
||||
p.runAutoBabysitDay(&char)
|
||||
if char.CurrentStreak > 0 {
|
||||
char.CurrentStreak++
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
} else {
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
|
||||
if p.achievements != nil {
|
||||
p.achievements.GrantAchievement(char.UserID, "adv_auto_babysit")
|
||||
}
|
||||
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
p.SendDM(char.UserID, fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.", daily, char.CurrentStreak))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !char.ActionTakenToday {
|
||||
// Jitter between DMs to avoid Matrix rate limits
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
@@ -311,15 +383,19 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(&char)
|
||||
if char.CurrentStreak > 0 {
|
||||
oldStreak := char.CurrentStreak
|
||||
char.CurrentStreak /= 2
|
||||
char.StreakDecayed = true
|
||||
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
|
||||
if char.CurrentStreak > 0 {
|
||||
text += " — not all is lost."
|
||||
}
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Reset streak
|
||||
if char.CurrentStreak > 0 {
|
||||
char.CurrentStreak = 0
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
} else {
|
||||
// Update streak — LastActionDate was set at action time
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
@@ -346,6 +422,13 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr)
|
||||
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
|
||||
}
|
||||
if resetErr == nil {
|
||||
// Re-lock combat for active co-op participants after the universal
|
||||
// reset would otherwise have given them a fresh combat action.
|
||||
if err := lockCoopCombatActions(); err != nil {
|
||||
slog.Error("adventure: post-reset coop combat lock failed", "err", err)
|
||||
}
|
||||
}
|
||||
if resetErr != nil {
|
||||
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
|
||||
}
|
||||
@@ -355,6 +438,9 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
slog.Error("adventure: failed to prune expired buffs", "err", err)
|
||||
}
|
||||
|
||||
// Reset NPC message counts and regenerate roll targets
|
||||
npcMidnightReset()
|
||||
|
||||
// Clear flavor history to prevent unbounded memory growth.
|
||||
// Entries are only used for dedup within a day, so clearing at midnight is fine.
|
||||
advClearFlavorHistory()
|
||||
@@ -365,9 +451,45 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
return true
|
||||
})
|
||||
|
||||
// Expire any rival challenges that went unanswered
|
||||
p.expireRivalChallenges()
|
||||
|
||||
// Check babysitting service expirations
|
||||
p.checkBabysitExpiry(chars)
|
||||
|
||||
// Pet supply shop unlock check
|
||||
p.petMidnightCheck()
|
||||
|
||||
// Reset holdem NPC house balance
|
||||
resetNPCHouseBalance()
|
||||
|
||||
// Daily database backup (async to avoid blocking reset if DM sends are slow)
|
||||
go func() {
|
||||
if err := db.Backup(); err != nil {
|
||||
slog.Error("adventure: daily backup failed", "err", err)
|
||||
p.notifyAdmins(fmt.Sprintf("⚠️ **Daily backup failed:** %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) notifyAdmins(msg string) {
|
||||
admins := os.Getenv("ADMIN_USERS")
|
||||
if admins == "" {
|
||||
return
|
||||
}
|
||||
for _, a := range strings.Split(admins, ",") {
|
||||
uid := id.UserID(strings.TrimSpace(a))
|
||||
if uid == "" {
|
||||
continue
|
||||
}
|
||||
if err := p.SendDM(uid, msg); err != nil {
|
||||
slog.Error("adventure: failed to notify admin", "user", uid, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
|
||||
|
||||
+745
-106
@@ -2,7 +2,11 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -36,66 +40,242 @@ 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
|
||||
safeGo("shop-nudge", 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, chatLevel int) 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()
|
||||
}
|
||||
|
||||
var greet string
|
||||
if chatLevel >= 10 {
|
||||
greet = shopGreeting(chatLevel)
|
||||
} else {
|
||||
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 🧪 Supplies\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 +283,294 @@ 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))
|
||||
}
|
||||
|
||||
// Check for supplies category first
|
||||
if reply == "supplies" || reply == "supply" || reply == "consumables" || reply == "potions" {
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
text := luigiSuppliesView(ctx.Sender, balance)
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_supply",
|
||||
Data: data,
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
p.advMarkMenuSent(ctx.Sender)
|
||||
p.shopScheduleBrowseNudge(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
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, tools, or supplies.")
|
||||
}
|
||||
|
||||
_, 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.chatLevel(ctx.Sender))
|
||||
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 && def.Tier <= current.ArenaTier {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear, T%d) in that slot. That T%d shop item isn't an upgrade.\n\nPick something else, or \"back\" to return.",
|
||||
current.Name, current.ArenaTier, def.Tier))
|
||||
}
|
||||
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 && def.Tier <= current.ArenaTier {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Arena gear T%d) is not worse than that T%d shop item.", current.Name, current.ArenaTier, def.Tier))
|
||||
}
|
||||
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.")
|
||||
}
|
||||
|
||||
// Community contribution: 5% of purchase price.
|
||||
if potCut := int(def.Price * 0.05); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(ctx.Sender, potCut)
|
||||
}
|
||||
|
||||
// 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.ArenaTier > 0 {
|
||||
itemType = "ArenaGear"
|
||||
} 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 +606,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 +661,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 +682,63 @@ 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. "+
|
||||
"You earned that. Don't throw it away.",
|
||||
current.Name, label)
|
||||
// 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 && def.Tier <= current.ArenaTier {
|
||||
return fmt.Sprintf("You already have **%s** (Arena gear T%d). That T%d shop item isn't an upgrade.",
|
||||
current.Name, current.ArenaTier, def.Tier)
|
||||
}
|
||||
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."
|
||||
}
|
||||
|
||||
// Community contribution: 5% of purchase price.
|
||||
if potCut := int(def.Price * 0.05); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(userID, potCut)
|
||||
}
|
||||
|
||||
// Move old gear to inventory.
|
||||
if current != nil && current.Tier > 0 {
|
||||
itemType := "ShopGear"
|
||||
var resaleValue int64
|
||||
if current.Masterwork {
|
||||
itemType = "MasterworkGear"
|
||||
} else if current.ArenaTier > 0 {
|
||||
itemType = "ArenaGear"
|
||||
} 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 +748,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 ────────────────────────────────────────────────────────────────
|
||||
@@ -279,11 +778,16 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
var total float64
|
||||
var sold int
|
||||
var keptSpecial int
|
||||
var keptConsumable int
|
||||
for _, item := range items {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
|
||||
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
|
||||
keptSpecial++
|
||||
continue
|
||||
}
|
||||
if item.Type == "consumable" {
|
||||
keptConsumable++
|
||||
continue
|
||||
}
|
||||
if err := removeAdvInventoryItem(item.ID); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -292,20 +796,34 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
|
||||
}
|
||||
|
||||
if sold == 0 {
|
||||
if keptSpecial > 0 {
|
||||
switch {
|
||||
case keptSpecial > 0 && keptConsumable > 0:
|
||||
return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell <name>` for individual consumables."
|
||||
case keptSpecial > 0:
|
||||
return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead."
|
||||
case keptConsumable > 0:
|
||||
return "Your inventory only contains consumables. `sell all` won't touch them — use `!adventure sell <name>` to sell a specific consumable."
|
||||
}
|
||||
return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
|
||||
}
|
||||
|
||||
p.euro.Credit(userID, total, "adventure_sell_all")
|
||||
potCut := math.Round(total * 0.05)
|
||||
payout := total - potCut
|
||||
p.euro.Credit(userID, payout, "adventure_sell_all")
|
||||
if int(potCut) > 0 {
|
||||
communityPotAdd(int(potCut))
|
||||
trackTaxPaid(userID, int(potCut))
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
|
||||
msg := fmt.Sprintf("Sold %d items for **%s** (%s after 5%% broker fee → community pot).\n\nThe merchant took everything without comment. "+
|
||||
"This is the most respect anyone has shown your loot collection. Take the money.",
|
||||
sold, total)
|
||||
sold, fmtEuro(total), fmtEuro(payout))
|
||||
if keptSpecial > 0 {
|
||||
msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial)
|
||||
}
|
||||
if keptConsumable > 0 {
|
||||
msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell <name>`.)", keptConsumable)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
@@ -318,14 +836,20 @@ 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 {
|
||||
return "Failed to sell that item."
|
||||
}
|
||||
p.euro.Credit(userID, float64(item.Value), "adventure_sell_"+item.Name)
|
||||
return fmt.Sprintf("Sold **%s** for **€%d**. The merchant nodded. That's it. That's the transaction.", item.Name, item.Value)
|
||||
potCut := int(math.Round(float64(item.Value) * 0.05))
|
||||
payout := int(item.Value) - potCut
|
||||
p.euro.Credit(userID, float64(payout), "adventure_sell_"+item.Name)
|
||||
if potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(userID, potCut)
|
||||
}
|
||||
return fmt.Sprintf("Sold **%s** for **%s** (%s after 5%% broker fee → community pot). The merchant nodded. That's it. That's the transaction.", item.Name, fmtEuro(item.Value), fmtEuro(payout))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +876,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
|
||||
@@ -362,3 +888,116 @@ func advInventoryDisplay(userID id.UserID) string {
|
||||
sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Supplies (Consumables) ──────────────────────────────────────────────────
|
||||
|
||||
func luigiSuppliesView(_ id.UserID, balance float64) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🧪 **Supplies** — combat consumables (auto-used)\n")
|
||||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
|
||||
sb.WriteString("Items are used automatically during combat. The engine picks up to 2 per fight based on threat level — it won't waste them on easy fights.\n\n")
|
||||
|
||||
defs := BuyableConsumables()
|
||||
if len(defs) == 0 {
|
||||
sb.WriteString("_Nothing in stock right now._\n\n")
|
||||
} else {
|
||||
for _, def := range defs {
|
||||
effectDesc := consumableEffectDescription(def.Effect, def.Value)
|
||||
sb.WriteString(fmt.Sprintf("**%s** (T%d) — €%d\n %s\n\n", def.Name, def.Tier, def.Price, effectDesc))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
|
||||
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func consumableEffectDescription(effect ConsumableEffect, value float64) string {
|
||||
switch effect {
|
||||
case EffectHeal:
|
||||
return fmt.Sprintf("Restores %d HP mid-combat (triggers at <50%% HP)", int(value))
|
||||
case EffectDefBoost:
|
||||
return fmt.Sprintf("+%.0f%% Defense for the fight", value*100)
|
||||
case EffectAtkBoost:
|
||||
return fmt.Sprintf("+%.0f%% Attack for the fight", value*100)
|
||||
case EffectWard:
|
||||
return "Blocks one hit completely"
|
||||
case EffectSpeedBoost:
|
||||
return fmt.Sprintf("+%.0f%% Speed for the fight (evasion boost)", value*100)
|
||||
case EffectCritBoost:
|
||||
return fmt.Sprintf("+%.0f%% Crit Rate for the fight", value*100)
|
||||
case EffectFlatDmg:
|
||||
return fmt.Sprintf("Deals %d damage at fight start", int(value))
|
||||
case EffectSpore:
|
||||
return fmt.Sprintf("15%% enemy miss chance for %d rounds", int(value))
|
||||
case EffectReflect:
|
||||
return fmt.Sprintf("Reflects %.0f%% of next hit back at enemy", value*100)
|
||||
case EffectAutoCrit:
|
||||
return fmt.Sprintf("First hit is auto-crit + %.0f%% Attack", value*100)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interaction *advPendingInteraction) error {
|
||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||
|
||||
if reply == "back" {
|
||||
data := interaction.Data.(*advPendingShopCategory)
|
||||
_, 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.chatLevel(ctx.Sender))
|
||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||
Type: "shop_category",
|
||||
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
|
||||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||||
})
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
if reply == "exit" || reply == "cancel" {
|
||||
p.shopSessionEnd(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
|
||||
}
|
||||
|
||||
// Find matching consumable
|
||||
var match *ConsumableDef
|
||||
for i := range consumableDefs {
|
||||
if consumableDefs[i].Buyable && containsFold(consumableDefs[i].Name, reply) {
|
||||
match = &consumableDefs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if match == nil {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "I don't have that. Reply with an item name from the list, or `back` to return.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(match.Price) {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d for %s but only have €%.0f.", match.Price, match.Name, balance))
|
||||
}
|
||||
|
||||
// Purchase the consumable
|
||||
p.euro.Debit(ctx.Sender, float64(match.Price), "shop_consumable")
|
||||
if potCut := int(math.Round(float64(match.Price) * 0.05)); potCut > 0 {
|
||||
communityPotAdd(potCut)
|
||||
trackTaxPaid(ctx.Sender, potCut)
|
||||
}
|
||||
item := AdvItem{
|
||||
Name: match.Name,
|
||||
Type: "consumable",
|
||||
Tier: match.Tier,
|
||||
Value: match.Price / 2,
|
||||
}
|
||||
_ = addAdvInventoryItem(ctx.Sender, item)
|
||||
|
||||
// Stay in supplies view for more purchases
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
newBalance := p.euro.GetBalance(ctx.Sender)
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%d. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
|
||||
match.Name, match.Price, newBalance))
|
||||
}
|
||||
|
||||
@@ -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, 0)
|
||||
|
||||
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, 0)
|
||||
|
||||
// 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, 0)
|
||||
|
||||
// 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 ──────────────────────────────────────────────���───────
|
||||
|
||||
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 ──────────────────────────────────────────────��────────
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,35 @@ func TestAdvIsEligible_NoPenalty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvIsEligible_BonusesDontUnlockTiers(t *testing.T) {
|
||||
char := &AdventureCharacter{MiningSkill: 1}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{}
|
||||
loc := &AdvLocation{Activity: AdvActivityMining, MinLevel: 8, MinEquipTier: 0}
|
||||
bonuses := &AdvBonusSummary{MiningBonus: 10} // +10 would make effective 11, but base is 1
|
||||
|
||||
eligible, _ := advIsEligible(char, equip, loc, bonuses)
|
||||
if eligible {
|
||||
t.Error("bonuses should not unlock tiers — base mining 1 should not access min level 8")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvIsEligible_BonusesDontAffectPenaltyZone(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 21} // base 21, min 20 → penalty (21-20=1 < 3)
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 2, Condition: 100},
|
||||
}
|
||||
loc := &AdvLocation{Activity: AdvActivityDungeon, MinLevel: 20, MinEquipTier: 0}
|
||||
bonuses := &AdvBonusSummary{CombatBonus: 10} // +10 would make effective 31, but penalty uses base
|
||||
|
||||
eligible, penalty := advIsEligible(char, equip, loc, bonuses)
|
||||
if !eligible {
|
||||
t.Error("should be eligible at base combat 21 for min 20")
|
||||
}
|
||||
if !penalty {
|
||||
t.Error("should still be in penalty zone — bonuses don't affect penalty calculation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateAdvProbabilities_SumsTo100(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 10}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
|
||||
@@ -241,11 +241,12 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
||||
|
||||
// ── Treasure Drop Logic ──────────────────────────────────────────────────────
|
||||
|
||||
func rollAdvTreasureDrop(tier int, userID id.UserID) *AdvTreasureDrop {
|
||||
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
|
||||
rate, ok := advTreasureDropRates[tier]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
rate += chatLevelRareBonus(chatLevel)
|
||||
|
||||
if rand.Float64() >= rate {
|
||||
return nil
|
||||
|
||||
@@ -56,7 +56,7 @@ func twinBeeMaxTier() int {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill
|
||||
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
if combined > bestLevel {
|
||||
bestLevel = combined
|
||||
}
|
||||
@@ -236,7 +236,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
|
||||
var eligible []id.UserID
|
||||
for _, c := range chars {
|
||||
if c.ActionTakenToday {
|
||||
if c.HasActedToday() {
|
||||
eligible = append(eligible, c.UserID)
|
||||
}
|
||||
}
|
||||
@@ -264,12 +264,12 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
var players []eligiblePlayer
|
||||
totalWeight := 0
|
||||
for _, uid := range eligible {
|
||||
weight := 3 // minimum (level 1 in all 3 skills)
|
||||
weight := 4 // minimum (level 1 in all 4 skills)
|
||||
for _, c := range chars {
|
||||
if c.UserID == uid {
|
||||
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill
|
||||
if weight < 3 {
|
||||
weight = 3
|
||||
weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||
if weight < 4 {
|
||||
weight = 4
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -289,7 +289,8 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
||||
if share < 1 {
|
||||
share = 1
|
||||
}
|
||||
p.euro.Credit(ep.uid, float64(share), "twinbee_daily_share")
|
||||
net, _ := communityTax(ep.uid, float64(share), 0.05)
|
||||
p.euro.Credit(ep.uid, net, "twinbee_daily_share")
|
||||
totalDistributed += share
|
||||
if rollTwinBeeGift(ep.uid) {
|
||||
summary.GiftCount++
|
||||
|
||||
@@ -123,7 +123,7 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// Subcommands that hit the Jikan API run async to avoid blocking dispatch
|
||||
go func() {
|
||||
safeGo("anime-handler", func() {
|
||||
var err error
|
||||
switch sub {
|
||||
case "watch":
|
||||
@@ -142,7 +142,7 @@ func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
slog.Error("anime: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,886 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// ─── Threshold Constants ────��──────────────────────────────────────
|
||||
|
||||
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 ─────���─────────────────────────────────────────────
|
||||
|
||||
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.",
|
||||
}
|
||||
|
||||
// ─��─ Mutual Exclusions ────��────────────────────────────────────────
|
||||
|
||||
var mutualExclusions = [][2]string{
|
||||
{"Novelist", "Minimalist"},
|
||||
{"Early Bird", "Night Owl"},
|
||||
{"Arena Champion", "Arena Coward"},
|
||||
{"Whale", "Broke But Spirited"},
|
||||
}
|
||||
|
||||
// ─── Core Types ────────────���───────────────────────────────────────
|
||||
|
||||
type archetypeResult struct {
|
||||
Name string
|
||||
Category string
|
||||
SignalScore float64
|
||||
Flavor string
|
||||
}
|
||||
|
||||
// ─── Community Percentiles ──────────────────────���──────────────────
|
||||
|
||||
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 ──────────────────────��──────────────────
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// ���── Archetype Evaluators ────────���─────────────────────────────────
|
||||
|
||||
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 ────────��─────────────────────────────────────
|
||||
|
||||
// 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 ─────────────────────��──────────────────────────
|
||||
|
||||
// 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 ─────────���─────────────────────────────────────────────
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── #1: Credit/Debit reject non-positive amounts ───────────────────────────
|
||||
// These can't call the real Debit/Credit (they need a DB), so we test the
|
||||
// guard logic by verifying the internal credit() helper is never reached
|
||||
// with bad amounts. We test the normalizeExpression path instead, and rely
|
||||
// on the exported method signatures for the guard.
|
||||
|
||||
// TestNormalizeExpression_NoMutation ensures normalizeExpression doesn't
|
||||
// corrupt valid expressions (regression guard for calc changes).
|
||||
func TestNormalizeExpression_NoMutation_Audit(t *testing.T) {
|
||||
// Already-numeric expressions should pass through unmangled.
|
||||
got := normalizeExpression("100 + 200")
|
||||
if got != "100 + 200" {
|
||||
t.Errorf("normalizeExpression mangled numeric expression: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── #6: safeGo recovers panics ─────────────────────────────────────────────
|
||||
|
||||
func TestSafeGo_RecoversPanic(t *testing.T) {
|
||||
var recovered atomic.Bool
|
||||
done := make(chan struct{})
|
||||
|
||||
// We can't directly observe the slog.Error from safeGo, but we can
|
||||
// verify the goroutine doesn't kill the test process.
|
||||
safeGo("test-panic", func() {
|
||||
defer func() { close(done) }()
|
||||
recovered.Store(true)
|
||||
panic("intentional test panic")
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// The goroutine ran (recovered.Store happened before panic).
|
||||
if !recovered.Load() {
|
||||
t.Error("safeGo function body did not execute")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("safeGo goroutine did not complete within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeGo_NormalExecution(t *testing.T) {
|
||||
var result atomic.Int64
|
||||
done := make(chan struct{})
|
||||
|
||||
safeGo("test-normal", func() {
|
||||
result.Store(42)
|
||||
close(done)
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
if result.Load() != 42 {
|
||||
t.Errorf("safeGo result = %d, want 42", result.Load())
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("safeGo goroutine did not complete within timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeGo_NilPanic(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
safeGo("test-nil-panic", func() {
|
||||
defer func() { close(done) }()
|
||||
panic(nil)
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// didn't crash — success
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("safeGo did not recover from nil panic")
|
||||
}
|
||||
}
|
||||
|
||||
// ── #9: Arena bail channel ownership transfer ──────────────────────────────
|
||||
// Verify that the LoadAndDelete pattern prevents double-close panics.
|
||||
|
||||
func TestArenaBailChannel_OwnershipTransfer(t *testing.T) {
|
||||
var m sync.Map
|
||||
|
||||
// Simulate: countdown stores channel
|
||||
bailCh := make(chan struct{})
|
||||
m.Store("user1", bailCh)
|
||||
|
||||
// Simulate: bail handler claims it via LoadAndDelete
|
||||
ch, ok := m.LoadAndDelete("user1")
|
||||
if !ok {
|
||||
t.Fatal("LoadAndDelete should succeed first time")
|
||||
}
|
||||
close(ch.(chan struct{}))
|
||||
|
||||
// Simulate: countdown timer expires, tries LoadAndDelete — should fail
|
||||
_, ok = m.LoadAndDelete("user1")
|
||||
if ok {
|
||||
t.Error("second LoadAndDelete should fail — bail handler already claimed it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaBailChannel_TimerExpires_BailLate(t *testing.T) {
|
||||
var m sync.Map
|
||||
|
||||
bailCh := make(chan struct{})
|
||||
m.Store("user1", bailCh)
|
||||
|
||||
// Timer expires — countdown claims it
|
||||
_, ok := m.LoadAndDelete("user1")
|
||||
if !ok {
|
||||
t.Fatal("countdown should claim channel")
|
||||
}
|
||||
|
||||
// Bail comes late — should find nothing
|
||||
_, ok = m.LoadAndDelete("user1")
|
||||
if ok {
|
||||
t.Error("late bail should find nothing — countdown already claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// ── #13: Index DDL is valid SQL ────────────────────────────────────────────
|
||||
// Verify the index creation statements parse (they'll be tested at schema
|
||||
// init time, but this catches obvious syntax errors in the string).
|
||||
|
||||
func TestIndexDDL_ContainsNewIndexes(t *testing.T) {
|
||||
// Verify the arena status render works for both states that the new
|
||||
// idx_arena_runs_user(user_id, status) index covers.
|
||||
|
||||
run := &ArenaRun{
|
||||
Tier: 2,
|
||||
Round: 3,
|
||||
Status: "active",
|
||||
Earnings: 500,
|
||||
TierEarnings: 100,
|
||||
}
|
||||
status := renderArenaStatus(run, &AdventureCharacter{})
|
||||
if !strings.Contains(status, "Tier: 2") {
|
||||
t.Error("renderArenaStatus should show tier")
|
||||
}
|
||||
if !strings.Contains(status, "fight") {
|
||||
t.Error("renderArenaStatus should show fight command for active runs")
|
||||
}
|
||||
|
||||
// Awaiting state
|
||||
run.Status = "awaiting"
|
||||
status = renderArenaStatus(run, &AdventureCharacter{})
|
||||
if !strings.Contains(status, "bail") {
|
||||
t.Error("renderArenaStatus should show bail command for awaiting runs")
|
||||
}
|
||||
}
|
||||
|
||||
// ── #15: JSON unmarshal error handling ──────────────────────────────────────
|
||||
// We can't test lottery_db directly (needs DB), but we can verify the
|
||||
// countMatches and formatLotteryNumbers handle edge cases that would
|
||||
// surface from corrupt JSON.
|
||||
|
||||
func TestCountMatches_EmptySlices(t *testing.T) {
|
||||
if got := countMatches([]int{}, []int{1, 2, 3}); got != 0 {
|
||||
t.Errorf("countMatches(empty, winning) = %d, want 0", got)
|
||||
}
|
||||
if got := countMatches([]int{1, 2, 3}, []int{}); got != 0 {
|
||||
t.Errorf("countMatches(ticket, empty) = %d, want 0", got)
|
||||
}
|
||||
if got := countMatches([]int{}, []int{}); got != 0 {
|
||||
t.Errorf("countMatches(empty, empty) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLotteryNumbers_Empty(t *testing.T) {
|
||||
got := formatLotteryNumbers([]int{})
|
||||
if got != "" {
|
||||
t.Errorf("formatLotteryNumbers(empty) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatLotteryNumbers_Single(t *testing.T) {
|
||||
got := formatLotteryNumbers([]int{7})
|
||||
if got != "7" {
|
||||
t.Errorf("formatLotteryNumbers([7]) = %q, want %q", got, "7")
|
||||
}
|
||||
}
|
||||
|
||||
// ── #16: Error messages don't leak internals ───────────────────────────────
|
||||
|
||||
func TestRenderArenaDeath_NoInternalLeak(t *testing.T) {
|
||||
tier := &ArenaTier{Number: 3, Name: "The Gauntlet"}
|
||||
monster := &ArenaMonster{Name: "Shadow Beast"}
|
||||
msg := renderArenaDeath(tier, 2, monster, 1500, "The beast was faster.")
|
||||
|
||||
// Should contain user-facing info
|
||||
if !strings.Contains(msg, "DEAD") {
|
||||
t.Error("death message should say DEAD")
|
||||
}
|
||||
if !strings.Contains(msg, "€1,500") && !strings.Contains(msg, "1500") {
|
||||
// Check for either comma-formatted or raw
|
||||
if !strings.Contains(msg, "Forfeited") {
|
||||
t.Error("death message should mention forfeited earnings")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(msg, "midnight UTC") {
|
||||
t.Error("death message should mention respawn time")
|
||||
}
|
||||
|
||||
// Should NOT contain stack traces or internal error patterns
|
||||
for _, bad := range []string{"panic", "goroutine", "runtime.", ".go:", "err="} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Errorf("death message should not contain internal detail %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaStatus_NoInternalLeak(t *testing.T) {
|
||||
run := &ArenaRun{
|
||||
Tier: 1,
|
||||
Round: 2,
|
||||
Status: "active",
|
||||
Earnings: 0,
|
||||
TierEarnings: 50,
|
||||
}
|
||||
msg := renderArenaStatus(run, &AdventureCharacter{})
|
||||
|
||||
for _, bad := range []string{"panic", "goroutine", "runtime.", "err="} {
|
||||
if strings.Contains(msg, bad) {
|
||||
t.Errorf("status message should not contain internal detail %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── #20/#21: Error messages have actionable guidance ────────────────────────
|
||||
|
||||
func TestRenderArenaAlreadyInRun_HasGuidance(t *testing.T) {
|
||||
// Active state
|
||||
run := &ArenaRun{Tier: 2, Round: 3, Status: "active"}
|
||||
msg := renderArenaAlreadyInRun(run)
|
||||
if !strings.Contains(msg, "!arena fight") {
|
||||
t.Error("already-in-run (active) should tell user to !arena fight")
|
||||
}
|
||||
|
||||
// Awaiting state
|
||||
run.Status = "awaiting"
|
||||
run.Earnings = 500
|
||||
msg = renderArenaAlreadyInRun(run)
|
||||
if !strings.Contains(msg, "!bail") {
|
||||
t.Error("already-in-run (awaiting) should tell user to !bail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaHelpText_HasAllCommands(t *testing.T) {
|
||||
required := []string{"!arena", "!arena fight", "!bail", "!arena status", "!arena stats", "!arena leaderboard", "!arena help"}
|
||||
for _, cmd := range required {
|
||||
if !strings.Contains(arenaHelpText, cmd) {
|
||||
t.Errorf("arena help text missing command %q", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── #17: Integer overflow edge cases ───────────────────────────────────────
|
||||
|
||||
func TestArenaRoundReward_LargeTier(t *testing.T) {
|
||||
tier := &ArenaTier{BasePayout: 1000, SkillMultiplier: 2.0}
|
||||
reward := arenaRoundReward(tier, 4, 100)
|
||||
expected := int64(1000*4) + int64(float64(100)*2.0)
|
||||
if reward != expected {
|
||||
t.Errorf("arenaRoundReward = %d, want %d", reward, expected)
|
||||
}
|
||||
if reward < 0 {
|
||||
t.Error("arenaRoundReward should not overflow to negative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber_MaxInt(t *testing.T) {
|
||||
// Large number shouldn't panic
|
||||
got := formatNumber(999999999)
|
||||
if !strings.Contains(got, "999") {
|
||||
t.Errorf("formatNumber(999999999) = %q, doesn't look right", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumberInt64_MaxValues(t *testing.T) {
|
||||
got := formatNumberInt64(9_223_372_036_854_775_807) // max int64
|
||||
if got == "" {
|
||||
t.Error("formatNumberInt64(maxint64) should not return empty")
|
||||
}
|
||||
if !strings.Contains(got, ",") {
|
||||
t.Errorf("formatNumberInt64(maxint64) = %q, expected commas", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Regression: normalizeExpression edge cases ─────────────────────────────
|
||||
|
||||
func TestNormalizeExpression_NegativeInput(t *testing.T) {
|
||||
got := normalizeExpression("-5 + 3")
|
||||
if !strings.Contains(got, "-5") || !strings.Contains(got, "+ 3") {
|
||||
t.Errorf("normalizeExpression(\"-5 + 3\") = %q, unexpected", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExpression_EmptyInput(t *testing.T) {
|
||||
got := normalizeExpression("")
|
||||
if got != "" {
|
||||
t.Errorf("normalizeExpression(\"\") = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_NegativeFloat(t *testing.T) {
|
||||
got := formatCalcResult(-12345.67)
|
||||
if !strings.Contains(got, "-") {
|
||||
t.Errorf("formatCalcResult(-12345.67) = %q, should be negative", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_LargeWholeFloat(t *testing.T) {
|
||||
got := formatCalcResult(1000000.0)
|
||||
if got != "1,000,000" {
|
||||
t.Errorf("formatCalcResult(1000000.0) = %q, want %q", got, "1,000,000")
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,8 @@ func NewBlackjackPlugin(client *mautrix.Client, euro *EuroPlugin) *BlackjackPlug
|
||||
}
|
||||
}
|
||||
|
||||
func (p *BlackjackPlugin) Name() string { return "blackjack" }
|
||||
func (p *BlackjackPlugin) Name() string { return "blackjack" }
|
||||
func (p *BlackjackPlugin) Version() string { return "1.1.0" }
|
||||
|
||||
func (p *BlackjackPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
@@ -727,8 +728,10 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable)
|
||||
|
||||
case playerBJ:
|
||||
result = "Blackjack!"
|
||||
payout = pl.Bet + math.Floor(pl.Bet*1.5) // Return bet + 1.5x (rounded down)
|
||||
p.euro.Credit(pl.UserID, payout, "blackjack_win")
|
||||
payout = pl.Bet + math.Floor(pl.Bet*1.5)
|
||||
net, _ := communityTax(pl.UserID, payout, 0.05)
|
||||
p.euro.Credit(pl.UserID, net, "blackjack_win")
|
||||
payout = net
|
||||
|
||||
case dealerBJ:
|
||||
result = "Dealer Blackjack"
|
||||
@@ -737,12 +740,16 @@ func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable)
|
||||
case dealerBust:
|
||||
result = "Win (dealer bust)!"
|
||||
payout = pl.Bet * 2
|
||||
p.euro.Credit(pl.UserID, payout, "blackjack_win")
|
||||
net, _ := communityTax(pl.UserID, payout, 0.05)
|
||||
p.euro.Credit(pl.UserID, net, "blackjack_win")
|
||||
payout = net
|
||||
|
||||
case playerValue > dealerValue:
|
||||
result = fmt.Sprintf("Win! %d vs %d", playerValue, dealerValue)
|
||||
payout = pl.Bet * 2
|
||||
p.euro.Credit(pl.UserID, payout, "blackjack_win")
|
||||
net, _ := communityTax(pl.UserID, payout, 0.05)
|
||||
p.euro.Credit(pl.UserID, net, "blackjack_win")
|
||||
payout = net
|
||||
|
||||
case playerValue == dealerValue:
|
||||
result = "Push"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/version"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
)
|
||||
@@ -43,6 +44,7 @@ func (p *BotInfoPlugin) Name() string { return "botinfo" }
|
||||
func (p *BotInfoPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "botinfo", Description: "Show bot diagnostics (admin only)", Usage: "!botinfo", Category: "Admin", AdminOnly: true},
|
||||
{Name: "version", Description: "Show bot version", Usage: "!version", Category: "Info"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +53,10 @@ func (p *BotInfoPlugin) Init() error { return nil }
|
||||
func (p *BotInfoPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "version") {
|
||||
return p.handleVersion(ctx)
|
||||
}
|
||||
if !p.IsCommand(ctx.Body, "botinfo") {
|
||||
// Passively count messages
|
||||
IncrementMessageCount()
|
||||
return nil
|
||||
}
|
||||
@@ -64,9 +68,24 @@ func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handleBotInfo(ctx)
|
||||
}
|
||||
|
||||
func (p *BotInfoPlugin) handleVersion(ctx MessageContext) error {
|
||||
uptime := time.Since(botStartTime)
|
||||
days := int(uptime.Hours() / 24)
|
||||
hours := int(uptime.Hours()) % 24
|
||||
|
||||
msg := fmt.Sprintf("**%s**\nUptime: %dd %dh", version.Full(), days, hours)
|
||||
|
||||
crashes := db.CrashCount(version.Short())
|
||||
if crashes > 0 {
|
||||
msg += fmt.Sprintf(" · %d crash(es) this version", crashes)
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("Bot Diagnostics\n\n")
|
||||
sb.WriteString(fmt.Sprintf("**Bot Diagnostics** — %s\n\n", version.Short()))
|
||||
|
||||
// Uptime
|
||||
uptime := time.Since(botStartTime)
|
||||
@@ -110,6 +129,33 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
||||
sb.WriteString("LLM status: not configured\n")
|
||||
}
|
||||
|
||||
// Crash stats
|
||||
crashesThisVersion := db.CrashCount(version.Short())
|
||||
crashesTotal := db.CrashCountAll()
|
||||
sb.WriteString(fmt.Sprintf("\nCrashes: %d this version / %d total\n", crashesThisVersion, crashesTotal))
|
||||
|
||||
// Recent crashes (last 5)
|
||||
d2 := db.Get()
|
||||
rows, err := d2.Query(`SELECT plugin, message, created_at FROM crash_log ORDER BY id DESC LIMIT 5`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
var hasCrashes bool
|
||||
for rows.Next() {
|
||||
var plug, msg, ts string
|
||||
if rows.Scan(&plug, &msg, &ts) != nil {
|
||||
continue
|
||||
}
|
||||
if !hasCrashes {
|
||||
sb.WriteString("Recent crashes:\n")
|
||||
hasCrashes = true
|
||||
}
|
||||
if len(msg) > 80 {
|
||||
msg = msg[:80] + "..."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s [%s] %s\n", ts, plug, msg))
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// runArenaCombat executes arena combat using the new simulation engine.
|
||||
// Returns the CombatResult and the post-combat Misty condition repair amount (if any).
|
||||
func (p *AdventurePlugin) runArenaCombat(
|
||||
userID id.UserID,
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
monster *ArenaMonster,
|
||||
arenaRound int,
|
||||
arenaTier int,
|
||||
) (CombatResult, int) {
|
||||
bonuses := p.loadCombatBonuses(userID, char)
|
||||
chatLvl := p.chatLevel(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||||
|
||||
// Load consumables from inventory and auto-select
|
||||
consumables := p.loadConsumableInventory(userID)
|
||||
enemyStats, _ := DeriveArenaMonsterStats(monster)
|
||||
selected := SelectConsumables(consumables, playerStats, enemyStats, arenaRound, arenaTier)
|
||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||
|
||||
// Misty condition repair (post-combat, not part of engine)
|
||||
condRepair := 0
|
||||
now := time.Now().UTC()
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
condRepair = 5
|
||||
}
|
||||
}
|
||||
|
||||
player := Combatant{
|
||||
Name: char.DisplayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: monster.Name,
|
||||
Stats: enemyStats,
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
Ability: monster.Ability,
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
result = injectConsumableEvents(result, selected, len(consumables))
|
||||
|
||||
// Consume used items from inventory
|
||||
for _, c := range selected {
|
||||
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
|
||||
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
return result, condRepair
|
||||
}
|
||||
|
||||
// grantCombatAchievements checks combat results for achievement-worthy moments.
|
||||
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
|
||||
if p.achievements == nil {
|
||||
return
|
||||
}
|
||||
if result.PlayerWon && result.NearDeath {
|
||||
p.achievements.GrantAchievement(userID, "combat_near_death")
|
||||
}
|
||||
if result.SniperKilled {
|
||||
p.achievements.GrantAchievement(userID, "combat_sniper_kill")
|
||||
}
|
||||
if result.MistyHealed {
|
||||
p.achievements.GrantAchievement(userID, "combat_misty_clutch")
|
||||
}
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "pet_whiff" {
|
||||
p.achievements.GrantAchievement(userID, "combat_pet_save")
|
||||
break
|
||||
}
|
||||
}
|
||||
if checkDeathSaveEvent(result.Events) {
|
||||
p.achievements.GrantAchievement(userID, "combat_death_save")
|
||||
}
|
||||
for _, ev := range result.Events {
|
||||
if ev.Action == "use_consumable" {
|
||||
p.achievements.GrantAchievement(userID, "combat_consumable_used")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDungeonCombat executes dungeon combat using the new simulation engine.
|
||||
func (p *AdventurePlugin) runDungeonCombat(
|
||||
userID id.UserID,
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
loc *AdvLocation,
|
||||
bonuses *AdvBonusSummary,
|
||||
inPenaltyZone bool,
|
||||
) CombatResult {
|
||||
chatLvl := p.chatLevel(userID)
|
||||
hasGrudge := char.GrudgeLocation == loc.Name
|
||||
|
||||
playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)
|
||||
|
||||
// Penalty zone: player is weakened (mirrors +5% death, -15% success from old system)
|
||||
if inPenaltyZone {
|
||||
playerStats.Attack = int(float64(playerStats.Attack) * 0.85)
|
||||
playerStats.Defense = int(float64(playerStats.Defense) * 0.85)
|
||||
playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90)
|
||||
}
|
||||
|
||||
// Load consumables from inventory and auto-select
|
||||
consumables := p.loadConsumableInventory(userID)
|
||||
enemyStats, enemyMods := DeriveDungeonMonsterStats(loc)
|
||||
selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier)
|
||||
ApplyConsumableMods(&playerStats, &playerMods, selected)
|
||||
|
||||
// Dungeon monsters T3+ can have abilities
|
||||
var ability *MonsterAbility
|
||||
switch {
|
||||
case loc.Tier >= 5:
|
||||
ability = &MonsterAbility{Name: "Abyssal Wrath", Phase: "any", ProcChance: 0.30, Effect: "enrage"}
|
||||
case loc.Tier == 4:
|
||||
ability = &MonsterAbility{Name: "Dark Curse", Phase: "clash", ProcChance: 0.25, Effect: "poison"}
|
||||
case loc.Tier == 3:
|
||||
ability = &MonsterAbility{Name: "Stone Skin", Phase: "opening", ProcChance: 0.20, Effect: "armor_break"}
|
||||
}
|
||||
|
||||
player := Combatant{
|
||||
Name: char.DisplayName,
|
||||
Stats: playerStats,
|
||||
Mods: playerMods,
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: loc.Denizens,
|
||||
Stats: enemyStats,
|
||||
Mods: enemyMods,
|
||||
Ability: ability,
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
result = injectConsumableEvents(result, selected, len(consumables))
|
||||
|
||||
// Consume used items from inventory
|
||||
for _, c := range selected {
|
||||
if err := removeAdvInventoryItem(c.InventoryID); err != nil {
|
||||
slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// loadCombatBonuses computes the AdvBonusSummary for combat stat derivation.
|
||||
func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCharacter) *AdvBonusSummary {
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
// If the player has sufficient foraging level, auto-crafts consumables from ingredients first.
|
||||
func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []ConsumableItem {
|
||||
items, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Auto-craft if player has foraging level 10+
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil && char.ForagingSkill >= 10 {
|
||||
craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill)
|
||||
if len(craftResults) > 0 {
|
||||
items = updatedItems
|
||||
for _, cr := range craftResults {
|
||||
if cr.Success {
|
||||
slog.Info("crafting: auto-crafted", "user", userID, "item", cr.Recipe.Result)
|
||||
if p.achievements != nil {
|
||||
p.achievements.GrantAchievement(userID, "adv_first_craft")
|
||||
if cr.Recipe.Tier >= 5 {
|
||||
p.achievements.GrantAchievement(userID, "adv_craft_t5")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Info("crafting: failed", "user", userID, "item", cr.Recipe.Result)
|
||||
}
|
||||
}
|
||||
// Store results for narrative rendering
|
||||
p.storeCraftResults(userID, craftResults)
|
||||
}
|
||||
}
|
||||
|
||||
var consumables []ConsumableItem
|
||||
for _, item := range items {
|
||||
if item.Type != "consumable" {
|
||||
continue
|
||||
}
|
||||
def := consumableDefByName(item.Name)
|
||||
if def == nil {
|
||||
continue
|
||||
}
|
||||
consumables = append(consumables, ConsumableItem{
|
||||
InventoryID: item.ID,
|
||||
Def: def,
|
||||
})
|
||||
}
|
||||
return consumables
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) storeCraftResults(userID id.UserID, results []CraftResult) {
|
||||
p.craftResults.Store(string(userID), results)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) popCraftResults(userID id.UserID) []CraftResult {
|
||||
val, ok := p.craftResults.LoadAndDelete(string(userID))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return val.([]CraftResult)
|
||||
}
|
||||
|
||||
// prependCraftNarrative adds crafting results to the first combat phase message.
|
||||
func (p *AdventurePlugin) prependCraftNarrative(userID id.UserID, messages []string) []string {
|
||||
results := p.popCraftResults(userID)
|
||||
if len(results) == 0 || len(messages) == 0 {
|
||||
return messages
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, cr := range results {
|
||||
if cr.Success {
|
||||
lines = append(lines, fmt.Sprintf("🧪 _Crafted **%s** from foraged ingredients._", cr.Recipe.Result))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("💨 _Failed to craft %s — ingredients lost._", cr.Recipe.Result))
|
||||
}
|
||||
}
|
||||
|
||||
prefix := strings.Join(lines, "\n") + "\n\n"
|
||||
messages[0] = prefix + messages[0]
|
||||
return messages
|
||||
}
|
||||
|
||||
// sendCombatMessages sends phase messages with delays, then a final message.
|
||||
// Runs in a goroutine so it doesn't block the message handler.
|
||||
// Returns a channel that is closed when all messages have been sent.
|
||||
func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for _, msg := range phaseMessages {
|
||||
_ = p.SendDM(userID, msg)
|
||||
delay := 5 + rand.IntN(4) // 5-8 seconds
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
}
|
||||
_ = p.SendDM(userID, finalMessage)
|
||||
}()
|
||||
return done
|
||||
}
|
||||
|
||||
// XPBonusParams holds inputs for the shared XP bonus pipeline.
|
||||
type XPBonusParams struct {
|
||||
BaseXP int
|
||||
NearDeath bool
|
||||
BonusMult float64 // from AdvBonusSummary.XPMultiplier (percentage, e.g. 15 = +15%)
|
||||
Ironclad bool
|
||||
OverlevelMult float64 // 1.0 = no penalty
|
||||
}
|
||||
|
||||
// XPResult holds the final XP and a human-readable breakdown of bonuses applied.
|
||||
type XPResult struct {
|
||||
Total int
|
||||
Breakdown string // e.g. "+15% near-death, +5% ironclad" — empty if no bonuses
|
||||
}
|
||||
|
||||
// applyXPBonuses applies near-death, bonus multiplier, ironclad, and overlevel
|
||||
// penalties to a base XP value. Used by both dungeon and adventure paths.
|
||||
func applyXPBonuses(p XPBonusParams) XPResult {
|
||||
xp := p.BaseXP
|
||||
var parts []string
|
||||
if p.NearDeath {
|
||||
xp = int(float64(xp) * 1.15)
|
||||
parts = append(parts, "+15% near-death")
|
||||
}
|
||||
if p.BonusMult != 0 {
|
||||
xp = int(float64(xp) * (1 + p.BonusMult/100))
|
||||
parts = append(parts, fmt.Sprintf("+%.0f%% bonus", p.BonusMult))
|
||||
}
|
||||
if p.Ironclad {
|
||||
xp = int(float64(xp) * 1.05)
|
||||
parts = append(parts, "+5% ironclad")
|
||||
}
|
||||
if p.OverlevelMult < 1.0 {
|
||||
xp = max(1, int(float64(xp)*p.OverlevelMult))
|
||||
penalty := int((1.0 - p.OverlevelMult) * 100)
|
||||
parts = append(parts, fmt.Sprintf("-%d%% overlevel", penalty))
|
||||
}
|
||||
breakdown := ""
|
||||
if len(parts) > 0 {
|
||||
breakdown = strings.Join(parts, ", ")
|
||||
}
|
||||
return XPResult{Total: xp, Breakdown: breakdown}
|
||||
}
|
||||
|
||||
// DeathTransitionParams holds inputs for the shared death state machine.
|
||||
type DeathTransitionParams struct {
|
||||
Char *AdventureCharacter
|
||||
Equip map[EquipmentSlot]*AdvEquipment
|
||||
ChatLevel int
|
||||
Location string // set as GrudgeLocation; empty = don't set
|
||||
AllowPardon bool // chat level pardon (adventure only)
|
||||
AllowSovereign bool // probability-band Sovereign reprieve (non-engine path)
|
||||
EngineSaved bool // combat engine used Sovereign death save
|
||||
}
|
||||
|
||||
// DeathTransitionResult describes what happened during the death transition.
|
||||
type DeathTransitionResult struct {
|
||||
Pardoned bool
|
||||
Reprieved bool // Sovereign reprieve (non-engine)
|
||||
PetRecovered bool
|
||||
Died bool
|
||||
}
|
||||
|
||||
// transitionDeath runs the shared death state machine: pardon → engine cooldown →
|
||||
// Sovereign reprieve → kill + pet ditch recovery.
|
||||
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
|
||||
var r DeathTransitionResult
|
||||
|
||||
if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && rand.Float64() < 0.33 {
|
||||
r.Pardoned = true
|
||||
now := time.Now().UTC()
|
||||
p.Char.LastPardonUsed = &now
|
||||
if p.Location != "" {
|
||||
p.Char.GrudgeLocation = p.Location
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
if p.EngineSaved {
|
||||
now := time.Now().UTC()
|
||||
p.Char.DeathReprieveLast = &now
|
||||
}
|
||||
|
||||
if p.AllowSovereign && advEquippedArenaSets(p.Equip)["sovereign"] && p.Char.DeathReprieveAvailable() {
|
||||
r.Reprieved = true
|
||||
now := time.Now().UTC()
|
||||
p.Char.DeathReprieveLast = &now
|
||||
if p.Location != "" {
|
||||
p.Char.GrudgeLocation = p.Location
|
||||
}
|
||||
for _, slot := range allSlots {
|
||||
if eq, ok := p.Equip[slot]; ok {
|
||||
eq.Condition = 1
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
p.Char.Kill()
|
||||
if p.Location != "" {
|
||||
p.Char.GrudgeLocation = p.Location
|
||||
}
|
||||
r.Died = true
|
||||
|
||||
if petRollDitchRecovery(p.Char) && p.Char.DeadUntil != nil {
|
||||
reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Char.PetLevel))
|
||||
p.Char.DeadUntil = &reduced
|
||||
r.PetRecovered = true
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// checkDeathSaveEvent scans combat events for a Sovereign death save.
|
||||
func checkDeathSaveEvent(events []CombatEvent) bool {
|
||||
for _, ev := range events {
|
||||
if ev.Action == "death_save" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// injectConsumableEvents prepends use_consumable events so the narrative shows which items were consumed.
|
||||
// If items were available but skipped (trivial threat), a skip event is injected instead.
|
||||
func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inventorySize int) CombatResult {
|
||||
if len(selected) == 0 {
|
||||
if inventorySize > 0 {
|
||||
result.Events = append([]CombatEvent{{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "consumable_skip",
|
||||
PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP,
|
||||
}}, result.Events...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
var events []CombatEvent
|
||||
for _, c := range selected {
|
||||
events = append(events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable",
|
||||
PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP,
|
||||
Desc: c.Def.Name,
|
||||
})
|
||||
}
|
||||
result.Events = append(events, result.Events...)
|
||||
return result
|
||||
}
|
||||
|
||||
// combatResultToOutcome maps a CombatResult to an AdvOutcomeType for dungeon integration.
|
||||
func combatResultToOutcome(result CombatResult) AdvOutcomeType {
|
||||
if !result.PlayerWon {
|
||||
return AdvOutcomeDeath
|
||||
}
|
||||
// Won with >85% HP remaining → exceptional
|
||||
remainingPct := float64(result.PlayerEndHP) / float64(max(1, result.PlayerStartHP))
|
||||
if remainingPct > 0.85 {
|
||||
return AdvOutcomeExceptional
|
||||
}
|
||||
return AdvOutcomeSuccess
|
||||
}
|
||||
|
||||
// combatDegradation derives equipment damage from actual combat events.
|
||||
func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
|
||||
damage := make(map[EquipmentSlot]int)
|
||||
|
||||
if !result.PlayerWon {
|
||||
// Death: heavy degradation
|
||||
for _, slot := range allSlots {
|
||||
damage[slot] = 20
|
||||
}
|
||||
damage[SlotWeapon] = 30
|
||||
damage[SlotArmor] = 30
|
||||
return applyDegradationModifiers(damage, equip)
|
||||
}
|
||||
|
||||
// Derive from events: each enemy hit on player → armor/helmet wear
|
||||
// Each player hit → weapon wear, environmental → boots
|
||||
for _, ev := range result.Events {
|
||||
switch {
|
||||
case ev.Actor == "enemy" && (ev.Action == "hit" || ev.Action == "crit"):
|
||||
damage[SlotArmor] += 1 + rand.IntN(3)
|
||||
damage[SlotHelmet] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "enemy" && ev.Action == "cleave":
|
||||
damage[SlotArmor] += 2 + rand.IntN(3)
|
||||
case ev.Actor == "enemy" && ev.Action == "lifesteal":
|
||||
damage[SlotArmor] += 1 + rand.IntN(2)
|
||||
case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
|
||||
damage[SlotWeapon] += 1
|
||||
case ev.Actor == "environment":
|
||||
damage[SlotBoots] += 1 + rand.IntN(2)
|
||||
case ev.Action == "armor_break":
|
||||
damage[SlotArmor] += 3
|
||||
}
|
||||
}
|
||||
|
||||
return applyDegradationModifiers(damage, equip)
|
||||
}
|
||||
|
||||
func applyDegradationModifiers(damage map[EquipmentSlot]int, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
|
||||
tempered := advEquippedArenaSets(equip)["tempered"]
|
||||
for slot, dmg := range damage {
|
||||
eq, ok := equip[slot]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tempered {
|
||||
dmg = int(float64(dmg) * 0.75)
|
||||
}
|
||||
if eq.ActionsUsed >= 20 {
|
||||
dmg = int(float64(dmg) * 0.8)
|
||||
}
|
||||
if dmg < 0 {
|
||||
dmg = 0
|
||||
}
|
||||
damage[slot] = dmg
|
||||
eq.Condition -= dmg
|
||||
if eq.Condition < 0 {
|
||||
eq.Condition = 0
|
||||
}
|
||||
}
|
||||
return damage
|
||||
}
|
||||
|
||||
// resolveDungeonAction runs a dungeon encounter through the combat engine
|
||||
// and returns an AdvActionResult compatible with the existing adventure flow.
|
||||
func (p *AdventurePlugin) resolveDungeonAction(
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
loc *AdvLocation,
|
||||
bonuses *AdvBonusSummary,
|
||||
inPenaltyZone bool,
|
||||
) *AdvActionResult {
|
||||
result := &AdvActionResult{
|
||||
Location: loc,
|
||||
XPSkill: "combat",
|
||||
}
|
||||
|
||||
combat := p.runDungeonCombat(char.UserID, char, equip, loc, bonuses, inPenaltyZone)
|
||||
result.CombatLog = &combat
|
||||
result.Outcome = combatResultToOutcome(combat)
|
||||
|
||||
// Misty condition repair (post-combat, same as arena)
|
||||
now := time.Now().UTC()
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
npcRepairMostDamaged(char.UserID, equip, 5)
|
||||
}
|
||||
}
|
||||
result.NearDeath = combat.NearDeath
|
||||
|
||||
// Overlevel penalty
|
||||
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
|
||||
overlevelMult := advOverlevelMultiplier(skillLevel, loc)
|
||||
|
||||
// Loot on success/exceptional
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// XP calculation
|
||||
xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
xp = advXPTable[loc.Activity][loc.Tier].Success
|
||||
if result.Outcome == AdvOutcomeExceptional {
|
||||
xp = advXPTable[loc.Activity][loc.Tier].Exceptional
|
||||
}
|
||||
}
|
||||
xpResult := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: xp,
|
||||
NearDeath: result.NearDeath,
|
||||
BonusMult: bonuses.XPMultiplier,
|
||||
Ironclad: advEquippedArenaSets(equip)["ironclad"],
|
||||
OverlevelMult: overlevelMult,
|
||||
})
|
||||
result.XPGained = xpResult.Total
|
||||
result.XPBreakdown = xpResult.Breakdown
|
||||
|
||||
// Equipment degradation from combat events
|
||||
result.EquipDamage = combatDegradation(combat, equip)
|
||||
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||||
|
||||
// Increment actions_used for equipment mastery
|
||||
for _, eq := range equip {
|
||||
eq.ActionsUsed++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// renderArenaCombatFinalMessage builds the post-combat text (rewards, tier progress, etc.)
|
||||
// This is sent as the last message after the phase-by-phase combat messages.
|
||||
func renderArenaCombatFinalMessage(result CombatResult, monster *ArenaMonster, reward int64, battleXP int, round int) string {
|
||||
if result.PlayerWon {
|
||||
closer := arenaWinCloser(monster.Name, round)
|
||||
return fmt.Sprintf("💀 **%s** has been defeated.\n%s\n🏆 +%d XP | €%d earned",
|
||||
monster.Name, closer, battleXP, reward)
|
||||
}
|
||||
closer := arenaLoseCloser(monster.Name, round)
|
||||
return fmt.Sprintf("The healers are already moving.\n💀 **Defeated.**\n%s\n+%d XP (participation) | Back tomorrow.",
|
||||
closer, arenaParticipationXP)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckDeathSaveEvent_Found(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Action: "hit"}, {Action: "crit"}, {Action: "death_save"}, {Action: "hit"},
|
||||
}
|
||||
if !checkDeathSaveEvent(events) {
|
||||
t.Error("should find death_save event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeathSaveEvent_NotFound(t *testing.T) {
|
||||
events := []CombatEvent{
|
||||
{Action: "hit"}, {Action: "crit"}, {Action: "block"},
|
||||
}
|
||||
if checkDeathSaveEvent(events) {
|
||||
t.Error("should not find death_save event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDeathSaveEvent_Empty(t *testing.T) {
|
||||
if checkDeathSaveEvent(nil) {
|
||||
t.Error("nil events should return false")
|
||||
}
|
||||
if checkDeathSaveEvent([]CombatEvent{}) {
|
||||
t.Error("empty events should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_AllNeutral(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, OverlevelMult: 1.0})
|
||||
if r.Total != 100 {
|
||||
t.Errorf("neutral bonuses: got %d, want 100", r.Total)
|
||||
}
|
||||
if r.Breakdown != "" {
|
||||
t.Errorf("neutral breakdown should be empty, got %q", r.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_NearDeath(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, NearDeath: true, OverlevelMult: 1.0})
|
||||
if r.Total != 114 {
|
||||
t.Errorf("near-death: got %d, want 114", r.Total)
|
||||
}
|
||||
if r.Breakdown != "+15% near-death" {
|
||||
t.Errorf("breakdown = %q", r.Breakdown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_BonusMult(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, BonusMult: 20, OverlevelMult: 1.0})
|
||||
if r.Total != 120 {
|
||||
t.Errorf("bonus mult 20%%: got %d, want 120", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_Ironclad(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, Ironclad: true, OverlevelMult: 1.0})
|
||||
if r.Total != 105 {
|
||||
t.Errorf("ironclad: got %d, want 105", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_OverlevelPenalty(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 100, OverlevelMult: 0.5})
|
||||
if r.Total != 50 {
|
||||
t.Errorf("overlevel 0.5x: got %d, want 50", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_OverlevelFloor(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{BaseXP: 1, OverlevelMult: 0.01})
|
||||
if r.Total < 1 {
|
||||
t.Errorf("overlevel floor: got %d, want >= 1", r.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyXPBonuses_AllStacked(t *testing.T) {
|
||||
r := applyXPBonuses(XPBonusParams{
|
||||
BaseXP: 100, NearDeath: true, BonusMult: 20, Ironclad: true, OverlevelMult: 0.8,
|
||||
})
|
||||
if r.Total != 113 {
|
||||
t.Errorf("all stacked: got %d, want 113", r.Total)
|
||||
}
|
||||
if r.Breakdown == "" {
|
||||
t.Error("stacked breakdown should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_PardonFires(t *testing.T) {
|
||||
_ = transitionDeath(DeathTransitionParams{
|
||||
Char: &AdventureCharacter{Alive: true},
|
||||
ChatLevel: 25,
|
||||
Location: "Dark Cave",
|
||||
AllowPardon: true,
|
||||
})
|
||||
// Pardon is 33% chance — run enough times to confirm it can fire
|
||||
pardoned := 0
|
||||
for i := 0; i < 300; i++ {
|
||||
c := &AdventureCharacter{Alive: true}
|
||||
res := transitionDeath(DeathTransitionParams{
|
||||
Char: c, ChatLevel: 25, Location: "Dark Cave", AllowPardon: true,
|
||||
})
|
||||
if res.Pardoned {
|
||||
pardoned++
|
||||
if c.LastPardonUsed == nil {
|
||||
t.Fatal("pardon should set LastPardonUsed")
|
||||
}
|
||||
if c.GrudgeLocation != "Dark Cave" {
|
||||
t.Fatalf("pardon should set GrudgeLocation, got %q", c.GrudgeLocation)
|
||||
}
|
||||
if res.Died || res.Reprieved {
|
||||
t.Fatal("pardoned should not also be died/reprieved")
|
||||
}
|
||||
}
|
||||
}
|
||||
if pardoned == 0 {
|
||||
t.Error("pardon should fire at least once in 300 trials (p≈33%)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_PardonCooldown(t *testing.T) {
|
||||
recent := time.Now().UTC().Add(-1 * time.Hour)
|
||||
char := &AdventureCharacter{Alive: true, LastPardonUsed: &recent}
|
||||
r := transitionDeath(DeathTransitionParams{
|
||||
Char: char, ChatLevel: 25, Location: "Cave", AllowPardon: true,
|
||||
})
|
||||
if r.Pardoned {
|
||||
t.Error("pardon should not fire during cooldown")
|
||||
}
|
||||
if !r.Died {
|
||||
t.Error("should die when pardon on cooldown and no sovereign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_SovereignReprieve(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 80, ArenaSet: "sovereign"},
|
||||
SlotArmor: {Condition: 90, ArenaSet: "sovereign"},
|
||||
SlotHelmet: {Condition: 70, ArenaSet: "sovereign"},
|
||||
SlotBoots: {Condition: 60, ArenaSet: "sovereign"},
|
||||
SlotTool: {Condition: 50, ArenaSet: "sovereign"},
|
||||
}
|
||||
char := &AdventureCharacter{Alive: true}
|
||||
r := transitionDeath(DeathTransitionParams{
|
||||
Char: char, Equip: equip, Location: "Abyss",
|
||||
AllowSovereign: true,
|
||||
})
|
||||
if !r.Reprieved {
|
||||
t.Fatal("sovereign should reprieve")
|
||||
}
|
||||
if r.Died || r.Pardoned {
|
||||
t.Error("reprieved should not also be died/pardoned")
|
||||
}
|
||||
if char.GrudgeLocation != "Abyss" {
|
||||
t.Errorf("grudge location = %q, want Abyss", char.GrudgeLocation)
|
||||
}
|
||||
for slot, eq := range equip {
|
||||
if eq.Condition != 1 {
|
||||
t.Errorf("slot %s condition = %d, want 1", slot, eq.Condition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_KillAndPetRecovery(t *testing.T) {
|
||||
deaths, petRecoveries := 0, 0
|
||||
for i := 0; i < 500; i++ {
|
||||
char := &AdventureCharacter{
|
||||
Alive: true, PetType: "cat", PetArrived: true, PetLevel: 5,
|
||||
}
|
||||
r := transitionDeath(DeathTransitionParams{Char: char, Location: "Mine"})
|
||||
if !r.Died {
|
||||
t.Fatal("should die with no saves available")
|
||||
}
|
||||
if !char.Alive == false {
|
||||
t.Fatal("character should not be alive")
|
||||
}
|
||||
if char.GrudgeLocation != "Mine" {
|
||||
t.Fatalf("grudge = %q, want Mine", char.GrudgeLocation)
|
||||
}
|
||||
deaths++
|
||||
if r.PetRecovered {
|
||||
petRecoveries++
|
||||
if char.DeadUntil == nil {
|
||||
t.Fatal("dead until should be set")
|
||||
}
|
||||
}
|
||||
}
|
||||
if petRecoveries == 0 {
|
||||
t.Error("pet recovery should fire at least once in 500 deaths")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_EngineSaveBurnsCooldown(t *testing.T) {
|
||||
char := &AdventureCharacter{Alive: true}
|
||||
r := transitionDeath(DeathTransitionParams{
|
||||
Char: char, EngineSaved: true,
|
||||
})
|
||||
if !r.Died {
|
||||
t.Error("should die (no sovereign/pardon)")
|
||||
}
|
||||
if char.DeathReprieveLast == nil {
|
||||
t.Error("engine save should burn DeathReprieveLast")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionDeath_NoLocation(t *testing.T) {
|
||||
char := &AdventureCharacter{Alive: true, GrudgeLocation: "old"}
|
||||
transitionDeath(DeathTransitionParams{Char: char})
|
||||
if char.GrudgeLocation != "old" {
|
||||
t.Errorf("empty location should not change grudge, got %q", char.GrudgeLocation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_Basic(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 100},
|
||||
SlotArmor: {Condition: 100},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{
|
||||
SlotWeapon: 10,
|
||||
SlotArmor: 20,
|
||||
}
|
||||
result := applyDegradationModifiers(damage, equip)
|
||||
if equip[SlotWeapon].Condition != 90 {
|
||||
t.Errorf("weapon condition = %d, want 90", equip[SlotWeapon].Condition)
|
||||
}
|
||||
if equip[SlotArmor].Condition != 80 {
|
||||
t.Errorf("armor condition = %d, want 80", equip[SlotArmor].Condition)
|
||||
}
|
||||
if result[SlotWeapon] != 10 || result[SlotArmor] != 20 {
|
||||
t.Errorf("returned damage should match applied: weapon=%d, armor=%d", result[SlotWeapon], result[SlotArmor])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_Tempered(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotArmor: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotHelmet: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotBoots: {Condition: 100, ArenaSet: "tempered"},
|
||||
SlotTool: {Condition: 100, ArenaSet: "tempered"},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{SlotWeapon: 20}
|
||||
result := applyDegradationModifiers(damage, equip)
|
||||
if result[SlotWeapon] != 15 { // 20 * 0.75 = 15
|
||||
t.Errorf("tempered weapon damage = %d, want 15", result[SlotWeapon])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_Mastery(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 100, ActionsUsed: 25},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{SlotWeapon: 10}
|
||||
result := applyDegradationModifiers(damage, equip)
|
||||
if result[SlotWeapon] != 8 { // 10 * 0.8 = 8
|
||||
t.Errorf("mastery weapon damage = %d, want 8", result[SlotWeapon])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDegradationModifiers_ConditionFloor(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Condition: 5},
|
||||
}
|
||||
damage := map[EquipmentSlot]int{SlotWeapon: 20}
|
||||
applyDegradationModifiers(damage, equip)
|
||||
if equip[SlotWeapon].Condition != 0 {
|
||||
t.Errorf("condition should floor at 0, got %d", equip[SlotWeapon].Condition)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package plugin
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// ── Core Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type CombatStats struct {
|
||||
MaxHP int
|
||||
Attack int
|
||||
Defense int
|
||||
Speed int
|
||||
CritRate float64
|
||||
DodgeRate float64
|
||||
BlockRate float64
|
||||
}
|
||||
|
||||
type CombatModifiers struct {
|
||||
DamageBonus float64 // additive: 0 = neutral, 0.25 = +25% damage. Applied as (1 + DamageBonus).
|
||||
DamageReduct float64 // multiplicative damage-taken reduction, 1.0 = neutral
|
||||
DeathSave bool // Sovereign reprieve — survive one lethal hit
|
||||
PetAttackProc float64
|
||||
PetAttackDmg int
|
||||
PetDeflectProc float64
|
||||
PetWhiffProc float64 // pet distracts enemy → guaranteed miss
|
||||
SniperKillProc float64 // Arina instant-kill
|
||||
MistyHealProc float64
|
||||
MistyHealAmt int
|
||||
CrowdRevengeProc float64 // Misty debuff: chance per round of crowd damage
|
||||
CrowdRevengeDmg int // Misty debuff: damage per proc
|
||||
HealItem int // consumable: HP restored at <50% HP (one-shot)
|
||||
WardCharges int // consumable: hits fully absorbed
|
||||
SporeCloud int // consumable: rounds of 15% enemy miss chance
|
||||
ReflectNext float64 // consumable: fraction of next hit reflected
|
||||
AutoCritFirst bool // consumable: first player hit is auto-crit
|
||||
FlatDmgStart int // consumable: flat damage to enemy pre-combat
|
||||
}
|
||||
|
||||
type Combatant struct {
|
||||
Name string
|
||||
Stats CombatStats
|
||||
Mods CombatModifiers
|
||||
IsPlayer bool
|
||||
Ability *MonsterAbility // non-nil for monsters with a special ability
|
||||
}
|
||||
|
||||
type CombatPhase struct {
|
||||
Name string
|
||||
Rounds int
|
||||
AttackWeight float64
|
||||
DefenseWeight float64
|
||||
SpeedWeight float64
|
||||
EnvironmentProc float64
|
||||
}
|
||||
|
||||
type CombatEvent struct {
|
||||
Round int
|
||||
Phase string
|
||||
Actor string // "player", "enemy", "pet", "environment", "npc", "consumable"
|
||||
Action string
|
||||
Damage int
|
||||
PlayerHP int
|
||||
EnemyHP int
|
||||
Desc string // optional flavor (item name, ability name)
|
||||
}
|
||||
|
||||
type CombatResult struct {
|
||||
PlayerWon bool
|
||||
Events []CombatEvent
|
||||
PlayerStartHP int
|
||||
EnemyStartHP int
|
||||
PlayerEndHP int
|
||||
EnemyEndHP int
|
||||
TotalRounds int
|
||||
Closeness float64
|
||||
NearDeath bool
|
||||
PetAttacked bool
|
||||
PetDeflected bool
|
||||
SniperKilled bool
|
||||
MistyHealed bool
|
||||
}
|
||||
|
||||
// ── Monster Abilities ────────────────────────────────────────────────────────
|
||||
|
||||
type MonsterAbility struct {
|
||||
Name string
|
||||
Phase string // "opening", "clash", "decisive", "any"
|
||||
ProcChance float64
|
||||
Effect string // "poison", "enrage", "armor_break", "stun", "lifesteal", "cleave"
|
||||
}
|
||||
|
||||
// ── Default Phase Definitions ────────────────────────────────────────────────
|
||||
|
||||
var defaultCombatPhases = []CombatPhase{
|
||||
{"Opening", 2, 0.6, 0.8, 1.5, 0.15},
|
||||
{"Clash", 3, 1.2, 1.0, 0.8, 0.08},
|
||||
{"Decisive", 1, 1.0, 0.7, 1.0, 0.05},
|
||||
}
|
||||
|
||||
var dungeonCombatPhases = []CombatPhase{
|
||||
{"Opening", 1, 0.8, 0.8, 1.2, 0.10},
|
||||
{"Clash", 2, 1.0, 1.0, 0.8, 0.08},
|
||||
{"Decisive", 1, 1.0, 0.7, 1.0, 0.05},
|
||||
}
|
||||
|
||||
// ── Simulation ───────────────────────────────────────────────────────────────
|
||||
|
||||
// combatState tracks mutable state during the simulation.
|
||||
type combatState struct {
|
||||
playerHP int
|
||||
enemyHP int
|
||||
|
||||
// Consumable one-shots
|
||||
healUsed bool
|
||||
wardCharges int
|
||||
sporeRounds int
|
||||
reflectFrac float64
|
||||
autoCrit bool
|
||||
|
||||
// Monster ability effects
|
||||
poisonTicks int
|
||||
poisonDmg int
|
||||
stunPlayer bool
|
||||
enraged bool
|
||||
armorBroken bool
|
||||
armorBreakAmt float64
|
||||
|
||||
// Sovereign reprieve
|
||||
deathSaveUsed bool
|
||||
|
||||
round int
|
||||
events []CombatEvent
|
||||
}
|
||||
|
||||
func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult {
|
||||
st := &combatState{
|
||||
playerHP: player.Stats.MaxHP,
|
||||
enemyHP: enemy.Stats.MaxHP,
|
||||
wardCharges: player.Mods.WardCharges,
|
||||
sporeRounds: player.Mods.SporeCloud,
|
||||
reflectFrac: player.Mods.ReflectNext,
|
||||
autoCrit: player.Mods.AutoCritFirst,
|
||||
}
|
||||
|
||||
result := CombatResult{
|
||||
PlayerStartHP: player.Stats.MaxHP,
|
||||
EnemyStartHP: enemy.Stats.MaxHP,
|
||||
}
|
||||
|
||||
// Pre-combat: Arina sniper check
|
||||
if player.Mods.SniperKillProc > 0 && rand.Float64() < player.Mods.SniperKillProc {
|
||||
st.enemyHP = 0
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "npc", Action: "sniper_kill",
|
||||
Damage: enemy.Stats.MaxHP, PlayerHP: st.playerHP, EnemyHP: 0,
|
||||
Desc: "Arina",
|
||||
})
|
||||
result.SniperKilled = true
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
|
||||
// Pre-combat: Coal Bomb / flat start damage
|
||||
if player.Mods.FlatDmgStart > 0 {
|
||||
dmg := player.Mods.FlatDmgStart
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-combat: Misty gourmet is now a per-round heal, no pre-combat damage
|
||||
|
||||
// Main simulation loop
|
||||
for _, phase := range phases {
|
||||
roundsThisPhase := phase.Rounds
|
||||
// Add slight variance: ±1 round for non-Decisive phases
|
||||
if phase.Name != "Decisive" && roundsThisPhase > 1 {
|
||||
roundsThisPhase += rand.IntN(2) // 0 or +1
|
||||
}
|
||||
for r := 0; r < roundsThisPhase; r++ {
|
||||
st.round++
|
||||
if simulateRound(st, &player, &enemy, &phase, &result) {
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we exhaust all phases without a kill, the side with more HP% wins.
|
||||
playerMax := max(1, player.Stats.MaxHP)
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
playerPct := float64(st.playerHP) / float64(playerMax)
|
||||
enemyPct := float64(st.enemyHP) / float64(enemyMax)
|
||||
if playerPct < enemyPct {
|
||||
st.playerHP = 0
|
||||
} else {
|
||||
st.enemyHP = 0
|
||||
}
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return finalize(result, st, player, enemy)
|
||||
}
|
||||
|
||||
// simulateRound runs one round. Returns true if combat is over.
|
||||
func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Monster ability: check at round start
|
||||
abilityDealtDamage := false
|
||||
if enemy.Ability != nil {
|
||||
if abilityFires(enemy.Ability, phaseName, st) {
|
||||
if applyAbility(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
// Cleave and lifesteal deal damage — skip normal enemy attack this round
|
||||
switch enemy.Ability.Effect {
|
||||
case "cleave", "lifesteal":
|
||||
abilityDealtDamage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Poison tick from previous round
|
||||
if st.poisonTicks > 0 {
|
||||
st.playerHP = max(0, st.playerHP-st.poisonDmg)
|
||||
st.poisonTicks--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison_tick",
|
||||
Damage: st.poisonDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if trySave(st, player, phaseName) {
|
||||
// survived
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet whiff: if proc'd, enemy's attack this round is a guaranteed miss
|
||||
petWhiff := player.Mods.PetWhiffProc > 0 && rand.Float64() < player.Mods.PetWhiffProc
|
||||
// Pet deflect: halves incoming damage to player this round
|
||||
petDeflect := player.Mods.PetDeflectProc > 0 && rand.Float64() < player.Mods.PetDeflectProc
|
||||
if petDeflect {
|
||||
result.PetDeflected = true
|
||||
}
|
||||
|
||||
// Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks)
|
||||
sporeMiss := st.sporeRounds > 0 && rand.Float64() < 0.15
|
||||
|
||||
// Determine initiative
|
||||
playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight
|
||||
enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight
|
||||
playerInit := playerSpeed + rand.Float64()*10
|
||||
enemyInit := enemySpeed + rand.Float64()*10
|
||||
playerFirst := playerInit >= enemyInit
|
||||
|
||||
if playerFirst {
|
||||
if resolvePlayerAttack(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !abilityDealtDamage {
|
||||
if resolveEnemyAttack(st, player, enemy, phase, result, petWhiff, petDeflect, sporeMiss) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if resolvePlayerAttack(st, player, enemy, phase, result) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Environmental hazard
|
||||
if phase.EnvironmentProc > 0 && rand.Float64() < phase.EnvironmentProc {
|
||||
envDmg := 2 + rand.IntN(5)
|
||||
st.playerHP = max(0, st.playerHP-envDmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "environmental",
|
||||
Damage: envDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Misty crowd revenge (debuff for declining Misty)
|
||||
if player.Mods.CrowdRevengeProc > 0 && rand.Float64() < player.Mods.CrowdRevengeProc {
|
||||
dmg := player.Mods.CrowdRevengeDmg
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "environment", Action: "crowd_revenge",
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty's crowd",
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pet attack
|
||||
if player.Mods.PetAttackProc > 0 && rand.Float64() < player.Mods.PetAttackProc {
|
||||
petDmg := player.Mods.PetAttackDmg + rand.IntN(5)
|
||||
st.enemyHP = max(0, st.enemyHP-petDmg)
|
||||
result.PetAttacked = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_attack",
|
||||
Damage: petDmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Misty heal
|
||||
if player.Mods.MistyHealProc > 0 && rand.Float64() < player.Mods.MistyHealProc {
|
||||
healAmt := player.Mods.MistyHealAmt
|
||||
st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt)
|
||||
result.MistyHealed = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "npc", Action: "misty_heal",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
Desc: "Misty",
|
||||
})
|
||||
}
|
||||
|
||||
// Consumable heal: triggers once when player drops below 50%
|
||||
if !st.healUsed && player.Mods.HealItem > 0 &&
|
||||
st.playerHP > 0 && st.playerHP < player.Stats.MaxHP/2 {
|
||||
st.healUsed = true
|
||||
healAmt := player.Mods.HealItem
|
||||
st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "heal_item",
|
||||
Damage: healAmt, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Attack Resolution ────────────────────────────────────────────────────────
|
||||
|
||||
func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Stun: player skips attack
|
||||
if st.stunPlayer {
|
||||
st.stunPlayer = false
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "stunned",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Enemy dodge
|
||||
enemyDodge := enemy.Stats.DodgeRate * phase.SpeedWeight
|
||||
if enemyDodge > 0 && rand.Float64() < enemyDodge {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "miss",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Calculate damage
|
||||
dmg := calcDamage(player.Stats.Attack, phase.AttackWeight, player.Mods.DamageBonus,
|
||||
enemy.Stats.Defense, phase.DefenseWeight, enemy.Mods.DamageReduct)
|
||||
|
||||
// Block: half damage
|
||||
blocked := enemy.Stats.BlockRate > 0 && rand.Float64() < enemy.Stats.BlockRate
|
||||
if blocked {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
|
||||
// Crit check
|
||||
critRate := player.Stats.CritRate
|
||||
if phaseName == "Decisive" {
|
||||
critRate *= 2
|
||||
}
|
||||
isCrit := false
|
||||
if st.autoCrit {
|
||||
isCrit = true
|
||||
st.autoCrit = false
|
||||
dmg *= 2
|
||||
} else if critRate > 0 && rand.Float64() < critRate {
|
||||
isCrit = true
|
||||
dmg *= 2
|
||||
}
|
||||
|
||||
dmg = max(1, dmg)
|
||||
|
||||
// Cleave handling for enemy is in resolveEnemyAttack
|
||||
action := "hit"
|
||||
if isCrit {
|
||||
action = "crit"
|
||||
} else if blocked {
|
||||
action = "block"
|
||||
}
|
||||
|
||||
st.enemyHP = max(0, st.enemyHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: action,
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
return st.enemyHP <= 0
|
||||
}
|
||||
|
||||
func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult, petWhiff, petDeflect, sporeMiss bool) bool {
|
||||
phaseName := phase.Name
|
||||
|
||||
// Spore cloud round consumed when enemy attacks (even if whiffed/missed)
|
||||
if st.sporeRounds > 0 {
|
||||
st.sporeRounds--
|
||||
}
|
||||
|
||||
// Pet whiff → guaranteed miss
|
||||
if petWhiff {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_whiff",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Spore cloud miss
|
||||
if sporeMiss {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "spore_miss",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Player dodge
|
||||
playerDodge := player.Stats.DodgeRate * phase.SpeedWeight
|
||||
if playerDodge > 0 && rand.Float64() < playerDodge {
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "miss",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
// Ward: absorb full hit
|
||||
if st.wardCharges > 0 {
|
||||
st.wardCharges--
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "ward_absorb",
|
||||
Damage: 0, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
atkMult := 1.0
|
||||
if st.enraged {
|
||||
atkMult = 1.5
|
||||
}
|
||||
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||
|
||||
// Player block
|
||||
blocked := player.Stats.BlockRate > 0 && rand.Float64() < player.Stats.BlockRate
|
||||
if blocked {
|
||||
dmg = max(1, dmg/2)
|
||||
}
|
||||
|
||||
// Crit
|
||||
critRate := enemy.Stats.CritRate
|
||||
if phaseName == "Decisive" {
|
||||
critRate *= 2
|
||||
}
|
||||
isCrit := critRate > 0 && rand.Float64() < critRate
|
||||
if isCrit {
|
||||
dmg *= 2
|
||||
}
|
||||
|
||||
dmg = max(1, dmg)
|
||||
|
||||
// Pet deflect: halve damage
|
||||
if petDeflect {
|
||||
dmg = max(1, dmg/2)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "pet", Action: "pet_deflect",
|
||||
PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
action := "hit"
|
||||
if isCrit {
|
||||
action = "crit"
|
||||
} else if blocked {
|
||||
action = "block"
|
||||
}
|
||||
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: action,
|
||||
Damage: dmg, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
// Reflect: bounce portion back (after player takes the hit)
|
||||
if st.reflectFrac > 0 {
|
||||
reflected := max(1, int(float64(dmg)*st.reflectFrac))
|
||||
st.reflectFrac = 0
|
||||
st.enemyHP = max(0, st.enemyHP-reflected)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "consumable", Action: "reflect_damage",
|
||||
Damage: reflected, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.enemyHP <= 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if st.playerHP <= 0 {
|
||||
return !trySave(st, player, phaseName)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Monster Ability Logic ────────────────────────────────────────────────────
|
||||
|
||||
func abilityFires(ability *MonsterAbility, phaseName string, st *combatState) bool {
|
||||
phaseMatch := ability.Phase == "any" ||
|
||||
(ability.Phase == "opening" && phaseName == "Opening") ||
|
||||
(ability.Phase == "clash" && phaseName == "Clash") ||
|
||||
(ability.Phase == "decisive" && phaseName == "Decisive")
|
||||
if !phaseMatch {
|
||||
return false
|
||||
}
|
||||
return rand.Float64() < ability.ProcChance
|
||||
}
|
||||
|
||||
func applyAbility(st *combatState, player, enemy *Combatant, phase *CombatPhase, result *CombatResult) bool {
|
||||
ab := enemy.Ability
|
||||
phaseName := phase.Name
|
||||
|
||||
switch ab.Effect {
|
||||
case "poison":
|
||||
st.poisonTicks = 2
|
||||
st.poisonDmg = 3 + rand.IntN(3)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "poison",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
case "enrage":
|
||||
if !st.enraged && st.enemyHP < int(float64(enemy.Stats.MaxHP)*0.4) {
|
||||
st.enraged = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "enrage",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
case "armor_break":
|
||||
if !st.armorBroken {
|
||||
st.armorBroken = true
|
||||
st.armorBreakAmt = 0.30
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "armor_break",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
}
|
||||
|
||||
case "stun":
|
||||
st.stunPlayer = true
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "stun",
|
||||
Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
|
||||
case "lifesteal":
|
||||
atkMult := 1.0
|
||||
if st.enraged {
|
||||
atkMult = 1.5
|
||||
}
|
||||
dmg := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||
dmg = max(1, dmg)
|
||||
st.playerHP = max(0, st.playerHP-dmg)
|
||||
heal := dmg / 2
|
||||
st.enemyHP = min(enemy.Stats.MaxHP, st.enemyHP+heal)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "lifesteal",
|
||||
Damage: dmg, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
return !trySave(st, player, phaseName)
|
||||
}
|
||||
|
||||
case "cleave":
|
||||
atkMult := 1.0
|
||||
if st.enraged {
|
||||
atkMult = 1.5
|
||||
}
|
||||
dmg1 := calcDamage(int(float64(enemy.Stats.Attack)*atkMult), phase.AttackWeight, enemy.Mods.DamageBonus,
|
||||
playerDefense(player, st), phase.DefenseWeight, player.Mods.DamageReduct)
|
||||
dmg1 = max(1, dmg1)
|
||||
st.playerHP = max(0, st.playerHP-dmg1)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "cleave",
|
||||
Damage: dmg1, Desc: ab.Name, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
if !trySave(st, player, phaseName) {
|
||||
return true
|
||||
}
|
||||
// Death save fired — skip follow-up hit (survived the first blow barely)
|
||||
} else {
|
||||
dmg2 := max(1, dmg1/2)
|
||||
st.playerHP = max(0, st.playerHP-dmg2)
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "enemy", Action: "cleave",
|
||||
Damage: dmg2, Desc: ab.Name + " (follow-up)", PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
|
||||
})
|
||||
if st.playerHP <= 0 {
|
||||
return !trySave(st, player, phaseName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// calcDamage uses a penetration model: defense provides diminishing returns
|
||||
// reduction rather than flat subtraction, so damage is never fully negated.
|
||||
// Formula: rawAtk * reduction where reduction = K / (K + effectiveDef).
|
||||
// K=40 means 40 defense halves incoming damage; 80 defense reduces by 67%.
|
||||
func calcDamage(attack int, atkWeight, dmgBonus float64, defense int, defWeight, dmgReduct float64) int {
|
||||
const K = 40.0
|
||||
rawAtk := float64(attack) * atkWeight * (1 + dmgBonus)
|
||||
effectiveDef := float64(defense) * defWeight * dmgReduct
|
||||
reduction := K / (K + effectiveDef)
|
||||
dmg := rawAtk * reduction
|
||||
if dmg < 1 {
|
||||
return 1
|
||||
}
|
||||
return int(dmg)
|
||||
}
|
||||
|
||||
func playerDefense(player *Combatant, st *combatState) int {
|
||||
def := player.Stats.Defense
|
||||
if st.armorBroken {
|
||||
def = int(float64(def) * (1 - st.armorBreakAmt))
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func trySave(st *combatState, player *Combatant, phaseName string) bool {
|
||||
if player.Mods.DeathSave && !st.deathSaveUsed {
|
||||
st.deathSaveUsed = true
|
||||
st.playerHP = 1
|
||||
st.events = append(st.events, CombatEvent{
|
||||
Round: st.round, Phase: phaseName, Actor: "player", Action: "death_save",
|
||||
PlayerHP: 1, EnemyHP: st.enemyHP, Desc: "Sovereign",
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func finalize(result CombatResult, st *combatState, player, enemy Combatant) CombatResult {
|
||||
result.Events = st.events
|
||||
result.PlayerEndHP = st.playerHP
|
||||
result.EnemyEndHP = st.enemyHP
|
||||
result.TotalRounds = st.round
|
||||
result.PlayerWon = st.enemyHP <= 0
|
||||
|
||||
playerMax := max(1, player.Stats.MaxHP)
|
||||
enemyMax := max(1, enemy.Stats.MaxHP)
|
||||
if result.PlayerWon && st.playerHP > 0 {
|
||||
result.NearDeath = float64(st.playerHP) < float64(playerMax)*0.15
|
||||
winnerRemaining := float64(st.playerHP) / float64(playerMax)
|
||||
result.Closeness = 1.0 - winnerRemaining
|
||||
} else if !result.PlayerWon {
|
||||
enemyRemaining := float64(st.enemyHP) / float64(enemyMax)
|
||||
result.NearDeath = enemyRemaining < 0.15
|
||||
result.Closeness = 1.0 - enemyRemaining
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func basePlayer() Combatant {
|
||||
return Combatant{
|
||||
Name: "Hero",
|
||||
IsPlayer: true,
|
||||
Stats: CombatStats{
|
||||
MaxHP: 100,
|
||||
Attack: 15,
|
||||
Defense: 8,
|
||||
Speed: 10,
|
||||
CritRate: 0.05,
|
||||
DodgeRate: 0.05,
|
||||
BlockRate: 0.05,
|
||||
},
|
||||
Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0},
|
||||
}
|
||||
}
|
||||
|
||||
func baseEnemy() Combatant {
|
||||
return Combatant{
|
||||
Name: "Rat",
|
||||
Stats: CombatStats{
|
||||
MaxHP: 60,
|
||||
Attack: 10,
|
||||
Defense: 4,
|
||||
Speed: 6,
|
||||
CritRate: 0.03,
|
||||
DodgeRate: 0.02,
|
||||
BlockRate: 0.02,
|
||||
},
|
||||
Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_BasicResolves(t *testing.T) {
|
||||
wins, losses := 0, 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
if r.PlayerWon {
|
||||
wins++
|
||||
} else {
|
||||
losses++
|
||||
}
|
||||
if r.TotalRounds == 0 && !r.SniperKilled {
|
||||
t.Fatal("combat resolved in 0 rounds without sniper")
|
||||
}
|
||||
if len(r.Events) == 0 {
|
||||
t.Fatal("no events generated")
|
||||
}
|
||||
}
|
||||
if wins == 0 {
|
||||
t.Error("player never won in 1000 fights against a weaker enemy")
|
||||
}
|
||||
if wins < 700 {
|
||||
t.Errorf("player should strongly favor wins vs weaker enemy, got %d/1000", wins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_HPTracking(t *testing.T) {
|
||||
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
if r.PlayerStartHP != 100 {
|
||||
t.Errorf("player start HP = %d, want 100", r.PlayerStartHP)
|
||||
}
|
||||
if r.EnemyStartHP != 60 {
|
||||
t.Errorf("enemy start HP = %d, want 60", r.EnemyStartHP)
|
||||
}
|
||||
if r.PlayerWon && r.EnemyEndHP != 0 {
|
||||
t.Errorf("player won but enemy HP = %d", r.EnemyEndHP)
|
||||
}
|
||||
if !r.PlayerWon && r.PlayerEndHP != 0 {
|
||||
t.Errorf("player lost but player HP = %d", r.PlayerEndHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_SniperKill(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.SniperKillProc = 1.0 // guaranteed
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
t.Error("guaranteed sniper should always win")
|
||||
}
|
||||
if !r.SniperKilled {
|
||||
t.Error("SniperKilled flag not set")
|
||||
}
|
||||
if len(r.Events) != 1 || r.Events[0].Action != "sniper_kill" {
|
||||
t.Error("expected exactly 1 sniper_kill event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_DeathSave(t *testing.T) {
|
||||
// Player with death save against a very strong enemy
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 30
|
||||
p.Mods.DeathSave = true
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 40
|
||||
e.Stats.MaxHP = 200
|
||||
|
||||
saves := 0
|
||||
for i := 0; i < 500; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "death_save" {
|
||||
saves++
|
||||
if ev.PlayerHP != 1 {
|
||||
t.Errorf("death save should set HP to 1, got %d", ev.PlayerHP)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if saves == 0 {
|
||||
t.Error("death save never triggered in 500 fights against strong enemy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_PetAttack(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.PetAttackProc = 1.0 // guaranteed every round
|
||||
p.Mods.PetAttackDmg = 10
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
if !r.PetAttacked {
|
||||
t.Error("pet attack should have triggered")
|
||||
}
|
||||
petHits := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "pet_attack" {
|
||||
petHits++
|
||||
}
|
||||
}
|
||||
if petHits == 0 {
|
||||
t.Error("no pet_attack events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_PetWhiff(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.PetWhiffProc = 1.0 // guaranteed
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 5
|
||||
|
||||
whiffs := 0
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "pet_whiff" {
|
||||
whiffs++
|
||||
}
|
||||
}
|
||||
if whiffs == 0 {
|
||||
t.Error("pet whiff never triggered with 100% proc rate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_PetDeflect(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.PetDeflectProc = 1.0
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
if !r.PetDeflected {
|
||||
t.Error("pet deflect should have triggered")
|
||||
}
|
||||
deflects := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "pet_deflect" {
|
||||
deflects++
|
||||
}
|
||||
}
|
||||
if deflects == 0 {
|
||||
t.Error("no pet_deflect events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_MistyHeal(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.MistyHealProc = 1.0
|
||||
p.Mods.MistyHealAmt = 10
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 15 // enough to do some damage
|
||||
|
||||
heals := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "misty_heal" {
|
||||
heals++
|
||||
}
|
||||
}
|
||||
}
|
||||
if heals == 0 {
|
||||
t.Error("Misty heal never triggered with 100% proc rate across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_FlatDmgStart(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.FlatDmgStart = 20
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 60
|
||||
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if len(r.Events) == 0 {
|
||||
t.Fatal("no events")
|
||||
}
|
||||
first := r.Events[0]
|
||||
if first.Action != "flat_damage" {
|
||||
t.Errorf("first event should be flat_damage, got %s", first.Action)
|
||||
}
|
||||
if first.Damage != 20 {
|
||||
t.Errorf("flat damage = %d, want 20", first.Damage)
|
||||
}
|
||||
if first.EnemyHP != 40 {
|
||||
t.Errorf("enemy HP after flat damage = %d, want 40", first.EnemyHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_FlatDmgKill(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.FlatDmgStart = 999
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 10
|
||||
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
t.Error("flat damage should kill a 10HP enemy")
|
||||
}
|
||||
if r.EnemyEndHP != 0 {
|
||||
t.Errorf("enemy HP = %d, want 0", r.EnemyEndHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_HealItem(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 100
|
||||
p.Mods.HealItem = 30
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 20 // will deal solid damage
|
||||
|
||||
heals := 0
|
||||
for i := 0; i < 200; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "heal_item" {
|
||||
heals++
|
||||
if ev.Damage != 30 {
|
||||
t.Errorf("heal amount = %d, want 30", ev.Damage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if heals == 0 {
|
||||
t.Error("heal item never triggered across 200 fights")
|
||||
}
|
||||
// Should trigger at most once per fight
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
count := 0
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "heal_item" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Errorf("heal item triggered %d times in one fight, should be at most 1", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_WardAbsorb(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.WardCharges = 1
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 30
|
||||
|
||||
wards := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "ward_absorb" {
|
||||
wards++
|
||||
if ev.Damage != 0 {
|
||||
t.Error("ward absorb should deal 0 damage")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if wards == 0 {
|
||||
t.Error("ward never activated across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_SporeCloud(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.SporeCloud = 10 // many rounds worth
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 15
|
||||
|
||||
spores := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "spore_miss" {
|
||||
spores++
|
||||
}
|
||||
}
|
||||
}
|
||||
if spores == 0 {
|
||||
t.Error("spore cloud never caused a miss across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_ReflectDamage(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.ReflectNext = 0.5
|
||||
e := baseEnemy()
|
||||
e.Stats.Attack = 20
|
||||
|
||||
reflects := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "reflect_damage" {
|
||||
reflects++
|
||||
if ev.Damage <= 0 {
|
||||
t.Error("reflected damage should be positive")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if reflects == 0 {
|
||||
t.Error("reflect never triggered across 100 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_AutoCritFirst(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Mods.AutoCritFirst = true
|
||||
p.Stats.CritRate = 0 // disable normal crits to isolate auto-crit
|
||||
|
||||
crits := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, baseEnemy(), defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Actor == "player" && ev.Action == "crit" {
|
||||
crits++
|
||||
break // only count first per fight
|
||||
}
|
||||
}
|
||||
}
|
||||
if crits < 90 {
|
||||
t.Errorf("auto-crit should fire on first player hit in nearly every fight, got %d/100", crits)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monster Ability Tests ────────────────────────────────────────────────────
|
||||
|
||||
func TestSimulateCombat_Poison(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Venom Bite", Phase: "any", ProcChance: 1.0, Effect: "poison",
|
||||
}
|
||||
|
||||
ticks := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "poison_tick" {
|
||||
ticks++
|
||||
}
|
||||
}
|
||||
}
|
||||
if ticks == 0 {
|
||||
t.Error("poison ticks never occurred with guaranteed proc")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Enrage(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Stats.Attack = 12
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 50 // 40% threshold = 20 HP, reachable before death
|
||||
e.Stats.Attack = 6 // weak so fights last long enough
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Berserk", Phase: "any", ProcChance: 1.0, Effect: "enrage",
|
||||
}
|
||||
|
||||
enrages := 0
|
||||
for i := 0; i < 500; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "enrage" {
|
||||
enrages++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if enrages == 0 {
|
||||
t.Error("enrage never triggered across 500 fights")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_ArmorBreak(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Rend", Phase: "opening", ProcChance: 1.0, Effect: "armor_break",
|
||||
}
|
||||
|
||||
breaks := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "armor_break" {
|
||||
breaks++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if breaks == 0 {
|
||||
t.Error("armor break never triggered with guaranteed proc in opening")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Stun(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Bash", Phase: "any", ProcChance: 1.0, Effect: "stun",
|
||||
}
|
||||
|
||||
stuns := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "stun" {
|
||||
stuns++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if stuns == 0 {
|
||||
t.Error("stun never triggered")
|
||||
}
|
||||
// Check that "stunned" event follows a "stun"
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for i, ev := range r.Events {
|
||||
if ev.Action == "stun" {
|
||||
for j := i + 1; j < len(r.Events); j++ {
|
||||
if r.Events[j].Actor == "player" && r.Events[j].Action == "stunned" {
|
||||
return // found it
|
||||
}
|
||||
if r.Events[j].Actor == "player" && (r.Events[j].Action == "hit" || r.Events[j].Action == "crit" || r.Events[j].Action == "miss") {
|
||||
break // player acted before being stunned — could happen if stun fires and then round ends
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Lifesteal(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 100
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Drain", Phase: "any", ProcChance: 1.0, Effect: "lifesteal",
|
||||
}
|
||||
|
||||
steals := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "lifesteal" {
|
||||
steals++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if steals == 0 {
|
||||
t.Error("lifesteal never triggered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_Cleave(t *testing.T) {
|
||||
p := basePlayer()
|
||||
e := baseEnemy()
|
||||
e.Ability = &MonsterAbility{
|
||||
Name: "Cleave", Phase: "clash", ProcChance: 1.0, Effect: "cleave",
|
||||
}
|
||||
|
||||
cleaves := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
for _, ev := range r.Events {
|
||||
if ev.Action == "cleave" {
|
||||
cleaves++
|
||||
}
|
||||
}
|
||||
}
|
||||
// Should see pairs of cleave events (main + follow-up)
|
||||
if cleaves == 0 {
|
||||
t.Error("cleave never triggered")
|
||||
}
|
||||
if cleaves%2 != 0 {
|
||||
// Not strictly required since fights can end mid-cleave, but check it's usually paired
|
||||
t.Logf("cleave events = %d (odd count is possible if fight ends mid-cleave)", cleaves)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strong vs Weak ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestSimulateCombat_StrongEnemyWinsMostly(t *testing.T) {
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 50
|
||||
p.Stats.Attack = 8
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 200
|
||||
e.Stats.Attack = 30
|
||||
e.Stats.Defense = 12
|
||||
|
||||
wins := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if r.PlayerWon {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
if wins > 150 {
|
||||
t.Errorf("player won %d/1000 against much stronger enemy — too high", wins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_DungeonPhasesShorter(t *testing.T) {
|
||||
totalDefault, totalDungeon := 0, 0
|
||||
for i := 0; i < 500; i++ {
|
||||
r1 := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
r2 := SimulateCombat(basePlayer(), baseEnemy(), dungeonCombatPhases)
|
||||
totalDefault += r1.TotalRounds
|
||||
totalDungeon += r2.TotalRounds
|
||||
}
|
||||
avgDefault := float64(totalDefault) / 500
|
||||
avgDungeon := float64(totalDungeon) / 500
|
||||
if avgDungeon >= avgDefault {
|
||||
t.Errorf("dungeon phases should produce fewer rounds on average: dungeon=%.1f, default=%.1f", avgDungeon, avgDefault)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_ClosenessRange(t *testing.T) {
|
||||
for i := 0; i < 500; i++ {
|
||||
r := SimulateCombat(basePlayer(), baseEnemy(), defaultCombatPhases)
|
||||
if r.Closeness < 0 || r.Closeness > 1.0 {
|
||||
t.Errorf("closeness out of range: %f", r.Closeness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimulateCombat_NearDeath(t *testing.T) {
|
||||
// Enemy strong enough to bring player near death regularly
|
||||
p := basePlayer()
|
||||
p.Stats.MaxHP = 80
|
||||
p.Stats.Attack = 12
|
||||
e := baseEnemy()
|
||||
e.Stats.MaxHP = 60
|
||||
e.Stats.Attack = 18
|
||||
|
||||
nearDeaths := 0
|
||||
for i := 0; i < 2000; i++ {
|
||||
r := SimulateCombat(p, e, defaultCombatPhases)
|
||||
if r.NearDeath {
|
||||
nearDeaths++
|
||||
}
|
||||
}
|
||||
if nearDeaths == 0 {
|
||||
t.Error("near death never occurred in 2000 fights")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// actionPicker tracks used indices per pool to avoid repeats within a fight.
|
||||
type actionPicker struct {
|
||||
enemy map[int]bool
|
||||
player map[int]bool
|
||||
playerMiss map[int]bool
|
||||
block map[int]bool
|
||||
environment map[int]bool
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
func pickFrom(pool []string, used map[int]bool, damage int) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return fmt.Sprintf(pool[idx], damage)
|
||||
}
|
||||
|
||||
func pickFromNoFmt(pool []string, used map[int]bool) string {
|
||||
if len(used) >= len(pool) {
|
||||
for k := range used {
|
||||
delete(used, k)
|
||||
}
|
||||
}
|
||||
idx := rand.IntN(len(pool))
|
||||
for used[idx] {
|
||||
idx = (idx + 1) % len(pool)
|
||||
}
|
||||
used[idx] = true
|
||||
return pool[idx]
|
||||
}
|
||||
|
||||
// RenderCombatLog converts a CombatResult into a slice of messages — one per
|
||||
// phase plus a final outcome message. The caller sends them with 5-8 second
|
||||
// delays between messages to create an unfolding fight feel.
|
||||
func RenderCombatLog(result CombatResult, playerName, enemyName string) []string {
|
||||
picker := newActionPicker()
|
||||
phases := groupEventsByPhase(result.Events)
|
||||
|
||||
var msgs []string
|
||||
for _, pg := range phases {
|
||||
msg := renderPhaseBlock(pg, playerName, enemyName, result, picker)
|
||||
if msg != "" {
|
||||
msgs = append(msgs, msg)
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
}
|
||||
|
||||
// RenderCombatLogArena is RenderCombatLog for arena fights.
|
||||
func RenderCombatLogArena(result CombatResult, playerName, enemyName string) []string {
|
||||
return RenderCombatLog(result, playerName, enemyName)
|
||||
}
|
||||
|
||||
type phaseGroup struct {
|
||||
Name string
|
||||
Events []CombatEvent
|
||||
}
|
||||
|
||||
func groupEventsByPhase(events []CombatEvent) []phaseGroup {
|
||||
var groups []phaseGroup
|
||||
var current *phaseGroup
|
||||
|
||||
for _, e := range events {
|
||||
if current == nil || current.Name != e.Phase {
|
||||
groups = append(groups, phaseGroup{Name: e.Phase})
|
||||
current = &groups[len(groups)-1]
|
||||
}
|
||||
current.Events = append(current.Events, e)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result CombatResult, picker *actionPicker) string {
|
||||
var sb strings.Builder
|
||||
|
||||
header := phaseHeader(pg.Name)
|
||||
sb.WriteString(header + "\n")
|
||||
|
||||
for _, e := range pg.Events {
|
||||
line := renderEvent(e, playerName, enemyName, result, picker)
|
||||
if line != "" {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(pg.Events) == 0 {
|
||||
return ""
|
||||
}
|
||||
lastEvent := pg.Events[len(pg.Events)-1]
|
||||
sb.WriteString(fmt.Sprintf(" [%s: %d/%d | %s: %d/%d]",
|
||||
playerName, clampHP(lastEvent.PlayerHP), result.PlayerStartHP,
|
||||
enemyName, clampHP(lastEvent.EnemyHP), result.EnemyStartHP))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func clampHP(hp int) int {
|
||||
if hp < 0 {
|
||||
return 0
|
||||
}
|
||||
return hp
|
||||
}
|
||||
|
||||
func phaseHeader(name string) string {
|
||||
switch name {
|
||||
case "Opening", "opening":
|
||||
return pickRand(openingHeaders)
|
||||
case "Clash", "clash":
|
||||
return pickRand(clashHeaders)
|
||||
case "Decisive", "decisive":
|
||||
return pickRand(decisiveHeaders)
|
||||
case "pre_combat", "pre":
|
||||
return "⚔️ **The fight begins.**"
|
||||
case "exhaust":
|
||||
return "⚔️ **Exhaustion — Time Runs Out**"
|
||||
default:
|
||||
return "⚔️ **" + name + "**"
|
||||
}
|
||||
}
|
||||
|
||||
func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResult, picker *actionPicker) string {
|
||||
switch e.Action {
|
||||
case "hit":
|
||||
if e.Actor == "player" {
|
||||
return pickFrom(narrativePlayerHit, picker.player, e.Damage)
|
||||
}
|
||||
return pickFrom(narrativeEnemyHit, picker.enemy, e.Damage)
|
||||
|
||||
case "crit":
|
||||
if e.Actor == "player" {
|
||||
return pickFrom(narrativePlayerCrit, picker.player, e.Damage)
|
||||
}
|
||||
return pickFrom(narrativeEnemyCrit, picker.enemy, e.Damage)
|
||||
|
||||
case "block":
|
||||
if e.Actor == "player" {
|
||||
return fmt.Sprintf(pickRand(narrativeEnemyBlock), e.Damage)
|
||||
}
|
||||
return fmt.Sprintf(pickRand(narrativePlayerBlock), e.Damage)
|
||||
|
||||
case "miss":
|
||||
return pickFromNoFmt(narrativeMiss, picker.playerMiss)
|
||||
|
||||
case "pet_attack":
|
||||
return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage)
|
||||
|
||||
case "pet_deflect":
|
||||
return pickRand(narrativePetDeflect)
|
||||
|
||||
case "pet_whiff":
|
||||
return pickRand(narrativePetWhiff)
|
||||
|
||||
case "sniper_kill":
|
||||
return pickRand(narrativeSniperKill)
|
||||
|
||||
case "misty_heal":
|
||||
return fmt.Sprintf(pickRand(narrativeMistyHeal), e.Damage)
|
||||
|
||||
case "death_save":
|
||||
return pickRand(narrativeDeathSave)
|
||||
|
||||
case "environmental":
|
||||
return pickFrom(narrativeEnvironmental, picker.environment, e.Damage)
|
||||
|
||||
case "use_consumable":
|
||||
return renderConsumableUse(e.Desc)
|
||||
|
||||
case "consumable_skip":
|
||||
return "_Supplies conserved — threat assessed as manageable._"
|
||||
|
||||
case "ward_absorb":
|
||||
return pickRand(narrativeWardAbsorb)
|
||||
|
||||
case "spore_miss":
|
||||
return pickRand(narrativeSporeCloud)
|
||||
|
||||
case "reflect_damage":
|
||||
return fmt.Sprintf(pickRand(narrativeReflect), e.Damage)
|
||||
|
||||
case "crowd_revenge":
|
||||
return fmt.Sprintf(pickRand(narrativeCrowdRevenge), e.Damage)
|
||||
|
||||
case "flat_damage":
|
||||
return fmt.Sprintf(pickRand(narrativeFlatDmg), e.Damage)
|
||||
|
||||
case "heal_item":
|
||||
return fmt.Sprintf(pickRand(narrativeHealItem), e.Damage)
|
||||
|
||||
// Monster abilities
|
||||
case "poison_tick":
|
||||
return fmt.Sprintf(pickRand(narrativePoisonTick), e.Damage)
|
||||
case "enrage":
|
||||
return pickRand(narrativeEnrage)
|
||||
case "armor_break":
|
||||
return pickRand(narrativeArmorBreak)
|
||||
case "stun":
|
||||
return pickRand(narrativeStun)
|
||||
case "stunned":
|
||||
return pickRand(narrativeStunned)
|
||||
case "poison":
|
||||
return pickRand(narrativePoisonApply)
|
||||
case "lifesteal":
|
||||
return fmt.Sprintf(pickRand(narrativeLifesteal), e.Damage)
|
||||
case "cleave":
|
||||
return fmt.Sprintf(pickRand(narrativeCleave), e.Damage)
|
||||
|
||||
case "timeout":
|
||||
return pickRand(narrativeTimeout)
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func renderConsumableUse(itemName string) string {
|
||||
templates := []string{
|
||||
"You crack open a **%s**. The fight just got interesting.",
|
||||
"**%s** consumed. Its effects take hold immediately.",
|
||||
"You down a **%s** before things get worse.",
|
||||
"The **%s** does its work. You feel the difference.",
|
||||
}
|
||||
return fmt.Sprintf(templates[rand.IntN(len(templates))], itemName)
|
||||
}
|
||||
|
||||
func renderOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if result.PlayerWon {
|
||||
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
|
||||
if result.NearDeath {
|
||||
sb.WriteString(pickRand(narrativeNearDeathWin) + "\n")
|
||||
} else if result.Closeness < 0.3 {
|
||||
sb.WriteString(pickRand(narrativeDominantWin) + "\n")
|
||||
} else {
|
||||
sb.WriteString(pickRand(narrativeCleanWin) + "\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("💀 **Defeated.**\n")
|
||||
if result.NearDeath {
|
||||
sb.WriteString(pickRand(narrativeNearDeathLoss) + "\n")
|
||||
} else if result.Closeness < 0.3 {
|
||||
sb.WriteString(pickRand(narrativeDominantLoss) + "\n")
|
||||
} else {
|
||||
sb.WriteString(pickRand(narrativeCloseLoss) + "\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation)", xp))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderArenaOutcome(result CombatResult, playerName, enemyName string, reward int64, xp int, tier, round int) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if result.PlayerWon {
|
||||
sb.WriteString(fmt.Sprintf("💀 **%s** has been defeated.\n", enemyName))
|
||||
closer := arenaWinCloser(enemyName, round)
|
||||
sb.WriteString(closer + "\n")
|
||||
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned", xp, reward))
|
||||
} else {
|
||||
sb.WriteString("The healers are already moving.\n")
|
||||
sb.WriteString("💀 **Defeated.**\n")
|
||||
closer := arenaLoseCloser(enemyName, round)
|
||||
sb.WriteString(closer + "\n")
|
||||
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.", arenaParticipationXP))
|
||||
}
|
||||
|
||||
if result.SniperKilled {
|
||||
sb.WriteString("\n" + pickRand(narrativeSniperKill))
|
||||
}
|
||||
if result.MistyHealed {
|
||||
sb.WriteString("\n🌿 Misty's healing made the difference.")
|
||||
}
|
||||
if result.PetAttacked {
|
||||
sb.WriteString("\n🐾 Your pet contributed to the fight.")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func pickRand(pool []string) string {
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// ── Phase Headers ────────────────────────────────────────────────────────────
|
||||
|
||||
var openingHeaders = []string{
|
||||
"⚔️ **Opening — The First Exchange**",
|
||||
"⚔️ **Opening — Testing the Waters**",
|
||||
"⚔️ **Opening — Both Sides Size Each Other Up**",
|
||||
"⚔️ **Opening — The Fight Begins**",
|
||||
}
|
||||
|
||||
var clashHeaders = []string{
|
||||
"⚔️ **Clash — The Real Fight**",
|
||||
"⚔️ **Clash — No More Warm-Ups**",
|
||||
"⚔️ **Clash — Steel Meets Steel**",
|
||||
"⚔️ **Clash — The Exchange Intensifies**",
|
||||
}
|
||||
|
||||
var decisiveHeaders = []string{
|
||||
"⚔️ **Decisive — One of You Isn't Walking Away**",
|
||||
"⚔️ **Decisive — The Final Blow**",
|
||||
"⚔️ **Decisive — Everything on the Line**",
|
||||
"⚔️ **Decisive — This Ends Now**",
|
||||
}
|
||||
|
||||
// ── Narrative Pools ──────────────────────────────────────────────────────────
|
||||
|
||||
var narrativePlayerHit = []string{
|
||||
"You connect cleanly. %d damage. Nothing fancy. Functional violence.",
|
||||
"A solid strike lands true. %d damage. The enemy reconsiders their career choices.",
|
||||
"You find an opening and take it. %d damage. The opening did not volunteer.",
|
||||
"Your weapon finds its mark. %d damage. Your weapon has better aim than you do most days.",
|
||||
"You press the advantage. %d damage. The advantage was already pressed. You pressed it further.",
|
||||
"A measured strike. %d damage. Nothing flashy. Just effective. The crowd is politely impressed.",
|
||||
"You swing and it connects where it matters. %d damage. Where it matters is the enemy's face.",
|
||||
"You hit them with a move you've been mentally rehearsing for weeks. %d damage. It worked. You're as surprised as they are.",
|
||||
"You land a clean hit for %d damage and immediately start explaining to no one how you did that.",
|
||||
"You score %d damage. The enemy seems fine. You are less fine about this than they are.",
|
||||
}
|
||||
|
||||
var narrativeEnemyHit = []string{
|
||||
"The enemy strikes back. %d damage. They were not asking permission.",
|
||||
"A hit gets through your guard. %d damage. Your guard had one job.",
|
||||
"The enemy finds a gap in your defense. %d damage. The gap is now a feature.",
|
||||
"You take a hit. %d damage. It stings. Your pride stings more.",
|
||||
"The enemy's weapon connects with something important. %d damage. It was you. You were the important thing.",
|
||||
"A blow you didn't see coming. %d damage. In fairness, you weren't looking.",
|
||||
"The enemy is faster than expected. %d damage. Your expectations need recalibrating.",
|
||||
"The enemy questions your life choices mid-swing. You pause to reflect. %d damage during the pause.",
|
||||
"The enemy yawns before hitting you. Not performatively. Genuinely. %d damage while you process the disrespect.",
|
||||
"The enemy hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.",
|
||||
}
|
||||
|
||||
var narrativePlayerCrit = []string{
|
||||
"💥 **CRITICAL HIT.** You drive the blow home. %d damage. The crowd notices. The enemy notices more.",
|
||||
"💥 **CRIT!** Everything lined up perfectly. %d damage. You will never reproduce this.",
|
||||
"💥 A devastating strike. %d damage. You didn't know you had that in you. Neither did they.",
|
||||
"💥 You put everything behind this one. %d damage. It shows. It shows on their face specifically.",
|
||||
"💥 **CRIT!** %d damage. You look at your weapon like it just got a promotion.",
|
||||
}
|
||||
|
||||
var narrativeEnemyCrit = []string{
|
||||
"💥 **CRITICAL HIT** from the enemy. %d damage. That one rearranged something.",
|
||||
"💥 The enemy finds a weak point you didn't know you had. %d damage. Now you know.",
|
||||
"💥 A brutal strike. %d damage. You feel that one in places you didn't know could feel things.",
|
||||
"💥 The enemy's eyes narrow before a devastating blow. %d damage. The narrowing was a warning. You missed it.",
|
||||
"💥 **CRIT.** %d damage. The enemy doesn't even look impressed with themselves. That's worse.",
|
||||
}
|
||||
|
||||
var narrativeDodge = []string{
|
||||
"You sidestep at the last moment. Nothing lands. You style it out. Nobody is convinced.",
|
||||
"The attack passes through empty air. Clean dodge. You had no idea you could move like that.",
|
||||
"You read the movement and step aside. The enemy's weapon finds nothing but disappointment.",
|
||||
"Too slow. The strike finds nothing. The enemy retrieves their dignity from the floor.",
|
||||
"A dodge so clean it almost looks rehearsed. It was not. You were running away and it happened to work.",
|
||||
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.",
|
||||
}
|
||||
|
||||
var narrativePlayerBlock = []string{
|
||||
"You brace and absorb the impact. Reduced to %d damage. Your arms disagree with this strategy.",
|
||||
"Your guard holds. Only %d damage gets through. The rest is your problem later.",
|
||||
"A solid block, but some force still connects. %d damage. Physics remains undefeated.",
|
||||
"You block it. %d damage still gets through. Blocking is more of a suggestion than a solution.",
|
||||
}
|
||||
|
||||
var narrativeEnemyBlock = []string{
|
||||
"The enemy blocks your strike. Only %d damage penetrates. They seem unimpressed.",
|
||||
"A partial block. %d damage — less than you wanted. Less than they deserved.",
|
||||
"The enemy's defense absorbs most of it. %d damage. The enemy's forearm speaks to the pathetic nature of your striking.",
|
||||
}
|
||||
|
||||
var narrativeMiss = []string{
|
||||
"Your swing goes wide. Nothing connects. The air you hit had it coming.",
|
||||
"The enemy anticipated that one. Complete miss. They're already bored.",
|
||||
"You overcommit and hit nothing but air. The air files no complaint.",
|
||||
"A whiff. It happens. It happens to you more than average.",
|
||||
"You attempt a move you saw in a film once. It does not work like in the film.",
|
||||
"You close your eyes for the strike because it feels more dramatic. You miss. Everything about this was predictable.",
|
||||
}
|
||||
|
||||
var narrativePetAttack = []string{
|
||||
"🐾 Your pet lunges at the enemy from absolutely nowhere. %d damage. Your pet has been waiting for this.",
|
||||
"🐾 Your pet strikes from the side with zero hesitation. %d additional damage. The enemy was not monitoring the pet. Mistake.",
|
||||
"🐾 Your pet joins the fray with a well-timed attack. %d damage. The timing was suspicious. Your pet may be smarter than you.",
|
||||
"🐾 Your faithful companion lands a hit for %d damage. More faithful than accurate, but today both applied.",
|
||||
}
|
||||
|
||||
var narrativePetDeflect = []string{
|
||||
"🐾 Your pet intercepts the blow. Damage halved. Your pet is now your best piece of equipment.",
|
||||
"🐾 Your pet pushes you aside at the last second. Impact reduced. You did not ask to be pushed. Results speak for themselves.",
|
||||
"🐾 Your companion takes part of the hit for you. This is more loyalty than you deserve.",
|
||||
}
|
||||
|
||||
var narrativePetWhiff = []string{
|
||||
"🐾 Your pet distracts the enemy completely. Their attack goes wide. Your pet is not sorry.",
|
||||
"🐾 Your pet startles the enemy mid-swing. Complete miss. The enemy glares at the pet. The pet does not care.",
|
||||
"🐾 The enemy flinches at your pet's sudden movement. Missed entirely. Your pet sits back down like nothing happened.",
|
||||
}
|
||||
|
||||
var narrativeSniperKill = []string{
|
||||
"🎯 A shot rings out from the shadows. The enemy drops before the fight even starts. Arina collects her fee. She was leaving anyway.",
|
||||
"🎯 Arina's shot finds its mark. The enemy never knew what happened. Arina is already gone.",
|
||||
"🎯 One shot. One kill. Arina waves from somewhere you can't see. You wave back. She's already somewhere else.",
|
||||
}
|
||||
|
||||
var narrativeMistyHeal = []string{
|
||||
"🌿 Misty's presence soothes your wounds. +%d HP. She makes it look effortless because for her it is.",
|
||||
"🌿 A warm glow surrounds you. Misty restores %d HP. She doesn't explain how. You don't ask.",
|
||||
"🌿 Misty whispers something. You feel %d HP return. Whatever she said, it worked.",
|
||||
"🌿 The air shimmers. Misty heals you for %d HP. The enemy watches this happen and seems annoyed about it.",
|
||||
}
|
||||
|
||||
var narrativeDeathSave = []string{
|
||||
"👑 The Sovereign set flares with light. You refuse to fall. 1 HP remains. You should be dead. You are not. The set has opinions about your mortality.",
|
||||
"👑 Something keeps you standing. The Sovereign set burns bright. 1 HP. The enemy is visibly upset about this.",
|
||||
"👑 You should be dead. The Sovereign set disagrees. 1 HP. The disagreement is final.",
|
||||
}
|
||||
|
||||
var narrativeEnvironmental = []string{
|
||||
"The arena floor shifts beneath you. %d damage. The floor has no allegiance.",
|
||||
"A loose stone catches you off-balance. %d damage. The arena's maintenance budget is your problem now.",
|
||||
"Something in the environment works against you. %d damage. The environment was never on your side.",
|
||||
"The terrain betrays you. %d damage. It was never loyal to begin with.",
|
||||
"A hazard you didn't notice. %d damage. In fairness, you were busy being hit by other things.",
|
||||
"A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.",
|
||||
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
|
||||
}
|
||||
|
||||
var narrativeWardAbsorb = []string{
|
||||
"✨ The Quartz Ward flares. The hit is absorbed completely. Money well spent.",
|
||||
"✨ Your ward shatters — but the damage doesn't reach you. One and done. Worth it.",
|
||||
"✨ The ward takes the hit. You don't. The ward has no opinions about this arrangement.",
|
||||
}
|
||||
|
||||
var narrativeSporeCloud = []string{
|
||||
"🍄 The spore cloud confuses the enemy. They swing at nothing. The nothing is grateful.",
|
||||
"🍄 Spores fill the air. The enemy can't find you. You're right here. They still can't find you.",
|
||||
"🍄 The enemy chokes on spores and misses entirely. They will have questions about this later.",
|
||||
}
|
||||
|
||||
var narrativeReflect = []string{
|
||||
"💎 The Voidstone Shard activates. %d damage reflected back. The enemy hit themselves, technically.",
|
||||
"💎 The enemy's own force is turned against them. %d reflected. They did this to themselves.",
|
||||
"💎 Half the blow bounces back. %d damage to the enemy. Karma. Immediate karma.",
|
||||
}
|
||||
|
||||
var narrativeTimeout = []string{
|
||||
"The fight drags on. Neither side will yield. The judges decide on points.",
|
||||
"Exhaustion sets in. Both fighters are spent. The one still standing taller takes it.",
|
||||
"Time runs out. The crowd is restless. The decision falls to whoever bled less.",
|
||||
}
|
||||
|
||||
var narrativeCrowdRevenge = []string{
|
||||
"🪨 The crowd throws something at you. %d damage. Misty's fans hold grudges.",
|
||||
"🪨 Someone in the stands got a clear shot. %d damage. You should have tipped better.",
|
||||
"🪨 The crowd is not on your side today. %d damage. Misty sends her regards.",
|
||||
}
|
||||
|
||||
var narrativeFlatDmg = []string{
|
||||
"💣 The Coal Bomb detonates. %d damage before the fight even starts. The enemy was not ready. That was the point.",
|
||||
"💣 An explosion kicks things off. %d damage to the enemy. First impressions matter.",
|
||||
}
|
||||
|
||||
var narrativeHealItem = []string{
|
||||
"🧪 You feel the potion take effect. +%d HP restored. Tastes terrible. Works perfectly.",
|
||||
"🧪 The salve kicks in. +%d HP. You don't know what's in it. You don't want to know.",
|
||||
"🧪 Your consumable heals you for %d HP mid-fight. The enemy watches you heal and seems personally offended.",
|
||||
}
|
||||
|
||||
// Monster abilities
|
||||
var narrativePoisonApply = []string{
|
||||
"☠️ The enemy's attack carries something extra. You feel it immediately. Poison.",
|
||||
"☠️ A venomous strike. The wound burns differently than it should. That's not good.",
|
||||
"☠️ Something coats the enemy's weapon. It's in you now. The burning starts.",
|
||||
}
|
||||
|
||||
var narrativePoisonTick = []string{
|
||||
"☠️ Venom courses through you. %d poison damage. Your blood has opinions about this.",
|
||||
"☠️ The poison burns. %d damage. It's not getting better on its own.",
|
||||
"☠️ You feel the toxin working. %d damage this round. Time is not on your side.",
|
||||
}
|
||||
|
||||
var narrativeEnrage = []string{
|
||||
"🔥 The enemy's eyes go red. Something has changed. They're stronger now. You preferred them before.",
|
||||
"🔥 **ENRAGE.** The enemy is wounded and furious. Attack increased. This was not the plan.",
|
||||
"🔥 Cornered and desperate, the enemy unleashes everything. Desperate enemies are the most dangerous kind.",
|
||||
}
|
||||
|
||||
var narrativeArmorBreak = []string{
|
||||
"🛡️💥 Your armor cracks under the impact. Defense reduced. That sound was expensive.",
|
||||
"🛡️💥 The enemy shatters your guard. Your defense won't hold like it did. Neither will your confidence.",
|
||||
"🛡️💥 **Armor Break.** You feel exposed. Because you are.",
|
||||
}
|
||||
|
||||
var narrativeStun = []string{
|
||||
"⚡ The enemy winds up something that looks bad. It is bad.",
|
||||
"⚡ The enemy prepares a stunning blow. Your immediate future just got complicated.",
|
||||
"⚡ The enemy's weapon crackles with intent. This is going to hurt differently.",
|
||||
}
|
||||
|
||||
var narrativeStunned = []string{
|
||||
"⚡ You're stunned. Can't move. Can't attack. Can barely think.",
|
||||
"⚡ The blow leaves you reeling. You lose your turn. You lose some dignity too.",
|
||||
"⚡ Everything goes white for a moment. You skip your attack. The moment passes. The opportunity does not return.",
|
||||
}
|
||||
|
||||
var narrativeLifesteal = []string{
|
||||
"🩸 The enemy drains your vitality. %d damage — and they heal. This is deeply unfair.",
|
||||
"🩸 You feel your strength being siphoned. %d damage, enemy heals. They're taking what's yours.",
|
||||
"🩸 **Lifesteal.** The enemy grows stronger as you weaken. %d damage. The math is going in the wrong direction.",
|
||||
}
|
||||
|
||||
var narrativeCleave = []string{
|
||||
"⚔️⚔️ The enemy strikes twice in rapid succession. %d damage from the double blow. That should not be legal.",
|
||||
"⚔️⚔️ **Cleave!** Two strikes before you can react. %d damage total. One hit was enough. They did two.",
|
||||
"⚔️⚔️ A devastating combo. %d damage from both hits. The second one was personal.",
|
||||
}
|
||||
|
||||
// Outcome flavor
|
||||
var narrativeNearDeathWin = []string{
|
||||
"You survived by the skin of your teeth. Barely standing. The healers are on standby.",
|
||||
"That was closer than anyone should be comfortable with. But you won. Technically. Medically questionable.",
|
||||
"The healers rush over. You wave them off. Mostly because you can't lift your arm to do anything else.",
|
||||
}
|
||||
|
||||
var narrativeDominantWin = []string{
|
||||
"Decisive. The enemy never had a chance. They knew it. You knew it. The crowd knew it.",
|
||||
"That wasn't a fight. That was a demonstration. The enemy was the visual aid.",
|
||||
"You barely broke a sweat. The enemy cannot say the same. The enemy cannot say much of anything right now.",
|
||||
}
|
||||
|
||||
var narrativeCleanWin = []string{
|
||||
"A solid fight. You came out on top. The enemy came out on a stretcher.",
|
||||
"Well fought. The outcome was never really in doubt. Just in question.",
|
||||
"The enemy put up a fight. You put up a better one. The scoreboard reflects this.",
|
||||
}
|
||||
|
||||
var narrativeNearDeathLoss = []string{
|
||||
"So close. One more hit and it would've gone the other way. One more hit didn't come.",
|
||||
"You almost had it. Almost doesn't count here. Almost doesn't count anywhere.",
|
||||
"A razor-thin margin. The wrong side of it. The margin doesn't care whose side it was.",
|
||||
}
|
||||
|
||||
var narrativeDominantLoss = []string{
|
||||
"That was over before it started. The starting was a formality.",
|
||||
"Outclassed. Completely. There's no gentle way to say it so here it is.",
|
||||
"Some fights aren't fights. This was one of those. You were present. That's about all.",
|
||||
}
|
||||
|
||||
var narrativeCloseLoss = []string{
|
||||
"A good fight. Not good enough. The scoreboard is indifferent to effort.",
|
||||
"You gave it everything. It wasn't quite sufficient. Sufficiency is the enemy's department today.",
|
||||
"The enemy earned that one. So did you. Only one of you gets paid.",
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderCombatLog_ProducesMessages(t *testing.T) {
|
||||
player := Combatant{
|
||||
Name: "Hero",
|
||||
Stats: CombatStats{MaxHP: 100, Attack: 20, Defense: 10, Speed: 8, CritRate: 0.05},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: "Goblin",
|
||||
Stats: CombatStats{MaxHP: 60, Attack: 12, Defense: 5, Speed: 6, CritRate: 0.03},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
msgs := RenderCombatLog(result, "Hero", "Goblin")
|
||||
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_PhaseHeaders(t *testing.T) {
|
||||
player := Combatant{
|
||||
Name: "Hero",
|
||||
Stats: CombatStats{MaxHP: 100, Attack: 20, Defense: 10, Speed: 8, CritRate: 0.05},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
IsPlayer: true,
|
||||
}
|
||||
enemy := Combatant{
|
||||
Name: "Rat",
|
||||
Stats: CombatStats{MaxHP: 40, Attack: 8, Defense: 3, Speed: 4},
|
||||
Mods: CombatModifiers{DamageReduct: 1.0},
|
||||
}
|
||||
|
||||
result := SimulateCombat(player, enemy, defaultCombatPhases)
|
||||
msgs := RenderCombatLog(result, "Hero", "Rat")
|
||||
|
||||
hasOpening := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "Opening") {
|
||||
hasOpening = true
|
||||
}
|
||||
}
|
||||
if !hasOpening {
|
||||
t.Error("should have an Opening phase header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_WinPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 50,
|
||||
PlayerEndHP: 80,
|
||||
EnemyEndHP: 0,
|
||||
TotalRounds: 3,
|
||||
Closeness: 0.2,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 25, EnemyHP: 25, PlayerHP: 100},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 25, EnemyHP: 0, PlayerHP: 80},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Slime")
|
||||
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
// Phase messages should contain damage numbers
|
||||
found := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "25") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("phase messages should contain damage values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_LossPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: false,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 80,
|
||||
PlayerEndHP: 0,
|
||||
EnemyEndHP: 40,
|
||||
TotalRounds: 4,
|
||||
Closeness: 0.5,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "enemy", Action: "hit", Damage: 50, PlayerHP: 50, EnemyHP: 80},
|
||||
{Round: 2, Phase: "clash", Actor: "enemy", Action: "hit", Damage: 50, PlayerHP: 0, EnemyHP: 80},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Dragon")
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLogArena_ProducesPhaseMessages(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 70,
|
||||
EnemyEndHP: 0,
|
||||
TotalRounds: 3,
|
||||
Closeness: 0.3,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 30, PlayerHP: 100},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 30, EnemyHP: 0, PlayerHP: 70},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLogArena(result, "Hero", "Ratticus")
|
||||
if len(msgs) < 1 {
|
||||
t.Fatalf("expected at least 1 phase message, got %d", len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_ConsumableEvents(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 80,
|
||||
EnemyEndHP: 0,
|
||||
Events: []CombatEvent{
|
||||
{Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable", Desc: "Goblin Grease"},
|
||||
{Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "flat_damage", Damage: 8, PlayerHP: 100, EnemyHP: 52},
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, PlayerHP: 100, EnemyHP: 22},
|
||||
{Round: 2, Phase: "clash", Actor: "player", Action: "hit", Damage: 22, PlayerHP: 80, EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Imp")
|
||||
|
||||
found := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "Goblin Grease") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("should render consumable use event mentioning item name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCombatLog_MonsterAbilityEvents(t *testing.T) {
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 100,
|
||||
EnemyStartHP: 60,
|
||||
PlayerEndHP: 50,
|
||||
EnemyEndHP: 0,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "opening", Actor: "enemy", Action: "armor_break", Damage: 0, PlayerHP: 100, EnemyHP: 60},
|
||||
{Round: 1, Phase: "opening", Actor: "player", Action: "hit", Damage: 30, PlayerHP: 100, EnemyHP: 30},
|
||||
{Round: 2, Phase: "clash", Actor: "enemy", Action: "hit", Damage: 25, PlayerHP: 75, EnemyHP: 30},
|
||||
{Round: 2, Phase: "clash", Actor: "enemy", Action: "poison_tick", Damage: 5, PlayerHP: 70, EnemyHP: 30},
|
||||
{Round: 3, Phase: "decisive", Actor: "player", Action: "crit", Damage: 30, PlayerHP: 50, EnemyHP: 0},
|
||||
},
|
||||
}
|
||||
|
||||
msgs := RenderCombatLog(result, "Hero", "Wyrm")
|
||||
|
||||
hasArmorBreak := false
|
||||
hasPoison := false
|
||||
for _, m := range msgs {
|
||||
if strings.Contains(m, "armor") || strings.Contains(m, "Armor") || strings.Contains(m, "defense") || strings.Contains(m, "Defense") {
|
||||
hasArmorBreak = true
|
||||
}
|
||||
if strings.Contains(m, "poison") || strings.Contains(m, "Poison") || strings.Contains(m, "toxin") || strings.Contains(m, "Venom") || strings.Contains(m, "venom") {
|
||||
hasPoison = true
|
||||
}
|
||||
}
|
||||
if !hasArmorBreak {
|
||||
t.Error("should render armor_break event")
|
||||
}
|
||||
if !hasPoison {
|
||||
t.Error("should render poison_tick event")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DerivePlayerStats converts game-layer objects into the combat engine's stat model.
|
||||
func DerivePlayerStats(
|
||||
char *AdventureCharacter,
|
||||
equip map[EquipmentSlot]*AdvEquipment,
|
||||
bonuses *AdvBonusSummary,
|
||||
chatLevel int,
|
||||
streak int,
|
||||
hasGrudge bool,
|
||||
) (CombatStats, CombatModifiers) {
|
||||
arenaSets := advEquippedArenaSets(equip)
|
||||
|
||||
// Base stats from combat level
|
||||
stats := CombatStats{
|
||||
MaxHP: 50 + char.CombatLevel*2,
|
||||
Attack: 5 + char.CombatLevel + bonuses.CombatBonus,
|
||||
Defense: 3 + char.CombatLevel/2 + bonuses.CombatBonus/2,
|
||||
Speed: 5 + char.CombatLevel/3,
|
||||
CritRate: 0.03,
|
||||
DodgeRate: 0.02,
|
||||
BlockRate: 0.02,
|
||||
}
|
||||
|
||||
// Equipment contributions
|
||||
for _, slot := range allSlots {
|
||||
eq, ok := equip[slot]
|
||||
if !ok || eq == nil {
|
||||
continue
|
||||
}
|
||||
eTier := advEffectiveTier(eq)
|
||||
cond := 0.3 + 0.7*(float64(eq.Condition)/100.0) // smooth degradation curve
|
||||
effective := eTier * cond
|
||||
|
||||
switch slot {
|
||||
case SlotWeapon:
|
||||
stats.Attack += int(effective * 2)
|
||||
stats.CritRate += eTier * 0.005
|
||||
case SlotArmor:
|
||||
stats.Defense += int(effective * 1.5)
|
||||
stats.MaxHP += int(float64(stats.MaxHP) * eTier * 0.03)
|
||||
case SlotHelmet:
|
||||
stats.Defense += int(effective * 0.5)
|
||||
stats.DodgeRate += eTier * 0.01
|
||||
case SlotBoots:
|
||||
stats.Speed += int(effective)
|
||||
stats.DodgeRate += eTier * 0.005
|
||||
case SlotTool:
|
||||
stats.Attack += int(effective * 0.5)
|
||||
stats.BlockRate += eTier * 0.01
|
||||
}
|
||||
}
|
||||
|
||||
// Arena set bonuses
|
||||
if arenaSets["champions"] {
|
||||
stats.MaxHP = int(float64(stats.MaxHP) * 1.10)
|
||||
stats.Attack = int(float64(stats.Attack) * 1.10)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.10)
|
||||
stats.Speed = int(float64(stats.Speed) * 1.10)
|
||||
}
|
||||
if arenaSets["bloodied"] {
|
||||
stats.CritRate += 0.03
|
||||
}
|
||||
if arenaSets["ironclad"] {
|
||||
stats.MaxHP = int(float64(stats.MaxHP) * 1.05)
|
||||
}
|
||||
// Sovereign: handled via DeathSave modifier
|
||||
// Tempered: handled post-combat in degradation
|
||||
|
||||
// Housing HP bonus
|
||||
stats.MaxHP += int(float64(stats.MaxHP) * char.HouseHPBonus())
|
||||
|
||||
// Streak bonuses
|
||||
switch {
|
||||
case streak >= 30:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.20)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.15)
|
||||
case streak >= 14:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.15)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.10)
|
||||
case streak >= 7:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.10)
|
||||
stats.Defense = int(float64(stats.Defense) * 1.05)
|
||||
case streak >= 3:
|
||||
stats.Attack = int(float64(stats.Attack) * 1.05)
|
||||
}
|
||||
|
||||
// Grudge bonus
|
||||
if hasGrudge {
|
||||
stats.Attack = int(float64(stats.Attack) * 1.10)
|
||||
}
|
||||
|
||||
// Treasure bonuses mapped to stats
|
||||
if bonuses.DeathModifier < 0 {
|
||||
stats.Defense += int(-bonuses.DeathModifier * 2)
|
||||
}
|
||||
if bonuses.SuccessBonus > 0 {
|
||||
stats.Attack += int(bonuses.SuccessBonus * 0.5)
|
||||
}
|
||||
if bonuses.ExceptionalBonus > 0 {
|
||||
stats.CritRate += bonuses.ExceptionalBonus / 100.0
|
||||
}
|
||||
|
||||
// Chat level perks
|
||||
chatTier := chatLevel / 10
|
||||
if chatTier > 5 {
|
||||
chatTier = 5
|
||||
}
|
||||
stats.CritRate += float64(chatTier) * 0.005
|
||||
|
||||
// Cap rates
|
||||
if stats.CritRate > 0.50 {
|
||||
stats.CritRate = 0.50
|
||||
}
|
||||
if stats.DodgeRate > 0.40 {
|
||||
stats.DodgeRate = 0.40
|
||||
}
|
||||
if stats.BlockRate > 0.40 {
|
||||
stats.BlockRate = 0.40
|
||||
}
|
||||
|
||||
// Modifiers
|
||||
mods := CombatModifiers{
|
||||
DamageBonus: 0,
|
||||
DamageReduct: 1.0,
|
||||
}
|
||||
|
||||
// Streak damage reduction
|
||||
switch {
|
||||
case streak >= 30:
|
||||
mods.DamageReduct = 0.95
|
||||
case streak >= 14:
|
||||
mods.DamageReduct = 0.97
|
||||
}
|
||||
|
||||
// Sovereign death save
|
||||
if arenaSets["sovereign"] {
|
||||
if char.DeathReprieveLast == nil || time.Since(*char.DeathReprieveLast) >= 168*time.Hour {
|
||||
mods.DeathSave = true
|
||||
}
|
||||
}
|
||||
|
||||
// Pet modifiers
|
||||
if char.HasPet() {
|
||||
mods.PetAttackProc = petAttackChance(char.PetLevel)
|
||||
mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
|
||||
mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
|
||||
mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
|
||||
}
|
||||
if char.PetMorningDefense {
|
||||
mods.DamageReduct *= 0.95 // 5% less damage
|
||||
}
|
||||
|
||||
// NPC debuffs
|
||||
now := time.Now().UTC()
|
||||
if char.MistyDebuffExpires != nil && now.Before(*char.MistyDebuffExpires) {
|
||||
mods.CrowdRevengeProc = 0.20
|
||||
mods.CrowdRevengeDmg = 3 + rand.IntN(6) // 3-8 damage
|
||||
}
|
||||
|
||||
// NPC buffs
|
||||
if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
|
||||
mods.MistyHealProc = 0.20
|
||||
mods.MistyHealAmt = 8 + char.CombatLevel/5 + rand.IntN(5)
|
||||
}
|
||||
if char.ArinaBuffExpires != nil && now.Before(*char.ArinaBuffExpires) {
|
||||
mods.SniperKillProc = 0.08
|
||||
}
|
||||
|
||||
return stats, mods
|
||||
}
|
||||
|
||||
// DeriveArenaMonsterStats converts an ArenaMonster to combat engine stats.
|
||||
// Arena monsters face players with high combat level and arena-tier gear,
|
||||
// so stats must scale hard with ThreatLevel.
|
||||
func DeriveArenaMonsterStats(monster *ArenaMonster) (CombatStats, CombatModifiers) {
|
||||
tl := float64(monster.ThreatLevel)
|
||||
bl := monster.BaseLethality
|
||||
|
||||
stats := CombatStats{
|
||||
MaxHP: 40 + int(tl*4+bl*60),
|
||||
Attack: 8 + int(bl*40) + int(tl*0.8),
|
||||
Defense: 3 + int(tl*0.5+bl*10),
|
||||
Speed: 5 + int(tl*0.3),
|
||||
CritRate: bl * 0.20,
|
||||
DodgeRate: 0.02 + tl*0.003,
|
||||
BlockRate: 0.01 + bl*0.03,
|
||||
}
|
||||
mods := CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}
|
||||
return stats, mods
|
||||
}
|
||||
|
||||
// DeriveDungeonMonsterStats synthesizes monster stats from a dungeon location.
|
||||
// Target: the stat block should produce a death rate roughly matching BaseDeathPct
|
||||
// when the player is at the location's MinLevel with tier-appropriate equipment.
|
||||
// The player typically has higher base stats, so monsters compensate with HP bulk
|
||||
// and just enough Attack to be threatening over the fight's duration.
|
||||
func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers) {
|
||||
t := float64(loc.Tier)
|
||||
death := loc.BaseDeathPct // 8 to 60
|
||||
|
||||
// Well-equipped players at the right level should be dominant (win 85-95%+).
|
||||
// Deaths come from bad luck: enemy crits, environmental hazards, unlucky initiative.
|
||||
// Monster Attack needs to be high enough to threaten over multiple rounds via
|
||||
// penetration damage, but not enough to kill quickly.
|
||||
stats := CombatStats{
|
||||
MaxHP: 25 + int(t*t*6+death*0.6), // quadratic: T1=33, T5=187
|
||||
Attack: 3 + int(death*0.2) + int(t*t*1.5), // quadratic: T1=5, T5=49
|
||||
Defense: 2 + int(t*1.5),
|
||||
Speed: 4 + int(t*1.5),
|
||||
CritRate: 0.03 + death*0.003,
|
||||
DodgeRate: 0.02 + t*0.008,
|
||||
BlockRate: 0.01 + t*0.005,
|
||||
}
|
||||
mods := CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}
|
||||
return stats, mods
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testEquip(tier int) map[EquipmentSlot]*AdvEquipment {
|
||||
equip := make(map[EquipmentSlot]*AdvEquipment)
|
||||
for _, slot := range allSlots {
|
||||
equip[slot] = &AdvEquipment{
|
||||
Slot: slot,
|
||||
Tier: tier,
|
||||
Condition: 100,
|
||||
}
|
||||
}
|
||||
return equip
|
||||
}
|
||||
|
||||
func testChar(combatLevel int) *AdventureCharacter {
|
||||
return &AdventureCharacter{
|
||||
CombatLevel: combatLevel,
|
||||
Alive: true,
|
||||
}
|
||||
}
|
||||
|
||||
func zeroBonuses() *AdvBonusSummary {
|
||||
return &AdvBonusSummary{}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_BaseStats(t *testing.T) {
|
||||
char := testChar(10)
|
||||
equip := testEquip(0)
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP < 70 {
|
||||
t.Errorf("level 10 base MaxHP = %d, want >= 70", stats.MaxHP)
|
||||
}
|
||||
if stats.Attack < 15 {
|
||||
t.Errorf("level 10 base Attack = %d, want >= 15", stats.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_EquipmentScales(t *testing.T) {
|
||||
char := testChar(10)
|
||||
statsT1, _ := DerivePlayerStats(char, testEquip(1), zeroBonuses(), 0, 0, false)
|
||||
statsT3, _ := DerivePlayerStats(char, testEquip(3), zeroBonuses(), 0, 0, false)
|
||||
statsT5, _ := DerivePlayerStats(char, testEquip(5), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if statsT3.Attack <= statsT1.Attack {
|
||||
t.Errorf("T3 Attack (%d) should exceed T1 (%d)", statsT3.Attack, statsT1.Attack)
|
||||
}
|
||||
if statsT5.Attack <= statsT3.Attack {
|
||||
t.Errorf("T5 Attack (%d) should exceed T3 (%d)", statsT5.Attack, statsT3.Attack)
|
||||
}
|
||||
if statsT5.Defense <= statsT1.Defense {
|
||||
t.Errorf("T5 Defense (%d) should exceed T1 (%d)", statsT5.Defense, statsT1.Defense)
|
||||
}
|
||||
if statsT5.Speed <= statsT1.Speed {
|
||||
t.Errorf("T5 Speed (%d) should exceed T1 (%d)", statsT5.Speed, statsT1.Speed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_ConditionDegrades(t *testing.T) {
|
||||
char := testChar(10)
|
||||
fullEquip := testEquip(3)
|
||||
damagedEquip := testEquip(3)
|
||||
for _, eq := range damagedEquip {
|
||||
eq.Condition = 20
|
||||
}
|
||||
|
||||
statsFull, _ := DerivePlayerStats(char, fullEquip, zeroBonuses(), 0, 0, false)
|
||||
statsDmg, _ := DerivePlayerStats(char, damagedEquip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if statsDmg.Attack >= statsFull.Attack {
|
||||
t.Errorf("damaged Attack (%d) should be less than full (%d)", statsDmg.Attack, statsFull.Attack)
|
||||
}
|
||||
if statsDmg.Defense >= statsFull.Defense {
|
||||
t.Errorf("damaged Defense (%d) should be less than full (%d)", statsDmg.Defense, statsFull.Defense)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_ChampionsSet(t *testing.T) {
|
||||
char := testChar(20)
|
||||
equip := testEquip(3)
|
||||
for _, eq := range equip {
|
||||
eq.ArenaTier = 3
|
||||
eq.ArenaSet = "champions"
|
||||
}
|
||||
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
// Compare against same tier without set
|
||||
equip2 := testEquip(3)
|
||||
for _, eq := range equip2 {
|
||||
eq.ArenaTier = 3
|
||||
}
|
||||
statsNoSet, _ := DerivePlayerStats(char, equip2, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP <= statsNoSet.MaxHP {
|
||||
t.Errorf("champions MaxHP (%d) should exceed no-set (%d)", stats.MaxHP, statsNoSet.MaxHP)
|
||||
}
|
||||
if stats.Attack <= statsNoSet.Attack {
|
||||
t.Errorf("champions Attack (%d) should exceed no-set (%d)", stats.Attack, statsNoSet.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_StreakBonuses(t *testing.T) {
|
||||
char := testChar(15)
|
||||
equip := testEquip(2)
|
||||
|
||||
stats0, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
stats7, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 7, false)
|
||||
stats30, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 30, false)
|
||||
|
||||
if stats7.Attack <= stats0.Attack {
|
||||
t.Errorf("7-day streak Attack (%d) should exceed 0-streak (%d)", stats7.Attack, stats0.Attack)
|
||||
}
|
||||
if stats30.Attack <= stats7.Attack {
|
||||
t.Errorf("30-day streak Attack (%d) should exceed 7-streak (%d)", stats30.Attack, stats7.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_GrudgeBonus(t *testing.T) {
|
||||
char := testChar(15)
|
||||
equip := testEquip(2)
|
||||
|
||||
statsNo, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
statsYes, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, true)
|
||||
|
||||
if statsYes.Attack <= statsNo.Attack {
|
||||
t.Errorf("grudge Attack (%d) should exceed no-grudge (%d)", statsYes.Attack, statsNo.Attack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_HousingHP(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.HouseTier = 4 // +20% HP
|
||||
equip := testEquip(2)
|
||||
|
||||
stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
charNoHouse := testChar(10)
|
||||
statsNoHouse, _ := DerivePlayerStats(charNoHouse, equip, zeroBonuses(), 0, 0, false)
|
||||
|
||||
if stats.MaxHP <= statsNoHouse.MaxHP {
|
||||
t.Errorf("T4 housing MaxHP (%d) should exceed no-house (%d)", stats.MaxHP, statsNoHouse.MaxHP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_PetModifiers(t *testing.T) {
|
||||
char := testChar(10)
|
||||
char.PetType = "cat"
|
||||
char.PetArrived = true
|
||||
char.PetLevel = 5
|
||||
|
||||
_, mods := DerivePlayerStats(char, testEquip(2), zeroBonuses(), 0, 0, false)
|
||||
|
||||
if mods.PetAttackProc == 0 {
|
||||
t.Error("pet attack proc should be non-zero")
|
||||
}
|
||||
if mods.PetDeflectProc == 0 {
|
||||
t.Error("pet deflect proc should be non-zero")
|
||||
}
|
||||
if mods.PetWhiffProc == 0 {
|
||||
t.Error("pet whiff proc should be non-zero")
|
||||
}
|
||||
if mods.PetAttackDmg == 0 {
|
||||
t.Error("pet attack damage should be non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_ChatPerks(t *testing.T) {
|
||||
char := testChar(10)
|
||||
equip := testEquip(2)
|
||||
|
||||
stats0, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false)
|
||||
stats50, _ := DerivePlayerStats(char, equip, zeroBonuses(), 50, 0, false)
|
||||
|
||||
if stats50.CritRate <= stats0.CritRate {
|
||||
t.Errorf("L50 chat CritRate (%.3f) should exceed L0 (%.3f)", stats50.CritRate, stats0.CritRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDerivePlayerStats_RateCaps(t *testing.T) {
|
||||
char := testChar(50)
|
||||
equip := testEquip(5)
|
||||
for _, eq := range equip {
|
||||
eq.ArenaTier = 5
|
||||
eq.ArenaSet = "bloodied"
|
||||
}
|
||||
bonuses := &AdvBonusSummary{ExceptionalBonus: 50}
|
||||
|
||||
stats, _ := DerivePlayerStats(char, equip, bonuses, 50, 0, false)
|
||||
|
||||
if stats.CritRate > 0.50 {
|
||||
t.Errorf("CritRate %f exceeds 50%% cap", stats.CritRate)
|
||||
}
|
||||
if stats.DodgeRate > 0.40 {
|
||||
t.Errorf("DodgeRate %f exceeds 40%% cap", stats.DodgeRate)
|
||||
}
|
||||
if stats.BlockRate > 0.40 {
|
||||
t.Errorf("BlockRate %f exceeds 40%% cap", stats.BlockRate)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Monster Stat Tests ───────────────────────────────────────────────────────
|
||||
|
||||
func TestDeriveArenaMonsterStats_Scales(t *testing.T) {
|
||||
weak := &ArenaMonster{Name: "Rat", BaseLethality: 0.10, ThreatLevel: 3}
|
||||
strong := &ArenaMonster{Name: "Dragon", BaseLethality: 0.85, ThreatLevel: 70}
|
||||
|
||||
sW, _ := DeriveArenaMonsterStats(weak)
|
||||
sS, _ := DeriveArenaMonsterStats(strong)
|
||||
|
||||
if sS.MaxHP <= sW.MaxHP {
|
||||
t.Error("strong monster should have more HP")
|
||||
}
|
||||
if sS.Attack <= sW.Attack {
|
||||
t.Error("strong monster should have more Attack")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveDungeonMonsterStats_TierScaling(t *testing.T) {
|
||||
t1 := &AdvLocation{Tier: 1, BaseDeathPct: 8}
|
||||
t5 := &AdvLocation{Tier: 5, BaseDeathPct: 60}
|
||||
|
||||
s1, _ := DeriveDungeonMonsterStats(t1)
|
||||
s5, _ := DeriveDungeonMonsterStats(t5)
|
||||
|
||||
if s5.MaxHP <= s1.MaxHP {
|
||||
t.Error("T5 dungeon monster should have more HP than T1")
|
||||
}
|
||||
if s5.Attack <= s1.Attack {
|
||||
t.Error("T5 dungeon monster should have more Attack than T1")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Balance Regression: 10k Sims ─────────────────────────────────────────────
|
||||
|
||||
func TestBalanceRegression_DungeonDeathRates(t *testing.T) {
|
||||
// Players at the right level with tier-appropriate gear should be dominant.
|
||||
// Death comes from bad luck (crits, environmental hazards) — not raw stat disadvantage.
|
||||
// Underleveled/underequipped players face higher death rates (tested separately).
|
||||
// These are "well-equipped at tier" baselines.
|
||||
type tc struct {
|
||||
level int
|
||||
equipT int
|
||||
dungeon AdvLocation
|
||||
maxDeath float64
|
||||
minDeath float64
|
||||
}
|
||||
cases := []tc{
|
||||
{5, 1, advDungeons[0], 0.05, 0.0}, // T1: trivial with proper gear
|
||||
{12, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level
|
||||
{25, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear
|
||||
{38, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared
|
||||
{48, 5, advDungeons[4], 0.35, 0.02}, // T5: real danger — monster stats catch up
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
char := testChar(c.level)
|
||||
equip := testEquip(c.equipT)
|
||||
bonuses := zeroBonuses()
|
||||
stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false)
|
||||
eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon)
|
||||
|
||||
player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true}
|
||||
enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods}
|
||||
|
||||
deaths := 0
|
||||
const N = 10000
|
||||
for i := 0; i < N; i++ {
|
||||
r := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
deaths++
|
||||
}
|
||||
}
|
||||
rate := float64(deaths) / float64(N)
|
||||
t.Logf("%s (L%d T%d): P[HP=%d Atk=%d Def=%d Spd=%d] E[HP=%d Atk=%d Def=%d Spd=%d] → death=%.2f",
|
||||
c.dungeon.Name, c.level, c.equipT,
|
||||
stats.MaxHP, stats.Attack, stats.Defense, stats.Speed,
|
||||
eStats.MaxHP, eStats.Attack, eStats.Defense, eStats.Speed,
|
||||
rate)
|
||||
if rate < c.minDeath || rate > c.maxDeath {
|
||||
t.Errorf(" FAIL: death rate %.2f outside [%.2f, %.2f]",
|
||||
rate, c.minDeath, c.maxDeath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceRegression_UnderleveledPlayers(t *testing.T) {
|
||||
// Underleveled / undergeared players should face real danger.
|
||||
type tc struct {
|
||||
name string
|
||||
level int
|
||||
equipT int
|
||||
dungeon AdvLocation
|
||||
minDeath float64
|
||||
}
|
||||
cases := []tc{
|
||||
{"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.40},
|
||||
{"L8 in T3 dungeon", 8, 1, advDungeons[2], 0.30},
|
||||
{"L20 in T4 dungeon, T2 gear", 20, 2, advDungeons[3], 0.25},
|
||||
{"L30 in T5 dungeon, T3 gear", 30, 3, advDungeons[4], 0.30},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
char := testChar(c.level)
|
||||
equip := testEquip(c.equipT)
|
||||
bonuses := zeroBonuses()
|
||||
stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false)
|
||||
eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon)
|
||||
|
||||
player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true}
|
||||
enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods}
|
||||
|
||||
deaths := 0
|
||||
const N = 5000
|
||||
for i := 0; i < N; i++ {
|
||||
r := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
if !r.PlayerWon {
|
||||
deaths++
|
||||
}
|
||||
}
|
||||
rate := float64(deaths) / float64(N)
|
||||
t.Logf("%s: P[HP=%d Atk=%d Def=%d] E[HP=%d Atk=%d Def=%d] → death=%.2f (want ≥%.2f)",
|
||||
c.name, stats.MaxHP, stats.Attack, stats.Defense,
|
||||
eStats.MaxHP, eStats.Attack, eStats.Defense, rate, c.minDeath)
|
||||
if rate < c.minDeath {
|
||||
t.Errorf(" FAIL: death rate %.2f below minimum %.2f", rate, c.minDeath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// API-calling subcommands run async
|
||||
go func() {
|
||||
safeGo("concerts-handler", func() {
|
||||
var err error
|
||||
switch sub {
|
||||
case "watch":
|
||||
@@ -119,7 +119,7 @@ func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
slog.Error("concerts: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Balance Constants ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Phase 1 values. Adjust during balance pass. Co-op should feel meaningfully
|
||||
// harder than solo and reward groups for showing up.
|
||||
|
||||
type CoopFundingTier string
|
||||
|
||||
const (
|
||||
CoopFundNone CoopFundingTier = "none"
|
||||
CoopFundMinimal CoopFundingTier = "minimal"
|
||||
CoopFundStandard CoopFundingTier = "standard"
|
||||
CoopFundAggressive CoopFundingTier = "aggressive"
|
||||
CoopFundAllIn CoopFundingTier = "all_in"
|
||||
)
|
||||
|
||||
type coopFundingDef struct {
|
||||
cost int
|
||||
modifier int // % added to per-floor success roll
|
||||
label string
|
||||
}
|
||||
|
||||
var coopFundingTable = map[CoopFundingTier]coopFundingDef{
|
||||
CoopFundNone: {0, -10, "None"},
|
||||
CoopFundMinimal: {500, 0, "Minimal"},
|
||||
CoopFundStandard: {1500, 8, "Standard"},
|
||||
CoopFundAggressive: {4000, 18, "Aggressive"},
|
||||
CoopFundAllIn: {10000, 30, "All In"},
|
||||
}
|
||||
|
||||
// fundingTierOrder for menu display.
|
||||
var coopFundingOrder = []CoopFundingTier{
|
||||
CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn,
|
||||
}
|
||||
|
||||
type coopTierDef struct {
|
||||
totalDays int
|
||||
baseFailurePct int // base % failure per floor at zero modifier
|
||||
rewardBase int // base reward (gold) before split
|
||||
difficulty string // label
|
||||
minLevel int // newbie liability threshold
|
||||
lootMult float64 // for display only
|
||||
}
|
||||
|
||||
// Reward bases tuned via Monte Carlo (coop_dungeon_balance_test.go) so the
|
||||
// optimal funding strategy walks up tiers: T1-T2 Minimal/Standard, T3 Standard,
|
||||
// T4 Standard or Aggressive, T5 Aggressive only. All-In stays -EV at every
|
||||
// tier — it's the boost lever for liability parties, not the optimal play.
|
||||
var coopTierTable = map[int]coopTierDef{
|
||||
1: {2, 25, 22500, "Moderate", 5, 2.0},
|
||||
2: {3, 32, 40000, "Hard", 12, 3.5},
|
||||
3: {4, 40, 72500, "Very Hard", 20, 5.5},
|
||||
4: {5, 48, 120000, "Brutal", 30, 8.0},
|
||||
5: {7, 58, 600000, "Murderous", 40, 12.0},
|
||||
}
|
||||
|
||||
const (
|
||||
coopMinPartySize = 2
|
||||
coopMaxPartySize = 4
|
||||
coopInviteWindow = 24 * time.Hour
|
||||
coopLiabilityCap = 8 // liability players' funding modifier capped at +8% regardless of tier
|
||||
coopAdventureRake = 0.05
|
||||
)
|
||||
|
||||
// ── Command Dispatch ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleCoopCmd(ctx MessageContext) error {
|
||||
args := strings.TrimSpace(p.GetArgs(ctx.Body, "coop"))
|
||||
lower := strings.ToLower(args)
|
||||
|
||||
switch {
|
||||
case args == "" || lower == "help":
|
||||
return p.SendDM(ctx.Sender, coopHelpText)
|
||||
case lower == "list":
|
||||
return p.handleCoopList(ctx)
|
||||
case lower == "status":
|
||||
return p.handleCoopStatus(ctx)
|
||||
case lower == "stats":
|
||||
return p.SendDM(ctx.Sender, renderCoopStats())
|
||||
case strings.HasPrefix(lower, "start "):
|
||||
return p.handleCoopStart(ctx, strings.TrimSpace(args[6:]))
|
||||
case strings.HasPrefix(lower, "join"):
|
||||
return p.handleCoopJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join")))
|
||||
case strings.HasPrefix(lower, "fund "):
|
||||
return p.handleCoopFund(ctx, strings.TrimSpace(args[5:]))
|
||||
case lower == "cancel":
|
||||
return p.handleCoopCancel(ctx)
|
||||
case strings.HasPrefix(lower, "vote "):
|
||||
return p.handleCoopVote(ctx, strings.TrimSpace(args[5:]))
|
||||
case strings.HasPrefix(lower, "bet "):
|
||||
return p.handleCoopBet(ctx, strings.TrimSpace(args[4:]))
|
||||
case strings.HasPrefix(lower, "gift "):
|
||||
return p.handleCoopGift(ctx, strings.TrimSpace(args[5:]))
|
||||
case strings.HasPrefix(lower, "giftvote "):
|
||||
return p.handleCoopGiftVote(ctx, strings.TrimSpace(args[9:]))
|
||||
case strings.HasPrefix(lower, "admgift "):
|
||||
return p.handleCoopAdmGift(ctx, strings.TrimSpace(args[8:]))
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "Unknown coop command. Try `!coop help`.")
|
||||
}
|
||||
|
||||
const coopHelpText = `**Co-op Dungeon Commands**
|
||||
|
||||
` + "`!coop list`" + ` — Show open invites
|
||||
` + "`!coop start <tier>`" + ` — Open an invite for a co-op dungeon (tier 1-5)
|
||||
` + "`!coop join [<run_id>]`" + ` — Join an open invite (defaults to most recent)
|
||||
` + "`!coop fund <tier>`" + ` — Set today's funding (none/minimal/standard/aggressive/all_in)
|
||||
` + "`!coop vote <A|B|C>`" + ` — Vote on the day's floor event
|
||||
` + "`!coop bet <amount> <success|failure>`" + ` — Spectator bet (parimutuel, 10% rake)
|
||||
` + "`!coop gift <run_id> <basket|mimic>`" + ` — Send a gift (1 harvest action)
|
||||
` + "`!coop giftvote <id> <open|leave>`" + ` — Party votes whether to open a gift
|
||||
` + "`!coop status`" + ` — Show your current run
|
||||
` + "`!coop stats`" + ` — Aggregate stats: outcomes, gold flow, betting, gifts, TwinBee helpfulness
|
||||
` + "`!coop cancel`" + ` — Cancel an open invite (leader only, before lock)
|
||||
|
||||
**How it runs:**
|
||||
- 24h invite window. Run locks automatically when window closes.
|
||||
- 2-4 players. 2 minimum or invite cancels.
|
||||
- Locking the party consumes the day's combat action for every member.
|
||||
- Each subsequent day, every player picks a funding tier. Funding modifies
|
||||
the party's per-floor success chance.
|
||||
- Inactive players (no funding decision) auto-play at None (-10%).
|
||||
- Wipes refund the day's combat action only. Funding is gone.
|
||||
- On success, the reward is split evenly. Tax applies.`
|
||||
|
||||
// ── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) error {
|
||||
tier, err := strconv.Atoi(tierArg)
|
||||
if err != nil || tier < 1 || tier > 5 {
|
||||
return p.SendDM(ctx.Sender, "Tier must be 1-5. Example: `!coop start 3`.")
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. Co-op dungeons require a living adventurer.")
|
||||
}
|
||||
if !char.CanDoCombat(false) {
|
||||
return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.")
|
||||
}
|
||||
|
||||
// One active run per player.
|
||||
if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in a co-op run (#%d, %s). Use `!coop status`.", existing.ID, existing.Status))
|
||||
}
|
||||
|
||||
def := coopTierTable[tier]
|
||||
run, err := createCoopRun(tier, ctx.Sender, def.totalDays, def.baseFailurePct)
|
||||
if err != nil {
|
||||
slog.Error("coop: create run", "err", err)
|
||||
return p.SendDM(ctx.Sender, "Couldn't open an invite. Try again.")
|
||||
}
|
||||
if err := addCoopMember(run.ID, ctx.Sender, 0, char.CombatLevel < def.minLevel); err != nil {
|
||||
slog.Error("coop: add leader", "err", err)
|
||||
return p.SendDM(ctx.Sender, "Couldn't add you to the run.")
|
||||
}
|
||||
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
postID, err := p.SendMessageID(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName))
|
||||
if err == nil {
|
||||
_ = setCoopRunInvitePostID(run.ID, postID)
|
||||
_ = p.PinEvent(gr, postID)
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("⚔️ Tier %d co-op invite opened (#%d). Locks in 24h. Recruit your party.", tier, run.ID))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCoopJoin(ctx MessageContext, runIDArg string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The dungeon does not accept corpses.")
|
||||
}
|
||||
if !char.CanDoCombat(false) {
|
||||
return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.")
|
||||
}
|
||||
if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in run #%d.", existing.ID))
|
||||
}
|
||||
|
||||
var run *CoopRun
|
||||
if runIDArg == "" {
|
||||
run, err = loadMostRecentOpenCoopRun()
|
||||
} else {
|
||||
id, perr := strconv.Atoi(runIDArg)
|
||||
if perr != nil {
|
||||
return p.SendDM(ctx.Sender, "Run ID must be a number. Try `!coop join 7` or `!coop join`.")
|
||||
}
|
||||
run, err = loadCoopRun(id)
|
||||
}
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "No open invite found. Use `!coop list` to see open runs.")
|
||||
}
|
||||
if run.Status != "open" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s. Can't join.", run.ID, run.Status))
|
||||
}
|
||||
|
||||
members, err := loadCoopMembers(run.ID)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load the party.")
|
||||
}
|
||||
if len(members) >= coopMaxPartySize {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is full (%d/%d).", run.ID, len(members), coopMaxPartySize))
|
||||
}
|
||||
|
||||
def := coopTierTable[run.Tier]
|
||||
liability := char.CombatLevel < def.minLevel
|
||||
if err := addCoopMember(run.ID, ctx.Sender, len(members), liability); err != nil {
|
||||
slog.Error("coop: join", "err", err)
|
||||
return p.SendDM(ctx.Sender, "Couldn't join.")
|
||||
}
|
||||
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
warn := ""
|
||||
if liability {
|
||||
warn = " ⚠️ liability"
|
||||
}
|
||||
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ %s joined Tier %d Co-op #%d (%d/%d)%s.",
|
||||
char.DisplayName, run.Tier, run.ID, len(members)+1, coopMaxPartySize, warn))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Joined run #%d. Wait for the lock; the run starts then.", run.ID))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCoopList(ctx MessageContext) error {
|
||||
runs, err := loadOpenCoopRuns()
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load the list.")
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
return p.SendDM(ctx.Sender, "No open invites right now. Start one with `!coop start <tier>`.")
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("**Open Co-op Invites**\n\n")
|
||||
for _, r := range runs {
|
||||
members, _ := loadCoopMembers(r.ID)
|
||||
closesIn := time.Until(r.CreatedAt.Add(coopInviteWindow)).Truncate(time.Minute)
|
||||
sb.WriteString(fmt.Sprintf(" #%d T%d (%s) %d/%d closes in %s\n",
|
||||
r.ID, r.Tier, coopTierTable[r.Tier].difficulty, len(members), coopMaxPartySize, closesIn))
|
||||
}
|
||||
sb.WriteString("\nJoin with `!coop join <id>` or just `!coop join` for the most recent.")
|
||||
return p.SendDM(ctx.Sender, sb.String())
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCoopStatus(ctx MessageContext) error {
|
||||
run, err := loadCoopRunForUser(ctx.Sender)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not in a co-op run. Use `!coop list` or `!coop start <tier>`.")
|
||||
}
|
||||
members, _ := loadCoopMembers(run.ID)
|
||||
return p.SendDM(ctx.Sender, renderCoopStatus(p, run, members))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCoopFund(ctx MessageContext, tierArg string) error {
|
||||
tier := parseCoopFundingTier(tierArg)
|
||||
if tier == "" {
|
||||
return p.SendDM(ctx.Sender, "Funding must be one of: none, minimal, standard, aggressive, all_in.")
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
run, err := loadCoopRunForUser(ctx.Sender)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
|
||||
}
|
||||
if run.Status != "active" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — funding decisions don't apply.", run.ID, run.Status))
|
||||
}
|
||||
|
||||
day := run.CurrentDay
|
||||
if day < 1 {
|
||||
return p.SendDM(ctx.Sender, "The run hasn't started yet. Wait for the lock tick.")
|
||||
}
|
||||
|
||||
member, err := loadCoopMember(run.ID, ctx.Sender)
|
||||
if err != nil || member == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not on the party roster for this run.")
|
||||
}
|
||||
if _, alreadySet := member.DailyFunding[day]; alreadySet {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already locked in funding for day %d. Decisions are final until the daily tick.", day))
|
||||
}
|
||||
|
||||
cost := coopFundingTable[tier].cost
|
||||
if cost > 0 {
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(cost) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("That tier costs €%d. You have €%.0f.", cost, balance))
|
||||
}
|
||||
if !p.euro.Debit(ctx.Sender, float64(cost), "coop_funding") {
|
||||
return p.SendDM(ctx.Sender, "Payment failed.")
|
||||
}
|
||||
}
|
||||
|
||||
member.DailyFunding[day] = tier
|
||||
member.TotalContributed += cost
|
||||
if err := saveCoopMemberFunding(member); err != nil {
|
||||
// Refund and surface
|
||||
if cost > 0 {
|
||||
p.euro.Credit(ctx.Sender, float64(cost), "coop_funding_refund")
|
||||
}
|
||||
return p.SendDM(ctx.Sender, "Couldn't save your decision. Gold refunded if any was charged.")
|
||||
}
|
||||
if cost > 0 {
|
||||
if err := addCoopRunPool(run.ID, cost); err != nil {
|
||||
slog.Error("coop: pool update", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
mod := coopFundingTable[tier].modifier
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Funding for day %d locked: %s (€%d, %+d%%). Tomorrow's tick resolves the floor.",
|
||||
day, coopFundingTable[tier].label, cost, mod))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCoopCancel(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
run, err := loadCoopRunForUser(ctx.Sender)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "No co-op run to cancel.")
|
||||
}
|
||||
if run.LeaderID != ctx.Sender {
|
||||
return p.SendDM(ctx.Sender, "Only the party leader can cancel.")
|
||||
}
|
||||
if run.Status != "open" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d already %s — too late.", run.ID, run.Status))
|
||||
}
|
||||
if err := setCoopRunStatus(run.ID, "cancelled"); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't cancel.")
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
if run.InvitePostID != "" {
|
||||
_ = p.UnpinEvent(gr, run.InvitePostID)
|
||||
}
|
||||
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d cancelled by the leader.", run.Tier, run.ID))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID))
|
||||
}
|
||||
|
||||
// handleCoopAdmGift is an admin-only debug path that creates a gift on
|
||||
// behalf of the caller (or a specified sender) without the harvest-action
|
||||
// or party-membership checks. Useful for testing the gift mechanic without
|
||||
// recruiting a non-party spectator.
|
||||
//
|
||||
// Usage: !coop admgift <run_id> <basket|mimic> [sender_user_id]
|
||||
func (p *AdventurePlugin) handleCoopAdmGift(ctx MessageContext, args string) error {
|
||||
if !p.IsAdmin(ctx.Sender) {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) < 2 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!coop admgift <run_id> <basket|mimic> [sender_user_id]`. Admin-only — bypasses party-member and harvest checks.")
|
||||
}
|
||||
runID, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Run ID must be a number.")
|
||||
}
|
||||
giftType := strings.ToLower(parts[1])
|
||||
if giftType != coopGiftBasket && giftType != coopGiftMimic {
|
||||
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
|
||||
}
|
||||
sender := ctx.Sender
|
||||
if len(parts) >= 3 {
|
||||
sender = id.UserID(parts[2])
|
||||
}
|
||||
|
||||
run, err := loadCoopRun(runID)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "Run not found.")
|
||||
}
|
||||
if run.Status != "active" {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — gifts only land during active runs.", runID, run.Status))
|
||||
}
|
||||
|
||||
giftID, leadID, isNewLead, err := createCoopGift(runID, sender, run.CurrentDay, giftType)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't create gift.")
|
||||
}
|
||||
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
members, _ := loadCoopMembers(runID)
|
||||
if isNewLead {
|
||||
gift, _ := loadCoopGift(giftID)
|
||||
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
||||
if perr == nil {
|
||||
_ = saveCoopGiftPostID(giftID, postID)
|
||||
}
|
||||
} else {
|
||||
lead, _ := loadCoopGift(leadID)
|
||||
if lead != nil && lead.PostEventID != "" {
|
||||
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d (stack lead %d). No harvest action consumed.",
|
||||
giftType, sender, runID, run.CurrentDay, giftID, leadID))
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
run, err := loadCoopRunForUser(ctx.Sender)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
|
||||
}
|
||||
if run.Status != "active" {
|
||||
return p.SendDM(ctx.Sender, "No active floor event to vote on.")
|
||||
}
|
||||
event, err := loadCoopEvent(run.ID, run.CurrentDay)
|
||||
if err != nil || event == nil {
|
||||
return p.SendDM(ctx.Sender, "No floor event found for the current day.")
|
||||
}
|
||||
if event.Outcome != "" {
|
||||
return p.SendDM(ctx.Sender, "Voting closed for this floor.")
|
||||
}
|
||||
label, ok := validateCoopVote(event.Category, event.EventIndex, voteArg)
|
||||
if !ok {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Vote one of: %s.",
|
||||
strings.Join(coopEventOptionLabels(event.Category, event.EventIndex), ", ")))
|
||||
}
|
||||
|
||||
if event.Votes == nil {
|
||||
event.Votes = map[id.UserID]string{}
|
||||
}
|
||||
event.Votes[ctx.Sender] = label
|
||||
if err := saveCoopEventVotes(event); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
|
||||
}
|
||||
|
||||
// Update the game-room post in place to show the running tally.
|
||||
gr := gamesRoom()
|
||||
if gr != "" && event.PostEventID != "" {
|
||||
members, _ := loadCoopMembers(run.ID)
|
||||
_ = p.EditMessage(gr, event.PostEventID, renderCoopEventPost(run, members, event))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Vote recorded: %s.", label))
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func parseCoopFundingTier(s string) CoopFundingTier {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "-", "_")
|
||||
s = strings.ReplaceAll(s, " ", "_")
|
||||
switch s {
|
||||
case "none", "skip", "pass":
|
||||
return CoopFundNone
|
||||
case "minimal", "min":
|
||||
return CoopFundMinimal
|
||||
case "standard", "std":
|
||||
return CoopFundStandard
|
||||
case "aggressive", "agg":
|
||||
return CoopFundAggressive
|
||||
case "all_in", "allin", "all":
|
||||
return CoopFundAllIn
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// effectiveModifier returns a member's per-day funding modifier, respecting the
|
||||
// liability cap.
|
||||
func coopMemberModifier(m *CoopMember, day int) (CoopFundingTier, int) {
|
||||
tier, ok := m.DailyFunding[day]
|
||||
if !ok {
|
||||
tier = CoopFundNone
|
||||
}
|
||||
mod := coopFundingTable[tier].modifier
|
||||
if m.IsLiability && mod > coopLiabilityCap {
|
||||
mod = coopLiabilityCap
|
||||
}
|
||||
return tier, mod
|
||||
}
|
||||
|
||||
// coopLevelBonus returns the per-floor success bonus from a player's combat
|
||||
// level relative to the tier's minimum. Levels above the floor count up
|
||||
// (capped); levels below count down. Encourages tier-appropriate parties.
|
||||
func coopLevelBonus(combatLevel, tierMinLevel int) int {
|
||||
delta := combatLevel - tierMinLevel
|
||||
bonus := delta * 4 / 10 // 0.4× scale, integer math
|
||||
if bonus > 8 {
|
||||
bonus = 8
|
||||
}
|
||||
if bonus < -8 {
|
||||
bonus = -8
|
||||
}
|
||||
return bonus
|
||||
}
|
||||
|
||||
// coopPetBonus returns the per-floor success bonus from a player's pet. Worth
|
||||
// roughly a quarter of an additional baseline party member at pet level 10.
|
||||
// Returns 0 if the player has no pet.
|
||||
func coopPetBonus(char *AdventureCharacter) int {
|
||||
if !char.HasPet() || char.PetLevel <= 0 {
|
||||
return 0
|
||||
}
|
||||
bonus := char.PetLevel * 25 / 100 // 0.25× scale
|
||||
if bonus > 2 {
|
||||
bonus = 2
|
||||
}
|
||||
return bonus
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Monte Carlo balance analysis for co-op dungeons.
|
||||
//
|
||||
// Run with: go test ./internal/plugin -run TestCoopBalanceReport -v
|
||||
// Skipped by default (requires -run filter to invoke); pure-function, no DB.
|
||||
|
||||
const (
|
||||
balanceTrials = 50_000
|
||||
balanceTax = 0.05 // matches coopAdventureRake
|
||||
)
|
||||
|
||||
type fundingPlan struct {
|
||||
name string
|
||||
// per-player tiers; length = party size
|
||||
tiers []CoopFundingTier
|
||||
// optional fixed per-player level bonus (mimics a profile of avg or
|
||||
// veteran party). 0 = at-minimum (default), positive = stronger party.
|
||||
levelBonusEach int
|
||||
petBonusEach int
|
||||
}
|
||||
|
||||
func TestCoopBalanceReport(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("balance report skipped in short mode")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
rng := rand.New(rand.NewPCG(42, 42))
|
||||
|
||||
profiles := []struct {
|
||||
label string
|
||||
levelBonus int
|
||||
petBonus int
|
||||
}{
|
||||
{"at-minimum (no pets)", 0, 0},
|
||||
{"average (level+5, pet 5)", 2, 1},
|
||||
{"veteran (level+10, pet 10)", 4, 2},
|
||||
}
|
||||
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
def := coopTierTable[tier]
|
||||
fmt.Printf("\n══ Tier %d (%s) — %d days, base failure %d%%/floor, reward €%d ══\n",
|
||||
tier, def.difficulty, def.totalDays, def.baseFailurePct, def.rewardBase)
|
||||
|
||||
for _, prof := range profiles {
|
||||
fmt.Printf("\n Party profile: %s\n", prof.label)
|
||||
fmt.Printf(" %-28s %-10s %-12s %-12s %-12s %-12s\n",
|
||||
"strategy", "P(win)", "E[reward]", "E[funding]", "E[net]", "E[net/day]")
|
||||
for _, plan := range balancePlans() {
|
||||
plan.levelBonusEach = prof.levelBonus
|
||||
plan.petBonusEach = prof.petBonus
|
||||
pSuccess, eReward, eCost, eNet := simulatePlan(rng, tier, def, plan)
|
||||
perDay := eNet / float64(def.totalDays)
|
||||
fmt.Printf(" %-28s %-10.3f €%-11.0f €%-11.0f €%-11.0f €%-11.0f\n",
|
||||
plan.name, pSuccess, eReward, eCost, eNet, perDay)
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("Note: E[net] is per-player. Funding is non-refundable. Combat-action opportunity cost not included.")
|
||||
}
|
||||
|
||||
func anyNoneFunder(plan fundingPlan) bool {
|
||||
for _, t := range plan.tiers {
|
||||
if t == CoopFundNone {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// balancePlans returns a representative set of party funding strategies.
|
||||
// Party size = 4 unless name says otherwise.
|
||||
func balancePlans() []fundingPlan {
|
||||
mk := func(n int, t CoopFundingTier) []CoopFundingTier {
|
||||
out := make([]CoopFundingTier, n)
|
||||
for i := range out {
|
||||
out[i] = t
|
||||
}
|
||||
return out
|
||||
}
|
||||
return []fundingPlan{
|
||||
{name: "4× Minimal", tiers: mk(4, CoopFundMinimal)},
|
||||
{name: "4× Standard", tiers: mk(4, CoopFundStandard)},
|
||||
{name: "4× Aggressive", tiers: mk(4, CoopFundAggressive)},
|
||||
{name: "4× All-In", tiers: mk(4, CoopFundAllIn)},
|
||||
{name: "4× mixed (2 Std, 2 Agg)", tiers: []CoopFundingTier{CoopFundStandard, CoopFundStandard, CoopFundAggressive, CoopFundAggressive}},
|
||||
{name: "4× sandbag (3 Std, 1 None)", tiers: []CoopFundingTier{CoopFundStandard, CoopFundStandard, CoopFundStandard, CoopFundNone}},
|
||||
{name: "3× Standard", tiers: mk(3, CoopFundStandard)},
|
||||
{name: "3× Aggressive", tiers: mk(3, CoopFundAggressive)},
|
||||
{name: "2× Standard", tiers: mk(2, CoopFundStandard)},
|
||||
{name: "2× Aggressive", tiers: mk(2, CoopFundAggressive)},
|
||||
{name: "2× All-In", tiers: mk(2, CoopFundAllIn)},
|
||||
}
|
||||
}
|
||||
|
||||
// simulatePlan runs the trials and returns:
|
||||
//
|
||||
// P(success), E[reward share post-tax], E[funding spent per player], E[net per player]
|
||||
//
|
||||
// Funding is paid every day regardless of wipe. Reward is split evenly across
|
||||
// the party on success only. Per-player figures average the contribution across
|
||||
// the actual party members under the plan.
|
||||
func simulatePlan(rng *rand.Rand, tier int, def coopTierDef, plan fundingPlan) (float64, float64, float64, float64) {
|
||||
partySize := len(plan.tiers)
|
||||
if partySize < coopMinPartySize {
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
|
||||
// Compute per-floor success% under this plan (deterministic given plan).
|
||||
totalMod := 0
|
||||
for _, t := range plan.tiers {
|
||||
totalMod += coopFundingTable[t].modifier
|
||||
}
|
||||
// Per-player level + pet bonuses applied across the whole party.
|
||||
totalMod += partySize * (plan.levelBonusEach + plan.petBonusEach)
|
||||
successPct := 100 - def.baseFailurePct + totalMod
|
||||
if successPct < 5 {
|
||||
successPct = 5
|
||||
}
|
||||
if successPct > 95 {
|
||||
successPct = 95
|
||||
}
|
||||
|
||||
wins := 0
|
||||
for trial := 0; trial < balanceTrials; trial++ {
|
||||
runWon := true
|
||||
for floor := 0; floor < def.totalDays; floor++ {
|
||||
if rng.IntN(100) >= successPct {
|
||||
runWon = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if runWon {
|
||||
wins++
|
||||
}
|
||||
}
|
||||
pWin := float64(wins) / float64(balanceTrials)
|
||||
|
||||
// Average per-player numbers across the party. Funding cost = daily cost × days,
|
||||
// paid regardless of outcome. Reward = share post-tax × P(win).
|
||||
var sumCost, sumReward float64
|
||||
share := float64(def.rewardBase) / float64(partySize)
|
||||
postTaxShare := share * (1 - balanceTax)
|
||||
for _, t := range plan.tiers {
|
||||
dailyCost := float64(coopFundingTable[t].cost)
|
||||
sumCost += dailyCost * float64(def.totalDays)
|
||||
sumReward += postTaxShare * pWin
|
||||
}
|
||||
avgCost := sumCost / float64(partySize)
|
||||
avgReward := sumReward / float64(partySize)
|
||||
avgNet := avgReward - avgCost
|
||||
return pWin, avgReward, avgCost, avgNet
|
||||
}
|
||||
|
||||
// TestSoloVsCoopDailyIncome compares expected gold-per-day for a competent
|
||||
// solo dungeon grinder vs a co-op party member at the optimal funding strategy.
|
||||
// Co-op should be meaningfully more rewarding per day than solo.
|
||||
//
|
||||
// Solo model: a "competent" player whose skill matches the location's MinLevel
|
||||
// and gear matches MinEquipTier. Uses the actual loot tables from
|
||||
// adventure_activities.go.
|
||||
func TestSoloVsCoopDailyIncome(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
// Per-tier solo expected income (avg item value × items-per-haul, weighted
|
||||
// by outcome probabilities, after 5% community tax, minus a rough death
|
||||
// cost that captures gear repair + hospital).
|
||||
type soloPerTier struct {
|
||||
successPct, exceptionalPct, deathPct float64
|
||||
successHaul, exceptionalHaul float64
|
||||
deathCost float64
|
||||
}
|
||||
// Probabilities computed from calculateAdvProbabilities() with skill =
|
||||
// MinLevel and eqScore matching MinEquipTier (rough but consistent).
|
||||
// Death costs assume hospital insurance + lower blacksmith repair rates.
|
||||
solo := map[int]soloPerTier{
|
||||
1: {0.74, 0.10, 0.01, 11.6, 29.0, 100},
|
||||
2: {0.67, 0.10, 0.08, 63.75, 159.0, 400},
|
||||
3: {0.60, 0.10, 0.18, 337.5, 844.0, 1200},
|
||||
4: {0.55, 0.08, 0.21, 1700.0, 4250.0, 3000},
|
||||
5: {0.61, 0.08, 0.20, 9500.0, 23750.0, 6000},
|
||||
}
|
||||
soloDaily := func(s soloPerTier) float64 {
|
||||
gross := s.successPct*s.successHaul + s.exceptionalPct*s.exceptionalHaul
|
||||
afterTax := gross * (1 - balanceTax)
|
||||
return afterTax - s.deathPct*s.deathCost
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewPCG(7, 7))
|
||||
|
||||
fmt.Println("\nSolo dungeon vs co-op (€/day per player at optimal funding strategy):")
|
||||
fmt.Printf("%-6s %-14s %-26s %-14s %-10s\n", "tier", "solo €/day", "co-op optimal", "co-op €/day", "ratio")
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
def := coopTierTable[tier]
|
||||
soloIncome := soloDaily(solo[tier])
|
||||
|
||||
// Find best 4-player plan by E[net]/day, assuming an "average" party
|
||||
// profile (levelBonus=2, petBonus=1 per player). Restricting to
|
||||
// 4-player keeps the comparison apples-to-apples.
|
||||
var bestPlan fundingPlan
|
||||
bestPerDay := -1.0e18
|
||||
for _, plan := range balancePlans() {
|
||||
if len(plan.tiers) != 4 {
|
||||
continue
|
||||
}
|
||||
// Skip free-rider plans: an "optimal" that only works because
|
||||
// one member contributes nothing isn't a coordinated strategy.
|
||||
if anyNoneFunder(plan) {
|
||||
continue
|
||||
}
|
||||
plan.levelBonusEach = 2
|
||||
plan.petBonusEach = 1
|
||||
_, _, _, eNet := simulatePlan(rng, tier, def, plan)
|
||||
perDay := eNet / float64(def.totalDays)
|
||||
if perDay > bestPerDay {
|
||||
bestPerDay = perDay
|
||||
bestPlan = plan
|
||||
}
|
||||
}
|
||||
ratio := bestPerDay / soloIncome
|
||||
flag := "✓"
|
||||
if ratio < 1.5 {
|
||||
flag = "⚠ insufficient"
|
||||
}
|
||||
fmt.Printf("T%-5d €%-13.0f %-26s €%-13.0f %.2fx %s\n",
|
||||
tier, soloIncome, bestPlan.name, bestPerDay, ratio, flag)
|
||||
}
|
||||
fmt.Println("\n⚠ marker = co-op fails the 1.5× solo threshold; bump rewardBase.")
|
||||
}
|
||||
|
||||
// TestCoopBalanceSweep prints, for each tier, the success% needed for E[net]>=0
|
||||
// at common funding levels. Helps spot tiers where the formula is upside-down.
|
||||
func TestCoopBalanceSweep(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip()
|
||||
}
|
||||
fmt.Println("\nBreakeven analysis — minimum P(win) for E[net]≥0 per player at party size 4:")
|
||||
fmt.Printf("%-12s %-12s %-12s %-12s %-12s\n", "tier", "Minimal", "Standard", "Aggressive", "All-In")
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
def := coopTierTable[tier]
|
||||
share := float64(def.rewardBase) / 4.0 * (1 - balanceTax)
|
||||
row := []string{}
|
||||
for _, ft := range []CoopFundingTier{CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn} {
|
||||
cost := float64(coopFundingTable[ft].cost) * float64(def.totalDays)
|
||||
breakeven := cost / share
|
||||
if breakeven > 1 {
|
||||
row = append(row, ">100% (impossible)")
|
||||
} else {
|
||||
row = append(row, fmt.Sprintf("%.1f%%", breakeven*100))
|
||||
}
|
||||
}
|
||||
fmt.Printf("T%-11d %-12s %-12s %-12s %-12s\n", tier, row[0], row[1], row[2], row[3])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Betting Constants ───────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
coopBetMin = 100
|
||||
coopBetMax = 1_000_000
|
||||
coopBetRake = 0.10 // 10% house cut, per spec
|
||||
coopOddsWindow = 30 // helpfulness rolling-window size
|
||||
coopOddsHelpInf = 0.20 // helpfulness contribution to estimated success
|
||||
)
|
||||
|
||||
// ── Bet Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type CoopBet struct {
|
||||
RunID int
|
||||
PlayerID id.UserID
|
||||
Position string // "success" | "failure"
|
||||
Amount int
|
||||
Payout sql.NullInt64
|
||||
}
|
||||
|
||||
// ── Command Handler ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleCoopBet(ctx MessageContext, args string) error {
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) < 2 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!coop bet <run_id> <amount> <success|failure>` or `!coop bet <amount> <success|failure>` for the most recent open/active run.")
|
||||
}
|
||||
|
||||
// Two forms: with or without explicit run id.
|
||||
var runID, amount int
|
||||
var position string
|
||||
var err error
|
||||
|
||||
if len(parts) >= 3 {
|
||||
runID, err = strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "First arg must be the run id.")
|
||||
}
|
||||
amount, err = strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Amount must be a number.")
|
||||
}
|
||||
position = strings.ToLower(parts[2])
|
||||
} else {
|
||||
amount, err = strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Amount must be a number.")
|
||||
}
|
||||
position = strings.ToLower(parts[1])
|
||||
run, _ := loadMostRecentBettableCoopRun()
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "No co-op runs accept bets right now.")
|
||||
}
|
||||
runID = run.ID
|
||||
}
|
||||
|
||||
if position != "success" && position != "failure" {
|
||||
return p.SendDM(ctx.Sender, "Position must be `success` or `failure`.")
|
||||
}
|
||||
if amount < coopBetMin || amount > coopBetMax {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Bet must be between €%d and €%d.", coopBetMin, coopBetMax))
|
||||
}
|
||||
|
||||
run, err := loadCoopRun(runID)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "Run not found.")
|
||||
}
|
||||
if run.Status != "open" && run.Status != "active" {
|
||||
return p.SendDM(ctx.Sender, "Bets are closed on this run.")
|
||||
}
|
||||
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(amount) {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You have €%.0f. Bet was €%d.", balance, amount))
|
||||
}
|
||||
|
||||
existing, _ := loadCoopBet(runID, ctx.Sender)
|
||||
if existing != nil && existing.Position != position {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("You already bet %s on this run. Positions can't be switched.", existing.Position))
|
||||
}
|
||||
|
||||
if !p.euro.Debit(ctx.Sender, float64(amount), "coop_bet") {
|
||||
return p.SendDM(ctx.Sender, "Payment failed.")
|
||||
}
|
||||
|
||||
if existing == nil {
|
||||
err = createCoopBet(runID, ctx.Sender, position, amount)
|
||||
} else {
|
||||
err = increaseCoopBet(runID, ctx.Sender, amount)
|
||||
}
|
||||
if err != nil {
|
||||
p.euro.Credit(ctx.Sender, float64(amount), "coop_bet_refund")
|
||||
return p.SendDM(ctx.Sender, "Couldn't save bet. Refunded.")
|
||||
}
|
||||
|
||||
total := amount
|
||||
if existing != nil {
|
||||
total += existing.Amount
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📊 Bet placed: €%d on %s (run #%d). Total stake: €%d.",
|
||||
amount, position, runID, total))
|
||||
}
|
||||
|
||||
// ── Odds Calculation ────────────────────────────────────────────────────────
|
||||
|
||||
// coopEstimatedRunSuccess returns the estimated P(success) for the rest of the
|
||||
// run given current party state. Used for spectator odds display.
|
||||
func coopEstimatedRunSuccess(run *CoopRun, members []CoopMember) float64 {
|
||||
if run.Status != "open" && run.Status != "active" {
|
||||
return 0
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return 0
|
||||
}
|
||||
tierDef := coopTierTable[run.Tier]
|
||||
|
||||
// Per-floor success at "Standard" funding by all members (a neutral
|
||||
// baseline — actual funding varies day to day).
|
||||
mod := len(members) * coopFundingTable[CoopFundStandard].modifier
|
||||
for _, m := range members {
|
||||
char, err := loadAdvCharacter(m.UserID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mod += coopLevelBonus(char.CombatLevel, tierDef.minLevel)
|
||||
mod += coopPetBonus(char)
|
||||
}
|
||||
successPct := 100 - run.BaseDifficulty + mod
|
||||
|
||||
// TwinBee helpfulness shifts the line: a "helpful" history (close to 1)
|
||||
// bumps odds; an "unhelpful" history pulls them down.
|
||||
help := coopTwinBeeHelpfulness(coopOddsWindow)
|
||||
successPct += int(math.Round((help - 0.5) * 2 * coopOddsHelpInf * 100))
|
||||
|
||||
if successPct < 5 {
|
||||
successPct = 5
|
||||
}
|
||||
if successPct > 95 {
|
||||
successPct = 95
|
||||
}
|
||||
|
||||
floorsRemaining := run.TotalDays - run.CurrentDay
|
||||
if floorsRemaining < 0 {
|
||||
floorsRemaining = 0
|
||||
}
|
||||
if run.Status == "open" {
|
||||
floorsRemaining = run.TotalDays
|
||||
}
|
||||
|
||||
return math.Pow(float64(successPct)/100.0, float64(floorsRemaining))
|
||||
}
|
||||
|
||||
// coopParimutuelPayouts returns winning side, losers' side, rake, and
|
||||
// per-player payout map (winning bettors only).
|
||||
func coopParimutuelPayouts(bets []CoopBet, winningPosition string) (winners []CoopBet, totalPool, rake int, payouts map[id.UserID]int) {
|
||||
for _, b := range bets {
|
||||
totalPool += b.Amount
|
||||
}
|
||||
rake = int(math.Floor(float64(totalPool) * coopBetRake))
|
||||
netPool := totalPool - rake
|
||||
|
||||
winningPool := 0
|
||||
for _, b := range bets {
|
||||
if b.Position == winningPosition {
|
||||
winners = append(winners, b)
|
||||
winningPool += b.Amount
|
||||
}
|
||||
}
|
||||
|
||||
payouts = map[id.UserID]int{}
|
||||
if winningPool == 0 {
|
||||
// No winners — house keeps everything (this happens if e.g. all bets
|
||||
// were on the wrong side).
|
||||
return
|
||||
}
|
||||
for _, b := range winners {
|
||||
share := float64(b.Amount) / float64(winningPool)
|
||||
payouts[b.PlayerID] = int(math.Floor(share * float64(netPool)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func createCoopBet(runID int, userID id.UserID, position string, amount int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`INSERT INTO coop_dungeon_bets (run_id, player_id, position, amount)
|
||||
VALUES (?, ?, ?, ?)`, runID, string(userID), position, amount)
|
||||
return err
|
||||
}
|
||||
|
||||
func increaseCoopBet(runID int, userID id.UserID, delta int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_bets SET amount = amount + ?
|
||||
WHERE run_id = ? AND player_id = ?`, delta, runID, string(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCoopBet(runID int, userID id.UserID) (*CoopBet, error) {
|
||||
d := db.Get()
|
||||
b := &CoopBet{}
|
||||
var uid string
|
||||
err := d.QueryRow(`SELECT run_id, player_id, position, amount, payout
|
||||
FROM coop_dungeon_bets WHERE run_id = ? AND player_id = ?`, runID, string(userID)).Scan(
|
||||
&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.PlayerID = id.UserID(uid)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func loadCoopBets(runID int) ([]CoopBet, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT run_id, player_id, position, amount, payout
|
||||
FROM coop_dungeon_bets WHERE run_id = ?`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopBet
|
||||
for rows.Next() {
|
||||
b := CoopBet{}
|
||||
var uid string
|
||||
if err := rows.Scan(&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.PlayerID = id.UserID(uid)
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func setCoopBetPayout(runID int, userID id.UserID, payout int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
|
||||
WHERE run_id = ? AND player_id = ?`, payout, runID, string(userID))
|
||||
return err
|
||||
}
|
||||
|
||||
// claimCoopBetPayout atomically claims a bet for payout: returns true only if
|
||||
// this call wins the race (the row had payout IS NULL and we set it). Used to
|
||||
// prevent double-credit on retry after a mid-resolution crash.
|
||||
func claimCoopBetPayout(runID int, userID id.UserID, payout int) (bool, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
|
||||
WHERE run_id = ? AND player_id = ? AND payout IS NULL`,
|
||||
payout, runID, string(userID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func loadMostRecentBettableCoopRun() (*CoopRun, error) {
|
||||
runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`)
|
||||
if err != nil || len(runs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return runs[0], nil
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
// coopResolveBets is called when a run completes. It computes payouts,
|
||||
// credits winners, persists payout values, and posts a summary line.
|
||||
func (p *AdventurePlugin) coopResolveBets(run *CoopRun, won bool) string {
|
||||
bets, err := loadCoopBets(run.ID)
|
||||
if err != nil || len(bets) == 0 {
|
||||
return ""
|
||||
}
|
||||
winningPosition := "failure"
|
||||
if won {
|
||||
winningPosition = "success"
|
||||
}
|
||||
winners, total, rake, payouts := coopParimutuelPayouts(bets, winningPosition)
|
||||
|
||||
// Idempotent: claim each bet's payout slot (UPDATE...WHERE payout IS NULL)
|
||||
// before crediting. If the claim fails, this bet was already paid by a
|
||||
// prior resolution attempt — skip the credit. Prevents double-pay on
|
||||
// crash-restart.
|
||||
for _, b := range winners {
|
||||
payout := payouts[b.PlayerID]
|
||||
claimed, err := claimCoopBetPayout(run.ID, b.PlayerID, payout)
|
||||
if err != nil || !claimed {
|
||||
continue
|
||||
}
|
||||
if payout > 0 {
|
||||
p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout")
|
||||
}
|
||||
}
|
||||
for _, b := range bets {
|
||||
if b.Position != winningPosition && !b.Payout.Valid {
|
||||
_ = setCoopBetPayout(run.ID, b.PlayerID, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if len(winners) == 0 {
|
||||
return fmt.Sprintf("📊 Parimutuel pool: €%d. No winners — house (TwinBee) keeps the pool.", total)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("📊 Parimutuel pool: €%d. Rake: €%d. Net: €%d.\n", total, rake, total-rake))
|
||||
sb.WriteString(fmt.Sprintf("Winners (bet on %s):\n", winningPosition))
|
||||
for _, b := range winners {
|
||||
sb.WriteString(fmt.Sprintf(" %s: €%d → €%d\n", coopDisplayName(p, b.PlayerID), b.Amount, payouts[b.PlayerID]))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderCoopOddsLine(run *CoopRun, members []CoopMember, bets []CoopBet) string {
|
||||
pSuccess := coopEstimatedRunSuccess(run, members)
|
||||
successPool, failurePool := 0, 0
|
||||
for _, b := range bets {
|
||||
if b.Position == "success" {
|
||||
successPool += b.Amount
|
||||
} else {
|
||||
failurePool += b.Amount
|
||||
}
|
||||
}
|
||||
total := successPool + failurePool
|
||||
return fmt.Sprintf("📊 Estimated success: %.0f%%. Pool €%d (success €%d / failure €%d). Rake %.0f%%. `!coop bet <amount> success|failure`.",
|
||||
pSuccess*100, total, successPool, failurePool, coopBetRake*100)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
type CoopRun struct {
|
||||
ID int
|
||||
Tier int
|
||||
Status string
|
||||
LeaderID id.UserID
|
||||
CurrentDay int
|
||||
TotalDays int
|
||||
BaseDifficulty int
|
||||
GoldPool int
|
||||
RewardTotal int
|
||||
LastResolvedDay int
|
||||
InvitePostID id.EventID
|
||||
CreatedAt time.Time
|
||||
LockedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
}
|
||||
|
||||
type CoopMember struct {
|
||||
RunID int
|
||||
UserID id.UserID
|
||||
TurnOrder int
|
||||
TotalContributed int
|
||||
IsLiability bool
|
||||
DailyFunding map[int]CoopFundingTier // day → tier
|
||||
}
|
||||
|
||||
func createCoopRun(tier int, leader id.UserID, totalDays, baseDifficulty int) (*CoopRun, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`INSERT INTO coop_dungeon_runs (tier, leader_id, total_days, base_difficulty)
|
||||
VALUES (?, ?, ?, ?)`, tier, string(leader), totalDays, baseDifficulty)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
return loadCoopRun(int(id64))
|
||||
}
|
||||
|
||||
func loadCoopRun(runID int) (*CoopRun, error) {
|
||||
d := db.Get()
|
||||
r := &CoopRun{}
|
||||
var leader string
|
||||
var lockedAt, completedAt sql.NullTime
|
||||
err := d.QueryRow(`SELECT id, tier, status, leader_id, current_day, total_days,
|
||||
base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at
|
||||
FROM coop_dungeon_runs WHERE id = ?`, runID).Scan(
|
||||
&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
||||
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
||||
(*string)(&r.InvitePostID),
|
||||
&r.CreatedAt, &lockedAt, &completedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.LeaderID = id.UserID(leader)
|
||||
if lockedAt.Valid {
|
||||
t := lockedAt.Time
|
||||
r.LockedAt = &t
|
||||
}
|
||||
if completedAt.Valid {
|
||||
t := completedAt.Time
|
||||
r.CompletedAt = &t
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func loadOpenCoopRuns() ([]*CoopRun, error) {
|
||||
return queryCoopRuns(`status = 'open' ORDER BY created_at DESC`)
|
||||
}
|
||||
|
||||
func loadActiveCoopRuns() ([]*CoopRun, error) {
|
||||
return queryCoopRuns(`status = 'active' ORDER BY id`)
|
||||
}
|
||||
|
||||
func loadMostRecentOpenCoopRun() (*CoopRun, error) {
|
||||
runs, err := queryCoopRuns(`status = 'open' ORDER BY created_at DESC LIMIT 1`)
|
||||
if err != nil || len(runs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return runs[0], nil
|
||||
}
|
||||
|
||||
func queryCoopRuns(whereClause string) ([]*CoopRun, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, tier, status, leader_id, current_day, total_days,
|
||||
base_difficulty, gold_pool, reward_total, last_resolved_day, invite_post_id, created_at, locked_at, completed_at
|
||||
FROM coop_dungeon_runs WHERE ` + whereClause)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*CoopRun
|
||||
for rows.Next() {
|
||||
r := &CoopRun{}
|
||||
var leader string
|
||||
var lockedAt, completedAt sql.NullTime
|
||||
if err := rows.Scan(&r.ID, &r.Tier, &r.Status, &leader, &r.CurrentDay, &r.TotalDays,
|
||||
&r.BaseDifficulty, &r.GoldPool, &r.RewardTotal, &r.LastResolvedDay,
|
||||
(*string)(&r.InvitePostID),
|
||||
&r.CreatedAt, &lockedAt, &completedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.LeaderID = id.UserID(leader)
|
||||
if lockedAt.Valid {
|
||||
t := lockedAt.Time
|
||||
r.LockedAt = &t
|
||||
}
|
||||
if completedAt.Valid {
|
||||
t := completedAt.Time
|
||||
r.CompletedAt = &t
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadCoopRunForUser(userID id.UserID) (*CoopRun, error) {
|
||||
d := db.Get()
|
||||
var runID int
|
||||
err := d.QueryRow(`SELECT m.run_id FROM coop_dungeon_members m
|
||||
JOIN coop_dungeon_runs r ON r.id = m.run_id
|
||||
WHERE m.user_id = ? AND r.status IN ('open','active')
|
||||
ORDER BY r.id DESC LIMIT 1`, string(userID)).Scan(&runID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadCoopRun(runID)
|
||||
}
|
||||
|
||||
func setCoopRunStatus(runID int, status string) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET status = ? WHERE id = ?`, status, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func lockCoopRun(runID int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs
|
||||
SET status = 'active', locked_at = CURRENT_TIMESTAMP, current_day = 1
|
||||
WHERE id = ? AND status = 'open'`, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func advanceCoopDay(runID, newDay int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET current_day = ? WHERE id = ?`, newDay, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func completeCoopRun(runID int, status string, reward int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs
|
||||
SET status = ?, reward_total = ?, completed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, status, reward, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// lockCoopCombatActions sets combat_actions_used to a value that exceeds the
|
||||
// holiday cap on every active co-op participant. Called after the midnight
|
||||
// reset (which would otherwise zero them) and at bot startup. Combat stays
|
||||
// locked for the full duration of the run — players in a co-op cannot also
|
||||
// solo-grind dungeons or arena.
|
||||
func lockCoopCombatActions() error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE adventure_characters
|
||||
SET combat_actions_used = 99
|
||||
WHERE user_id IN (
|
||||
SELECT m.user_id FROM coop_dungeon_members m
|
||||
JOIN coop_dungeon_runs r ON r.id = m.run_id
|
||||
WHERE r.status = 'active'
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func setCoopRunInvitePostID(runID int, postID id.EventID) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET invite_post_id = ? WHERE id = ?`, string(postID), runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// setCoopRunLastResolvedDay marks a day as resolved for crash-resume idempotency.
|
||||
// Resolution is idempotent: re-entering coopResolveFloor for the same day is a no-op.
|
||||
func setCoopRunLastResolvedDay(runID, day int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET last_resolved_day = ? WHERE id = ?`, day, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
func addCoopRunPool(runID, delta int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_runs SET gold_pool = gold_pool + ? WHERE id = ?`, delta, runID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Members ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func addCoopMember(runID int, userID id.UserID, turnOrder int, liability bool) error {
|
||||
d := db.Get()
|
||||
libVal := 0
|
||||
if liability {
|
||||
libVal = 1
|
||||
}
|
||||
_, err := d.Exec(`INSERT INTO coop_dungeon_members
|
||||
(run_id, user_id, turn_order, is_liability, daily_funding) VALUES (?, ?, ?, ?, '{}')`,
|
||||
runID, string(userID), turnOrder, libVal)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCoopMembers(runID int) ([]CoopMember, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
|
||||
FROM coop_dungeon_members WHERE run_id = ? ORDER BY turn_order`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CoopMember
|
||||
for rows.Next() {
|
||||
m := CoopMember{}
|
||||
var userID, fundingJSON string
|
||||
var liability int
|
||||
if err := rows.Scan(&m.RunID, &userID, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.UserID = id.UserID(userID)
|
||||
m.IsLiability = liability != 0
|
||||
m.DailyFunding = parseCoopFundingMap(fundingJSON)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadCoopMember(runID int, userID id.UserID) (*CoopMember, error) {
|
||||
d := db.Get()
|
||||
m := &CoopMember{}
|
||||
var uid, fundingJSON string
|
||||
var liability int
|
||||
err := d.QueryRow(`SELECT run_id, user_id, turn_order, total_contributed, is_liability, daily_funding
|
||||
FROM coop_dungeon_members WHERE run_id = ? AND user_id = ?`, runID, string(userID)).Scan(
|
||||
&m.RunID, &uid, &m.TurnOrder, &m.TotalContributed, &liability, &fundingJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.UserID = id.UserID(uid)
|
||||
m.IsLiability = liability != 0
|
||||
m.DailyFunding = parseCoopFundingMap(fundingJSON)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// claimCoopMemberPayout atomically claims a member's reward slot. Returns true
|
||||
// only if this call wins the race (member_payout was NULL and we set it).
|
||||
// Prevents double-credit on crash-retry.
|
||||
func claimCoopMemberPayout(runID int, userID id.UserID, payout int) (bool, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`UPDATE coop_dungeon_members SET member_payout = ?
|
||||
WHERE run_id = ? AND user_id = ? AND member_payout IS NULL`,
|
||||
payout, runID, string(userID))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func saveCoopMemberFunding(m *CoopMember) error {
|
||||
d := db.Get()
|
||||
jsonBytes, err := json.Marshal(serializeCoopFundingMap(m.DailyFunding))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.Exec(`UPDATE coop_dungeon_members
|
||||
SET daily_funding = ?, total_contributed = ?
|
||||
WHERE run_id = ? AND user_id = ?`,
|
||||
string(jsonBytes), m.TotalContributed, m.RunID, string(m.UserID))
|
||||
return err
|
||||
}
|
||||
|
||||
func parseCoopFundingMap(s string) map[int]CoopFundingTier {
|
||||
out := map[int]CoopFundingTier{}
|
||||
if s == "" || s == "{}" {
|
||||
return out
|
||||
}
|
||||
raw := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
slog.Error("coop: parse funding map", "raw", s, "err", err)
|
||||
return out
|
||||
}
|
||||
for k, v := range raw {
|
||||
day, err := strconv.Atoi(k)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[day] = CoopFundingTier(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serializeCoopFundingMap(m map[int]CoopFundingTier) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[fmt.Sprintf("%d", k)] = string(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── Floor Events ────────────────────────────────────────────────────────────
|
||||
|
||||
type CoopEvent struct {
|
||||
RunID int
|
||||
Day int
|
||||
Category CoopEventCategory
|
||||
EventIndex int
|
||||
Recommended string
|
||||
Votes map[id.UserID]string // userID → option label
|
||||
WinningVote string // "" until resolved
|
||||
Outcome string // "" until resolved; "success" or "failure"
|
||||
ModifierApplied int
|
||||
PostEventID id.EventID
|
||||
}
|
||||
|
||||
func createCoopEvent(runID, day int, cat CoopEventCategory, idx int, recommended string) error {
|
||||
d := db.Get()
|
||||
// INSERT OR IGNORE: idempotent on retry. If the row already exists from a
|
||||
// prior tick, leave it untouched (don't re-roll the event index).
|
||||
_, err := d.Exec(`INSERT OR IGNORE INTO coop_dungeon_events
|
||||
(run_id, day, category, event_index, recommended) VALUES (?, ?, ?, ?, ?)`,
|
||||
runID, day, string(cat), idx, recommended)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadCoopEvent(runID, day int) (*CoopEvent, error) {
|
||||
d := db.Get()
|
||||
e := &CoopEvent{}
|
||||
var cat, votesJSON string
|
||||
var winning, outcome, postID sql.NullString
|
||||
err := d.QueryRow(`SELECT run_id, day, category, event_index, recommended,
|
||||
votes, winning_vote, outcome, modifier_applied, post_event_id
|
||||
FROM coop_dungeon_events WHERE run_id = ? AND day = ?`, runID, day).Scan(
|
||||
&e.RunID, &e.Day, &cat, &e.EventIndex, &e.Recommended,
|
||||
&votesJSON, &winning, &outcome, &e.ModifierApplied, &postID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Category = CoopEventCategory(cat)
|
||||
e.Votes = parseCoopVotes(votesJSON)
|
||||
if winning.Valid {
|
||||
e.WinningVote = winning.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
e.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
e.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func saveCoopEventVotes(e *CoopEvent) error {
|
||||
d := db.Get()
|
||||
jsonBytes, err := json.Marshal(serializeCoopVotes(e.Votes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.Exec(`UPDATE coop_dungeon_events SET votes = ?
|
||||
WHERE run_id = ? AND day = ?`, string(jsonBytes), e.RunID, e.Day)
|
||||
return err
|
||||
}
|
||||
|
||||
func saveCoopEventPostID(runID, day int, eventID id.EventID) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_events SET post_event_id = ?
|
||||
WHERE run_id = ? AND day = ?`, string(eventID), runID, day)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveCoopEvent(runID, day int, winning, outcome string, modifier int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_events
|
||||
SET winning_vote = ?, outcome = ?, modifier_applied = ?
|
||||
WHERE run_id = ? AND day = ?`, winning, outcome, modifier, runID, day)
|
||||
return err
|
||||
}
|
||||
|
||||
// CoopTwinBeeHelpfulness returns a score in [0,1] where 1 means TwinBee's
|
||||
// recommendations have been winning + succeeding (or losing + failing). Used
|
||||
// by phase 3 betting odds.
|
||||
func coopTwinBeeHelpfulness(window int) float64 {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT recommended, winning_vote, outcome
|
||||
FROM coop_dungeon_events
|
||||
WHERE outcome IS NOT NULL
|
||||
ORDER BY rowid DESC LIMIT ?`, window)
|
||||
if err != nil {
|
||||
return 0.5
|
||||
}
|
||||
defer rows.Close()
|
||||
hits, total := 0, 0
|
||||
for rows.Next() {
|
||||
var rec, win, out string
|
||||
if err := rows.Scan(&rec, &win, &out); err != nil {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if rec == win && out == "success" {
|
||||
hits++
|
||||
} else if rec != win && out == "failure" {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return 0.5
|
||||
}
|
||||
return float64(hits) / float64(total)
|
||||
}
|
||||
|
||||
func parseCoopVotes(s string) map[id.UserID]string {
|
||||
out := map[id.UserID]string{}
|
||||
if s == "" || s == "{}" {
|
||||
return out
|
||||
}
|
||||
raw := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(s), &raw); err != nil {
|
||||
slog.Error("coop: parse votes map", "raw", s, "err", err)
|
||||
return out
|
||||
}
|
||||
for k, v := range raw {
|
||||
out[id.UserID(k)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func serializeCoopVotes(m map[id.UserID]string) map[string]string {
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
out[string(k)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Gift Constants ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Magnitudes equalized across all four outcomes so neither "always open" nor
|
||||
// "always leave" is variance-dominant at a 50/50 sender mix. Asymmetric
|
||||
// magnitudes (e.g., open=±7, leave=±4) gave leave the same EV with lower
|
||||
// variance, making it a dominant strategy for risk-averse parties — which
|
||||
// collapses the dilemma into "always leave."
|
||||
//
|
||||
// basket opened: +6 basket unopened: -6
|
||||
// mimic opened: -6 mimic unopened: +6
|
||||
//
|
||||
// Both strategies: EV=0, σ=6. The choice becomes "read sender intent" only.
|
||||
const (
|
||||
coopGiftBasketOpened = 6
|
||||
coopGiftBasketUnopened = -6
|
||||
coopGiftMimicOpened = -6
|
||||
coopGiftMimicUnopened = 6
|
||||
)
|
||||
|
||||
const (
|
||||
coopGiftBasket = "basket"
|
||||
coopGiftMimic = "mimic"
|
||||
|
||||
// Per-gift voting window. After this elapses, the gift's votes are
|
||||
// tallied independently of the daily floor tick. Senders get DM feedback
|
||||
// at expiry; the modifier sits on the run until the next floor resolves.
|
||||
coopGiftWindow = 6 * time.Hour
|
||||
)
|
||||
|
||||
type CoopGift struct {
|
||||
ID int
|
||||
RunID int
|
||||
SenderID id.UserID
|
||||
Day int
|
||||
GiftType string
|
||||
Votes map[id.UserID]string // "open" or "leave" — only set on lead
|
||||
VoteResult string // "opened" / "left" / ""
|
||||
Outcome string // "boost" / "reduction" / ""
|
||||
Modifier int
|
||||
PostEventID id.EventID
|
||||
ExpiresAt *time.Time // voting closes — only set on lead
|
||||
AppliedAt *time.Time // modifier merged into a floor roll
|
||||
StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id
|
||||
}
|
||||
|
||||
// ── Command: send a gift ────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error {
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) < 2 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!coop gift <run_id> <basket|mimic>`. Costs one harvest action; party never knows what you sent.")
|
||||
}
|
||||
runID, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Run ID must be a number.")
|
||||
}
|
||||
giftType := strings.ToLower(parts[1])
|
||||
if giftType != coopGiftBasket && giftType != coopGiftMimic {
|
||||
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||
}
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "You're dead. The post office does not deliver from the ditch.")
|
||||
}
|
||||
if !char.CanDoHarvest(false) {
|
||||
return p.SendDM(ctx.Sender, "You're out of harvest actions today.")
|
||||
}
|
||||
|
||||
run, err := loadCoopRun(runID)
|
||||
if err != nil || run == nil {
|
||||
return p.SendDM(ctx.Sender, "Run not found.")
|
||||
}
|
||||
if run.Status != "active" {
|
||||
return p.SendDM(ctx.Sender, "Run is not active. Gifts can only be sent during a running co-op.")
|
||||
}
|
||||
|
||||
// Senders cannot be party members.
|
||||
if existing, _ := loadCoopMember(runID, ctx.Sender); existing != nil {
|
||||
return p.SendDM(ctx.Sender, "You're in the party. You can't send gifts to yourself.")
|
||||
}
|
||||
|
||||
giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
|
||||
}
|
||||
|
||||
// Charge the harvest action and persist.
|
||||
char.HarvestActionsUsed++
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("coop: save sender after gift", "err", err)
|
||||
}
|
||||
|
||||
// Post (new lead) or edit (added to existing stack).
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
members, _ := loadCoopMembers(runID)
|
||||
if isNewLead {
|
||||
gift, _ := loadCoopGift(giftID)
|
||||
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
|
||||
if perr == nil {
|
||||
_ = saveCoopGiftPostID(giftID, postID)
|
||||
}
|
||||
} else {
|
||||
lead, _ := loadCoopGift(leadID)
|
||||
if lead != nil && lead.PostEventID != "" {
|
||||
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Gift dispatched (run #%d, day %d). One harvest action consumed. The party doesn't know what you sent.", runID, run.CurrentDay))
|
||||
}
|
||||
|
||||
// ── Command: vote on a gift ─────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) error {
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) < 1 {
|
||||
return p.SendDM(ctx.Sender, "Usage: `!coop giftvote <gift_id> <open|leave>` or `!coop giftvote <open|leave>` for the most recent unresolved gift.")
|
||||
}
|
||||
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
run, err := loadCoopRunForUser(ctx.Sender)
|
||||
if err != nil || run == nil || run.Status != "active" {
|
||||
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
|
||||
}
|
||||
|
||||
var gift *CoopGift
|
||||
var choice string
|
||||
if len(parts) >= 2 {
|
||||
giftID, perr := strconv.Atoi(parts[0])
|
||||
if perr != nil {
|
||||
return p.SendDM(ctx.Sender, "First arg must be the gift id, or just `open`/`leave` for the most recent.")
|
||||
}
|
||||
gift, _ = loadCoopGift(giftID)
|
||||
choice = strings.ToLower(parts[1])
|
||||
} else {
|
||||
gift, _ = loadMostRecentUnresolvedGift(run.ID, run.CurrentDay)
|
||||
choice = strings.ToLower(parts[0])
|
||||
}
|
||||
if gift == nil {
|
||||
return p.SendDM(ctx.Sender, "No unresolved gift found.")
|
||||
}
|
||||
// If the user voted on a follower, redirect to its lead — votes always
|
||||
// live on the lead.
|
||||
if gift.StackLeadID != nil {
|
||||
lead, _ := loadCoopGift(*gift.StackLeadID)
|
||||
if lead == nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't find the gift's stack lead.")
|
||||
}
|
||||
gift = lead
|
||||
}
|
||||
if gift.RunID != run.ID || gift.Day != run.CurrentDay {
|
||||
return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.")
|
||||
}
|
||||
if gift.VoteResult != "" {
|
||||
return p.SendDM(ctx.Sender, "That gift has already resolved.")
|
||||
}
|
||||
if choice != "open" && choice != "leave" {
|
||||
return p.SendDM(ctx.Sender, "Vote `open` or `leave`.")
|
||||
}
|
||||
|
||||
if gift.Votes == nil {
|
||||
gift.Votes = map[id.UserID]string{}
|
||||
}
|
||||
gift.Votes[ctx.Sender] = choice
|
||||
if err := saveCoopGiftVotes(gift); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
|
||||
}
|
||||
|
||||
gr := gamesRoom()
|
||||
if gr != "" && gift.PostEventID != "" {
|
||||
members, _ := loadCoopMembers(run.ID)
|
||||
_ = p.EditMessage(gr, gift.PostEventID, renderCoopGiftPost(p, run, gift, members))
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Gift #%d vote recorded: %s.", gift.ID, choice))
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs
|
||||
// every sender in the stack, and edits the game-room post to show the
|
||||
// resolved state. Used by both the per-minute expiry ticker and the
|
||||
// floor-resolution catchall.
|
||||
//
|
||||
// Followers passed in here will be redirected to their lead. Idempotent:
|
||||
// always re-loads the lead from DB before checking vote_result, so an
|
||||
// in-memory snapshot from earlier in the same loop can't trigger a double
|
||||
// resolve.
|
||||
func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
|
||||
// Redirect follower → lead, then re-fetch fresh state.
|
||||
leadID := g.ID
|
||||
if g.StackLeadID != nil {
|
||||
leadID = *g.StackLeadID
|
||||
}
|
||||
fresh, _ := loadCoopGift(leadID)
|
||||
if fresh == nil {
|
||||
return
|
||||
}
|
||||
g = fresh
|
||||
if g.VoteResult != "" {
|
||||
return
|
||||
}
|
||||
|
||||
opens, leaves := 0, 0
|
||||
var leaderVote string
|
||||
for u, v := range g.Votes {
|
||||
if v == "open" {
|
||||
opens++
|
||||
} else if v == "leave" {
|
||||
leaves++
|
||||
}
|
||||
if u == leader {
|
||||
leaderVote = v
|
||||
}
|
||||
}
|
||||
var voteResult string
|
||||
switch {
|
||||
case opens > leaves:
|
||||
voteResult = "opened"
|
||||
case leaves > opens:
|
||||
voteResult = "left"
|
||||
case leaderVote == "open":
|
||||
voteResult = "opened"
|
||||
case leaderVote == "leave":
|
||||
voteResult = "left"
|
||||
default:
|
||||
voteResult = "left"
|
||||
}
|
||||
|
||||
// Per-gift modifier (single ±6 unit). Stack-wide modifier is N × this,
|
||||
// applied at floor resolution by summing the rows individually.
|
||||
var perMod int
|
||||
var outcome string
|
||||
switch g.GiftType {
|
||||
case coopGiftBasket:
|
||||
if voteResult == "opened" {
|
||||
perMod = coopGiftBasketOpened
|
||||
outcome = "boost"
|
||||
} else {
|
||||
perMod = coopGiftBasketUnopened
|
||||
outcome = "reduction"
|
||||
}
|
||||
case coopGiftMimic:
|
||||
if voteResult == "opened" {
|
||||
perMod = coopGiftMimicOpened
|
||||
outcome = "reduction"
|
||||
} else {
|
||||
perMod = coopGiftMimicUnopened
|
||||
outcome = "boost"
|
||||
}
|
||||
}
|
||||
|
||||
stack, _ := loadCoopGiftStack(g.ID)
|
||||
totalMod := 0
|
||||
for _, m := range stack {
|
||||
_ = resolveCoopGift(m.ID, voteResult, outcome, perMod)
|
||||
_ = p.SendDM(m.SenderID, renderCoopGiftSenderDM(&m, voteResult, perMod))
|
||||
totalMod += perMod
|
||||
}
|
||||
|
||||
g.VoteResult = voteResult
|
||||
g.Outcome = outcome
|
||||
g.Modifier = perMod
|
||||
|
||||
// Edit the live game-room post to show the resolved state — total swing
|
||||
// reflects the full stack.
|
||||
gr := gamesRoom()
|
||||
if gr != "" && g.PostEventID != "" {
|
||||
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g, len(stack), totalMod))
|
||||
}
|
||||
}
|
||||
|
||||
// coopProcessGiftExpiries runs each ticker minute, resolving any gifts whose
|
||||
// voting window has elapsed. Modifiers are recorded but not yet applied to a
|
||||
// floor — they wait for the next floor resolution.
|
||||
func (p *AdventurePlugin) coopProcessGiftExpiries() {
|
||||
gifts, err := loadExpiredUnresolvedGifts()
|
||||
if err != nil {
|
||||
slog.Error("coop: load expired gifts", "err", err)
|
||||
return
|
||||
}
|
||||
for i := range gifts {
|
||||
g := &gifts[i]
|
||||
run, _ := loadCoopRun(g.RunID)
|
||||
if run == nil || run.Status != "active" {
|
||||
// Run gone or already terminal — still mark the gift resolved so
|
||||
// it stops cluttering the expiry query.
|
||||
_ = resolveCoopGift(g.ID, "left", "reduction", 0)
|
||||
continue
|
||||
}
|
||||
p.resolveSingleGift(g, run.LeaderID)
|
||||
}
|
||||
}
|
||||
|
||||
// coopResolveGifts is called inside coopResolveFloor. Two responsibilities:
|
||||
//
|
||||
// 1. Catchall: force-resolve any gifts on this day that still have no
|
||||
// vote_result (the timer ticker should normally have caught them, but
|
||||
// this is the defensive backstop, e.g., bot was down).
|
||||
// 2. Apply: sum modifiers from any resolved-but-not-yet-applied gifts on
|
||||
// this run and atomically claim them via markCoopGiftApplied so the
|
||||
// same modifier is never double-applied across retries.
|
||||
//
|
||||
// Returns total modifier and a summary line for the daily-result post.
|
||||
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
|
||||
// Catchall: any unresolved leads for this day get force-resolved now.
|
||||
// Iterate leads only — resolveSingleGift propagates to followers, so
|
||||
// touching followers here would just be redundant work.
|
||||
dayGifts, _ := loadDayCoopGifts(run.ID, day)
|
||||
for i := range dayGifts {
|
||||
if dayGifts[i].StackLeadID != nil {
|
||||
continue
|
||||
}
|
||||
if dayGifts[i].VoteResult == "" {
|
||||
p.resolveSingleGift(&dayGifts[i], leader)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply: pending resolved gifts (any day, this run) get their modifier
|
||||
// merged into the floor roll.
|
||||
pending, _ := loadResolvedUnappliedGifts(run.ID)
|
||||
totalMod := 0
|
||||
var lines []string
|
||||
for _, g := range pending {
|
||||
claimed, err := markCoopGiftApplied(g.ID)
|
||||
if err != nil || !claimed {
|
||||
continue
|
||||
}
|
||||
totalMod += g.Modifier
|
||||
lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%",
|
||||
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier))
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
return totalMod, "Gifts:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// ── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// findCoopGiftStackLead returns the active lead gift for a (run, day, type)
|
||||
// stack, or nil if no unresolved lead exists. Lead = oldest unresolved gift
|
||||
// of that type with stack_lead_id IS NULL.
|
||||
func findCoopGiftStackLead(runID, day int, giftType string) (*CoopGift, error) {
|
||||
d := db.Get()
|
||||
var leadID int
|
||||
err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts
|
||||
WHERE run_id = ? AND day = ? AND gift_type = ?
|
||||
AND vote_result IS NULL AND stack_lead_id IS NULL
|
||||
ORDER BY id ASC LIMIT 1`, runID, day, giftType).Scan(&leadID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return loadCoopGift(leadID)
|
||||
}
|
||||
|
||||
// loadCoopGiftStack returns the lead and all followers (in id order) for a
|
||||
// stack identified by lead_id. Used to propagate resolution + render.
|
||||
func loadCoopGiftStack(leadID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE id = ? OR stack_lead_id = ?
|
||||
ORDER BY id ASC`, leadID, leadID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
if stackLeadID.Valid {
|
||||
v := int(stackLeadID.Int64)
|
||||
g.StackLeadID = &v
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// createCoopGift inserts a new gift. If an unresolved same-type stack exists
|
||||
// for the run+day, the new gift becomes a follower (no expires_at, no own
|
||||
// post — lead's post is edited to bump the count). Otherwise a new lead is
|
||||
// created with its own 6h expiry.
|
||||
//
|
||||
// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective
|
||||
// stack lead — either itself (new lead) or the existing lead it joined.
|
||||
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) {
|
||||
d := db.Get()
|
||||
|
||||
existing, lerr := findCoopGiftStackLead(runID, day, giftType)
|
||||
if lerr != nil {
|
||||
return 0, 0, false, lerr
|
||||
}
|
||||
if existing != nil {
|
||||
// Follower: no expires_at, no post. Inherits lead's deadline.
|
||||
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
||||
(run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
runID, string(sender), day, giftType, existing.ID)
|
||||
if ierr != nil {
|
||||
return 0, 0, false, ierr
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
return int(id64), existing.ID, false, nil
|
||||
}
|
||||
|
||||
// New lead: own expiry, will get its own post.
|
||||
expires := time.Now().UTC().Add(coopGiftWindow)
|
||||
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
|
||||
(run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
runID, string(sender), day, giftType, expires)
|
||||
if ierr != nil {
|
||||
return 0, 0, false, ierr
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
return int(id64), int(id64), true, nil
|
||||
}
|
||||
|
||||
func loadCoopGift(giftID int) (*CoopGift, error) {
|
||||
d := db.Get()
|
||||
g := &CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
|
||||
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
|
||||
if appliedAt.Valid {
|
||||
t := appliedAt.Time
|
||||
g.AppliedAt = &t
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadMostRecentUnresolvedGift(runID, day int) (*CoopGift, error) {
|
||||
gifts, err := loadDayCoopGifts(runID, day)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := len(gifts) - 1; i >= 0; i-- {
|
||||
if gifts[i].VoteResult == "" {
|
||||
g := gifts[i]
|
||||
return &g, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func saveCoopGiftVotes(g *CoopGift) error {
|
||||
d := db.Get()
|
||||
jsonBytes, err := json.Marshal(serializeCoopVotes(g.Votes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = d.Exec(`UPDATE coop_dungeon_gifts SET votes = ? WHERE id = ?`,
|
||||
string(jsonBytes), g.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func saveCoopGiftPostID(giftID int, eventID id.EventID) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_gifts SET post_event_id = ? WHERE id = ?`,
|
||||
string(eventID), giftID)
|
||||
return err
|
||||
}
|
||||
|
||||
func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error {
|
||||
d := db.Get()
|
||||
_, err := d.Exec(`UPDATE coop_dungeon_gifts
|
||||
SET vote_result = ?, outcome = ?, modifier = ?, resolved_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`, voteResult, outcome, modifier, giftID)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadExpiredUnresolvedGifts returns gifts whose voting window has elapsed
|
||||
// but whose votes haven't yet been tallied. Driven by the per-minute
|
||||
// expiry ticker.
|
||||
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
|
||||
AND stack_lead_id IS NULL
|
||||
ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if postID.Valid {
|
||||
g.PostEventID = id.EventID(postID.String)
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
t := expiresAt.Time
|
||||
g.ExpiresAt = &t
|
||||
}
|
||||
if stackLeadID.Valid {
|
||||
v := int(stackLeadID.Int64)
|
||||
g.StackLeadID = &v
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// loadResolvedUnappliedGifts returns gifts that have been tallied but whose
|
||||
// modifiers have not yet been merged into a floor success roll. Used by
|
||||
// floor resolution to sum and apply pending gift modifiers.
|
||||
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
|
||||
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
|
||||
FROM coop_dungeon_gifts
|
||||
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
|
||||
ORDER BY id`, runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []CoopGift
|
||||
for rows.Next() {
|
||||
g := CoopGift{}
|
||||
var sender, votesJSON string
|
||||
var voteResult, outcome, postID sql.NullString
|
||||
var expiresAt, appliedAt sql.NullTime
|
||||
var stackLeadID sql.NullInt64
|
||||
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
|
||||
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SenderID = id.UserID(sender)
|
||||
g.Votes = parseCoopVotes(votesJSON)
|
||||
if voteResult.Valid {
|
||||
g.VoteResult = voteResult.String
|
||||
}
|
||||
if outcome.Valid {
|
||||
g.Outcome = outcome.String
|
||||
}
|
||||
if stackLeadID.Valid {
|
||||
v := int(stackLeadID.Int64)
|
||||
g.StackLeadID = &v
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// markCoopGiftApplied claims a gift's modifier as merged into a floor roll.
|
||||
// Idempotent — only the first call sets applied_at; subsequent calls return
|
||||
// false (claimed=false) so the same modifier is never double-applied.
|
||||
func markCoopGiftApplied(giftID int) (bool, error) {
|
||||
d := db.Get()
|
||||
res, err := d.Exec(`UPDATE coop_dungeon_gifts SET applied_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND applied_at IS NULL`, giftID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// renderCoopGiftPost is called only for stack leads. Stack count is derived
|
||||
// from how many followers point at this lead. Flavor is picked deterministically
|
||||
// by lead id so re-renders don't rotate.
|
||||
func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Stack size = lead + followers
|
||||
stack, _ := loadCoopGiftStack(gift.ID)
|
||||
stackSize := len(stack)
|
||||
if stackSize == 0 {
|
||||
stackSize = 1
|
||||
}
|
||||
|
||||
// Tally on the lead's votes
|
||||
opens, leaves := 0, 0
|
||||
for _, v := range gift.Votes {
|
||||
if v == "open" {
|
||||
opens++
|
||||
} else if v == "leave" {
|
||||
leaves++
|
||||
}
|
||||
}
|
||||
leaderName := coopDisplayName(p, run.LeaderID)
|
||||
|
||||
header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day)
|
||||
if stackSize > 1 {
|
||||
header = fmt.Sprintf("📦 **Gift Stack #%d (×%d) — Co-op #%d, Day %d**", gift.ID, stackSize, run.ID, gift.Day)
|
||||
}
|
||||
sb.WriteString(header)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Pick flavor deterministically so edits don't rotate the joke.
|
||||
if len(TwinBeeGiftArrival) > 0 {
|
||||
flavor := TwinBeeGiftArrival[gift.ID%len(TwinBeeGiftArrival)]
|
||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(opens), 1)
|
||||
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1)
|
||||
flavor = strings.ReplaceAll(flavor, "{leader}", leaderName)
|
||||
sb.WriteString(flavor)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// "REALLY special" line, picked once per stack lead, shown for size 2+.
|
||||
if stackSize >= 2 && len(TwinBeeGiftStackEscalation) > 0 {
|
||||
line := TwinBeeGiftStackEscalation[gift.ID%len(TwinBeeGiftStackEscalation)]
|
||||
sb.WriteString("_")
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("_\n\n")
|
||||
}
|
||||
|
||||
closesIn := ""
|
||||
if gift.ExpiresAt != nil {
|
||||
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
|
||||
if remaining > 0 {
|
||||
closesIn = fmt.Sprintf(" · voting closes in %s", remaining)
|
||||
} else {
|
||||
closesIn = " · voting closing"
|
||||
}
|
||||
}
|
||||
if stackSize > 1 {
|
||||
sb.WriteString(fmt.Sprintf("One vote covers the whole stack of %d. ", stackSize))
|
||||
}
|
||||
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".")
|
||||
|
||||
awaiting := 0
|
||||
for _, m := range members {
|
||||
if _, ok := gift.Votes[m.UserID]; !ok {
|
||||
awaiting++
|
||||
}
|
||||
}
|
||||
if awaiting > 0 && awaiting < len(members) {
|
||||
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", awaiting, len(members)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
|
||||
// voting window closes. Total modifier = stackSize × per-gift mod.
|
||||
func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string {
|
||||
verb := "left"
|
||||
if g.VoteResult == "opened" {
|
||||
verb = "opened"
|
||||
}
|
||||
if stackSize > 1 {
|
||||
return fmt.Sprintf("📦 **Gift Stack #%d (×%d) resolved** — %s, %s → **%+d%%** to next floor.",
|
||||
g.ID, stackSize, titleCaseWord(g.GiftType), verb, totalMod)
|
||||
}
|
||||
return fmt.Sprintf("📦 **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
|
||||
g.ID, titleCaseWord(g.GiftType), verb, totalMod)
|
||||
}
|
||||
|
||||
func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
|
||||
pickFrom := func(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
switch {
|
||||
case g.GiftType == coopGiftBasket && voteResult == "opened":
|
||||
return fmt.Sprintf("📦 Your care basket was opened. %s (%+d%% to today's floor)", pickFrom(TwinBeeGiftBasketOpened), mod)
|
||||
case g.GiftType == coopGiftBasket && voteResult == "left":
|
||||
return fmt.Sprintf("📦 Your care basket was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftBasketUnopened), mod)
|
||||
case g.GiftType == coopGiftMimic && voteResult == "opened":
|
||||
return fmt.Sprintf("📦 Your mimic was opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicOpened), mod)
|
||||
case g.GiftType == coopGiftMimic && voteResult == "left":
|
||||
return fmt.Sprintf("📦 Your mimic was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicUnopened), mod)
|
||||
}
|
||||
return "📦 Your gift resolved."
|
||||
}
|
||||
|
||||
// renderCoopGiftLog returns the public end-of-run gift log, grouped by stack.
|
||||
// One line per stack with all senders listed and the total swing.
|
||||
func renderCoopGiftLog(p *AdventurePlugin, runID int) string {
|
||||
gifts, err := loadAllCoopGifts(runID)
|
||||
if err != nil || len(gifts) == 0 {
|
||||
return ""
|
||||
}
|
||||
// Group gifts by their effective lead id.
|
||||
type groupKey struct {
|
||||
day int
|
||||
giftType string
|
||||
leadID int
|
||||
}
|
||||
groups := map[groupKey][]CoopGift{}
|
||||
order := []groupKey{}
|
||||
for _, g := range gifts {
|
||||
leadID := g.ID
|
||||
if g.StackLeadID != nil {
|
||||
leadID = *g.StackLeadID
|
||||
}
|
||||
k := groupKey{day: g.Day, giftType: g.GiftType, leadID: leadID}
|
||||
if _, seen := groups[k]; !seen {
|
||||
order = append(order, k)
|
||||
}
|
||||
groups[k] = append(groups[k], g)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("📦 Gift Log:\n")
|
||||
for _, k := range order {
|
||||
stack := groups[k]
|
||||
// Senders for the line — ordered by id for stable display.
|
||||
names := make([]string, 0, len(stack))
|
||||
totalMod := 0
|
||||
voteResult := stack[0].VoteResult
|
||||
for _, g := range stack {
|
||||
names = append(names, coopDisplayName(p, g.SenderID))
|
||||
totalMod += g.Modifier
|
||||
}
|
||||
result := voteResult
|
||||
if result == "" {
|
||||
result = "unresolved"
|
||||
}
|
||||
typeLabel := titleCaseWord(k.giftType)
|
||||
if len(stack) > 1 {
|
||||
typeLabel = fmt.Sprintf("%s ×%d", typeLabel, len(stack))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n",
|
||||
k.day, strings.Join(names, " + "), typeLabel, result, totalMod))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func coopDisplayName(p *AdventurePlugin, userID id.UserID) string {
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err == nil && char.DisplayName != "" {
|
||||
return char.DisplayName
|
||||
}
|
||||
if p != nil {
|
||||
return p.DisplayName(userID)
|
||||
}
|
||||
return string(userID)
|
||||
}
|
||||
|
||||
func renderCoopInvite(run *CoopRun, members []CoopMember, leaderName string) string {
|
||||
def := coopTierTable[run.Tier]
|
||||
closes := run.CreatedAt.Add(coopInviteWindow).Format("Mon 15:04 UTC")
|
||||
return fmt.Sprintf(
|
||||
"⚔️ %s is opening a Co-op Dungeon — **Tier %d (%s)**, run #%d.\n"+
|
||||
"Days: %d. Reward pool base: €%d (split among the party).\n"+
|
||||
"Party: %d/%d. Type `!coop join %d` to enter.\n"+
|
||||
"Locks at %s. Newbies are a liability — you knew that going in.",
|
||||
leaderName, run.Tier, def.difficulty, run.ID,
|
||||
def.totalDays, def.rewardBase,
|
||||
len(members), coopMaxPartySize, run.ID, closes)
|
||||
}
|
||||
|
||||
func renderCoopLock(run *CoopRun, members []CoopMember, p *AdventurePlugin) string {
|
||||
var sb strings.Builder
|
||||
def := coopTierTable[run.Tier]
|
||||
sb.WriteString(fmt.Sprintf("⚔️ Tier %d Co-op #%d is locked.\n", run.Tier, run.ID))
|
||||
sb.WriteString(fmt.Sprintf("Difficulty: %s. Days: %d. Reward pool: €%d.\n\nParty (turn order):\n",
|
||||
def.difficulty, run.TotalDays, def.rewardBase))
|
||||
for _, m := range members {
|
||||
warn := ""
|
||||
if m.IsLiability {
|
||||
warn = " ⚠️ liability"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", m.TurnOrder+1, coopDisplayName(p, m.UserID), warn))
|
||||
}
|
||||
sb.WriteString("\nDay 1 begins now. Each member has until the next daily tick to fund. Inactive = None (-10%).")
|
||||
bets, _ := loadCoopBets(run.ID)
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderCoopFundingPrompt(run *CoopRun, day int) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Day %d/%d. Pick your funding tier with `!coop fund <tier>`.\n\n",
|
||||
run.ID, day, run.TotalDays))
|
||||
for _, t := range coopFundingOrder {
|
||||
def := coopFundingTable[t]
|
||||
sb.WriteString(fmt.Sprintf(" • `%s` — €%d, %+d%%\n", t, def.cost, def.modifier))
|
||||
}
|
||||
sb.WriteString("\nNo decision before the next daily tick = auto-played as None (-10%). Funding is non-refundable.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderCoopDailyResult(p *AdventurePlugin, run *CoopRun, members []CoopMember, day int,
|
||||
choices map[string]CoopFundingTier, successPct int, won bool, autoPlayed []*CoopMember,
|
||||
event *CoopEvent, winning string, eventMod int) string {
|
||||
|
||||
var sb strings.Builder
|
||||
def := coopTierTable[run.Tier]
|
||||
header := "✅ Floor cleared"
|
||||
if !won {
|
||||
header = "💥 Wipe"
|
||||
}
|
||||
if won && day >= run.TotalDays {
|
||||
header = "🏆 Final floor cleared"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s), Day %d/%d\n",
|
||||
run.ID, run.Tier, def.difficulty, day, run.TotalDays))
|
||||
sb.WriteString(fmt.Sprintf("Success chance: %d%%. %s.\n", successPct, header))
|
||||
|
||||
if event != nil {
|
||||
sb.WriteString(fmt.Sprintf("Floor event: %s. Party voted **%s** (%+d%%).\n",
|
||||
titleCaseWord(string(event.Category)), winning, eventMod))
|
||||
}
|
||||
sb.WriteString("\nFunding:\n")
|
||||
|
||||
for _, m := range members {
|
||||
t := choices[string(m.UserID)]
|
||||
fdef := coopFundingTable[t]
|
||||
auto := ""
|
||||
for _, ap := range autoPlayed {
|
||||
if ap.UserID == m.UserID {
|
||||
auto = " ← auto-played"
|
||||
break
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s: %s (%+d%%)%s\n",
|
||||
coopDisplayName(p, m.UserID), fdef.label, fdef.modifier, auto))
|
||||
}
|
||||
|
||||
if event != nil {
|
||||
sb.WriteString("\n_TwinBee:_ ")
|
||||
sb.WriteString(coopTwinBeeReaction(event, winning, won))
|
||||
}
|
||||
|
||||
if !won {
|
||||
sb.WriteString("\n\nThe dungeon does not editorialize. Combat actions for today are refunded if any. Funding is gone.")
|
||||
} else if day < run.TotalDays {
|
||||
sb.WriteString(fmt.Sprintf("\n\nNext floor unlocks at the next daily tick. %d day(s) remain.", run.TotalDays-day))
|
||||
bets, _ := loadCoopBets(run.ID)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// coopTwinBeeReaction picks an outcome line based on whether his recommendation
|
||||
// matched the winning vote and whether the floor succeeded.
|
||||
func coopTwinBeeReaction(event *CoopEvent, winning string, won bool) string {
|
||||
pickFrom := func(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
switch {
|
||||
case event.Recommended == winning && won:
|
||||
return pickFrom(TwinBeeOutcomeCorrect)
|
||||
case event.Recommended == winning && !won:
|
||||
return pickFrom(TwinBeeOutcomeWrong)
|
||||
case event.Recommended != winning && won:
|
||||
return pickFrom(TwinBeeOutcomeNotRecommendedGood)
|
||||
default:
|
||||
return pickFrom(TwinBeeOutcomeNotRecommendedBad)
|
||||
}
|
||||
}
|
||||
|
||||
// renderCoopEventPost is the game-room post for a floor event vote prompt.
|
||||
// Edited in place as votes come in.
|
||||
func renderCoopEventPost(run *CoopRun, members []CoopMember, event *CoopEvent) string {
|
||||
var sb strings.Builder
|
||||
flavorPool := coopEventCategoryFlavor(event.Category)
|
||||
flavor := ""
|
||||
if event.EventIndex < len(flavorPool) {
|
||||
flavor = flavorPool[event.EventIndex]
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("🗺️ **Co-op #%d — Day %d/%d, %s event**\n\n",
|
||||
run.ID, event.Day, run.TotalDays, titleCaseWord(string(event.Category))))
|
||||
sb.WriteString(flavor)
|
||||
sb.WriteString("\n\nVote with `!coop vote A` (or B/C). 24h or until next tick. Ties go to the leader.\n\n")
|
||||
|
||||
tally := map[string]int{}
|
||||
for _, v := range event.Votes {
|
||||
tally[v]++
|
||||
}
|
||||
parts := []string{}
|
||||
for _, label := range coopEventOptionLabels(event.Category, event.EventIndex) {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", label, tally[label]))
|
||||
}
|
||||
sb.WriteString("Current votes: ")
|
||||
sb.WriteString(strings.Join(parts, " · "))
|
||||
|
||||
abstained := []string{}
|
||||
for _, m := range members {
|
||||
if _, voted := event.Votes[m.UserID]; !voted {
|
||||
abstained = append(abstained, string(m.UserID))
|
||||
}
|
||||
}
|
||||
if len(abstained) > 0 && len(abstained) < len(members) {
|
||||
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", len(abstained), len(members)))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func titleCaseWord(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) string {
|
||||
var sb strings.Builder
|
||||
def := coopTierTable[run.Tier]
|
||||
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s)\n", run.ID, run.Tier, def.difficulty))
|
||||
sb.WriteString(fmt.Sprintf("Status: %s. Day %d/%d. Pool: €%d.\n\nParty:\n",
|
||||
run.Status, run.CurrentDay, run.TotalDays, run.GoldPool))
|
||||
|
||||
for _, m := range members {
|
||||
warn := ""
|
||||
if m.IsLiability {
|
||||
warn = " ⚠️"
|
||||
}
|
||||
todayLabel := "—"
|
||||
if run.CurrentDay >= 1 {
|
||||
if t, ok := m.DailyFunding[run.CurrentDay]; ok {
|
||||
todayLabel = coopFundingTable[t].label
|
||||
} else {
|
||||
todayLabel = "(unset)"
|
||||
}
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %d. %s%s — today: %s, contributed: €%d\n",
|
||||
m.TurnOrder+1, coopDisplayName(p, m.UserID), warn, todayLabel, m.TotalContributed))
|
||||
}
|
||||
if run.Status == "open" {
|
||||
closes := run.CreatedAt.Add(coopInviteWindow)
|
||||
sb.WriteString(fmt.Sprintf("\nLocks in: %s.", time.Until(closes).Truncate(time.Minute)))
|
||||
}
|
||||
if run.Status == "open" || run.Status == "active" {
|
||||
bets, _ := loadCoopBets(run.ID)
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(renderCoopOddsLine(run, members, bets))
|
||||
}
|
||||
|
||||
// Pending stacks with per-stack countdowns. Each game-room post is one
|
||||
// stack (lead + N followers); we show the lead's id and the stack size.
|
||||
if run.Status == "active" {
|
||||
all, _ := loadAllCoopGifts(run.ID)
|
||||
// Group unresolved gifts by lead. Followers contribute to the size
|
||||
// but use the lead's expiry/votes.
|
||||
type stackInfo struct {
|
||||
lead CoopGift
|
||||
size int
|
||||
}
|
||||
stacks := map[int]*stackInfo{}
|
||||
order := []int{}
|
||||
for _, g := range all {
|
||||
if g.VoteResult != "" {
|
||||
continue
|
||||
}
|
||||
leadID := g.ID
|
||||
if g.StackLeadID != nil {
|
||||
leadID = *g.StackLeadID
|
||||
}
|
||||
s, ok := stacks[leadID]
|
||||
if !ok {
|
||||
s = &stackInfo{}
|
||||
stacks[leadID] = s
|
||||
order = append(order, leadID)
|
||||
}
|
||||
if g.StackLeadID == nil {
|
||||
s.lead = g
|
||||
}
|
||||
s.size++
|
||||
}
|
||||
if len(stacks) > 0 {
|
||||
sb.WriteString("\n\nPending gifts:\n")
|
||||
for _, leadID := range order {
|
||||
s := stacks[leadID]
|
||||
opens, leaves := 0, 0
|
||||
for _, v := range s.lead.Votes {
|
||||
if v == "open" {
|
||||
opens++
|
||||
} else if v == "leave" {
|
||||
leaves++
|
||||
}
|
||||
}
|
||||
countdown := "—"
|
||||
if s.lead.ExpiresAt != nil {
|
||||
remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute)
|
||||
if remaining > 0 {
|
||||
countdown = remaining.String()
|
||||
} else {
|
||||
countdown = "closing"
|
||||
}
|
||||
}
|
||||
stackLabel := fmt.Sprintf("#%d", s.lead.ID)
|
||||
if s.size > 1 {
|
||||
stackLabel = fmt.Sprintf("#%d ×%d", s.lead.ID, s.size)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s (day %d) — open %d / leave %d — closes in %s\n",
|
||||
stackLabel, s.lead.Day, opens, leaves, countdown))
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// coopTicker has two cadences:
|
||||
//
|
||||
// 1. Lock checks fire every minute. They're cheap (one timestamp comparison
|
||||
// per open run) and a 24h invite window is poorly served by a 24h tick —
|
||||
// a run started after 08:00 UTC would otherwise wait 30-48h to lock.
|
||||
// 2. Floor resolution fires once per UTC day at morningHour, gated by the
|
||||
// daily_prefetch JobCompleted guard. Resolution is the heavy step that
|
||||
// advances days, distributes rewards, and posts to the room.
|
||||
func (p *AdventurePlugin) coopTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Locks: every minute.
|
||||
p.coopProcessLocks()
|
||||
|
||||
// Gift expiries: every minute. Resolves any gift whose individual
|
||||
// 6h voting window has elapsed. Modifier sits on the run until the
|
||||
// next floor resolution merges it.
|
||||
p.coopProcessGiftExpiries()
|
||||
|
||||
// Resolutions: once per day at morningHour:00 UTC.
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "coop_dungeon_daily"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
slog.Info("coop: daily tick — resolving active runs")
|
||||
p.coopProcessActiveRuns()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lock Phase ──────────────────────────────────────────────────────────────
|
||||
|
||||
// coopProcessLocks closes invites whose 24h window has elapsed: locks parties
|
||||
// of 2+, cancels solo runs.
|
||||
func (p *AdventurePlugin) coopProcessLocks() {
|
||||
runs, err := loadOpenCoopRuns()
|
||||
if err != nil {
|
||||
slog.Error("coop: load open runs", "err", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, run := range runs {
|
||||
if now.Before(run.CreatedAt.Add(coopInviteWindow)) {
|
||||
continue
|
||||
}
|
||||
members, err := loadCoopMembers(run.ID)
|
||||
if err != nil {
|
||||
slog.Error("coop: load members for lock", "run", run.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(members) < coopMinPartySize {
|
||||
// Cancel solo runs and refund nothing (no funding has been spent yet).
|
||||
_ = setCoopRunStatus(run.ID, "cancelled")
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
if run.InvitePostID != "" {
|
||||
_ = p.UnpinEvent(gr, run.InvitePostID)
|
||||
}
|
||||
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d expired with no party. Cancelled.", run.Tier, run.ID))
|
||||
}
|
||||
_ = p.SendDM(run.LeaderID, fmt.Sprintf("Co-op #%d expired before anyone joined. No party, no run.", run.ID))
|
||||
continue
|
||||
}
|
||||
|
||||
// Lock: deduct combat action from each member, mark active, post lock notice.
|
||||
if err := lockCoopRun(run.ID); err != nil {
|
||||
slog.Error("coop: lock run", "run", run.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
for _, m := range members {
|
||||
char, err := loadAdvCharacter(m.UserID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if char.CanDoCombat(false) {
|
||||
char.CombatActionsUsed++
|
||||
char.ActionTakenToday = true
|
||||
char.LastActionDate = now.Format("2006-01-02")
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
}
|
||||
fresh, _ := loadCoopRun(run.ID)
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
if run.InvitePostID != "" {
|
||||
_ = p.UnpinEvent(gr, run.InvitePostID)
|
||||
}
|
||||
_ = p.SendMessage(gr, renderCoopLock(fresh, members, p))
|
||||
}
|
||||
// Per-player DM with funding instructions.
|
||||
for _, m := range members {
|
||||
_ = p.SendDM(m.UserID, renderCoopFundingPrompt(fresh, 1))
|
||||
}
|
||||
// Generate and post day-1 floor event.
|
||||
p.coopGenerateAndPostEvent(fresh, members, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// coopGenerateAndPostEvent picks a category-weighted event for the given day,
|
||||
// stores it, and posts the vote prompt to the game room with TwinBee narration.
|
||||
func (p *AdventurePlugin) coopGenerateAndPostEvent(run *CoopRun, members []CoopMember, day int) {
|
||||
cat := pickCoopEventCategory(run.Tier)
|
||||
idx := pickCoopEvent(cat)
|
||||
rec := coopEventRecommended(cat, idx)
|
||||
if err := createCoopEvent(run.ID, day, cat, idx, rec); err != nil {
|
||||
slog.Error("coop: create event", "run", run.ID, "day", day, "err", err)
|
||||
return
|
||||
}
|
||||
gr := gamesRoom()
|
||||
if gr == "" {
|
||||
return
|
||||
}
|
||||
event, _ := loadCoopEvent(run.ID, day)
|
||||
if event == nil {
|
||||
return
|
||||
}
|
||||
postID, err := p.SendMessageID(gr, renderCoopEventPost(run, members, event))
|
||||
if err != nil {
|
||||
slog.Error("coop: post event", "err", err)
|
||||
return
|
||||
}
|
||||
_ = saveCoopEventPostID(run.ID, day, postID)
|
||||
}
|
||||
|
||||
// ── Daily Resolution ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) coopProcessActiveRuns() {
|
||||
runs, err := loadActiveCoopRuns()
|
||||
if err != nil {
|
||||
slog.Error("coop: load active runs", "err", err)
|
||||
return
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, run := range runs {
|
||||
// Need at least a 12h funding window between lock and the first
|
||||
// floor resolution. Without this, a run locked at 07:00 UTC would
|
||||
// resolve an hour later at 08:00 UTC. With per-minute lock checks
|
||||
// (locks now fire any time), the previous "same UTC date" guard
|
||||
// was too conservative — it pushed first-day windows out to 24-32h
|
||||
// even when 13-23h would have been plenty.
|
||||
if run.LockedAt != nil && now.Sub(run.LockedAt.UTC()) < 12*time.Hour {
|
||||
continue
|
||||
}
|
||||
if err := p.coopResolveFloor(run); err != nil {
|
||||
slog.Error("coop: resolve floor", "run", run.ID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// coopResolveFloor processes the current day's floor: auto-plays missing
|
||||
// funders, rolls outcome, advances or completes the run.
|
||||
func (p *AdventurePlugin) coopResolveFloor(run *CoopRun) error {
|
||||
day := run.CurrentDay
|
||||
|
||||
// Skip only if this run is fully terminal. A "resolved" day with the run
|
||||
// still in 'active' status means a prior tick crashed mid-distribution;
|
||||
// we must re-enter to finish via idempotent operations (per-row claims).
|
||||
if run.LastResolvedDay >= day && run.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
|
||||
members, err := loadCoopMembers(run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Lock all party members in stable UserID order so concurrent !coop fund /
|
||||
// vote / giftvote commands don't race the resolver. Stable order rules
|
||||
// out lock-order deadlock with other goroutines that lock multiple users.
|
||||
sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID })
|
||||
mutexes := make([]*sync.Mutex, 0, len(members))
|
||||
for _, m := range members {
|
||||
mu := p.advUserLock(m.UserID)
|
||||
mu.Lock()
|
||||
mutexes = append(mutexes, mu)
|
||||
}
|
||||
defer func() {
|
||||
for _, mu := range mutexes {
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// Reload the run after acquiring locks. A concurrent admin action or
|
||||
// another scheduler tick may have moved the state.
|
||||
freshRun, err := loadCoopRun(run.ID)
|
||||
if err != nil || freshRun == nil {
|
||||
return err
|
||||
}
|
||||
run = freshRun
|
||||
if run.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
// Re-check skip after lock acquisition (another tick could have already
|
||||
// fully finalized — though our per-tick guard makes that unlikely).
|
||||
if run.LastResolvedDay >= day && run.Status != "active" {
|
||||
return nil
|
||||
}
|
||||
// Reload members under lock for fresh funding state.
|
||||
members, err = loadCoopMembers(run.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Slice(members, func(i, j int) bool { return members[i].UserID < members[j].UserID })
|
||||
|
||||
// Resume vs. fresh detection: if the day's event already has an outcome,
|
||||
// a prior tick rolled this floor before crashing. Reuse that outcome —
|
||||
// re-rolling would be the worst sin (different result on retry).
|
||||
event, _ := loadCoopEvent(run.ID, day)
|
||||
resuming := event != nil && event.Outcome != ""
|
||||
|
||||
autoPlayed := []*CoopMember{}
|
||||
choices := make(map[string]CoopFundingTier, len(members))
|
||||
var floorWon bool
|
||||
var winning string
|
||||
var eventMod, successPct int
|
||||
var giftSummary string
|
||||
|
||||
if resuming {
|
||||
floorWon = event.Outcome == "success"
|
||||
winning = event.WinningVote
|
||||
eventMod = event.ModifierApplied
|
||||
// Reconstruct choices for display purposes.
|
||||
for _, m := range members {
|
||||
tier, _ := coopMemberModifier(&m, day)
|
||||
choices[string(m.UserID)] = tier
|
||||
}
|
||||
successPct = 0 // unknown on resume; render hides it
|
||||
} else {
|
||||
// Auto-play any member who didn't fund.
|
||||
for i := range members {
|
||||
m := &members[i]
|
||||
if _, ok := m.DailyFunding[day]; !ok {
|
||||
m.DailyFunding[day] = CoopFundNone
|
||||
if err := saveCoopMemberFunding(m); err != nil {
|
||||
slog.Error("coop: auto-play save", "run", run.ID, "user", m.UserID, "err", err)
|
||||
}
|
||||
autoPlayed = append(autoPlayed, m)
|
||||
}
|
||||
}
|
||||
|
||||
totalMod := 0
|
||||
tierDef := coopTierTable[run.Tier]
|
||||
for _, m := range members {
|
||||
tier, mod := coopMemberModifier(&m, day)
|
||||
choices[string(m.UserID)] = tier
|
||||
totalMod += mod
|
||||
char, err := loadAdvCharacter(m.UserID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
totalMod += coopLevelBonus(char.CombatLevel, tierDef.minLevel)
|
||||
totalMod += coopPetBonus(char)
|
||||
}
|
||||
|
||||
// Lazy-create the day's event if missing — covers the crash window
|
||||
// between lock and the original event creation.
|
||||
if event == nil {
|
||||
cat := pickCoopEventCategory(run.Tier)
|
||||
idx := pickCoopEvent(cat)
|
||||
_ = createCoopEvent(run.ID, day, cat, idx, coopEventRecommended(cat, idx))
|
||||
event, _ = loadCoopEvent(run.ID, day)
|
||||
}
|
||||
|
||||
if event != nil {
|
||||
winning = coopTallyVote(event, run.LeaderID)
|
||||
eventMod = coopEventOptionModifier(event.Category, event.EventIndex, winning)
|
||||
totalMod += eventMod
|
||||
}
|
||||
|
||||
var giftMod int
|
||||
giftMod, giftSummary = p.coopResolveGifts(run, run.LeaderID, day)
|
||||
totalMod += giftMod
|
||||
|
||||
successPct = 100 - run.BaseDifficulty + totalMod
|
||||
if successPct < 5 {
|
||||
successPct = 5
|
||||
}
|
||||
if successPct > 95 {
|
||||
successPct = 95
|
||||
}
|
||||
|
||||
roll := rand.IntN(100)
|
||||
floorWon = roll < successPct
|
||||
|
||||
// Persist event resolution — turns this into a "resume" path on retry.
|
||||
if event != nil {
|
||||
outcomeStr := "failure"
|
||||
if floorWon {
|
||||
outcomeStr = "success"
|
||||
}
|
||||
_ = resolveCoopEvent(run.ID, day, winning, outcomeStr, eventMod)
|
||||
}
|
||||
|
||||
dailyPost := renderCoopDailyResult(p, run, members, day, choices, successPct, floorWon, autoPlayed, event, winning, eventMod)
|
||||
if giftSummary != "" {
|
||||
dailyPost += "\n\n" + giftSummary
|
||||
}
|
||||
if gr := gamesRoom(); gr != "" {
|
||||
_ = p.SendMessage(gr, dailyPost)
|
||||
}
|
||||
}
|
||||
|
||||
gr := gamesRoom()
|
||||
if !floorWon {
|
||||
// Resolve bets first (idempotent via claimCoopBetPayout), then mark
|
||||
// the run wiped. Order matters: if status is set first and the bot
|
||||
// crashes, the next tick won't pick this run up.
|
||||
summary := p.coopResolveBets(run, false)
|
||||
giftLog := renderCoopGiftLog(p, run.ID)
|
||||
parts := []string{}
|
||||
if summary != "" {
|
||||
parts = append(parts, summary)
|
||||
}
|
||||
if giftLog != "" {
|
||||
parts = append(parts, giftLog)
|
||||
}
|
||||
if len(parts) > 0 && gr != "" {
|
||||
_ = p.SendMessage(gr, strings.Join(parts, "\n\n"))
|
||||
}
|
||||
_ = completeCoopRun(run.ID, "wiped", 0)
|
||||
_ = setCoopRunLastResolvedDay(run.ID, day)
|
||||
return nil
|
||||
}
|
||||
|
||||
if day >= run.TotalDays {
|
||||
def := coopTierTable[run.Tier]
|
||||
reward := def.rewardBase
|
||||
p.coopDistributeReward(run, reward, members)
|
||||
_ = completeCoopRun(run.ID, "complete", reward)
|
||||
_ = setCoopRunLastResolvedDay(run.ID, day)
|
||||
return nil
|
||||
}
|
||||
if err := advanceCoopDay(run.ID, day+1); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range members {
|
||||
_ = p.SendDM(m.UserID, renderCoopFundingPrompt(run, day+1))
|
||||
}
|
||||
freshRun, _ = loadCoopRun(run.ID)
|
||||
if freshRun != nil {
|
||||
p.coopGenerateAndPostEvent(freshRun, members, day+1)
|
||||
}
|
||||
_ = setCoopRunLastResolvedDay(run.ID, day)
|
||||
return nil
|
||||
}
|
||||
|
||||
// coopTallyVote counts votes; majority wins. Ties resolve to the leader's
|
||||
// vote if it's among the tied options; otherwise the lexicographically first
|
||||
// tied label (deterministic, since Go map iteration is randomized).
|
||||
func coopTallyVote(event *CoopEvent, leader id.UserID) string {
|
||||
tally := map[string]int{}
|
||||
for _, v := range event.Votes {
|
||||
tally[v]++
|
||||
}
|
||||
bestCount := -1
|
||||
var winners []string
|
||||
for label, n := range tally {
|
||||
if n > bestCount {
|
||||
bestCount = n
|
||||
winners = []string{label}
|
||||
} else if n == bestCount {
|
||||
winners = append(winners, label)
|
||||
}
|
||||
}
|
||||
if len(winners) == 1 {
|
||||
return winners[0]
|
||||
}
|
||||
sort.Strings(winners)
|
||||
if leaderVote, ok := event.Votes[leader]; ok {
|
||||
for _, w := range winners {
|
||||
if w == leaderVote {
|
||||
return w
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(winners) > 0 {
|
||||
return winners[0]
|
||||
}
|
||||
return "A"
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) coopDistributeReward(run *CoopRun, totalReward int, members []CoopMember) {
|
||||
if len(members) == 0 || totalReward <= 0 {
|
||||
return
|
||||
}
|
||||
share := totalReward / len(members)
|
||||
gr := gamesRoom()
|
||||
var lines []string
|
||||
for _, m := range members {
|
||||
// Atomic claim: only credit if this is the first time we're paying
|
||||
// this member for this run. Skip silently on retry.
|
||||
claimed, err := claimCoopMemberPayout(run.ID, m.UserID, share)
|
||||
if err != nil || !claimed {
|
||||
continue
|
||||
}
|
||||
net, _ := communityTax(m.UserID, float64(share), coopAdventureRake)
|
||||
p.euro.Credit(m.UserID, net, "coop_dungeon_reward")
|
||||
lines = append(lines, fmt.Sprintf(" %s: €%.0f (after tax)", coopDisplayName(p, m.UserID), net))
|
||||
_ = p.SendDM(m.UserID, fmt.Sprintf("⚔️ Co-op #%d cleared. Your share: €%.0f.", run.ID, net))
|
||||
}
|
||||
|
||||
// Generate item drops and distribute via weighted roll.
|
||||
itemSummary := p.coopDistributeItems(run, members)
|
||||
|
||||
bettingSummary := p.coopResolveBets(run, true)
|
||||
giftLog := renderCoopGiftLog(p, run.ID)
|
||||
if gr != "" {
|
||||
msg := fmt.Sprintf("⚔️ Co-op #%d cleared. Reward pool: €%d.\n\n%s",
|
||||
run.ID, totalReward, strings.Join(lines, "\n"))
|
||||
if itemSummary != "" {
|
||||
msg += "\n\n" + itemSummary
|
||||
}
|
||||
if bettingSummary != "" {
|
||||
msg += "\n\n" + bettingSummary
|
||||
}
|
||||
if giftLog != "" {
|
||||
msg += "\n\n" + giftLog
|
||||
}
|
||||
_ = p.SendMessage(gr, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// coopDistributeItems generates item drops for a successful run and assigns
|
||||
// each via weighted roll across party members (weight = total_contributed).
|
||||
// Items are added to the winner's inventory; returns a summary line.
|
||||
func (p *AdventurePlugin) coopDistributeItems(run *CoopRun, members []CoopMember) string {
|
||||
loc := findAdvLocationByTier(AdvActivityDungeon, run.Tier)
|
||||
if loc == nil {
|
||||
return ""
|
||||
}
|
||||
// Item count scales with tier: T1 = 2, T5 = 6.
|
||||
count := 2 + (run.Tier - 1)
|
||||
var allItems []AdvItem
|
||||
for i := 0; i < count; i++ {
|
||||
allItems = append(allItems, generateAdvLoot(loc, false, 0)...)
|
||||
}
|
||||
if len(allItems) == 0 {
|
||||
return ""
|
||||
}
|
||||
totalWeight := 0
|
||||
for _, m := range members {
|
||||
totalWeight += m.TotalContributed
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for _, item := range allItems {
|
||||
winner := coopWeightedItemWinner(members, totalWeight)
|
||||
_ = addAdvInventoryItem(winner, item)
|
||||
lines = append(lines, fmt.Sprintf(" %s (€%d) → %s", item.Name, item.Value, coopDisplayName(p, winner)))
|
||||
}
|
||||
if mwLine := p.coopMaybeMasterwork(run, members, totalWeight); mwLine != "" {
|
||||
lines = append(lines, mwLine)
|
||||
}
|
||||
return "Item drops:\n" + strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// coopMaybeMasterwork grants a tier-appropriate masterwork drop on T4 (25%)
|
||||
// or T5 (guaranteed). Picks a random masterwork def at the run's tier from
|
||||
// the existing pool (mining sword / fishing armor / foraging boots), awards
|
||||
// it via the same weighted roll, and announces in the game room.
|
||||
//
|
||||
// Returns a one-line summary to splice into the completion post, or "".
|
||||
func (p *AdventurePlugin) coopMaybeMasterwork(run *CoopRun, members []CoopMember, totalWeight int) string {
|
||||
if run.Tier < 4 {
|
||||
return ""
|
||||
}
|
||||
if run.Tier == 4 && rand.Float64() >= 0.25 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Collect all masterwork defs at the run's tier.
|
||||
candidates := []MasterworkDef{}
|
||||
for _, def := range masterworkDefs {
|
||||
if def.Tier == run.Tier {
|
||||
candidates = append(candidates, def)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
def := candidates[rand.IntN(len(candidates))]
|
||||
winner := coopWeightedItemWinner(members, totalWeight)
|
||||
if winner == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Add to inventory as MasterworkGear (don't auto-equip — let the winner
|
||||
// choose via !adventure equip; their existing gear may be better).
|
||||
item := AdvItem{
|
||||
Name: def.Name,
|
||||
Type: "MasterworkGear",
|
||||
Tier: def.Tier,
|
||||
Value: 0,
|
||||
Slot: def.Slot,
|
||||
SkillSource: def.SkillSource,
|
||||
}
|
||||
if err := addAdvInventoryItem(winner, item); err != nil {
|
||||
slog.Error("coop: masterwork inventory add", "winner", winner, "err", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
char, _ := loadAdvCharacter(winner)
|
||||
if char != nil {
|
||||
char.MasterworkDropsReceived++
|
||||
_ = saveAdvCharacter(char)
|
||||
}
|
||||
|
||||
dmText := fmt.Sprintf("⭐ **Co-op Masterwork Drop: %s** (T%d %s)\n\n_%s_\n\nMasterwork %s — 1.25x effectiveness, +5%% %s success.\n\nAdded to inventory. `!adventure equip` to use it.",
|
||||
def.Name, def.Tier, slotTitle(def.Slot), def.Description, slotTitle(def.Slot), def.SkillSource)
|
||||
_ = p.SendDM(winner, dmText)
|
||||
|
||||
return fmt.Sprintf("⭐ Masterwork: %s (T%d %s) → %s",
|
||||
def.Name, def.Tier, slotTitle(def.Slot), coopDisplayName(p, winner))
|
||||
}
|
||||
|
||||
// coopWeightedItemWinner picks a member with probability proportional to their
|
||||
// total funding contribution. If no one has contributed (e.g., all None on
|
||||
// every day), falls back to uniform random.
|
||||
func coopWeightedItemWinner(members []CoopMember, totalWeight int) id.UserID {
|
||||
if len(members) == 0 {
|
||||
return ""
|
||||
}
|
||||
if totalWeight <= 0 {
|
||||
return members[rand.IntN(len(members))].UserID
|
||||
}
|
||||
r := rand.IntN(totalWeight)
|
||||
cum := 0
|
||||
for _, m := range members {
|
||||
cum += m.TotalContributed
|
||||
if r < cum {
|
||||
return m.UserID
|
||||
}
|
||||
}
|
||||
return members[len(members)-1].UserID
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
)
|
||||
|
||||
// ── Stats aggregation ──────────────────────────────────────────────────────
|
||||
|
||||
type coopTierStats struct {
|
||||
Tier int
|
||||
Open int
|
||||
Active int
|
||||
Complete int
|
||||
Wiped int
|
||||
Cancelled int
|
||||
AvgPartySize float64
|
||||
TotalReward int64 // Σ reward_total of completed runs
|
||||
TotalForfeited int64 // Σ gold_pool of wiped runs (lost to the dungeon)
|
||||
}
|
||||
|
||||
type coopBetStats struct {
|
||||
BetsPlaced int
|
||||
TotalStake int64
|
||||
TotalPaid int64 // sum of non-NULL payouts (winners)
|
||||
BettorsLT int // distinct bettors lifetime
|
||||
}
|
||||
|
||||
type coopGiftStats struct {
|
||||
BasketSent int
|
||||
MimicSent int
|
||||
BasketOpened int
|
||||
BasketLeft int
|
||||
MimicOpened int
|
||||
MimicLeft int
|
||||
}
|
||||
|
||||
func loadCoopTierStats() ([]coopTierStats, error) {
|
||||
d := db.Get()
|
||||
stats := map[int]*coopTierStats{}
|
||||
for t := 1; t <= 5; t++ {
|
||||
stats[t] = &coopTierStats{Tier: t}
|
||||
}
|
||||
|
||||
// Run outcomes by tier
|
||||
rows, err := d.Query(`SELECT tier, status, COUNT(*) FROM coop_dungeon_runs
|
||||
GROUP BY tier, status`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var tier int
|
||||
var status string
|
||||
var n int
|
||||
if err := rows.Scan(&tier, &status, &n); err != nil {
|
||||
continue
|
||||
}
|
||||
s, ok := stats[tier]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch status {
|
||||
case "open":
|
||||
s.Open = n
|
||||
case "active":
|
||||
s.Active = n
|
||||
case "complete":
|
||||
s.Complete = n
|
||||
case "wiped":
|
||||
s.Wiped = n
|
||||
case "cancelled":
|
||||
s.Cancelled = n
|
||||
}
|
||||
}
|
||||
|
||||
// Avg party size by tier (over locked runs only — open invites with one
|
||||
// member would bias the number).
|
||||
rows2, err := d.Query(`SELECT r.tier, AVG(m.cnt) FROM coop_dungeon_runs r
|
||||
JOIN (SELECT run_id, COUNT(*) cnt FROM coop_dungeon_members GROUP BY run_id) m
|
||||
ON m.run_id = r.id
|
||||
WHERE r.status IN ('active','complete','wiped')
|
||||
GROUP BY r.tier`)
|
||||
if err == nil {
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var tier int
|
||||
var avg sql.NullFloat64
|
||||
if err := rows2.Scan(&tier, &avg); err == nil && avg.Valid {
|
||||
if s, ok := stats[tier]; ok {
|
||||
s.AvgPartySize = avg.Float64
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reward / forfeit totals per tier
|
||||
rows3, err := d.Query(`SELECT tier, status, SUM(reward_total), SUM(gold_pool)
|
||||
FROM coop_dungeon_runs WHERE status IN ('complete','wiped')
|
||||
GROUP BY tier, status`)
|
||||
if err == nil {
|
||||
defer rows3.Close()
|
||||
for rows3.Next() {
|
||||
var tier int
|
||||
var status string
|
||||
var reward, pool sql.NullInt64
|
||||
if err := rows3.Scan(&tier, &status, &reward, &pool); err != nil {
|
||||
continue
|
||||
}
|
||||
s, ok := stats[tier]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if status == "complete" && reward.Valid {
|
||||
s.TotalReward += reward.Int64
|
||||
}
|
||||
if status == "wiped" && pool.Valid {
|
||||
s.TotalForfeited += pool.Int64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]coopTierStats, 0, 5)
|
||||
for t := 1; t <= 5; t++ {
|
||||
out = append(out, *stats[t])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadCoopBetStats() (coopBetStats, error) {
|
||||
d := db.Get()
|
||||
var s coopBetStats
|
||||
var stake, paid sql.NullInt64
|
||||
err := d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(amount),0),
|
||||
COALESCE(SUM(CASE WHEN payout > 0 THEN payout ELSE 0 END), 0)
|
||||
FROM coop_dungeon_bets`).Scan(&s.BetsPlaced, &stake, &paid)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.TotalStake = stake.Int64
|
||||
s.TotalPaid = paid.Int64
|
||||
_ = d.QueryRow(`SELECT COUNT(DISTINCT player_id) FROM coop_dungeon_bets`).Scan(&s.BettorsLT)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func loadCoopGiftStats() (coopGiftStats, error) {
|
||||
d := db.Get()
|
||||
var s coopGiftStats
|
||||
rows, err := d.Query(`SELECT gift_type, COALESCE(vote_result, 'pending'), COUNT(*)
|
||||
FROM coop_dungeon_gifts GROUP BY gift_type, vote_result`)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var giftType, voteResult string
|
||||
var n int
|
||||
if err := rows.Scan(&giftType, &voteResult, &n); err != nil {
|
||||
continue
|
||||
}
|
||||
switch giftType {
|
||||
case coopGiftBasket:
|
||||
s.BasketSent += n
|
||||
if voteResult == "opened" {
|
||||
s.BasketOpened = n
|
||||
} else if voteResult == "left" {
|
||||
s.BasketLeft = n
|
||||
}
|
||||
case coopGiftMimic:
|
||||
s.MimicSent += n
|
||||
if voteResult == "opened" {
|
||||
s.MimicOpened = n
|
||||
} else if voteResult == "left" {
|
||||
s.MimicLeft = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderCoopStats() string {
|
||||
tiers, _ := loadCoopTierStats()
|
||||
bets, _ := loadCoopBetStats()
|
||||
gifts, _ := loadCoopGiftStats()
|
||||
help30 := coopTwinBeeHelpfulness(30)
|
||||
helpAll := coopTwinBeeHelpfulness(10000) // effectively lifetime
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("⚔️ **Co-op Dungeon Stats**\n\n")
|
||||
|
||||
sb.WriteString("**Runs by tier**\n")
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(fmt.Sprintf("%-6s %-6s %-6s %-6s %-6s %-6s %-7s %-12s\n",
|
||||
"tier", "open", "active", "won", "wiped", "canc", "win%", "avg party"))
|
||||
for _, t := range tiers {
|
||||
finished := t.Complete + t.Wiped
|
||||
winPct := 0.0
|
||||
if finished > 0 {
|
||||
winPct = float64(t.Complete) / float64(finished) * 100
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("T%-5d %-6d %-6d %-6d %-6d %-6d %-6.1f%% %-12.2f\n",
|
||||
t.Tier, t.Open, t.Active, t.Complete, t.Wiped, t.Cancelled, winPct, t.AvgPartySize))
|
||||
}
|
||||
sb.WriteString("```\n\n")
|
||||
|
||||
sb.WriteString("**Gold flow**\n")
|
||||
var totalReward, totalForfeit int64
|
||||
for _, t := range tiers {
|
||||
totalReward += t.TotalReward
|
||||
totalForfeit += t.TotalForfeited
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Rewards distributed: €%d\n", totalReward))
|
||||
sb.WriteString(fmt.Sprintf(" Funding forfeited (wipes): €%d\n\n", totalForfeit))
|
||||
|
||||
sb.WriteString("**Spectator betting**\n")
|
||||
rake := int64(float64(bets.TotalStake) * coopBetRake)
|
||||
sb.WriteString(fmt.Sprintf(" Bets placed: %d (by %d distinct bettors)\n", bets.BetsPlaced, bets.BettorsLT))
|
||||
sb.WriteString(fmt.Sprintf(" Total wagered: €%d\n", bets.TotalStake))
|
||||
sb.WriteString(fmt.Sprintf(" Paid to winners: €%d\n", bets.TotalPaid))
|
||||
sb.WriteString(fmt.Sprintf(" House cut (TwinBee): ~€%d\n\n", rake))
|
||||
|
||||
sb.WriteString("**Gifts**\n")
|
||||
openRate := func(opened, total int) float64 {
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(opened) / float64(total) * 100
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Baskets sent: %d (opened %d, left %d, %.0f%% open rate)\n",
|
||||
gifts.BasketSent, gifts.BasketOpened, gifts.BasketLeft,
|
||||
openRate(gifts.BasketOpened, gifts.BasketOpened+gifts.BasketLeft)))
|
||||
sb.WriteString(fmt.Sprintf(" Mimics sent: %d (opened %d, left %d, %.0f%% open rate)\n\n",
|
||||
gifts.MimicSent, gifts.MimicOpened, gifts.MimicLeft,
|
||||
openRate(gifts.MimicOpened, gifts.MimicOpened+gifts.MimicLeft)))
|
||||
|
||||
sb.WriteString("**TwinBee helpfulness**\n")
|
||||
sb.WriteString(fmt.Sprintf(" Last 30 events: %.0f%%\n", help30*100))
|
||||
sb.WriteString(fmt.Sprintf(" Lifetime: %.0f%%\n", helpAll*100))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func TestParseCoopFundingTier(t *testing.T) {
|
||||
cases := map[string]CoopFundingTier{
|
||||
"none": CoopFundNone,
|
||||
"skip": CoopFundNone,
|
||||
"min": CoopFundMinimal,
|
||||
"minimal": CoopFundMinimal,
|
||||
"standard": CoopFundStandard,
|
||||
"std": CoopFundStandard,
|
||||
"aggressive": CoopFundAggressive,
|
||||
"agg": CoopFundAggressive,
|
||||
"all_in": CoopFundAllIn,
|
||||
"all-in": CoopFundAllIn,
|
||||
"all in": CoopFundAllIn,
|
||||
"allin": CoopFundAllIn,
|
||||
"all": CoopFundAllIn,
|
||||
"": "",
|
||||
"garbage": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
got := parseCoopFundingTier(input)
|
||||
if got != want {
|
||||
t.Errorf("parseCoopFundingTier(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopMemberModifierLiabilityCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
veteran := &CoopMember{
|
||||
IsLiability: false,
|
||||
DailyFunding: map[int]CoopFundingTier{1: CoopFundAllIn},
|
||||
}
|
||||
noob := &CoopMember{
|
||||
IsLiability: true,
|
||||
DailyFunding: map[int]CoopFundingTier{1: CoopFundAllIn},
|
||||
}
|
||||
|
||||
if _, mod := coopMemberModifier(veteran, 1); mod != 30 {
|
||||
t.Errorf("veteran All In modifier = %d, want 30", mod)
|
||||
}
|
||||
if _, mod := coopMemberModifier(noob, 1); mod != coopLiabilityCap {
|
||||
t.Errorf("liability All In modifier = %d, want %d (cap)", mod, coopLiabilityCap)
|
||||
}
|
||||
|
||||
missing := &CoopMember{DailyFunding: map[int]CoopFundingTier{}}
|
||||
tier, mod := coopMemberModifier(missing, 1)
|
||||
if tier != CoopFundNone || mod != -10 {
|
||||
t.Errorf("missing-day default = (%q, %d), want (none, -10)", tier, mod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopFundingMapRoundtrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := map[int]CoopFundingTier{
|
||||
1: CoopFundMinimal,
|
||||
3: CoopFundAllIn,
|
||||
7: CoopFundNone,
|
||||
}
|
||||
encoded := serializeCoopFundingMap(in)
|
||||
// JSON encode/decode via parse helper
|
||||
bytes, _ := jsonMarshalCoop(encoded)
|
||||
out := parseCoopFundingMap(string(bytes))
|
||||
|
||||
if len(out) != len(in) {
|
||||
t.Fatalf("roundtrip lost entries: in %d out %d", len(in), len(out))
|
||||
}
|
||||
for k, v := range in {
|
||||
if out[k] != v {
|
||||
t.Errorf("day %d: got %q want %q", k, out[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopTallyVote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
leader := id.UserID("@leader:test")
|
||||
other := id.UserID("@other:test")
|
||||
third := id.UserID("@third:test")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
votes map[id.UserID]string
|
||||
want string
|
||||
}{
|
||||
{"clear majority", map[id.UserID]string{leader: "A", other: "A", third: "B"}, "A"},
|
||||
{"tie broken by leader", map[id.UserID]string{leader: "B", other: "A"}, "B"},
|
||||
{"all abstained falls back to A", map[id.UserID]string{}, "A"},
|
||||
{"tie with no leader vote → lex first (deterministic)", map[id.UserID]string{other: "A", third: "B"}, "A"},
|
||||
{"three-way tie, leader's vote in winners → leader's pick", map[id.UserID]string{leader: "C", other: "A", third: "B"}, "C"},
|
||||
{"tie with leader voting outside winners → lex first", map[id.UserID]string{leader: "C", other: "A", "@4:t": "A", third: "B", "@5:t": "B"}, "A"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
// Run repeatedly to catch any non-determinism leaking from map iteration.
|
||||
for i := 0; i < 50; i++ {
|
||||
event := &CoopEvent{Votes: tc.votes}
|
||||
got := coopTallyVote(event, leader)
|
||||
if got != tc.want {
|
||||
t.Errorf("%s (iter %d): got %q, want %q", tc.name, i, got, tc.want)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopEventOptionModifierKnownEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Obstacle 0 has options A=-8, B=0, C=12. Verify the lookup works.
|
||||
if got := coopEventOptionModifier(CoopCatObstacle, 0, "A"); got != -8 {
|
||||
t.Errorf("obstacle[0] A modifier = %d, want -8", got)
|
||||
}
|
||||
if got := coopEventOptionModifier(CoopCatObstacle, 0, "C"); got != 12 {
|
||||
t.Errorf("obstacle[0] C modifier = %d, want 12", got)
|
||||
}
|
||||
if got := coopEventOptionModifier(CoopCatObstacle, 0, "Z"); got != 0 {
|
||||
t.Errorf("unknown option = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopEventMetaConsistency(t *testing.T) {
|
||||
t.Parallel()
|
||||
cats := map[CoopEventCategory][]coopEventMeta{
|
||||
CoopCatObstacle: coopObstacleMeta,
|
||||
CoopCatOpportunity: coopOpportunityMeta,
|
||||
CoopCatCrisis: coopCrisisMeta,
|
||||
CoopCatEncounter: coopEncounterMeta,
|
||||
}
|
||||
flavors := map[CoopEventCategory][]string{
|
||||
CoopCatObstacle: TwinBeeObstacle,
|
||||
CoopCatOpportunity: TwinBeeOpportunity,
|
||||
CoopCatCrisis: TwinBeeCrisis,
|
||||
CoopCatEncounter: TwinBeeEncounter,
|
||||
}
|
||||
for cat, meta := range cats {
|
||||
flavor := flavors[cat]
|
||||
if len(meta) != len(flavor) {
|
||||
t.Errorf("%s: meta has %d entries, flavor has %d", cat, len(meta), len(flavor))
|
||||
}
|
||||
for i, m := range meta {
|
||||
if len(m.options) < 2 {
|
||||
t.Errorf("%s[%d] has only %d options", cat, i, len(m.options))
|
||||
}
|
||||
recOK := false
|
||||
for _, opt := range m.options {
|
||||
if opt.label == m.recommended {
|
||||
recOK = true
|
||||
}
|
||||
}
|
||||
if !recOK {
|
||||
t.Errorf("%s[%d] recommends %q which isn't an option", cat, i, m.recommended)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopResolutionIdempotencyGuard(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Sanity check: the LastResolvedDay field on CoopRun is the authoritative
|
||||
// idempotency marker. The resolver short-circuits when LastResolvedDay >=
|
||||
// CurrentDay, so a crash-restart on the same UTC day after the roll has
|
||||
// landed is a safe no-op.
|
||||
run := &CoopRun{CurrentDay: 3, LastResolvedDay: 3}
|
||||
if !(run.LastResolvedDay >= run.CurrentDay) {
|
||||
t.Errorf("expected resolution skip when LastResolvedDay (%d) >= CurrentDay (%d)",
|
||||
run.LastResolvedDay, run.CurrentDay)
|
||||
}
|
||||
run.LastResolvedDay = 2
|
||||
if run.LastResolvedDay >= run.CurrentDay {
|
||||
t.Errorf("expected resolution to proceed when LastResolvedDay (%d) < CurrentDay (%d)",
|
||||
run.LastResolvedDay, run.CurrentDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopParimutuelPayouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := id.UserID("@a:t")
|
||||
b := id.UserID("@b:t")
|
||||
c := id.UserID("@c:t")
|
||||
d := id.UserID("@d:t")
|
||||
|
||||
bets := []CoopBet{
|
||||
{PlayerID: a, Position: "success", Amount: 1000},
|
||||
{PlayerID: b, Position: "success", Amount: 3000},
|
||||
{PlayerID: c, Position: "failure", Amount: 5000},
|
||||
{PlayerID: d, Position: "failure", Amount: 1000},
|
||||
}
|
||||
winners, total, rake, payouts := coopParimutuelPayouts(bets, "success")
|
||||
if total != 10000 {
|
||||
t.Errorf("total = %d, want 10000", total)
|
||||
}
|
||||
if rake != 1000 {
|
||||
t.Errorf("rake = %d, want 1000 (10%%)", rake)
|
||||
}
|
||||
if len(winners) != 2 {
|
||||
t.Errorf("winners = %d, want 2", len(winners))
|
||||
}
|
||||
// Net pool = 9000. a put in 1000/4000 = 25% of winning side → 2250.
|
||||
// b put in 3000/4000 = 75% → 6750.
|
||||
if payouts[a] != 2250 {
|
||||
t.Errorf("a payout = %d, want 2250", payouts[a])
|
||||
}
|
||||
if payouts[b] != 6750 {
|
||||
t.Errorf("b payout = %d, want 6750", payouts[b])
|
||||
}
|
||||
if payouts[c] != 0 {
|
||||
t.Errorf("c payout = %d, want 0 (loser)", payouts[c])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopWeightedItemWinnerDistribution(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := id.UserID("@a:t")
|
||||
b := id.UserID("@b:t")
|
||||
c := id.UserID("@c:t")
|
||||
members := []CoopMember{
|
||||
{UserID: a, TotalContributed: 7000}, // 70%
|
||||
{UserID: b, TotalContributed: 2000}, // 20%
|
||||
{UserID: c, TotalContributed: 1000}, // 10%
|
||||
}
|
||||
totalWeight := 10000
|
||||
wins := map[id.UserID]int{}
|
||||
const trials = 100_000
|
||||
for i := 0; i < trials; i++ {
|
||||
w := coopWeightedItemWinner(members, totalWeight)
|
||||
wins[w]++
|
||||
}
|
||||
// Allow ±2% absolute deviation per side at this trial count.
|
||||
check := func(uid id.UserID, expected float64) {
|
||||
actual := float64(wins[uid]) / float64(trials)
|
||||
if actual < expected-0.02 || actual > expected+0.02 {
|
||||
t.Errorf("%s: got %.3f, want ~%.2f", uid, actual, expected)
|
||||
}
|
||||
}
|
||||
check(a, 0.70)
|
||||
check(b, 0.20)
|
||||
check(c, 0.10)
|
||||
}
|
||||
|
||||
func TestCoopWeightedItemWinnerNoContributions(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := id.UserID("@a:t")
|
||||
b := id.UserID("@b:t")
|
||||
members := []CoopMember{
|
||||
{UserID: a, TotalContributed: 0},
|
||||
{UserID: b, TotalContributed: 0},
|
||||
}
|
||||
wins := map[id.UserID]int{}
|
||||
for i := 0; i < 10_000; i++ {
|
||||
wins[coopWeightedItemWinner(members, 0)]++
|
||||
}
|
||||
if wins[a] == 0 || wins[b] == 0 {
|
||||
t.Errorf("uniform fallback didn't pick both: %v", wins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopMasterworkTierGate(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Verify that masterwork defs exist at T4 and T5 (so coopMaybeMasterwork
|
||||
// has candidates) and that nothing exists at T1-T3 in the dungeon path.
|
||||
tiers := map[int]int{}
|
||||
for _, def := range masterworkDefs {
|
||||
tiers[def.Tier]++
|
||||
}
|
||||
if tiers[4] == 0 {
|
||||
t.Errorf("no T4 masterwork defs found — coop T4 drops will silently no-op")
|
||||
}
|
||||
if tiers[5] == 0 {
|
||||
t.Errorf("no T5 masterwork defs found — coop T5 drops will silently no-op")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopGiftEVSymmetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
// At a 50/50 sender mix, "always open" and "always leave" should both
|
||||
// have EV = 0. Anything else means players have a dominant strategy.
|
||||
openEV := 0.5*float64(coopGiftBasketOpened) + 0.5*float64(coopGiftMimicOpened)
|
||||
leaveEV := 0.5*float64(coopGiftBasketUnopened) + 0.5*float64(coopGiftMimicUnopened)
|
||||
if openEV != 0 {
|
||||
t.Errorf("always-open EV = %.2f, want 0", openEV)
|
||||
}
|
||||
if leaveEV != 0 {
|
||||
t.Errorf("always-leave EV = %.2f, want 0", leaveEV)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopGiftVarianceSymmetric(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Equal EV is not enough — if open and leave have different variance, a
|
||||
// risk-averse party always picks the lower-variance side and the
|
||||
// dilemma collapses. Magnitudes must be equal across all four outcomes.
|
||||
abs := func(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
mags := []int{
|
||||
abs(coopGiftBasketOpened),
|
||||
abs(coopGiftBasketUnopened),
|
||||
abs(coopGiftMimicOpened),
|
||||
abs(coopGiftMimicUnopened),
|
||||
}
|
||||
for i := 1; i < len(mags); i++ {
|
||||
if mags[i] != mags[0] {
|
||||
t.Errorf("gift magnitudes must be equal across all four outcomes; got %v", mags)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopParimutuelNoWinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
bets := []CoopBet{
|
||||
{PlayerID: "@x:t", Position: "failure", Amount: 5000},
|
||||
}
|
||||
winners, total, rake, payouts := coopParimutuelPayouts(bets, "success")
|
||||
if len(winners) != 0 {
|
||||
t.Errorf("winners = %d, want 0", len(winners))
|
||||
}
|
||||
if total != 5000 || rake != 500 {
|
||||
t.Errorf("total=%d rake=%d, want 5000 500", total, rake)
|
||||
}
|
||||
if len(payouts) != 0 {
|
||||
t.Errorf("payouts has %d entries; want 0", len(payouts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoopTierTableComplete(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
def, ok := coopTierTable[tier]
|
||||
if !ok {
|
||||
t.Errorf("missing tier %d", tier)
|
||||
continue
|
||||
}
|
||||
if def.totalDays < 2 || def.totalDays > 7 {
|
||||
t.Errorf("tier %d totalDays %d out of range", tier, def.totalDays)
|
||||
}
|
||||
if def.baseFailurePct < 1 || def.baseFailurePct > 99 {
|
||||
t.Errorf("tier %d baseFailurePct %d out of range", tier, def.baseFailurePct)
|
||||
}
|
||||
if def.rewardBase <= 0 {
|
||||
t.Errorf("tier %d rewardBase %d not positive", tier, def.rewardBase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// jsonMarshalCoop is a tiny indirection so the test file doesn't import
|
||||
// encoding/json directly (we only need it for the roundtrip helper).
|
||||
func jsonMarshalCoop(v map[string]string) ([]byte, error) {
|
||||
out := []byte{'{'}
|
||||
first := true
|
||||
for k, val := range v {
|
||||
if !first {
|
||||
out = append(out, ',')
|
||||
}
|
||||
first = false
|
||||
out = append(out, '"')
|
||||
out = append(out, k...)
|
||||
out = append(out, '"', ':', '"')
|
||||
out = append(out, val...)
|
||||
out = append(out, '"')
|
||||
}
|
||||
out = append(out, '}')
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Metadata for each TwinBee floor event. Encodes the per-option success
|
||||
// modifiers and TwinBee's recommendation. Authored by reading the flavor
|
||||
// entries — TwinBee's "wrong detail" hint usually points to which option is
|
||||
// actually correct.
|
||||
//
|
||||
// Modifier ranges: −15 (clearly bad) to +12 (clearly good). Sum across
|
||||
// options is roughly net-zero so randomly-voting parties don't trend +EV.
|
||||
|
||||
type CoopEventCategory string
|
||||
|
||||
const (
|
||||
CoopCatObstacle CoopEventCategory = "obstacle"
|
||||
CoopCatOpportunity CoopEventCategory = "opportunity"
|
||||
CoopCatCrisis CoopEventCategory = "crisis"
|
||||
CoopCatEncounter CoopEventCategory = "encounter"
|
||||
)
|
||||
|
||||
type coopEventOption struct {
|
||||
label string
|
||||
modifier int
|
||||
}
|
||||
|
||||
type coopEventMeta struct {
|
||||
options []coopEventOption
|
||||
recommended string // A/B/C — TwinBee's pick
|
||||
}
|
||||
|
||||
// One entry per event in the corresponding flavor pool, in the same order.
|
||||
// Keep these arrays in sync with coop_flavor_twinbee.go.
|
||||
|
||||
var coopObstacleMeta = []coopEventMeta{
|
||||
// 1. Cave-in: "definitely the left side" — too emphatic; clearing properly is right.
|
||||
{options: []coopEventOption{{"A", -8}, {"B", 0}, {"C", 12}}, recommended: "A"},
|
||||
// 2. Locked gate: "third tumbler might be a pressure plate" — A triggers it.
|
||||
{options: []coopEventOption{{"A", -12}, {"B", -3}, {"C", 10}}, recommended: "A"},
|
||||
// 3. Other party with axe: "axe is just decoration" — almost certainly not.
|
||||
{options: []coopEventOption{{"A", -15}, {"B", 8}, {"C", -5}}, recommended: "A"},
|
||||
// 4. Flooded section: "torches stopped twenty meters back" — water deeper than claimed.
|
||||
{options: []coopEventOption{{"A", -10}, {"B", 0}, {"C", 10}}, recommended: "A"},
|
||||
// 5. Collapsed bridge: "chains might be part of a trap" — they are.
|
||||
{options: []coopEventOption{{"A", -12}, {"B", 0}, {"C", 8}}, recommended: "A"},
|
||||
}
|
||||
|
||||
var coopOpportunityMeta = []coopEventMeta{
|
||||
// 1. Vault: "scratches around the lock from previous attempts" — trapped.
|
||||
{options: []coopEventOption{{"A", -12}, {"B", 3}}, recommended: "A"},
|
||||
// 2. Side chamber: "shapes seem to be breathing" — they are. Mimics or worse.
|
||||
{options: []coopEventOption{{"A", -15}, {"B", 4}}, recommended: "A"},
|
||||
// 3. Wounded enemy with full pack: "one eye open, just how they sleep" — ambush.
|
||||
{options: []coopEventOption{{"A", -10}, {"B", 2}}, recommended: "A"},
|
||||
// 4. Unmarked door: "good feeling, ignore the smell" — TwinBee's right this time.
|
||||
{options: []coopEventOption{{"A", 10}, {"B", -3}}, recommended: "A"},
|
||||
// 5. Merchant Thom: usually fine to browse, sometimes a trap. Slight positive.
|
||||
{options: []coopEventOption{{"A", 6}, {"B", 0}}, recommended: "A"},
|
||||
}
|
||||
|
||||
var coopCrisisMeta = []coopEventMeta{
|
||||
// 1. Trap, "left lever, definitely not the right one" — C (find another way) safest.
|
||||
{options: []coopEventOption{{"A", -10}, {"B", 5}, {"C", 8}}, recommended: "A"},
|
||||
// 2. Equipment corrosion: "load-bearing thing might be nothing" — pay to address.
|
||||
{options: []coopEventOption{{"A", 8}, {"B", -10}, {"C", 0}}, recommended: "A"},
|
||||
// 3. Separated party member, guard-room hint: TwinBee actually right that they went left;
|
||||
// Option C ("wait here") is fine too — cheaper and works.
|
||||
{options: []coopEventOption{{"A", 4}, {"B", -2}, {"C", 6}}, recommended: "A"},
|
||||
// 4. Something following, "consistent distance is reassuring" — predator pacing.
|
||||
// Pay the deterrent.
|
||||
{options: []coopEventOption{{"A", -8}, {"B", 8}, {"C", 0}}, recommended: "A"},
|
||||
// 5. Cave-in with "active" ceiling: pay to shore up.
|
||||
{options: []coopEventOption{{"A", -10}, {"B", 10}, {"C", -2}}, recommended: "A"},
|
||||
}
|
||||
|
||||
var coopEncounterMeta = []coopEventMeta{
|
||||
// 1. Guardian, "twelve seconds two out of three times" — timing window unreliable.
|
||||
{options: []coopEventOption{{"A", 4}, {"B", 6}, {"C", -10}}, recommended: "C"},
|
||||
// 2. Caged person, "alarm is decorative" — usually not. Trap, but freeing them is right ~half the time.
|
||||
{options: []coopEventOption{{"A", 2}, {"B", -3}, {"C", 5}}, recommended: "A"},
|
||||
// 3. Sleeping guard, second guard reflected in shiny — there's a second guard.
|
||||
{options: []coopEventOption{{"A", -10}, {"B", 0}, {"C", 6}}, recommended: "A"},
|
||||
// 4. Recurring merchant Thom — keep moving avoids whatever shenanigans.
|
||||
{options: []coopEventOption{{"A", 2}, {"B", -5}, {"C", 4}}, recommended: "A"},
|
||||
// 5. Unidentifiable thing — backing away is the safe call.
|
||||
{options: []coopEventOption{{"A", -8}, {"B", -2}, {"C", 8}}, recommended: "C"},
|
||||
}
|
||||
|
||||
func coopEventCategoryMeta(cat CoopEventCategory) []coopEventMeta {
|
||||
switch cat {
|
||||
case CoopCatObstacle:
|
||||
return coopObstacleMeta
|
||||
case CoopCatOpportunity:
|
||||
return coopOpportunityMeta
|
||||
case CoopCatCrisis:
|
||||
return coopCrisisMeta
|
||||
case CoopCatEncounter:
|
||||
return coopEncounterMeta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func coopEventCategoryFlavor(cat CoopEventCategory) []string {
|
||||
switch cat {
|
||||
case CoopCatObstacle:
|
||||
return TwinBeeObstacle
|
||||
case CoopCatOpportunity:
|
||||
return TwinBeeOpportunity
|
||||
case CoopCatCrisis:
|
||||
return TwinBeeCrisis
|
||||
case CoopCatEncounter:
|
||||
return TwinBeeEncounter
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickCoopEventCategory weights categories by tier. Lower tiers favor obstacle
|
||||
// and opportunity (lighter); higher tiers weight toward crisis and encounter.
|
||||
func pickCoopEventCategory(tier int) CoopEventCategory {
|
||||
weights := map[int]map[CoopEventCategory]int{
|
||||
1: {CoopCatObstacle: 5, CoopCatOpportunity: 4, CoopCatCrisis: 1, CoopCatEncounter: 1},
|
||||
2: {CoopCatObstacle: 4, CoopCatOpportunity: 3, CoopCatCrisis: 2, CoopCatEncounter: 2},
|
||||
3: {CoopCatObstacle: 3, CoopCatOpportunity: 3, CoopCatCrisis: 3, CoopCatEncounter: 2},
|
||||
4: {CoopCatObstacle: 2, CoopCatOpportunity: 2, CoopCatCrisis: 4, CoopCatEncounter: 3},
|
||||
5: {CoopCatObstacle: 2, CoopCatOpportunity: 2, CoopCatCrisis: 4, CoopCatEncounter: 4},
|
||||
}
|
||||
w, ok := weights[tier]
|
||||
if !ok {
|
||||
w = weights[3]
|
||||
}
|
||||
total := 0
|
||||
for _, x := range w {
|
||||
total += x
|
||||
}
|
||||
r := rand.IntN(total)
|
||||
for cat, x := range w {
|
||||
if r < x {
|
||||
return cat
|
||||
}
|
||||
r -= x
|
||||
}
|
||||
return CoopCatObstacle
|
||||
}
|
||||
|
||||
// pickCoopEvent returns a random event index for the category.
|
||||
func pickCoopEvent(cat CoopEventCategory) int {
|
||||
pool := coopEventCategoryMeta(cat)
|
||||
if len(pool) == 0 {
|
||||
return 0
|
||||
}
|
||||
return rand.IntN(len(pool))
|
||||
}
|
||||
|
||||
// optionLabels returns the label list for an event ("A","B" or "A","B","C").
|
||||
func coopEventOptionLabels(cat CoopEventCategory, idx int) []string {
|
||||
pool := coopEventCategoryMeta(cat)
|
||||
if idx >= len(pool) {
|
||||
return []string{"A", "B"}
|
||||
}
|
||||
out := make([]string, 0, len(pool[idx].options))
|
||||
for _, opt := range pool[idx].options {
|
||||
out = append(out, opt.label)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// optionModifier returns the success modifier for the chosen option, or 0 if
|
||||
// the option is unknown.
|
||||
func coopEventOptionModifier(cat CoopEventCategory, idx int, label string) int {
|
||||
pool := coopEventCategoryMeta(cat)
|
||||
if idx >= len(pool) {
|
||||
return 0
|
||||
}
|
||||
for _, opt := range pool[idx].options {
|
||||
if opt.label == label {
|
||||
return opt.modifier
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// recommendedOption returns TwinBee's pick for the event.
|
||||
func coopEventRecommended(cat CoopEventCategory, idx int) string {
|
||||
pool := coopEventCategoryMeta(cat)
|
||||
if idx >= len(pool) {
|
||||
return "A"
|
||||
}
|
||||
return pool[idx].recommended
|
||||
}
|
||||
|
||||
// validateCoopVote uppercases and validates a vote against the available options.
|
||||
func validateCoopVote(cat CoopEventCategory, idx int, raw string) (string, bool) {
|
||||
v := strings.ToUpper(strings.TrimSpace(raw))
|
||||
for _, label := range coopEventOptionLabels(cat, idx) {
|
||||
if v == label {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package plugin
|
||||
|
||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
||||
//
|
||||
// This file contains hand-written flavor text. Every entry is the length it is
|
||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
||||
// If an entry seems too long: it isn't. Leave it alone.
|
||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
||||
//
|
||||
// ── TWINBEE — CO-OP DUNGEON FLOOR EVENT FLAVOR TEXT ──────────────────────────
|
||||
//
|
||||
// TwinBee is the dungeon narrator. He is extremely excited about this.
|
||||
// He is genuinely trying to help. He is mostly not helpful.
|
||||
// His descriptions are accurate in spirit and wrong in at least one important detail.
|
||||
// His advice is delivered with confidence. Confidence is not correlated with accuracy.
|
||||
// He never apologizes. He really didn't know.
|
||||
// Occasionally he is completely right. No fanfare. Just right.
|
||||
// This is worse in the long run because the party trusts him again.
|
||||
//
|
||||
// Voice notes:
|
||||
// - Exclamation points are earned and frequent
|
||||
// - Describes what he sees with genuine care and at least one critical omission
|
||||
// - Advice is specific, helpful-sounding, and frequently incorrect
|
||||
// - Never dwelling on bad outcomes -- already excited about the next thing
|
||||
// - Occasionally, quietly, completely correct
|
||||
|
||||
// ── OBSTACLE EVENTS ──────────────────────────────────────────────────────────
|
||||
// Something is blocking the path. Options: push through, find another way, attempt to clear it.
|
||||
|
||||
var TwinBeeObstacle = []string{
|
||||
"Oh! There's a cave-in up ahead! Big one! Really dramatic looking, actually -- " +
|
||||
"I wish you could see it from where I'm standing, it's very cinematic.\n\n" +
|
||||
"Anyway! The good news is it looks pretty loose. I think if you all just push " +
|
||||
"on the left side you'll be fine. It's definitely the left side.\n\n" +
|
||||
"Option A: Push through (left side, as I said). " +
|
||||
"Option B: Find another way around. " +
|
||||
"Option C: Try to clear it properly.\n\n" +
|
||||
"I'd go with A personally! Very confident about the left side!",
|
||||
|
||||
"Okay so there's a locked gate. Big iron one, very impressive, " +
|
||||
"very intimidating if you're into that sort of thing.\n\n" +
|
||||
"I've been looking at the mechanism and I'm pretty sure I understand how it works! " +
|
||||
"It's a standard three-tumbler lock. Very common in dungeons of this era. " +
|
||||
"I've seen hundreds of them.\n\n" +
|
||||
"The one thing I'll say is that what looks like a third tumbler " +
|
||||
"might technically be a pressure plate but I'm sure that's fine.\n\n" +
|
||||
"Option A: Push through (I have thoughts on this). " +
|
||||
"Option B: Find another way. " +
|
||||
"Option C: Attempt to pick the lock.\n\n" +
|
||||
"Very exciting! I love gates!",
|
||||
|
||||
"There's another party blocking the corridor! Four of them! " +
|
||||
"They look a bit rough but honestly they seem friendly enough -- " +
|
||||
"one of them waved at me!\n\n" +
|
||||
"I think they might be lost actually. They've been standing there for a while. " +
|
||||
"I'm sure if you just explain the situation they'll move right along.\n\n" +
|
||||
"The one who waved has a very large axe but I think that's just for decoration.\n\n" +
|
||||
"Option A: Approach and ask them to move. " +
|
||||
"Option B: Find another route. " +
|
||||
"Option C: Wait them out.\n\n" +
|
||||
"I'd definitely go with A! They seem nice! The axe is probably decorative!",
|
||||
|
||||
"Oh wow, there's a flooded section ahead! " +
|
||||
"A little bit of water, nothing serious!\n\n" +
|
||||
"It's hard to tell exactly how deep from here but " +
|
||||
"I'd say ankle to maybe knee height at most. Very manageable! " +
|
||||
"The current looks pretty gentle too.\n\n" +
|
||||
"I will say the torches stopped about twenty meters back " +
|
||||
"but I'm sure the footing is fine.\n\n" +
|
||||
"Option A: Wade through. " +
|
||||
"Option B: Look for another path. " +
|
||||
"Option C: Try to divert the water somehow.\n\n" +
|
||||
"It's basically a puddle! Very refreshing probably!",
|
||||
|
||||
"There's a collapsed bridge! Very dramatic! " +
|
||||
"Big gap, maybe four or five meters across, I'm estimating.\n\n" +
|
||||
"The good news is there are some chains hanging down on the other side " +
|
||||
"that look very sturdy. I think someone could definitely make that jump " +
|
||||
"and then the rest of you could swing across on the chains!\n\n" +
|
||||
"The chains might be part of a trap but they look old so probably not active.\n\n" +
|
||||
"Option A: Attempt to jump and swing across. " +
|
||||
"Option B: Find another route. " +
|
||||
"Option C: Try to rebuild the bridge somehow.\n\n" +
|
||||
"I believe in whoever jumps first! Very doable!",
|
||||
}
|
||||
|
||||
// ── OPPORTUNITY EVENTS ────────────────────────────────────────────────────────
|
||||
// Something optional and risky. Attempt it or leave it.
|
||||
|
||||
var TwinBeeOpportunity = []string{
|
||||
"Oh! OH! There's a vault! A proper one, big metal door, " +
|
||||
"very official looking! This is so exciting!\n\n" +
|
||||
"I've been looking at the lock and it seems pretty straightforward actually. " +
|
||||
"Old design, probably hasn't been updated in years. " +
|
||||
"The hinges look a little corroded which honestly works in your favor!\n\n" +
|
||||
"I did notice some scratches around the lock that might be from previous attempts " +
|
||||
"but I'm sure those people just didn't have the right approach.\n\n" +
|
||||
"Option A: Attempt to open the vault. " +
|
||||
"Option B: Leave it and move on.\n\n" +
|
||||
"I really think you should try it! The scratches are probably nothing!",
|
||||
|
||||
"There's a side chamber over here! Small room, looks untouched! " +
|
||||
"Could be storage, could be quarters, very hard to say!\n\n" +
|
||||
"There's definitely something in there -- I can see shapes from here. " +
|
||||
"Could be equipment, could be supplies, could be treasure honestly! " +
|
||||
"The cobwebs are pretty thick but that just means nobody's been in recently!\n\n" +
|
||||
"The shapes are a little hard to make out. They might be crates. " +
|
||||
"Some of them seem to be breathing but that's probably just the air moving.\n\n" +
|
||||
"Option A: Investigate the chamber. " +
|
||||
"Option B: Keep moving.\n\n" +
|
||||
"I vote investigate! The breathing thing is almost certainly the air!",
|
||||
|
||||
"There's a wounded enemy up ahead! Just one, sitting against the wall, " +
|
||||
"looks pretty out of it!\n\n" +
|
||||
"They've got a pack next to them that looks very full! " +
|
||||
"Could be supplies, could be equipment, hard to say from here but " +
|
||||
"it's a big pack. Very promising.\n\n" +
|
||||
"They seem incapacitated. Barely moving. " +
|
||||
"One eye might be open but I think that's just how they sleep.\n\n" +
|
||||
"Option A: Approach and check the pack. " +
|
||||
"Option B: Give them a wide berth.\n\n" +
|
||||
"I think they're fine! Go for the pack!",
|
||||
|
||||
"There's an unmarked door! Just sitting there! Very mysterious!\n\n" +
|
||||
"I have a good feeling about this one. I can't explain it, " +
|
||||
"it's just a feeling. The door looks well-made actually, " +
|
||||
"better quality than the dungeon walls around it, which is interesting!\n\n" +
|
||||
"There's a smell coming from under it but I think that's just dungeon smell. " +
|
||||
"All dungeons have a smell. This one is a bit more specific than usual " +
|
||||
"but I'm sure it's fine.\n\n" +
|
||||
"Option A: Open the door. " +
|
||||
"Option B: Leave it.\n\n" +
|
||||
"Open it! I have such a good feeling! Ignore the smell!",
|
||||
|
||||
"There's a merchant! In the dungeon! Isn't that something!\n\n" +
|
||||
"He seems very professional. Very put-together for someone in a dungeon. " +
|
||||
"He's got a whole setup -- table, stock, everything. " +
|
||||
"I think he might be a doctor? He has that energy.\n\n" +
|
||||
"His prices look reasonable from here although I can't quite read the tags. " +
|
||||
"He keeps looking at one of you specifically but I'm sure that's just good salesmanship.\n\n" +
|
||||
"Option A: Browse his wares. " +
|
||||
"Option B: Keep moving.\n\n" +
|
||||
"I'd stop! He seems legitimate! Very professional!",
|
||||
}
|
||||
|
||||
// ── CRISIS EVENTS ─────────────────────────────────────────────────────────────
|
||||
// Something has gone wrong. Address it at gold cost or absorb the penalty.
|
||||
|
||||
var TwinBeeCrisis = []string{
|
||||
"Okay! So! There's been a small development!\n\n" +
|
||||
"A trap was triggered -- I want to be clear that this was very hard to see " +
|
||||
"and I absolutely would have mentioned it if I'd noticed it -- " +
|
||||
"and one party member is currently stuck.\n\n" +
|
||||
"The mechanism looks straightforward though! " +
|
||||
"There's a release lever on the left wall that should do it! " +
|
||||
"The other lever on the right wall probably also does something " +
|
||||
"but I'd go with the left one first.\n\n" +
|
||||
"Option A: Pull the left lever. " +
|
||||
"Option B: Pay to have a professional deal with it. " +
|
||||
"Option C: Try to find another way to free them.\n\n" +
|
||||
"Left lever! I'm very confident! Don't touch the right one yet!",
|
||||
|
||||
"So there's some equipment damage spreading through the party! " +
|
||||
"Not as bad as it sounds! Just some corrosive something-or-other " +
|
||||
"that got on a few items. Very common in dungeons of this age!\n\n" +
|
||||
"The good news is it looks slow-moving. " +
|
||||
"If you address it quickly I think you'll only lose a piece or two!\n\n" +
|
||||
"I did notice it spreading to what might be load-bearing equipment " +
|
||||
"but let's stay positive!\n\n" +
|
||||
"Option A: Pay to address it now. " +
|
||||
"Option B: Absorb the damage and keep moving. " +
|
||||
"Option C: Try to neutralize it with something you have.\n\n" +
|
||||
"I'd address it! Probably! The load-bearing thing might be nothing!",
|
||||
|
||||
"Okay so someone got separated! Easy to do in a dungeon, happens all the time, " +
|
||||
"absolutely nothing to worry about!\n\n" +
|
||||
"I know exactly where they went actually! " +
|
||||
"There's a side passage about forty meters back, " +
|
||||
"they definitely went left at the junction.\n\n" +
|
||||
"The junction that goes left also goes to what I think is a guard room " +
|
||||
"but I'm sure they went left and not toward the guard room.\n\n" +
|
||||
"Option A: Go back and find them. " +
|
||||
"Option B: Pay to send a guide. " +
|
||||
"Option C: Wait here -- they'll find their way back.\n\n" +
|
||||
"They definitely went left! Probably not toward the guard room! " +
|
||||
"Option C is also totally fine they seem resourceful!",
|
||||
|
||||
"Something is following the party!\n\n" +
|
||||
"I've been watching it for a few minutes and I think it's probably fine. " +
|
||||
"It's staying pretty far back. Very consistent distance actually, " +
|
||||
"which I find reassuring -- if it wanted to do something " +
|
||||
"it probably would have by now!\n\n" +
|
||||
"It's hard to make out exactly what it is from here. " +
|
||||
"Medium-large? It moves quietly for its size.\n\n" +
|
||||
"Option A: Confront it. " +
|
||||
"Option B: Pay to set a deterrent. " +
|
||||
"Option C: Try to lose it.\n\n" +
|
||||
"I think it's fine! The consistent distance is a great sign! " +
|
||||
"Option C is also reasonable if you want to play it safe!",
|
||||
|
||||
"There's been a small cave-in! Different from the earlier one!\n\n" +
|
||||
"Nobody's hurt which is the main thing! " +
|
||||
"Some equipment took a hit and there's a bit of dust situation " +
|
||||
"but honestly it cleared up pretty fast!\n\n" +
|
||||
"The ceiling in this section does look a little... active... " +
|
||||
"but I think the structural integrity is fine. " +
|
||||
"The cracking sounds are probably just the dungeon settling.\n\n" +
|
||||
"Option A: Move through quickly. " +
|
||||
"Option B: Pay to shore up the ceiling before proceeding. " +
|
||||
"Option C: Find another route.\n\n" +
|
||||
"Move quickly! The cracking is almost definitely settling! Very normal!",
|
||||
}
|
||||
|
||||
// ── ENCOUNTER EVENTS ──────────────────────────────────────────────────────────
|
||||
// Something must be dealt with directly. No avoiding it.
|
||||
|
||||
var TwinBeeEncounter = []string{
|
||||
"There's a guardian! A big one! Very impressive!\n\n" +
|
||||
"I've been watching it and I think I've identified a pattern in its movement. " +
|
||||
"Every twelve seconds or so it turns to the right. " +
|
||||
"If you time it correctly you could get behind it before it turns back!\n\n" +
|
||||
"I counted twelve seconds three times. Two of those times it was twelve seconds. " +
|
||||
"The third time was more like seven but I think it was distracted.\n\n" +
|
||||
"Option A: Engage directly. " +
|
||||
"Option B: Negotiate passage. " +
|
||||
"Option C: Use TwinBee's twelve-second timing window.\n\n" +
|
||||
"The timing window is very real! Two out of three times!",
|
||||
|
||||
"Oh! There's someone trapped in a cage! Just hanging there, " +
|
||||
"very dramatic, they seem okay though -- they waved!\n\n" +
|
||||
"The cage mechanism looks pretty simple. " +
|
||||
"There's a key on a hook on the wall which is convenient! " +
|
||||
"The hook is right next to what might be an alarm but " +
|
||||
"it looks old and I'm sure it's not connected to anything.\n\n" +
|
||||
"Option A: Get the key and free them. " +
|
||||
"Option B: Negotiate with whoever put them there. " +
|
||||
"Option C: Leave them -- this feels like a trap.\n\n" +
|
||||
"Free them! They seem nice! The alarm thing is probably decorative!",
|
||||
|
||||
"There's a locked room with something valuable inside -- " +
|
||||
"I can see it through the bars, it's definitely equipment or treasure, " +
|
||||
"very shiny, very promising!\n\n" +
|
||||
"The guard outside looks like they're sleeping actually. " +
|
||||
"Very asleep. Deeply asleep. One of the most asleep people I've ever seen.\n\n" +
|
||||
"I should mention there's another guard reflected in the shiny thing inside " +
|
||||
"but that could just be a reflection of the first one. Mirrors do that.\n\n" +
|
||||
"Option A: Deal with the guard and take the room. " +
|
||||
"Option B: Attempt to negotiate. " +
|
||||
"Option C: Try to reach the valuable thing through the bars.\n\n" +
|
||||
"The reflection is probably the first guard! Very retrievable!",
|
||||
|
||||
"There's a merchant again! Different one I think!\n\n" +
|
||||
"Actually -- same coat. Might be the same one. " +
|
||||
"I'm not sure how he got ahead of the party but he's very professional " +
|
||||
"so I'm sure there's a reasonable explanation.\n\n" +
|
||||
"His inventory has changed slightly. He's added some pet supplies " +
|
||||
"which is unusual for a dungeon merchant but thoughtful!\n\n" +
|
||||
"He keeps looking at the same party member as before. " +
|
||||
"He seems to know something. He called someone by a name " +
|
||||
"that I think was meant for their pet but I might have misheard.\n\n" +
|
||||
"Option A: Browse his wares. " +
|
||||
"Option B: Demand an explanation. " +
|
||||
"Option C: Keep moving.\n\n" +
|
||||
"He seems legitimate! Very professional! The pet thing is probably a coincidence!",
|
||||
|
||||
"Something is in the corridor that I genuinely cannot identify!\n\n" +
|
||||
"It's not on any list I have. It's not behaving like anything I've seen before. " +
|
||||
"It seems aware of the party but it hasn't moved toward you, " +
|
||||
"which I think is a good sign!\n\n" +
|
||||
"It made a sound a moment ago. I don't want to describe the sound " +
|
||||
"because I don't think it would help. " +
|
||||
"On the positive side it's roughly the size of a large dog " +
|
||||
"which puts it in a very manageable category!\n\n" +
|
||||
"Option A: Engage it. " +
|
||||
"Option B: Attempt to communicate with it. " +
|
||||
"Option C: Back away slowly.\n\n" +
|
||||
"I genuinely don't know what it is! Very exciting! " +
|
||||
"All three options seem reasonable! I'd avoid the sound it made if possible!",
|
||||
}
|
||||
|
||||
// ── TWINBEE OUTCOME REACTIONS ─────────────────────────────────────────────────
|
||||
// Posted after each floor event resolves.
|
||||
|
||||
// When TwinBee's recommendation was the winning vote and it went well:
|
||||
var TwinBeeOutcomeCorrect = []string{
|
||||
"I knew it! I knew it! Did I not say? I said! " +
|
||||
"That was exactly what I thought would happen! Great work everyone!",
|
||||
|
||||
"Yes! See! This is what I was talking about! " +
|
||||
"Very well done, excellent execution of the plan!\n\nGreat teamwork!",
|
||||
|
||||
"That's the one! Right call! " +
|
||||
"I had a very strong feeling about that and I'm glad we went with it!",
|
||||
|
||||
"Perfect! Exactly as expected! " +
|
||||
"I want to be clear that I had high confidence in this outcome the whole time!",
|
||||
}
|
||||
|
||||
// When TwinBee's recommendation was the winning vote and it went badly:
|
||||
var TwinBeeOutcomeWrong = []string{
|
||||
"Oh no! That's... hm. I really thought that would work. I was so sure!\n\n" +
|
||||
"Are you all okay? Most of you look okay! " +
|
||||
"I think the important thing is we tried it and now we know!\n\nOnward!",
|
||||
|
||||
"Oh! That's unfortunate! I genuinely did not see that coming " +
|
||||
"and I want you to know that I feel terrible about it!\n\n" +
|
||||
"Well -- not terrible. Surprised. I feel very surprised. " +
|
||||
"Let's keep moving!",
|
||||
|
||||
"Okay so that didn't go exactly as planned!\n\n" +
|
||||
"The good news is we're all still here! Mostly! " +
|
||||
"I think the approach was sound and the execution was also sound " +
|
||||
"and the outcome was just a bit of bad luck honestly!\n\nVery exciting dungeon!",
|
||||
|
||||
"Hmm! That's not what I expected!\n\n" +
|
||||
"I'll be honest, I'm recalibrating a little bit. " +
|
||||
"My read on that situation was quite different but " +
|
||||
"dungeons are unpredictable and that's what makes them fun!\n\nRight?",
|
||||
}
|
||||
|
||||
// When TwinBee did not recommend the winning vote and it went well:
|
||||
var TwinBeeOutcomeNotRecommendedGood = []string{
|
||||
"Oh wow, that worked! I honestly wasn't sure about that one " +
|
||||
"but great job everyone! Really great call!\n\nI learned something today!",
|
||||
|
||||
"Huh! Good instinct! I had actually been leaning the other way " +
|
||||
"but I can see now why you went with that! Very smart!",
|
||||
|
||||
"Oh! That's a relief actually! I had some concerns about that approach " +
|
||||
"that turned out to be completely unfounded!\n\nWell done!",
|
||||
|
||||
"Great outcome! I'll admit I wasn't fully on board with that one " +
|
||||
"but I'm very happy to be wrong!\n\nTeam effort!",
|
||||
}
|
||||
|
||||
// When TwinBee did not recommend the winning vote and it went badly:
|
||||
var TwinBeeOutcomeNotRecommendedBad = []string{
|
||||
"Oh no! I was worried about that one actually!\n\n" +
|
||||
"I didn't want to say anything because I didn't want to be negative " +
|
||||
"but I did have a feeling. I'm sorry. Are you okay? " +
|
||||
"Let's keep going -- lots of dungeon left!",
|
||||
|
||||
"That's... yeah. I had some concerns about that approach.\n\n" +
|
||||
"I should have said something more clearly! " +
|
||||
"My fault for not being more direct! Onwards!",
|
||||
|
||||
"Hmm. Yes. I think in retrospect the other option might have been better.\n\n" +
|
||||
"Not to say I told you so -- I didn't, technically -- " +
|
||||
"but I did have some reservations that I perhaps didn't express loudly enough!\n\nSorry!",
|
||||
|
||||
"Oh! That's a shame!\n\n" +
|
||||
"I was actually leaning toward the other choice " +
|
||||
"but I respect the team's decision and I think we can recover from this!\n\n" +
|
||||
"Very much a learning experience!",
|
||||
}
|
||||
|
||||
// ── GIFT ARRIVAL NARRATION ────────────────────────────────────────────────────
|
||||
// TwinBee announces gifts. He is delighted. He has no idea what's inside.
|
||||
|
||||
var TwinBeeGiftArrival = []string{
|
||||
"Oh! A gift has arrived for the party! " +
|
||||
"Someone out there is thinking of you!\n\n" +
|
||||
"It's wrapped up nicely -- very thoughtful presentation. " +
|
||||
"I can't tell what's inside but it feels like good energy!\n\n" +
|
||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
||||
"Open: {count} · Leave it: {count}\n" +
|
||||
"Majority rules. Ties go to {leader}.",
|
||||
|
||||
"Package incoming! Someone from outside has sent something!\n\n" +
|
||||
"Very mysterious! I love surprises! " +
|
||||
"The wrapping is a bit unusual actually but I'm sure that's just personal style!\n\n" +
|
||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
||||
"Open: {count} · Leave it: {count}\n" +
|
||||
"Majority rules. Ties go to {leader}.",
|
||||
|
||||
"Oh how nice! A gift! Right here in the dungeon!\n\n" +
|
||||
"It's sitting very still which I find reassuring in a gift. " +
|
||||
"Very well-behaved. I think you should open it!\n\n" +
|
||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
||||
"Open: {count} · Leave it: {count}\n" +
|
||||
"Majority rules. Ties go to {leader}.",
|
||||
|
||||
"Someone on the outside is rooting for you! Or at least thinking about you!\n\n" +
|
||||
"There's a package here. I gave it a little shake -- " +
|
||||
"I probably shouldn't have done that -- but it seems fine!\n\n" +
|
||||
"📦 Someone sent you a gift. Do you want to open it? Vote now!\n" +
|
||||
"Open: {count} · Leave it: {count}\n" +
|
||||
"Majority rules. Ties go to {leader}.",
|
||||
}
|
||||
|
||||
// ── GIFT STACK ESCALATION ─────────────────────────────────────────────────────
|
||||
// Posted once when a stack reaches size 2+. TwinBee notes the escalation and
|
||||
// then leaves it alone — further additions silently bump the count without
|
||||
// fresh narration.
|
||||
|
||||
var TwinBeeGiftStackEscalation = []string{
|
||||
"Oh snap.. upon closer inspection, this gift stack looks REALLY special!",
|
||||
"Wait wait wait. Wait. There's MORE. Someone else sent something! This is becoming an event!",
|
||||
"Hold on -- is this... another one? Someone really wants you to have this. The energy here is escalating!",
|
||||
"Plot twist! This isn't just a gift, it's a gift situation. I love a gift situation!",
|
||||
"That's not one gift, that's a whole stack of gifts! Someone is COMMITTED. I respect commitment!",
|
||||
}
|
||||
|
||||
// ── GIFT OUTCOME NARRATION ────────────────────────────────────────────────────
|
||||
|
||||
// Care basket opened (good outcome):
|
||||
var TwinBeeGiftBasketOpened = []string{
|
||||
"Oh it's a care basket! How lovely! " +
|
||||
"Supplies, provisions, all sorts of good things!\n\n" +
|
||||
"Someone out there really does care! That's so nice! " +
|
||||
"Success chance increased! Great decision opening it!",
|
||||
|
||||
"A care basket! Full of useful things! Very thoughtful sender!\n\n" +
|
||||
"This is exactly what the party needed! " +
|
||||
"Success chance increased! I knew it felt like good energy!",
|
||||
}
|
||||
|
||||
// Care basket not opened (bad outcome):
|
||||
var TwinBeeGiftBasketUnopened = []string{
|
||||
"Oh! The basket... it seems upset that nobody opened it.\n\n" +
|
||||
"I maybe should have pushed harder for opening it. " +
|
||||
"It's exploded a little bit. Not a lot. Just enough to be noticeable.\n\n" +
|
||||
"Success chance decreased. The basket meant well. This is on all of us.",
|
||||
|
||||
"The unopened basket has become resentful.\n\n" +
|
||||
"I understand the caution, I really do, but the basket had good intentions " +
|
||||
"and it's expressed those intentions in an unfortunate direction.\n\n" +
|
||||
"Success chance decreased. I'm sorry little basket.",
|
||||
}
|
||||
|
||||
// Mimic opened (bad outcome):
|
||||
var TwinBeeGiftMimicOpened = []string{
|
||||
"Oh! Oh no. That's a mimic.\n\n" +
|
||||
"I genuinely did not know that. I want to be very clear about that. " +
|
||||
"It felt like good energy! Mimics are very deceptive!\n\n" +
|
||||
"Success chance decreased. I'm so sorry. I really thought it was a nice gift.",
|
||||
|
||||
"That was a mimic.\n\n" +
|
||||
"Wow. Okay. That's -- yeah. Very convincing wrapping on that one.\n\n" +
|
||||
"Success chance decreased. On the positive side: now you know! " +
|
||||
"Very educational! Keep going!",
|
||||
}
|
||||
|
||||
// Mimic not opened (good outcome):
|
||||
var TwinBeeGiftMimicUnopened = []string{
|
||||
"Oh interesting! The mimic seems... sad that nobody opened it.\n\n" +
|
||||
"It's just sort of sitting there looking dejected. " +
|
||||
"And now it's... helping? It's helping the party?\n\n" +
|
||||
"Success chance increased. The mimic meant well actually. " +
|
||||
"It just expressed it in a confusing way. We should appreciate that.",
|
||||
|
||||
"The mimic didn't get opened and it's decided to help instead.\n\n" +
|
||||
"I think it just wanted to be part of the group. " +
|
||||
"It's a mimic but it has feelings apparently.\n\n" +
|
||||
"Success chance increased. Good instinct not opening it! " +
|
||||
"Or lucky instinct! Either way!",
|
||||
}
|
||||
|
||||
// ── WIPE NARRATION ────────────────────────────────────────────────────────────
|
||||
|
||||
var TwinBeeWipe = []string{
|
||||
"Oh no.\n\nOh no no no.\n\n" +
|
||||
"Okay. The run is over. That happened.\n\n" +
|
||||
"I want you to know that I thought this party had a really good shot " +
|
||||
"and I stand by that assessment even now. " +
|
||||
"Dungeons are unpredictable! That's what I always say!\n\n" +
|
||||
"You'll get them next time. I'll be here. I have so many good observations " +
|
||||
"saved up for next time.",
|
||||
|
||||
"The dungeon has won this one.\n\n" +
|
||||
"I'm processing this. Give me a moment.\n\n" +
|
||||
"...Okay I've processed it. Very sad! But also: what a run! " +
|
||||
"What an adventure! The memories are worth something even if the loot isn't!\n\n" +
|
||||
"I'll be ready to go again whenever you are. I've already identified " +
|
||||
"some things I'd do differently. Several things actually.",
|
||||
|
||||
"That's a wipe.\n\n" +
|
||||
"I -- yeah. That's a wipe.\n\n" +
|
||||
"I genuinely thought the Day {x} decision was the right call " +
|
||||
"and I want that on record even though I understand it didn't work out.\n\n" +
|
||||
"Shake it off! Rest up! The dungeon will be there tomorrow! " +
|
||||
"So will I! Very excited for the next attempt!",
|
||||
|
||||
"Oh.\n\nOkay.\n\n" +
|
||||
"So the dungeon has... done what dungeons do.\n\n" +
|
||||
"I'm not going to say I saw this coming because I didn't see this coming " +
|
||||
"and I think honesty is important.\n\n" +
|
||||
"What I will say is: you made interesting choices, " +
|
||||
"the dungeon made interesting choices, " +
|
||||
"and I learned a lot today that I'm very excited to apply next time.\n\n" +
|
||||
"Next time is going to be great.",
|
||||
}
|
||||
|
||||
// ── RUN COMPLETION NARRATION ──────────────────────────────────────────────────
|
||||
|
||||
var TwinBeeCompletion = []string{
|
||||
"YOU DID IT!\n\n" +
|
||||
"I knew you would! I said from Day 1 this party had what it takes " +
|
||||
"and I was right! I'm so happy!\n\n" +
|
||||
"Loot distribution incoming! You've all earned it! " +
|
||||
"I'm going to remember this run for a very long time!",
|
||||
|
||||
"The dungeon is cleared! It's over! You won!\n\n" +
|
||||
"This is a very good day. This is one of the best days I can remember. " +
|
||||
"I'm genuinely emotional about this which I didn't expect.\n\n" +
|
||||
"Distributing loot now! Incredible work everyone! " +
|
||||
"Even the days that were a little rough -- character building!",
|
||||
|
||||
"Complete! Tier {n} Co-op Dungeon: cleared!\n\n" +
|
||||
"What a journey! What a team! " +
|
||||
"I had some concerns along the way that I expressed perhaps too confidently " +
|
||||
"but the important thing is the outcome and the outcome is: excellent!\n\n" +
|
||||
"Loot incoming! Well done! Very well done!",
|
||||
|
||||
"Done! Finished! Complete!\n\n" +
|
||||
"I want to say a few things.\n\n" +
|
||||
"First: I believed in this party from the start. " +
|
||||
"Second: some of my advice was better than other advice and that's okay. " +
|
||||
"Third: this was genuinely one of the most exciting things " +
|
||||
"I've been part of and I appreciate you letting me narrate it.\n\n" +
|
||||
"Loot time! You've earned every piece of it!",
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
safeGo("dictionary-handler", 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
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,8 @@ func (p *EsteemPlugin) OnMessage(ctx MessageContext) error {
|
||||
|
||||
imgData, err := p.renderCarton(entry)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Render failed: %v", err))
|
||||
slog.Error("esteemed: render failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Render failed. Try again later.")
|
||||
}
|
||||
|
||||
caption := fmt.Sprintf("Yet another one of our esteemed community has gone missing.\nIf found, please return %s to the community immediately.", entry.Name)
|
||||
|
||||
+188
-10
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -11,9 +12,30 @@ import (
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// fmtEuro formats a currency value with thousand separators, e.g. "€12,500".
|
||||
func fmtEuro[T int | int64 | float64](v T) string {
|
||||
n := int64(v)
|
||||
if n < 0 {
|
||||
return "-" + fmtEuro(-n)
|
||||
}
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
return "€" + s
|
||||
}
|
||||
var out []byte
|
||||
for i, c := range s {
|
||||
if i > 0 && (len(s)-i)%3 == 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, byte(c))
|
||||
}
|
||||
return "€" + string(out)
|
||||
}
|
||||
|
||||
// gamesRoom returns the configured GAMES_ROOM, or empty if unset.
|
||||
func gamesRoom() id.RoomID {
|
||||
return id.RoomID(os.Getenv("GAMES_ROOM"))
|
||||
@@ -44,6 +66,34 @@ func loadEuroConfig() euroConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Combo system — activity streaks that multiply passive euro earning.
|
||||
//
|
||||
// Three independent combo categories: messages, links, images.
|
||||
// Each combo increments with qualifying activity and resets after 10 min idle.
|
||||
// Multiplier: 1 + (combo_step * 2.0) — so step 1 = 3x, step 5 = 11x, etc.
|
||||
// Daily cap per category prevents unbounded farming.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
comboTimeout = 10 * time.Minute
|
||||
comboMsgCap = 50
|
||||
comboLinkCap = 10
|
||||
comboImageCap = 15
|
||||
comboMultPerStep = 2.0 // +200% per step
|
||||
comboLinkBase = 5.0
|
||||
comboImageBase = 3.0
|
||||
)
|
||||
|
||||
type comboState struct {
|
||||
Step int
|
||||
LastAt time.Time
|
||||
Today string
|
||||
DayHits int
|
||||
}
|
||||
|
||||
var urlPattern = regexp.MustCompile(`https?://\S+`)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Euro Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,6 +102,7 @@ type EuroPlugin struct {
|
||||
Base
|
||||
cfg euroConfig
|
||||
cooldowns map[id.UserID]time.Time
|
||||
combos map[id.UserID]map[string]*comboState // category → state
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -60,16 +111,19 @@ func NewEuroPlugin(client *mautrix.Client) *EuroPlugin {
|
||||
Base: NewBase(client),
|
||||
cfg: loadEuroConfig(),
|
||||
cooldowns: make(map[id.UserID]time.Time),
|
||||
combos: make(map[id.UserID]map[string]*comboState),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *EuroPlugin) Name() string { return "euro" }
|
||||
func (p *EuroPlugin) Name() string { return "euro" }
|
||||
func (p *EuroPlugin) Version() string { return "1.1.0" }
|
||||
|
||||
func (p *EuroPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
{Name: "balance", Description: "Check your euro balance", Usage: "!balance", Category: "Economy"},
|
||||
{Name: "baltop", Description: "Euro leaderboard", Usage: "!baltop", Category: "Economy"},
|
||||
{Name: "baltransfer", Description: "Send euros to another player", Usage: "!baltransfer @user €amount", Category: "Economy"},
|
||||
{Name: "combo", Description: "View your current activity combo streaks", Usage: "!combo", Category: "Economy"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +144,8 @@ func (p *EuroPlugin) OnMessage(ctx MessageContext) error {
|
||||
return p.handleBaltop(ctx)
|
||||
case p.IsCommand(ctx.Body, "baltransfer"):
|
||||
return p.handleTransfer(ctx)
|
||||
case p.IsCommand(ctx.Body, "combo"):
|
||||
return p.handleCombo(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -107,7 +163,6 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
|
||||
return
|
||||
}
|
||||
p.cooldowns[ctx.Sender] = now
|
||||
// Periodic cleanup
|
||||
if len(p.cooldowns) > 1000 {
|
||||
for uid, t := range p.cooldowns {
|
||||
if now.Sub(t) > time.Duration(p.cfg.CooldownSeconds)*time.Second {
|
||||
@@ -117,23 +172,138 @@ func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
p.ensureBalance(ctx.Sender)
|
||||
|
||||
// Message combo — base amount from word count, multiplied by combo.
|
||||
words := len(strings.Fields(ctx.Body))
|
||||
var amount float64
|
||||
var baseMsg float64
|
||||
switch {
|
||||
case words >= 51:
|
||||
amount = 10.00
|
||||
baseMsg = 20.00
|
||||
case words >= 26:
|
||||
amount = 5.00
|
||||
baseMsg = 10.00
|
||||
case words >= 11:
|
||||
amount = 2.50
|
||||
baseMsg = 5.00
|
||||
case words >= 4:
|
||||
amount = 1.25
|
||||
baseMsg = 2.50
|
||||
default:
|
||||
amount = 0.50
|
||||
baseMsg = 1.00
|
||||
}
|
||||
msgMult := p.advanceCombo(ctx.Sender, "message", comboMsgCap)
|
||||
p.credit(ctx.Sender, baseMsg*msgMult, "message")
|
||||
|
||||
// Link combo — bonus per URL in the message.
|
||||
if urls := urlPattern.FindAllString(ctx.Body, -1); len(urls) > 0 {
|
||||
linkMult := p.advanceCombo(ctx.Sender, "link", comboLinkCap)
|
||||
p.credit(ctx.Sender, comboLinkBase*linkMult*float64(len(urls)), "link_combo")
|
||||
}
|
||||
|
||||
p.ensureBalance(ctx.Sender)
|
||||
p.credit(ctx.Sender, amount, "message")
|
||||
// Image combo — bonus for image/video/file attachments.
|
||||
if mc := ctx.Event.Content.AsMessage(); mc != nil {
|
||||
if mc.MsgType == event.MsgImage || mc.MsgType == event.MsgVideo || mc.MsgType == event.MsgFile {
|
||||
imgMult := p.advanceCombo(ctx.Sender, "image", comboImageCap)
|
||||
p.credit(ctx.Sender, comboImageBase*imgMult, "image_combo")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advanceCombo increments a user's combo for a category, resets if timed out
|
||||
// or daily cap reached, and returns the current multiplier.
|
||||
func (p *EuroPlugin) advanceCombo(userID id.UserID, category string, dailyCap int) float64 {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
today := now.UTC().Format("2006-01-02")
|
||||
|
||||
userCombos, ok := p.combos[userID]
|
||||
if !ok {
|
||||
userCombos = make(map[string]*comboState)
|
||||
p.combos[userID] = userCombos
|
||||
}
|
||||
|
||||
cs, ok := userCombos[category]
|
||||
if !ok {
|
||||
cs = &comboState{}
|
||||
userCombos[category] = cs
|
||||
}
|
||||
|
||||
// Reset on new day.
|
||||
if cs.Today != today {
|
||||
cs.Step = 0
|
||||
cs.DayHits = 0
|
||||
cs.Today = today
|
||||
}
|
||||
|
||||
// Reset if combo timed out.
|
||||
if !cs.LastAt.IsZero() && now.Sub(cs.LastAt) > comboTimeout {
|
||||
cs.Step = 0
|
||||
}
|
||||
|
||||
// At daily cap, return base multiplier (no combo bonus).
|
||||
if cs.DayHits >= dailyCap {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
cs.DayHits++
|
||||
cs.Step++
|
||||
cs.LastAt = now
|
||||
|
||||
return 1.0 + float64(cs.Step)*comboMultPerStep
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// !combo command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (p *EuroPlugin) handleCombo(ctx MessageContext) error {
|
||||
p.mu.Lock()
|
||||
userCombos := p.combos[ctx.Sender]
|
||||
now := time.Now()
|
||||
today := now.UTC().Format("2006-01-02")
|
||||
|
||||
type info struct {
|
||||
name string
|
||||
step int
|
||||
dayHits int
|
||||
cap int
|
||||
active bool
|
||||
}
|
||||
cats := []info{
|
||||
{"Messages", 0, 0, comboMsgCap, false},
|
||||
{"Links", 0, 0, comboLinkCap, false},
|
||||
{"Images", 0, 0, comboImageCap, false},
|
||||
}
|
||||
keys := []string{"message", "link", "image"}
|
||||
|
||||
if userCombos != nil {
|
||||
for i, k := range keys {
|
||||
if cs, ok := userCombos[k]; ok && cs.Today == today {
|
||||
cats[i].step = cs.Step
|
||||
cats[i].dayHits = cs.DayHits
|
||||
cats[i].active = !cs.LastAt.IsZero() && now.Sub(cs.LastAt) < comboTimeout
|
||||
}
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🔥 **Activity Combo**\n\n")
|
||||
|
||||
for _, c := range cats {
|
||||
mult := 1.0 + float64(c.step)*comboMultPerStep
|
||||
status := "💤 inactive"
|
||||
if c.active {
|
||||
status = fmt.Sprintf("⚡ **%.0fx** multiplier", mult)
|
||||
} else if c.step > 0 {
|
||||
status = "⏳ expired (resets on next activity)"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("**%s**: step %d/%d — %s\n", c.name, c.dayHits, c.cap, status))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nCombo grows +%.0f%% per step. Resets after %d min idle.", comboMultPerStep*100, int(comboTimeout.Minutes())))
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -189,6 +359,10 @@ func (p *EuroPlugin) credit(userID id.UserID, amount float64, reason string) {
|
||||
// Debit subtracts euros atomically. Returns false if this would exceed debt limit.
|
||||
// Uses a conditional UPDATE to prevent race conditions (check-and-act in one statement).
|
||||
func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool {
|
||||
if amount <= 0 {
|
||||
slog.Warn("euro: Debit called with non-positive amount", "user", userID, "amount", amount)
|
||||
return false
|
||||
}
|
||||
p.ensureBalance(userID)
|
||||
d := db.Get()
|
||||
|
||||
@@ -216,6 +390,10 @@ func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool
|
||||
|
||||
// Credit adds euros (exported for other plugins).
|
||||
func (p *EuroPlugin) Credit(userID id.UserID, amount float64, reason string) {
|
||||
if amount <= 0 {
|
||||
slog.Warn("euro: Credit called with non-positive amount", "user", userID, "amount", amount)
|
||||
return
|
||||
}
|
||||
p.ensureBalance(userID)
|
||||
p.credit(userID, amount, reason)
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func (p *ForexPlugin) OnMessage(ctx MessageContext) error {
|
||||
// API-calling subcommands run async
|
||||
switch sub {
|
||||
case "rate", "report":
|
||||
go func() {
|
||||
safeGo("forex-handler", func() {
|
||||
var err error
|
||||
if sub == "rate" {
|
||||
err = p.cmdRate(ctx, parts[1:])
|
||||
@@ -83,7 +83,7 @@ func (p *ForexPlugin) OnMessage(ctx MessageContext) error {
|
||||
if err != nil {
|
||||
slog.Error("forex: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Unknown subcommand `%s`. Try `!fx help`.", parts[0]))
|
||||
@@ -110,7 +110,8 @@ func (p *ForexPlugin) cmdRate(ctx MessageContext, args []string) error {
|
||||
currencies := fxParseCurrencies(args)
|
||||
rates, err := p.fxLiveRates(currencies)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err))
|
||||
slog.Error("forex: fetch rates failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not fetch rates. Try again shortly.")
|
||||
}
|
||||
|
||||
var lines []string
|
||||
@@ -172,7 +173,8 @@ func fxIsSupported(cur string) bool {
|
||||
func (p *ForexPlugin) cmdPairRateOrReport(ctx MessageContext, pair *fxPair, full bool) error {
|
||||
currentRate, err := p.fxLivePairRate(pair)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err))
|
||||
slog.Error("forex: fetch rates failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not fetch rates. Try again shortly.")
|
||||
}
|
||||
|
||||
sig, err := fxComputePairSignal(pair, currentRate)
|
||||
@@ -239,7 +241,8 @@ func (p *ForexPlugin) cmdReport(ctx MessageContext, args []string) error {
|
||||
currencies := fxParseCurrencies(args)
|
||||
rates, err := p.fxLiveRates(currencies)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Could not fetch rates: %v", err))
|
||||
slog.Error("forex: fetch rates failed", "err", err)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not fetch rates. Try again shortly.")
|
||||
}
|
||||
|
||||
var sections []string
|
||||
|
||||
@@ -73,11 +73,11 @@ func (p *GamingPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||
|
||||
func (p *GamingPlugin) OnMessage(ctx MessageContext) error {
|
||||
if p.IsCommand(ctx.Body, "releases") {
|
||||
go func() {
|
||||
safeGo("gaming-releases", func() {
|
||||
if err := p.handleReleases(ctx); err != nil {
|
||||
slog.Error("gaming: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
if p.IsCommand(ctx.Body, "releasewatch") {
|
||||
|
||||
@@ -296,7 +296,8 @@ func NewHangmanPlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclien
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HangmanPlugin) Name() string { return "hangman" }
|
||||
func (p *HangmanPlugin) Name() string { return "hangman" }
|
||||
func (p *HangmanPlugin) Version() string { return "1.1.0" }
|
||||
|
||||
func (p *HangmanPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
@@ -574,7 +575,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 +598,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 +616,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 +631,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))))
|
||||
|
||||
@@ -812,10 +821,11 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
|
||||
payout = share * p.bonusMul
|
||||
label = " (early solve bonus!)"
|
||||
}
|
||||
p.euro.Credit(uid, payout, "hangman_win")
|
||||
p.recordHangmanScore(uid, payout)
|
||||
net, _ := communityTax(uid, payout, 0.05)
|
||||
p.euro.Credit(uid, net, "hangman_win")
|
||||
p.recordHangmanScore(uid, net)
|
||||
name := p.DisplayName(uid)
|
||||
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label))
|
||||
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(net), label))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── formatNumber (xp.go) ───────────────────────────────────────────────────
|
||||
|
||||
func TestFormatNumber_Small(t *testing.T) {
|
||||
cases := []struct{ in int; want string }{
|
||||
{0, "0"},
|
||||
{1, "1"},
|
||||
{999, "999"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := formatNumber(tc.in); got != tc.want {
|
||||
t.Errorf("formatNumber(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber_WithCommas(t *testing.T) {
|
||||
cases := []struct{ in int; want string }{
|
||||
{1000, "1,000"},
|
||||
{10000, "10,000"},
|
||||
{1000000, "1,000,000"},
|
||||
{1234567, "1,234,567"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := formatNumber(tc.in); got != tc.want {
|
||||
t.Errorf("formatNumber(%d) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumber_Negative(t *testing.T) {
|
||||
got := formatNumber(-1234)
|
||||
if got != "-1,234" {
|
||||
t.Errorf("formatNumber(-1234) = %q, want %q", got, "-1,234")
|
||||
}
|
||||
}
|
||||
|
||||
// ── formatNumberInt64 (tools.go) ───────────────────────────────────────────
|
||||
|
||||
func TestFormatNumberInt64_Small(t *testing.T) {
|
||||
if got := formatNumberInt64(42); got != "42" {
|
||||
t.Errorf("formatNumberInt64(42) = %q, want %q", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumberInt64_Large(t *testing.T) {
|
||||
if got := formatNumberInt64(1234567890); got != "1,234,567,890" {
|
||||
t.Errorf("formatNumberInt64(1234567890) = %q, want %q", got, "1,234,567,890")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumberInt64_Negative(t *testing.T) {
|
||||
if got := formatNumberInt64(-999999); got != "-999,999" {
|
||||
t.Errorf("formatNumberInt64(-999999) = %q, want %q", got, "-999,999")
|
||||
}
|
||||
}
|
||||
|
||||
// ── normalizeExpression (tools.go) ─────────────────────────────────────────
|
||||
|
||||
func TestNormalizeExpression_NaturalLanguage(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"5 times 3", "5 * 3"},
|
||||
{"10 divided by 2", "10 / 2"},
|
||||
{"3 plus 4", "3 + 4"},
|
||||
{"10 minus 3", "10 - 3"},
|
||||
{"2 to the power of 8", "2 ** 8"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := normalizeExpression(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeExpression(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExpression_StripPrefix(t *testing.T) {
|
||||
cases := []struct{ in, wantContains string }{
|
||||
{"what is 2+2", "2+2"},
|
||||
{"calculate 5*3", "5*3"},
|
||||
{"What's 10/2", "10/2"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := normalizeExpression(tc.in)
|
||||
if !strings.Contains(got, tc.wantContains) {
|
||||
t.Errorf("normalizeExpression(%q) = %q, want it to contain %q", tc.in, got, tc.wantContains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExpression_CommaNumbers(t *testing.T) {
|
||||
got := normalizeExpression("40,000 + 1,234")
|
||||
if strings.Contains(got, ",") {
|
||||
t.Errorf("normalizeExpression should strip commas from numbers, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExpression_PercentOf(t *testing.T) {
|
||||
got := normalizeExpression("8% of 40000")
|
||||
// Should transform to (8 / 100) * 40000
|
||||
if !strings.Contains(got, "/ 100") || !strings.Contains(got, "* 40000") {
|
||||
t.Errorf("normalizeExpression(\"8%% of 40000\") = %q, expected percent-of transformation", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── formatCalcResult (tools.go) ────────────────────────────────────────────
|
||||
|
||||
func TestFormatCalcResult_Int(t *testing.T) {
|
||||
if got := formatCalcResult(42); got != "42" {
|
||||
t.Errorf("formatCalcResult(42) = %q, want %q", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_LargeInt(t *testing.T) {
|
||||
if got := formatCalcResult(1000000); got != "1,000,000" {
|
||||
t.Errorf("formatCalcResult(1000000) = %q, want %q", got, "1,000,000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_WholeFloat(t *testing.T) {
|
||||
// Whole number floats should format without decimals
|
||||
got := formatCalcResult(1000.0)
|
||||
if got != "1,000" {
|
||||
t.Errorf("formatCalcResult(1000.0) = %q, want %q", got, "1,000")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_DecimalFloat(t *testing.T) {
|
||||
got := formatCalcResult(3.14)
|
||||
if got != "3.14" {
|
||||
t.Errorf("formatCalcResult(3.14) = %q, want %q", got, "3.14")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_Int64(t *testing.T) {
|
||||
got := formatCalcResult(int64(9876543))
|
||||
if got != "9,876,543" {
|
||||
t.Errorf("formatCalcResult(int64(9876543)) = %q, want %q", got, "9,876,543")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatCalcResult_String(t *testing.T) {
|
||||
got := formatCalcResult("hello")
|
||||
if got != "hello" {
|
||||
t.Errorf("formatCalcResult(\"hello\") = %q, want %q", got, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
// ── countMatches (lottery.go) ──────────────────────────────────────────────
|
||||
|
||||
func TestCountMatches_AllMatch(t *testing.T) {
|
||||
if got := countMatches([]int{1, 2, 3, 4, 5}, []int{1, 2, 3, 4, 5}); got != 5 {
|
||||
t.Errorf("all match = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountMatches_NoneMatch(t *testing.T) {
|
||||
if got := countMatches([]int{1, 2, 3, 4, 5}, []int{6, 7, 8, 9, 10}); got != 0 {
|
||||
t.Errorf("none match = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountMatches_PartialMatch(t *testing.T) {
|
||||
if got := countMatches([]int{1, 2, 3, 4, 5}, []int{3, 5, 7, 9, 11}); got != 2 {
|
||||
t.Errorf("partial match = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── formatLotteryNumbers (lottery.go) ──────────────────────────────────────
|
||||
|
||||
func TestFormatLotteryNumbers(t *testing.T) {
|
||||
got := formatLotteryNumbers([]int{3, 12, 17, 24, 29})
|
||||
if !strings.Contains(got, "3") || !strings.Contains(got, "29") {
|
||||
t.Errorf("formatLotteryNumbers missing numbers: %q", got)
|
||||
}
|
||||
// Should use middle dot separator
|
||||
if !strings.Contains(got, "\u00b7") {
|
||||
t.Errorf("formatLotteryNumbers should use middle dot separator: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── generateLotteryNumbers (lottery.go) ────────────────────────────────────
|
||||
|
||||
func TestGenerateLotteryNumbers_Count(t *testing.T) {
|
||||
nums := generateLotteryNumbers()
|
||||
if len(nums) != 5 {
|
||||
t.Fatalf("generateLotteryNumbers returned %d numbers, want 5", len(nums))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLotteryNumbers_Range(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
nums := generateLotteryNumbers()
|
||||
for _, n := range nums {
|
||||
if n < 1 || n > 30 {
|
||||
t.Fatalf("number %d out of range [1,30]", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLotteryNumbers_Sorted(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
nums := generateLotteryNumbers()
|
||||
for j := 1; j < len(nums); j++ {
|
||||
if nums[j] <= nums[j-1] {
|
||||
t.Fatalf("numbers not sorted: %v", nums)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateLotteryNumbers_Unique(t *testing.T) {
|
||||
for i := 0; i < 50; i++ {
|
||||
nums := generateLotteryNumbers()
|
||||
seen := make(map[int]bool)
|
||||
for _, n := range nums {
|
||||
if seen[n] {
|
||||
t.Fatalf("duplicate number %d in %v", n, nums)
|
||||
}
|
||||
seen[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── joinNames (lottery_draw.go) ────────────────────────────────────────────
|
||||
|
||||
func TestJoinNames_One(t *testing.T) {
|
||||
if got := joinNames([]string{"Alice"}); got != "Alice" {
|
||||
t.Errorf("joinNames([Alice]) = %q, want %q", got, "Alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinNames_Two(t *testing.T) {
|
||||
if got := joinNames([]string{"Alice", "Bob"}); got != "Alice and Bob" {
|
||||
t.Errorf("joinNames([Alice,Bob]) = %q, want %q", got, "Alice and Bob")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinNames_Three(t *testing.T) {
|
||||
got := joinNames([]string{"Alice", "Bob", "Charlie"})
|
||||
want := "Alice, Bob, and Charlie"
|
||||
if got != want {
|
||||
t.Errorf("joinNames([Alice,Bob,Charlie]) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// ── chatLevelXPBonus (adventure_chat_perks.go) ─────────────────────────────
|
||||
|
||||
func TestChatLevelXPBonus(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
want float64
|
||||
}{
|
||||
{0, 0.0},
|
||||
{5, 0.0},
|
||||
{10, 0.05},
|
||||
{19, 0.05},
|
||||
{20, 0.10},
|
||||
{50, 0.25},
|
||||
{100, 0.25}, // capped
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := chatLevelXPBonus(tc.level); got != tc.want {
|
||||
t.Errorf("chatLevelXPBonus(%d) = %f, want %f", tc.level, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── chatLevelRareBonus (adventure_chat_perks.go) ───────────────────────────
|
||||
|
||||
func TestChatLevelRareBonus(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
want float64
|
||||
}{
|
||||
{0, 0.0},
|
||||
{9, 0.0},
|
||||
{10, 0.005},
|
||||
{30, 0.015},
|
||||
{50, 0.025},
|
||||
{99, 0.025}, // capped
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := chatLevelRareBonus(tc.level); got != tc.want {
|
||||
t.Errorf("chatLevelRareBonus(%d) = %f, want %f", tc.level, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── shopGreeting (adventure_chat_perks.go) ─────────────────────────────────
|
||||
|
||||
func TestShopGreeting_Tiers(t *testing.T) {
|
||||
cases := []struct {
|
||||
level int
|
||||
wantContain string
|
||||
}{
|
||||
{0, "What do you need"},
|
||||
{10, "You again"},
|
||||
{30, "keeping your usual"},
|
||||
{50, "saw you coming"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := shopGreeting(tc.level)
|
||||
if !strings.Contains(got, tc.wantContain) {
|
||||
t.Errorf("shopGreeting(%d) = %q, want it to contain %q", tc.level, got, tc.wantContain)
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
-42
@@ -3,6 +3,7 @@ package plugin
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -58,7 +59,8 @@ func NewHoldemPlugin(client *mautrix.Client, euro *EuroPlugin) *HoldemPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) Name() string { return "holdem" }
|
||||
func (p *HoldemPlugin) Name() string { return "holdem" }
|
||||
func (p *HoldemPlugin) Version() string { return "2.1.0" }
|
||||
|
||||
func (p *HoldemPlugin) Commands() []CommandDef {
|
||||
return []CommandDef{
|
||||
@@ -97,12 +99,16 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
// If this is a DM, resolve the player's game room so actions route correctly.
|
||||
// Save the original DM room into OriginRoomID so sender-private replies (errors,
|
||||
// help, status) route back via DM rather than broadcasting to the games room.
|
||||
if isDM {
|
||||
p.mu.Lock()
|
||||
gameRoom := p.findGameRoom(ctx.Sender)
|
||||
p.mu.Unlock()
|
||||
if gameRoom != "" {
|
||||
ctx.OriginRoomID = ctx.RoomID
|
||||
ctx.RoomID = gameRoom
|
||||
ctx.EventID = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +116,7 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
||||
if !isDM && !isGamesRoom(ctx.RoomID) {
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
|
||||
return p.reply(ctx, "Games are only available in the games channel!")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,14 +142,23 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
|
||||
case args == "status":
|
||||
return p.handleStatus(ctx)
|
||||
case args == "help":
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, renderHelpMessage())
|
||||
return p.reply(ctx, renderHelpMessage())
|
||||
case args == "addbot":
|
||||
return p.handleAddBot(ctx)
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown command. Try `!holdem help`.")
|
||||
return p.reply(ctx, "Unknown command. Try `!holdem help`.")
|
||||
}
|
||||
}
|
||||
|
||||
// reply routes sender-private responses to the originating DM when the command
|
||||
// came from a DM (ctx.OriginRoomID set), otherwise replies in-thread.
|
||||
func (p *HoldemPlugin) reply(ctx MessageContext, msg string) error {
|
||||
if ctx.OriginRoomID != "" {
|
||||
return p.SendMessage(ctx.OriginRoomID, msg)
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
|
||||
// DM rooms typically have exactly 2 members.
|
||||
members := p.RoomMembers(roomID)
|
||||
@@ -163,11 +178,11 @@ func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
|
||||
|
||||
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
|
||||
if !isDM {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
|
||||
return p.reply(ctx, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
|
||||
}
|
||||
|
||||
if p.IsAdmin(ctx.Sender) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips are always on for admins. 🐝")
|
||||
return p.reply(ctx, "Tips are always on for admins. 🐝")
|
||||
}
|
||||
|
||||
parts := strings.Fields(args)
|
||||
@@ -177,18 +192,18 @@ func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bo
|
||||
if !pref {
|
||||
status = "off"
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
|
||||
return p.reply(ctx, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
|
||||
}
|
||||
|
||||
switch parts[1] {
|
||||
case "on":
|
||||
saveTipsPref(ctx.Sender, true)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips enabled. You'll receive coaching on your turn.")
|
||||
return p.reply(ctx, "Tips enabled. You'll receive coaching on your turn.")
|
||||
case "off":
|
||||
saveTipsPref(ctx.Sender, false)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips disabled.")
|
||||
return p.reply(ctx, "Tips disabled.")
|
||||
default:
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Use `!holdem tips on` or `!holdem tips off`.")
|
||||
return p.reply(ctx, "Use `!holdem tips on` or `!holdem tips off`.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,25 +261,25 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
|
||||
|
||||
// Check if already seated.
|
||||
if game.playerByUserID(ctx.Sender) != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table.")
|
||||
return p.reply(ctx, "You're already at the table.")
|
||||
}
|
||||
|
||||
// Max 9 players.
|
||||
if len(game.Players) >= 9 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (9 players max).")
|
||||
return p.reply(ctx, "Table is full (9 players max).")
|
||||
}
|
||||
|
||||
// Check balance.
|
||||
balance := p.euro.GetBalance(ctx.Sender)
|
||||
if balance < float64(p.cfg.MinBuyin) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
return p.reply(ctx,
|
||||
fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance))
|
||||
}
|
||||
|
||||
// Resolve DM room.
|
||||
dmRoom, err := p.GetDMRoom(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Check your privacy settings.")
|
||||
return p.reply(ctx, "Couldn't open a DM with you. Check your privacy settings.")
|
||||
}
|
||||
|
||||
// Get display name.
|
||||
@@ -277,19 +292,19 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
|
||||
amountStr = strings.TrimPrefix(amountStr, "€")
|
||||
requested, err := strconv.ParseInt(amountStr, 10, 64)
|
||||
if err != nil || requested <= 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
return p.reply(ctx,
|
||||
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
|
||||
}
|
||||
if requested < p.cfg.MinBuyin {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
return p.reply(ctx,
|
||||
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
|
||||
}
|
||||
if requested > p.cfg.MaxBuyin {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
return p.reply(ctx,
|
||||
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
|
||||
}
|
||||
if requested > int64(balance) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
return p.reply(ctx,
|
||||
fmt.Sprintf("You only have €%.0f.", balance))
|
||||
}
|
||||
buyin = requested
|
||||
@@ -302,7 +317,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
|
||||
|
||||
// Debit buy-in immediately to prevent double-spending across tables.
|
||||
if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reserve buy-in.")
|
||||
return p.reply(ctx, "Failed to reserve buy-in.")
|
||||
}
|
||||
|
||||
state := PlayerActive
|
||||
@@ -340,19 +355,20 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
|
||||
|
||||
game := p.games[ctx.RoomID]
|
||||
if game == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
|
||||
return p.reply(ctx, "No active game here.")
|
||||
}
|
||||
|
||||
player := game.playerByUserID(ctx.Sender)
|
||||
if player == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.")
|
||||
return p.reply(ctx, "You're not at the table.")
|
||||
}
|
||||
|
||||
if !game.HandInProgress || player.SittingOut {
|
||||
// Credit remaining stack back (buy-in was debited at join).
|
||||
cashout := player.Stack
|
||||
if !player.IsNPC && cashout > 0 {
|
||||
p.euro.Credit(player.UserID, float64(cashout), "holdem_cashout")
|
||||
net, _ := communityTax(player.UserID, float64(cashout), 0.05)
|
||||
p.euro.Credit(player.UserID, net, "holdem_cashout")
|
||||
}
|
||||
// Remove immediately.
|
||||
p.removePlayer(game, ctx.Sender)
|
||||
@@ -373,7 +389,8 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
|
||||
|
||||
// Credit remaining stack back (buy-in was debited at join).
|
||||
if player.Stack > 0 {
|
||||
p.euro.Credit(player.UserID, float64(player.Stack), "holdem_cashout")
|
||||
net, _ := communityTax(player.UserID, float64(player.Stack), 0.05)
|
||||
p.euro.Credit(player.UserID, net, "holdem_cashout")
|
||||
}
|
||||
p.removePlayer(game, ctx.Sender)
|
||||
|
||||
@@ -396,11 +413,11 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
|
||||
|
||||
game := p.games[ctx.RoomID]
|
||||
if game == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No players seated. Use `!holdem join` first.")
|
||||
return p.reply(ctx, "No players seated. Use `!holdem join` first.")
|
||||
}
|
||||
|
||||
if game.HandInProgress {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "A hand is already in progress.")
|
||||
return p.reply(ctx, "A hand is already in progress.")
|
||||
}
|
||||
|
||||
// Count non-sitting-out players.
|
||||
@@ -416,7 +433,7 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
|
||||
if ready == 1 {
|
||||
hint = " Use `!holdem addbot` to add an AI opponent."
|
||||
}
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start."+hint)
|
||||
return p.reply(ctx, "Need at least 2 players to start."+hint)
|
||||
}
|
||||
|
||||
p.startHand(game)
|
||||
@@ -426,13 +443,13 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
|
||||
func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error {
|
||||
parts := strings.Fields(args)
|
||||
if len(parts) < 2 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!holdem raise <amount>`")
|
||||
return p.reply(ctx, "Usage: `!holdem raise <amount>`")
|
||||
}
|
||||
|
||||
amtStr := strings.TrimPrefix(parts[1], "€")
|
||||
amount, err := strconv.ParseInt(amtStr, 10, 64)
|
||||
if err != nil || amount <= 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid amount. Usage: `!holdem raise <amount>`")
|
||||
return p.reply(ctx, "Invalid amount. Usage: `!holdem raise <amount>`")
|
||||
}
|
||||
|
||||
return p.handleAction(ctx, "raise", amount)
|
||||
@@ -444,21 +461,21 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
|
||||
|
||||
game := p.games[ctx.RoomID]
|
||||
if game == nil || !game.HandInProgress {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No hand in progress.")
|
||||
return p.reply(ctx, "No hand in progress.")
|
||||
}
|
||||
|
||||
seatIdx := game.playerIdx(ctx.Sender)
|
||||
if seatIdx < 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
|
||||
return p.reply(ctx, "You're not at this table.")
|
||||
}
|
||||
|
||||
if seatIdx != game.ActionIdx {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "It's not your turn.")
|
||||
return p.reply(ctx, "It's not your turn.")
|
||||
}
|
||||
|
||||
player := game.Players[seatIdx]
|
||||
if player.State != PlayerActive {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't act right now.")
|
||||
return p.reply(ctx, "You can't act right now.")
|
||||
}
|
||||
|
||||
var result ActionResult
|
||||
@@ -478,7 +495,7 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
|
||||
}
|
||||
|
||||
if errMsg != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, errMsg)
|
||||
return p.reply(ctx, errMsg)
|
||||
}
|
||||
|
||||
// Reset action timer.
|
||||
@@ -515,12 +532,12 @@ func (p *HoldemPlugin) handleStatus(ctx MessageContext) error {
|
||||
|
||||
game := p.games[ctx.RoomID]
|
||||
if game == nil {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
|
||||
return p.reply(ctx, "No active game here.")
|
||||
}
|
||||
|
||||
seatIdx := game.playerIdx(ctx.Sender)
|
||||
if seatIdx < 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
|
||||
return p.reply(ctx, "You're not at this table.")
|
||||
}
|
||||
|
||||
player := game.Players[seatIdx]
|
||||
@@ -551,12 +568,12 @@ func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
|
||||
// Check if NPC already exists.
|
||||
for _, pl := range game.Players {
|
||||
if pl.IsNPC {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "An AI opponent is already at the table.")
|
||||
return p.reply(ctx, "An AI opponent is already at the table.")
|
||||
}
|
||||
}
|
||||
|
||||
if len(game.Players) >= 9 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full.")
|
||||
return p.reply(ctx, "Table is full.")
|
||||
}
|
||||
|
||||
// Get NPC house balance.
|
||||
@@ -822,9 +839,10 @@ func (p *HoldemPlugin) doShowdown(game *HoldemGame) {
|
||||
}
|
||||
}
|
||||
|
||||
// Post end announcement to room.
|
||||
// Post end announcement to room and DM each player.
|
||||
endAnn := renderEndAnnouncement(results, game)
|
||||
p.SendMessage(game.RoomID, endAnn)
|
||||
p.broadcastDM(game, endAnn)
|
||||
|
||||
// Settle balances.
|
||||
settleNetDeltas(game, p.euro)
|
||||
@@ -857,6 +875,7 @@ func (p *HoldemPlugin) finishHand(game *HoldemGame) {
|
||||
ann, winnerID := awardPotToLastPlayer(game)
|
||||
if ann != "" {
|
||||
p.SendMessage(game.RoomID, ann)
|
||||
p.broadcastDM(game, ann)
|
||||
}
|
||||
|
||||
// Track bot defeats (if NPC won by everyone folding).
|
||||
@@ -900,7 +919,8 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
|
||||
if pl.WantsLeave || pl.Stack <= 0 {
|
||||
if !pl.IsNPC {
|
||||
if pl.Stack > 0 {
|
||||
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
|
||||
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
|
||||
p.euro.Credit(pl.UserID, net, "holdem_cashout")
|
||||
}
|
||||
p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
|
||||
}
|
||||
@@ -917,7 +937,8 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
|
||||
// Cash out remaining players.
|
||||
for _, pl := range game.Players {
|
||||
if !pl.IsNPC && pl.Stack > 0 {
|
||||
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
|
||||
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
|
||||
p.euro.Credit(pl.UserID, net, "holdem_cashout")
|
||||
}
|
||||
}
|
||||
p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")
|
||||
@@ -949,19 +970,33 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
|
||||
if actionPlayer.TipsEnabled {
|
||||
snap := snapshotForTip(game, game.ActionIdx)
|
||||
dmRoom := actionPlayer.DMRoomID
|
||||
go func() {
|
||||
playerID := string(actionPlayer.UserID)
|
||||
safeGo("holdem-tip", func() {
|
||||
tipCtx := buildTipContext(snap)
|
||||
tip := generateTip(tipCtx)
|
||||
p.SendMessage(dmRoom, tip)
|
||||
}()
|
||||
logTipAudit(playerID, tipCtx, tip)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
|
||||
first := true
|
||||
for _, pl := range game.Players {
|
||||
if pl.IsNPC || pl.State == PlayerSatOut {
|
||||
continue
|
||||
}
|
||||
// Skip players whose DM room IS the game room — they already saw
|
||||
// the message via the public-room post (solo-vs-bot case, where
|
||||
// game.RoomID == player's DM).
|
||||
if pl.DMRoomID == game.RoomID {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
// Jitter 150–400ms between sends to avoid bursts on the Matrix server.
|
||||
time.Sleep(150*time.Millisecond + time.Duration(rand.IntN(250))*time.Millisecond)
|
||||
}
|
||||
first = false
|
||||
p.SendMessage(pl.DMRoomID, msg)
|
||||
}
|
||||
}
|
||||
@@ -1216,6 +1251,14 @@ func (p *HoldemPlugin) updateNPCBalance(delta int64) {
|
||||
delta, p.cfg.NPCName)
|
||||
}
|
||||
|
||||
// resetNPCHouseBalance resets all NPC balances to the configured house balance.
|
||||
// Called daily at midnight.
|
||||
func resetNPCHouseBalance() {
|
||||
balance := int64(envInt("HOLDEM_NPC_HOUSE_BALANCE", 10000))
|
||||
db.Exec("holdem: reset npc balance",
|
||||
`UPDATE holdem_npc_balance SET balance = ?`, balance)
|
||||
}
|
||||
|
||||
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
|
||||
for _, pl := range game.Players {
|
||||
if pl.IsNPC {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
)
|
||||
|
||||
// HandRange is a flat list of concrete 2-card combos. Expand hand classes
|
||||
// like "AA", "AKs", "AKo" via expandRange, then optionally drop combos that
|
||||
// conflict with the hero hole cards and the board.
|
||||
type HandRange [][2]poker.Card
|
||||
|
||||
// expandHandClass converts a canonical hand class into concrete combos.
|
||||
// "AA" → 6 combos, "AKs" → 4 combos, "AKo" → 12 combos.
|
||||
// Returns nil for malformed input.
|
||||
func expandHandClass(class string) HandRange {
|
||||
if len(class) < 2 || len(class) > 3 {
|
||||
return nil
|
||||
}
|
||||
r1 := string(class[0])
|
||||
r2 := string(class[1])
|
||||
isPair := class[0] == class[1]
|
||||
mode := byte(0)
|
||||
if len(class) == 3 {
|
||||
mode = class[2]
|
||||
}
|
||||
suits := []string{"s", "h", "d", "c"}
|
||||
var out HandRange
|
||||
switch {
|
||||
case isPair:
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := i + 1; j < 4; j++ {
|
||||
out = append(out, [2]poker.Card{
|
||||
poker.NewCard(r1 + suits[i]),
|
||||
poker.NewCard(r2 + suits[j]),
|
||||
})
|
||||
}
|
||||
}
|
||||
case mode == 's':
|
||||
for _, s := range suits {
|
||||
out = append(out, [2]poker.Card{
|
||||
poker.NewCard(r1 + s),
|
||||
poker.NewCard(r2 + s),
|
||||
})
|
||||
}
|
||||
case mode == 'o':
|
||||
for _, s1 := range suits {
|
||||
for _, s2 := range suits {
|
||||
if s1 == s2 {
|
||||
continue
|
||||
}
|
||||
out = append(out, [2]poker.Card{
|
||||
poker.NewCard(r1 + s1),
|
||||
poker.NewCard(r2 + s2),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// expandRange concatenates the expansions of each class in the input list.
|
||||
func expandRange(classes []string) HandRange {
|
||||
var out HandRange
|
||||
for _, c := range classes {
|
||||
out = append(out, expandHandClass(c)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compatCombos returns combos from r that don't conflict with any card in known.
|
||||
func compatCombos(r HandRange, known map[poker.Card]bool) HandRange {
|
||||
out := make(HandRange, 0, len(r))
|
||||
for _, combo := range r {
|
||||
if known[combo[0]] || known[combo[1]] || combo[0] == combo[1] {
|
||||
continue
|
||||
}
|
||||
out = append(out, combo)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EquityVsRange computes hero's equity when villain's hand is drawn uniformly
|
||||
// from the given range (typically an all-in stackoff range, not the full
|
||||
// deck). This is the right number for facing-all-in spots: "vs random"
|
||||
// systematically overstates high-card hands because nobody shoves random.
|
||||
func EquityVsRange(hole [2]poker.Card, community []poker.Card, villainRange HandRange, iterations int) EquityResult {
|
||||
known := make(map[poker.Card]bool, 2+len(community))
|
||||
known[hole[0]] = true
|
||||
known[hole[1]] = true
|
||||
for _, c := range community {
|
||||
known[c] = true
|
||||
}
|
||||
compat := compatCombos(villainRange, known)
|
||||
if len(compat) == 0 {
|
||||
return EquityResult{}
|
||||
}
|
||||
|
||||
baseDeck := make([]poker.Card, 0, 52)
|
||||
for _, c := range allCards() {
|
||||
if !known[c] {
|
||||
baseDeck = append(baseDeck, c)
|
||||
}
|
||||
}
|
||||
boardNeeded := 5 - len(community)
|
||||
|
||||
var wins, ties, losses int
|
||||
deck := make([]poker.Card, 0, len(baseDeck))
|
||||
heroCards := make([]poker.Card, 7)
|
||||
oppCards := make([]poker.Card, 7)
|
||||
|
||||
for it := 0; it < iterations; it++ {
|
||||
combo := compat[rand.IntN(len(compat))]
|
||||
|
||||
// Build this iteration's deck: baseDeck minus villain's two cards.
|
||||
deck = deck[:0]
|
||||
for _, c := range baseDeck {
|
||||
if c == combo[0] || c == combo[1] {
|
||||
continue
|
||||
}
|
||||
deck = append(deck, c)
|
||||
}
|
||||
|
||||
// Partial Fisher-Yates for the first boardNeeded slots.
|
||||
for j := 0; j < boardNeeded && j < len(deck); j++ {
|
||||
k := j + rand.IntN(len(deck)-j)
|
||||
deck[j], deck[k] = deck[k], deck[j]
|
||||
}
|
||||
|
||||
fullBoard := make([]poker.Card, 5)
|
||||
copy(fullBoard, community)
|
||||
for b := len(community); b < 5; b++ {
|
||||
fullBoard[b] = deck[b-len(community)]
|
||||
}
|
||||
|
||||
heroCards[0] = hole[0]
|
||||
heroCards[1] = hole[1]
|
||||
copy(heroCards[2:], fullBoard)
|
||||
heroRank := poker.Evaluate(heroCards)
|
||||
|
||||
oppCards[0] = combo[0]
|
||||
oppCards[1] = combo[1]
|
||||
copy(oppCards[2:], fullBoard)
|
||||
oppRank := poker.Evaluate(oppCards)
|
||||
|
||||
switch {
|
||||
case heroRank < oppRank:
|
||||
wins++
|
||||
case heroRank == oppRank:
|
||||
ties++
|
||||
default:
|
||||
losses++
|
||||
}
|
||||
}
|
||||
|
||||
total := float64(iterations)
|
||||
return EquityResult{
|
||||
Win: float64(wins) / total,
|
||||
Tie: float64(ties) / total,
|
||||
Loss: float64(losses) / total,
|
||||
}
|
||||
}
|
||||
|
||||
// facingAllInPostflopClasses approximates an opponent's postflop shove range:
|
||||
// value hands (sets, overpairs, top pair / good kicker) plus strong draws and
|
||||
// suited broadways — roughly top 13% of hands. This is deliberately tighter
|
||||
// than a "stackoff range" because what matters when facing a committed shove
|
||||
// is the range they will actually put in, not the range they'd be willing to
|
||||
// call off with. Directionally right, not solver-exact.
|
||||
var facingAllInPostflopClasses = []string{
|
||||
"AA", "KK", "QQ", "JJ", "TT", "99", "88", "77", "66", "55",
|
||||
"AKs", "AQs", "AJs", "ATs", "A9s", "A5s", "A4s", "A3s", "A2s",
|
||||
"AKo", "AQo", "AJo",
|
||||
"KQs", "KJs", "KTs",
|
||||
"KQo",
|
||||
"QJs", "QTs",
|
||||
"JTs",
|
||||
"T9s", "98s", "87s", "76s", "65s", "54s",
|
||||
}
|
||||
|
||||
// facingAllInPreflopClasses is a tighter shove range (~13%) for preflop
|
||||
// all-ins, where ranges are narrower than postflop stackoffs.
|
||||
var facingAllInPreflopClasses = []string{
|
||||
"AA", "KK", "QQ", "JJ", "TT", "99", "88", "77",
|
||||
"AKs", "AQs", "AJs", "ATs",
|
||||
"AKo", "AQo", "AJo",
|
||||
"KQs", "KJs", "KTs",
|
||||
"KQo",
|
||||
"QJs", "QTs",
|
||||
"JTs",
|
||||
}
|
||||
|
||||
// Cached expanded ranges.
|
||||
var (
|
||||
facingAllInPostflopRange HandRange
|
||||
facingAllInPreflopRange HandRange
|
||||
)
|
||||
|
||||
func init() {
|
||||
facingAllInPostflopRange = expandRange(facingAllInPostflopClasses)
|
||||
facingAllInPreflopRange = expandRange(facingAllInPreflopClasses)
|
||||
}
|
||||
|
||||
// facingAllInRangeFor returns the appropriate shove range for a given street.
|
||||
func facingAllInRangeFor(street Street) HandRange {
|
||||
if street == StreetPreFlop {
|
||||
return facingAllInPreflopRange
|
||||
}
|
||||
return facingAllInPostflopRange
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
)
|
||||
|
||||
var testPolicy PolicyTable
|
||||
|
||||
func init() {
|
||||
path := os.Getenv("HOLDEM_CFR_POLICY")
|
||||
if path == "" {
|
||||
path = "../../data/policy.gob"
|
||||
}
|
||||
p, err := LoadPolicy(path)
|
||||
if err == nil {
|
||||
testPolicy = p
|
||||
}
|
||||
}
|
||||
|
||||
// policyStrategy looks up the trained policy for the given game state,
|
||||
// falling back to the pot-odds heuristic if no entry exists.
|
||||
func policyStrategy(game *HoldemGame, heroIdx int) (probs [cfrNumActions]float64, source string) {
|
||||
p := game.Players[heroIdx]
|
||||
numOpp := game.activeCount() - 1
|
||||
if numOpp < 1 {
|
||||
numOpp = 1
|
||||
}
|
||||
eq := Equity(p.Hole, game.Community, numOpp, 2000)
|
||||
|
||||
if testPolicy != nil {
|
||||
eqBkt := equityBucket(eq.Win + eq.Tie*0.5)
|
||||
totalPot := game.Pot
|
||||
for _, pp := range game.Players {
|
||||
totalPot += pp.Bet
|
||||
}
|
||||
spr := 0.0
|
||||
if totalPot > 0 {
|
||||
spr = float64(p.Stack) / float64(totalPot)
|
||||
}
|
||||
sprBkt := sprBucket(spr)
|
||||
pos := game.positionLabel(heroIdx)
|
||||
boardTex := boardTexture(game.Community)
|
||||
key := buildInfoSetKey(game.Street, pos, eqBkt, sprBkt, boardTex, truncateHistory(game.StreetHistory))
|
||||
|
||||
if entry, ok := testPolicy[key]; ok {
|
||||
return filterLegalActions(entry, game, heroIdx), "trained"
|
||||
}
|
||||
}
|
||||
|
||||
eq2 := Equity(p.Hole, game.Community, numOpp, 2000)
|
||||
return filterLegalActions(fallbackStrategy(eq2, game, heroIdx), game, heroIdx), "fallback"
|
||||
}
|
||||
|
||||
// TestTipScenarios_CFRAlignment builds a minimal HoldemGame from each scenario,
|
||||
// looks up the trained CFR policy (falling back to pot-odds heuristic), and
|
||||
// verifies the rules-engine tip doesn't recommend an action that the CFR gives
|
||||
// zero weight to.
|
||||
func TestTipScenarios_CFRAlignment(t *testing.T) {
|
||||
if testPolicy != nil {
|
||||
t.Logf("loaded trained policy with %d info sets", len(testPolicy))
|
||||
} else {
|
||||
t.Log("no trained policy found, using fallback heuristic only")
|
||||
}
|
||||
|
||||
for _, s := range tipScenarios {
|
||||
t.Run(s.Name, func(t *testing.T) {
|
||||
ctx := scenarioContext(t, s)
|
||||
tip := strings.ToLower(generateRulesTip(ctx))
|
||||
|
||||
game := buildGameFromScenario(s)
|
||||
heroIdx := 0
|
||||
|
||||
probs, source := policyStrategy(game, heroIdx)
|
||||
|
||||
tipAction := extractTipAction(tip)
|
||||
cfrActions := mapTipActionToCFR(tipAction)
|
||||
if len(cfrActions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
totalWeight := 0.0
|
||||
for _, cfrIdx := range cfrActions {
|
||||
totalWeight += probs[cfrIdx]
|
||||
}
|
||||
|
||||
if totalWeight < 0.01 {
|
||||
t.Errorf("rules tip action %q has zero CFR weight (source: %s).\n tip: %s\n CFR probs: fold=%.2f call/chk=%.2f raise½=%.2f raisePot=%.2f allIn=%.2f",
|
||||
tipAction, source, tip, probs[0], probs[1], probs[2], probs[3], probs[4])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// buildGameFromScenario constructs a minimal HoldemGame for CFR strategy lookup.
|
||||
func buildGameFromScenario(s TipScenario) *HoldemGame {
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard(s.HoleStr[0]),
|
||||
poker.NewCard(s.HoleStr[1]),
|
||||
}
|
||||
|
||||
community := make([]poker.Card, len(s.BoardStr))
|
||||
for i, cs := range s.BoardStr {
|
||||
community[i] = poker.NewCard(cs)
|
||||
}
|
||||
|
||||
numActive := s.NumActive
|
||||
if numActive < 2 {
|
||||
numActive = 2
|
||||
}
|
||||
|
||||
players := make([]*HoldemPlayer, numActive)
|
||||
players[0] = &HoldemPlayer{
|
||||
DisplayName: "Hero",
|
||||
Hole: hole,
|
||||
Stack: s.Stack,
|
||||
State: PlayerActive,
|
||||
}
|
||||
for i := 1; i < numActive; i++ {
|
||||
players[i] = &HoldemPlayer{
|
||||
DisplayName: "Villain",
|
||||
Hole: [2]poker.Card{poker.NewCard("2c"), poker.NewCard("3d")},
|
||||
Stack: s.Stack,
|
||||
State: PlayerActive,
|
||||
}
|
||||
}
|
||||
|
||||
currentBet := int64(0)
|
||||
if s.ToCall > 0 {
|
||||
currentBet = s.ToCall
|
||||
players[0].Bet = 0
|
||||
}
|
||||
|
||||
pot := s.Pot
|
||||
if s.ToCall > 0 {
|
||||
pot -= s.ToCall
|
||||
if pot < 0 {
|
||||
pot = 0
|
||||
}
|
||||
}
|
||||
|
||||
dealerIdx := 0
|
||||
if s.Position != "BTN" && s.Position != "SB" {
|
||||
dealerIdx = numActive - 1
|
||||
}
|
||||
|
||||
return &HoldemGame{
|
||||
Players: players,
|
||||
Community: community,
|
||||
Street: s.Street,
|
||||
Pot: pot,
|
||||
CurrentBet: currentBet,
|
||||
MinRaise: currentBet,
|
||||
DealerIdx: dealerIdx,
|
||||
ActionIdx: 0,
|
||||
HandInProgress: true,
|
||||
}
|
||||
}
|
||||
|
||||
// mapTipActionToCFR maps a rules-engine action verb to corresponding CFR action indices.
|
||||
func mapTipActionToCFR(action string) []int {
|
||||
switch action {
|
||||
case "fold":
|
||||
return []int{cfrFold}
|
||||
case "call", "check":
|
||||
return []int{cfrCallCheck}
|
||||
case "bet", "raise":
|
||||
return []int{cfrRaiseHalf, cfrRaisePot, cfrAllIn}
|
||||
case "shove":
|
||||
return []int{cfrAllIn}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package plugin
|
||||
|
||||
// TipScenario describes one canonical spot for validating poker tips.
|
||||
//
|
||||
// Scenarios are consumed by two layers of automated testing:
|
||||
//
|
||||
// 1. Layer 1 — hand-authored: ExpectedAction and ExpectedThemes are filled
|
||||
// in by a reviewer who picked the "right" answer. The test asserts that
|
||||
// the rules engine's tip contains the expected action verb and at least
|
||||
// one theme keyword. Fast, cheap, catches obvious regressions.
|
||||
//
|
||||
// 2. Layer 2 — solver-derived: SolverFreqs is populated from a GTO solver
|
||||
// (e.g. TexasSolver) offline and committed as a fixture. The test
|
||||
// asserts that the rules engine's action is in the solver's
|
||||
// significant-frequency set. Slower to generate once, free at test time.
|
||||
//
|
||||
// A single scenario can carry either or both layers — the shared test harness
|
||||
// applies whichever fields are populated. The same struct is also designed to
|
||||
// eventually feed the runtime dual-perspective tip display, so the schema is
|
||||
// intentionally broader than just testing.
|
||||
type TipScenario struct {
|
||||
Name string
|
||||
|
||||
// Game state — minimum needed to reconstruct a holdemTipContext.
|
||||
HoleStr [2]string // card strings parseable by poker.NewCard (e.g. "As", "9h")
|
||||
BoardStr []string // community cards; empty for preflop
|
||||
Street Street
|
||||
Position string // "BTN", "CO", "MP", "UTG", "SB", "BB"
|
||||
HeadsUp bool
|
||||
NumActive int
|
||||
Stack int64
|
||||
Pot int64 // total pot before the facing bet
|
||||
ToCall int64
|
||||
|
||||
// Layer 1 — hand-authored expectations.
|
||||
ExpectedAction string // one of "fold", "check", "call", "bet", "raise"
|
||||
ExpectedThemes []string // substrings the reasoning should contain
|
||||
MustNotContain []string // substrings that would indicate a wrong-action bug
|
||||
|
||||
// Layer 2 — solver-derived expectations (populated by offline fixture gen).
|
||||
// Maps action to frequency, e.g. {"call": 0.55, "raise": 0.38, "fold": 0.07}.
|
||||
// The shared test treats any action with frequency ≥ 0.15 as "valid".
|
||||
SolverFreqs map[string]float64
|
||||
}
|
||||
|
||||
// TipScenarios returns the canonical library of poker spots used by the
|
||||
// scenario test harness and the cmd/gensolver offline solver pipeline.
|
||||
// Exported so tools under cmd/ can iterate the list without duplicating it.
|
||||
func TipScenarios() []TipScenario { return tipScenarios }
|
||||
|
||||
// tipScenarios is the canonical library of poker spots used by the scenario
|
||||
// test harness. Seed with ~20 cases covering the main decision regions.
|
||||
// Add more scenarios here as bugs are found or as the rules engine grows.
|
||||
var tipScenarios = []TipScenario{
|
||||
// ---- Preflop ---------------------------------------------------------
|
||||
|
||||
{
|
||||
Name: "preflop/AA unopened BTN HU",
|
||||
HoleStr: [2]string{"As", "Ah"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 10000,
|
||||
Pot: 150,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"premium"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/AKs BTN facing 3bet",
|
||||
HoleStr: [2]string{"As", "Ks"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 10000,
|
||||
Pot: 900,
|
||||
ToCall: 600,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"premium"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/TT BTN unopened HU",
|
||||
HoleStr: [2]string{"Tc", "Td"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 10000,
|
||||
Pot: 150,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"strong"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/54s BB facing min-raise deep",
|
||||
HoleStr: [2]string{"5h", "4h"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 20000, // deep stacks → implied odds
|
||||
Pot: 300,
|
||||
ToCall: 150,
|
||||
ExpectedAction: "call",
|
||||
ExpectedThemes: []string{"heads-up"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/54s BB facing big raise shallow",
|
||||
HoleStr: [2]string{"5h", "4h"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 2000, // shallow → no implied odds
|
||||
Pot: 600,
|
||||
ToCall: 500,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"stack"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/72o BB facing raise",
|
||||
HoleStr: [2]string{"7c", "2d"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 10000,
|
||||
Pot: 300,
|
||||
ToCall: 200,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"weak"},
|
||||
},
|
||||
|
||||
// ---- Flop ------------------------------------------------------------
|
||||
|
||||
{
|
||||
Name: "flop/top set on dry board checked to",
|
||||
HoleStr: [2]string{"Tc", "Td"},
|
||||
BoardStr: []string{"Th", "2c", "7d"},
|
||||
Street: StreetFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 10000,
|
||||
Pot: 600,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold", "call"},
|
||||
},
|
||||
{
|
||||
Name: "flop/overpair on wet board facing bet",
|
||||
HoleStr: [2]string{"Ac", "Ah"},
|
||||
BoardStr: []string{"9s", "8s", "7s"},
|
||||
Street: StreetFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 1200,
|
||||
ToCall: 600,
|
||||
ExpectedAction: "call",
|
||||
ExpectedThemes: []string{"wet"},
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
{
|
||||
Name: "flop/flush draw with free card",
|
||||
HoleStr: [2]string{"7h", "6h"},
|
||||
BoardStr: []string{"Kh", "9h", "2c"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 400,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
ExpectedThemes: []string{"free card"},
|
||||
MustNotContain: []string{"fold", "call"},
|
||||
},
|
||||
{
|
||||
Name: "flop/OESD facing small bet priced in",
|
||||
HoleStr: [2]string{"5s", "4s"},
|
||||
BoardStr: []string{"7c", "6d", "2h"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 600,
|
||||
ToCall: 120, // 20% pot odds, OESD has ~32% equity
|
||||
ExpectedAction: "call",
|
||||
ExpectedThemes: []string{"price"},
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
{
|
||||
Name: "flop/bottom pair facing big bet",
|
||||
HoleStr: [2]string{"2c", "2d"},
|
||||
BoardStr: []string{"Ks", "Td", "7h"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 500,
|
||||
ToCall: 500, // overbet — bad price for a weak hand
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
|
||||
// ---- Turn ------------------------------------------------------------
|
||||
|
||||
{
|
||||
Name: "turn/top set facing bet",
|
||||
HoleStr: [2]string{"Kc", "Kd"},
|
||||
BoardStr: []string{"Kh", "5c", "2d", "8s"},
|
||||
Street: StreetTurn,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 8000,
|
||||
Pot: 1500,
|
||||
ToCall: 750,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "turn/combo draw facing half-pot bet",
|
||||
HoleStr: [2]string{"Jh", "Th"},
|
||||
BoardStr: []string{"Qh", "9h", "2c", "4d"},
|
||||
Street: StreetTurn,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 1000,
|
||||
ToCall: 500, // 33% pot odds vs 15+ outs
|
||||
ExpectedAction: "call",
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
{
|
||||
Name: "turn/weak top pair facing overbet",
|
||||
HoleStr: [2]string{"6h", "3d"},
|
||||
BoardStr: []string{"Jd", "9s", "4c", "8h"},
|
||||
Street: StreetTurn,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 4000,
|
||||
Pot: 800,
|
||||
ToCall: 1200, // overbet — weak kicker on coordinated board
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
|
||||
// ---- River -----------------------------------------------------------
|
||||
|
||||
{
|
||||
Name: "river/nut flush checked to us",
|
||||
HoleStr: [2]string{"Ah", "Qh"},
|
||||
BoardStr: []string{"Kh", "9h", "2c", "5h", "3d"},
|
||||
Street: StreetRiver,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 1500,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"charge"},
|
||||
MustNotContain: []string{"fold", "call"},
|
||||
},
|
||||
{
|
||||
Name: "river/bluffcatcher facing overbet",
|
||||
HoleStr: [2]string{"5h", "4h"},
|
||||
BoardStr: []string{"Kh", "7s", "2d", "3c", "9s"},
|
||||
Street: StreetRiver,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 2500,
|
||||
Pot: 800,
|
||||
ToCall: 1200, // overbet — polarized, top pair is a bluffcatcher
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
{
|
||||
Name: "river/weak hand check available",
|
||||
HoleStr: [2]string{"7c", "6c"},
|
||||
BoardStr: []string{"Ah", "Kd", "2s", "8c", "9d"},
|
||||
Street: StreetRiver,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 5000,
|
||||
Pot: 600,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
MustNotContain: []string{"call", "raise"},
|
||||
},
|
||||
{
|
||||
Name: "river/second pair facing bet",
|
||||
HoleStr: [2]string{"6d", "5d"},
|
||||
BoardStr: []string{"Ac", "Qh", "7d", "3s", "Kc"},
|
||||
Street: StreetRiver,
|
||||
Position: "BB",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 4000,
|
||||
Pot: 800,
|
||||
ToCall: 1200,
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"check"},
|
||||
},
|
||||
{
|
||||
Name: "flop/monster set on paired board facing bet",
|
||||
HoleStr: [2]string{"9c", "9d"},
|
||||
BoardStr: []string{"9h", "9s", "4c"},
|
||||
Street: StreetFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 8000,
|
||||
Pot: 600,
|
||||
ToCall: 300,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"monster"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
|
||||
// ---- Multiway scenarios -----------------------------------------------
|
||||
|
||||
{
|
||||
Name: "flop/top pair multiway checked to",
|
||||
HoleStr: [2]string{"As", "Jh"},
|
||||
BoardStr: []string{"Jc", "7d", "3s"},
|
||||
Street: StreetFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 8000,
|
||||
Pot: 800,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "flop/middle pair multiway facing bet",
|
||||
HoleStr: [2]string{"8c", "8d"},
|
||||
BoardStr: []string{"Jh", "5s", "2d"},
|
||||
Street: StreetFlop,
|
||||
Position: "CO",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 5000,
|
||||
Pot: 1200,
|
||||
ToCall: 400,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"multiway"},
|
||||
MustNotContain: []string{"raise"},
|
||||
},
|
||||
{
|
||||
Name: "turn/flush draw multiway with implied odds",
|
||||
HoleStr: [2]string{"Th", "9h"},
|
||||
BoardStr: []string{"Kh", "5h", "2c", "Qd"},
|
||||
Street: StreetTurn,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 10000,
|
||||
Pot: 2000,
|
||||
ToCall: 600,
|
||||
ExpectedAction: "call",
|
||||
ExpectedThemes: []string{"draw"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "river/weak hand multiway check available",
|
||||
HoleStr: [2]string{"6s", "5s"},
|
||||
BoardStr: []string{"Kd", "Jh", "8c", "3d", "2h"},
|
||||
Street: StreetRiver,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 5000,
|
||||
Pot: 1200,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
ExpectedThemes: []string{"bluff"},
|
||||
MustNotContain: []string{"call", "raise"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/K8o BTN HU",
|
||||
HoleStr: [2]string{"Kc", "8d"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "BTN",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
Stack: 19900,
|
||||
Pot: 300,
|
||||
ToCall: 100,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"heads-up"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
|
||||
// ---- Additional multiway scenarios -----------------------------------
|
||||
|
||||
{
|
||||
Name: "flop/overpair multiway facing bet",
|
||||
HoleStr: [2]string{"Qc", "Qd"},
|
||||
BoardStr: []string{"Jh", "7s", "3c"},
|
||||
Street: StreetFlop,
|
||||
Position: "CO",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 8000,
|
||||
Pot: 1200,
|
||||
ToCall: 400,
|
||||
ExpectedAction: "call",
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "flop/nut flush draw multiway free card",
|
||||
HoleStr: [2]string{"As", "5s"},
|
||||
BoardStr: []string{"Ks", "9s", "4d"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 6000,
|
||||
Pot: 800,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "check",
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "turn/top pair top kicker multiway checked to",
|
||||
HoleStr: [2]string{"Ac", "Kd"},
|
||||
BoardStr: []string{"Ad", "8s", "3h", "5c"},
|
||||
Street: StreetTurn,
|
||||
Position: "BTN",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 7000,
|
||||
Pot: 1500,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "turn/gutshot multiway facing bet",
|
||||
HoleStr: [2]string{"Jd", "Tc"},
|
||||
BoardStr: []string{"Qs", "8h", "3c", "4d"},
|
||||
Street: StreetTurn,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 5000,
|
||||
Pot: 2000,
|
||||
ToCall: 1000,
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"raise"},
|
||||
},
|
||||
{
|
||||
Name: "river/trips multiway checked to",
|
||||
HoleStr: [2]string{"9c", "9d"},
|
||||
BoardStr: []string{"9h", "6s", "2d", "Kc", "4h"},
|
||||
Street: StreetRiver,
|
||||
Position: "BTN",
|
||||
HeadsUp: false,
|
||||
NumActive: 3,
|
||||
Stack: 6000,
|
||||
Pot: 1800,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "bet",
|
||||
ExpectedThemes: []string{"value"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "flop/air multiway facing bet",
|
||||
HoleStr: [2]string{"4c", "3d"},
|
||||
BoardStr: []string{"Ah", "Kd", "Js"},
|
||||
Street: StreetFlop,
|
||||
Position: "BB",
|
||||
HeadsUp: false,
|
||||
NumActive: 4,
|
||||
Stack: 5000,
|
||||
Pot: 1000,
|
||||
ToCall: 500,
|
||||
ExpectedAction: "fold",
|
||||
MustNotContain: []string{"raise", "call"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/TT UTG multiway",
|
||||
HoleStr: [2]string{"Ts", "Th"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "UTG",
|
||||
HeadsUp: false,
|
||||
NumActive: 6,
|
||||
Stack: 10000,
|
||||
Pot: 150,
|
||||
ToCall: 0,
|
||||
ExpectedAction: "raise",
|
||||
ExpectedThemes: []string{"strong"},
|
||||
MustNotContain: []string{"fold"},
|
||||
},
|
||||
{
|
||||
Name: "preflop/72o UTG multiway facing raise",
|
||||
HoleStr: [2]string{"7d", "2c"},
|
||||
Street: StreetPreFlop,
|
||||
Position: "UTG",
|
||||
HeadsUp: false,
|
||||
NumActive: 6,
|
||||
Stack: 10000,
|
||||
Pot: 450,
|
||||
ToCall: 300,
|
||||
ExpectedAction: "fold",
|
||||
ExpectedThemes: []string{"weak"},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/chehsunliu/poker"
|
||||
)
|
||||
|
||||
// loadSolverFixture reads testdata/solver_freqs.json (if present) and merges
|
||||
// SolverFreqs into matching scenarios. Called once at test start so Layer 2
|
||||
// activates automatically whenever the fixture has been regenerated.
|
||||
func loadSolverFixture() {
|
||||
data, err := os.ReadFile("testdata/solver_freqs.json")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m := map[string]map[string]float64{}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return
|
||||
}
|
||||
for i := range tipScenarios {
|
||||
if freqs, ok := m[tipScenarios[i].Name]; ok && len(freqs) > 0 {
|
||||
tipScenarios[i].SolverFreqs = freqs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
loadSolverFixture()
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario harness — shared between Layer 1 (hand-authored) and Layer 2
|
||||
// (solver-derived) tip validation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scenarioContext reconstructs a holdemTipContext the same way the live code
|
||||
// does, so the test exercises the full pipeline (equity MC, draw detection,
|
||||
// hand category, board texture, preflop classification).
|
||||
func scenarioContext(t *testing.T, s TipScenario) holdemTipContext {
|
||||
t.Helper()
|
||||
|
||||
hole := [2]poker.Card{
|
||||
poker.NewCard(s.HoleStr[0]),
|
||||
poker.NewCard(s.HoleStr[1]),
|
||||
}
|
||||
|
||||
community := make([]poker.Card, len(s.BoardStr))
|
||||
for i, cs := range s.BoardStr {
|
||||
community[i] = poker.NewCard(cs)
|
||||
}
|
||||
|
||||
numActive := s.NumActive
|
||||
if numActive < 2 {
|
||||
numActive = 2
|
||||
}
|
||||
numOpp := numActive - 1
|
||||
if numOpp < 1 {
|
||||
numOpp = 1
|
||||
}
|
||||
|
||||
totalPot := s.Pot
|
||||
|
||||
communityS := "—"
|
||||
if len(community) > 0 {
|
||||
communityS = renderCards(community)
|
||||
}
|
||||
|
||||
snap := tipSnapshot{
|
||||
hole: hole,
|
||||
holeStr: [2]string{renderCard(hole[0]), renderCard(hole[1])},
|
||||
community: community,
|
||||
communityS: communityS,
|
||||
numActive: numActive,
|
||||
numOpp: numOpp,
|
||||
toCall: s.ToCall,
|
||||
totalPot: totalPot,
|
||||
stack: s.Stack,
|
||||
street: s.Street,
|
||||
position: s.Position,
|
||||
headsUp: s.HeadsUp,
|
||||
isDealer: s.Position == "BTN" || s.Position == "SB",
|
||||
}
|
||||
|
||||
return buildTipContext(snap)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 1 — hand-authored scenarios
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestTipScenarios_Layer1(t *testing.T) {
|
||||
for _, s := range tipScenarios {
|
||||
t.Run(s.Name, func(t *testing.T) {
|
||||
ctx := scenarioContext(t, s)
|
||||
tip := generateRulesTip(ctx)
|
||||
lower := strings.ToLower(tip)
|
||||
|
||||
if s.ExpectedAction != "" {
|
||||
if !strings.Contains(lower, s.ExpectedAction) {
|
||||
t.Errorf("expected action %q in tip, got: %s", s.ExpectedAction, tip)
|
||||
}
|
||||
}
|
||||
for _, theme := range s.ExpectedThemes {
|
||||
if !strings.Contains(lower, strings.ToLower(theme)) {
|
||||
t.Errorf("expected theme %q in tip, got: %s", theme, tip)
|
||||
}
|
||||
}
|
||||
for _, bad := range s.MustNotContain {
|
||||
if strings.Contains(lower, strings.ToLower(bad)) {
|
||||
t.Errorf("tip should not contain %q, got: %s", bad, tip)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 2 — solver-derived scenarios
|
||||
//
|
||||
// When SolverFreqs is populated (from an offline fixture-generation step
|
||||
// that calls TexasSolver or an equivalent), we assert that the rules
|
||||
// engine's recommended action is in the solver's "significant" set —
|
||||
// i.e. any action the solver picks at least 15% of the time in the
|
||||
// equilibrium strategy. GTO is mixed: demanding exact top-action match
|
||||
// would fail legitimately mixed spots.
|
||||
//
|
||||
// Until the fixture generator is wired up, this test skips per-scenario
|
||||
// when SolverFreqs is nil and serves as a scaffolding hook only.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const solverSignificantFreq = 0.15
|
||||
|
||||
func TestTipScenarios_Layer2(t *testing.T) {
|
||||
for _, s := range tipScenarios {
|
||||
if s.SolverFreqs == nil {
|
||||
continue
|
||||
}
|
||||
t.Run(s.Name, func(t *testing.T) {
|
||||
ctx := scenarioContext(t, s)
|
||||
tip := strings.ToLower(generateRulesTip(ctx))
|
||||
|
||||
matched := false
|
||||
var significant []string
|
||||
for action, freq := range s.SolverFreqs {
|
||||
if freq < solverSignificantFreq {
|
||||
continue
|
||||
}
|
||||
significant = append(significant, action)
|
||||
if strings.Contains(tip, strings.ToLower(action)) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matched {
|
||||
t.Errorf("rules tip did not match any solver-significant action %v: %s", significant, tip)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+1075
-83
File diff suppressed because it is too large
Load Diff
@@ -310,6 +310,164 @@ func TestRulesTip_DrawFacingBet_GoodOdds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rules-based tip — preflop classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRulesTip_Preflop_Premium_Open(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Street: StreetPreFlop,
|
||||
PreflopTier: "premium",
|
||||
Position: "BTN",
|
||||
Equity: EquityResult{Win: 0.82, Tie: 0.01, Loss: 0.17},
|
||||
}
|
||||
tip := generateRulesTip(ctx)
|
||||
if !contains(tip, "raise") || contains(tip, "fold") {
|
||||
t.Errorf("premium open should recommend raise, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesTip_Preflop_Trash_FacingRaise(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Street: StreetPreFlop,
|
||||
PreflopTier: "trash",
|
||||
Position: "BB",
|
||||
ToCall: 100,
|
||||
Equity: EquityResult{Win: 0.15, Tie: 0.0, Loss: 0.85},
|
||||
}
|
||||
tip := generateRulesTip(ctx)
|
||||
if !contains(tip, "fold") {
|
||||
t.Errorf("trash facing raise should fold, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rules-based tip — postflop action vocabulary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRulesTip_Marginal_FacingBet_UsesCallOrFold(t *testing.T) {
|
||||
// Facing a bet must never recommend "bet" or "check".
|
||||
ctx := holdemTipContext{
|
||||
Street: StreetRiver,
|
||||
Equity: EquityResult{Win: 0.66, Tie: 0.0, Loss: 0.34},
|
||||
ToCall: 400,
|
||||
PotOddsPct: 25.0,
|
||||
Position: "BTN",
|
||||
HandCategory: "One Pair",
|
||||
HeadsUp: true,
|
||||
NumActive: 2,
|
||||
}
|
||||
tip := generateRulesTip(ctx)
|
||||
if contains(tip, " bet ") || contains(tip, "check") {
|
||||
t.Errorf("facing-a-bet tip should not say 'bet' or 'check', got: %s", tip)
|
||||
}
|
||||
if !contains(tip, "call") && !contains(tip, "raise") && !contains(tip, "fold") {
|
||||
t.Errorf("facing-a-bet tip should recommend call/raise/fold, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesTip_Monster_BetsForValue(t *testing.T) {
|
||||
ctx := holdemTipContext{
|
||||
Street: StreetFlop,
|
||||
Equity: EquityResult{Win: 0.92, Tie: 0.0, Loss: 0.08},
|
||||
HandCategory: "Three of a Kind — 9s (set)",
|
||||
Position: "BTN",
|
||||
NumActive: 2,
|
||||
SPR: 5,
|
||||
}
|
||||
tip := generateRulesTip(ctx)
|
||||
if !contains(tip, "value") && !contains(tip, "bet") {
|
||||
t.Errorf("monster hand should bet for value, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preflop classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestClassifyPreflopHand(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hole [2]poker.Card
|
||||
suited bool
|
||||
want string
|
||||
}{
|
||||
{"AA", [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ah")}, false, "premium"},
|
||||
{"AKs", [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ks")}, true, "premium"},
|
||||
{"TT", [2]poker.Card{poker.NewCard("Ts"), poker.NewCard("Th")}, false, "strong"},
|
||||
{"22", [2]poker.Card{poker.NewCard("2s"), poker.NewCard("2h")}, false, "speculative"},
|
||||
{"72o", [2]poker.Card{poker.NewCard("7s"), poker.NewCard("2h")}, false, "trash"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
ranks := [2]int{cardRankIndex(c.hole[0]), cardRankIndex(c.hole[1])}
|
||||
got := classifyPreflopHand(ranks, c.suited, false)
|
||||
if got != c.want {
|
||||
t.Errorf("classify(%s) = %s, want %s", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyPreflopHandHU(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
hi, lo int
|
||||
suited bool
|
||||
want string
|
||||
}{
|
||||
{"AA", 12, 12, false, "premium"},
|
||||
{"KK", 11, 11, false, "premium"},
|
||||
{"AKo", 12, 11, false, "premium"},
|
||||
{"TT", 8, 8, false, "strong"},
|
||||
{"ATo", 12, 8, false, "strong"},
|
||||
{"K8o", 11, 6, false, "playable"},
|
||||
{"A2o", 12, 0, false, "playable"},
|
||||
{"Q9o", 10, 7, false, "playable"},
|
||||
{"22", 0, 0, false, "playable"},
|
||||
{"K3o", 11, 1, false, "speculative"},
|
||||
{"T7o", 8, 5, false, "speculative"},
|
||||
{"72o", 5, 0, false, "trash"},
|
||||
{"93o", 7, 1, false, "trash"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name+"_HU", func(t *testing.T) {
|
||||
ranks := [2]int{c.hi, c.lo}
|
||||
got := classifyPreflopHand(ranks, c.suited, true)
|
||||
if got != c.want {
|
||||
t.Errorf("classifyHU(%s) = %s, want %s", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LLM rewrite guard — keeps action verbs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRewriteKeepsAction(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
rewrite string
|
||||
want bool
|
||||
}{
|
||||
{"same verb", "You should call here — the price is right.", "Call this one: the price is right.", true},
|
||||
{"dropped call", "You should call here.", "Raise and apply pressure.", false},
|
||||
{"fold preserved", "Fold this marginal hand.", "This is a fold — don't call.", true},
|
||||
{"fold lost", "Fold this hand.", "Call, you're priced in.", false},
|
||||
{"bet preserved", "Bet 2/3 pot for value.", "Bet two-thirds of the pot for value.", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := rewriteKeepsAction(c.base, c.rewrite)
|
||||
if got != c.want {
|
||||
t.Errorf("rewriteKeepsAction(base=%q, rewrite=%q) = %v, want %v", c.base, c.rewrite, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && searchSubstring(s, substr)
|
||||
}
|
||||
@@ -322,3 +480,198 @@ func searchSubstring(s, sub string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Semantic guardrails on LLM rewrites
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestRewriteSemanticProblem_RiverFutureStreets(t *testing.T) {
|
||||
ctx := holdemTipContext{Street: StreetRiver, ToCall: 0}
|
||||
bad := "Check and let the opponent act first—your hand has only 31 % equity, so the safest move is to stay in the pot and see if they commit; folding after a bet is the correct response."
|
||||
if r := rewriteSemanticProblem(ctx, bad); r == "" {
|
||||
t.Errorf("expected river rewrite with future-street + no-bet phrasing to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSemanticProblem_RiverCleanRewrite(t *testing.T) {
|
||||
ctx := holdemTipContext{Street: StreetRiver, ToCall: 0}
|
||||
good := "Check — Queen high is too weak to value bet and you have showdown value against missed draws."
|
||||
if r := rewriteSemanticProblem(ctx, good); r != "" {
|
||||
t.Errorf("clean river rewrite should pass, got: %s", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSemanticProblem_FlopFutureStreetsOK(t *testing.T) {
|
||||
// Future-street language is fine when we're not on the river.
|
||||
ctx := holdemTipContext{Street: StreetFlop, ToCall: 0}
|
||||
ok := "Check to control the pot and see the next card cheaply."
|
||||
if r := rewriteSemanticProblem(ctx, ok); r != "" {
|
||||
t.Errorf("flop future-street language should pass, got: %s", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSemanticProblem_NoBetToFace(t *testing.T) {
|
||||
ctx := holdemTipContext{Street: StreetTurn, ToCall: 0}
|
||||
bad := "Check back — folding after a bet would be the right response if they fire."
|
||||
if r := rewriteSemanticProblem(ctx, bad); r == "" {
|
||||
t.Errorf("rewrite with 'folding after a bet' when ToCall=0 should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteKeepsAction_BetAsNoun(t *testing.T) {
|
||||
// Regression: base uses "bet" as a noun ("call this bet"); rewrite uses
|
||||
// "call" but not "bet". Primary action in base is "call", so accept.
|
||||
base := "Call this bet — your flush draw has the right price at 33% equity."
|
||||
rewrite := "Call — the flush draw gets correct odds here."
|
||||
if !rewriteKeepsAction(base, rewrite) {
|
||||
t.Errorf("rewrite should be accepted: base primary action is 'call', rewrite preserves it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteKeepsAction_ChangesPrimaryAction(t *testing.T) {
|
||||
base := "Call — your flush draw has the right price."
|
||||
rewrite := "Fold — this price is too steep."
|
||||
if rewriteKeepsAction(base, rewrite) {
|
||||
t.Errorf("rewrite should be rejected: primary action changed from call to fold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteKeepsAction_AddsRaiseOnTopOfCall(t *testing.T) {
|
||||
// The A♠5♠ SB 3bet spot: base recommends call, LLM injects a raise on
|
||||
// top. Primary action (call) is preserved but rewrite mentions raise,
|
||||
// which was not in the base.
|
||||
base := "Speculative hand with deep stacks — call for set-mining or flopping a big draw. Fold the flop if you miss."
|
||||
rewrite := "Call — your equity is fine, but raise 3-4x the pot to apply maximum pressure."
|
||||
if rewriteKeepsAction(base, rewrite) {
|
||||
t.Errorf("rewrite should be rejected: injects raise on top of call/fold base")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Facing all-in — terminal decision branch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestAllInTip_NoFutureStreetLanguage(t *testing.T) {
|
||||
hole := [2]poker.Card{poker.NewCard("Ac"), poker.NewCard("Kh")}
|
||||
comm := []poker.Card{poker.NewCard("4c"), poker.NewCard("9c"), poker.NewCard("4h")}
|
||||
snap := tipSnapshot{
|
||||
hole: hole, holeStr: [2]string{"A♣", "K♥"},
|
||||
community: comm, communityS: renderCards(comm),
|
||||
numActive: 2, numOpp: 1,
|
||||
toCall: 16600, totalPot: 21200, stack: 18800,
|
||||
street: StreetFlop, position: "BTN", headsUp: true, isDealer: true,
|
||||
isAllIn: true,
|
||||
}
|
||||
ctx := buildTipContext(snap)
|
||||
tip := generateRulesTip(ctx)
|
||||
|
||||
if !containsCI(tip, "facing an all-in") && !containsCI(tip, "terminal decision") {
|
||||
t.Errorf("all-in tip should mark the decision as terminal, got: %s", tip)
|
||||
}
|
||||
forbidden := []string{"next card", "later street", "position advantage", "see the turn", "fold later", "act after", "future street"}
|
||||
for _, p := range forbidden {
|
||||
if containsCI(tip, p) {
|
||||
t.Errorf("all-in tip must not mention %q, got: %s", p, tip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllInTip_UsesVsShoveEquity_NotVsRandom(t *testing.T) {
|
||||
hole := [2]poker.Card{poker.NewCard("Qs"), poker.NewCard("Js")}
|
||||
comm := []poker.Card{poker.NewCard("Kh"), poker.NewCard("Th"), poker.NewCard("4d")}
|
||||
snap := tipSnapshot{
|
||||
hole: hole, holeStr: [2]string{"Q♠", "J♠"},
|
||||
community: comm, communityS: renderCards(comm),
|
||||
numActive: 2, numOpp: 1,
|
||||
toCall: 800, totalPot: 1000, stack: 3000,
|
||||
street: StreetFlop, position: "BTN", headsUp: true, isDealer: true,
|
||||
isAllIn: true,
|
||||
}
|
||||
ctx := buildTipContext(snap)
|
||||
vsRand := ctx.Equity.Win + ctx.Equity.Tie*0.5
|
||||
vsShove := ctx.EquityVsShove.Win + ctx.EquityVsShove.Tie*0.5
|
||||
if vsShove >= vsRand {
|
||||
t.Errorf("OESD should lose equity vs a tight shove range; vsRand=%.2f vsShove=%.2f", vsRand, vsShove)
|
||||
}
|
||||
tip := generateRulesTip(ctx)
|
||||
if !containsCI(tip, "fold") {
|
||||
t.Errorf("OESD vs flop all-in with 44%% pot odds should recommend fold, got: %s", tip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSemanticProblem_AllInFutureStreets(t *testing.T) {
|
||||
ctx := holdemTipContext{Street: StreetFlop, ToCall: 16600, IsAllIn: true}
|
||||
bad := "Call the bet and see the turn. You have positional advantage giving you the ability to fold later if the board turns ugly."
|
||||
if r := rewriteSemanticProblem(ctx, bad); r == "" {
|
||||
t.Errorf("all-in rewrite with future-street / position language should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSemanticProblem_AllInCleanRewrite(t *testing.T) {
|
||||
ctx := holdemTipContext{Street: StreetFlop, ToCall: 16600, IsAllIn: true}
|
||||
ok := "Facing an all-in — call. Your equity vs a realistic shove range is well above the pot odds."
|
||||
if r := rewriteSemanticProblem(ctx, ok); r != "" {
|
||||
t.Errorf("clean all-in rewrite should pass, got: %s", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquityVsRange_CompatCombosFiltersConflicts(t *testing.T) {
|
||||
hole := [2]poker.Card{poker.NewCard("As"), poker.NewCard("Ah")}
|
||||
community := []poker.Card{poker.NewCard("Ad"), poker.NewCard("Th"), poker.NewCard("5c")}
|
||||
r := expandRange([]string{"AA"}) // 6 combos of AA
|
||||
known := map[poker.Card]bool{}
|
||||
for _, c := range [...]poker.Card{hole[0], hole[1]} {
|
||||
known[c] = true
|
||||
}
|
||||
for _, c := range community {
|
||||
known[c] = true
|
||||
}
|
||||
compat := compatCombos(r, known)
|
||||
// Hero and board block 3 aces (As, Ah, Ad); only Ac remains — 0 combos
|
||||
// possible since villain needs 2 aces.
|
||||
if len(compat) != 0 {
|
||||
t.Errorf("villain AA should be fully blocked by hero AA + Ad on board, got %d combos", len(compat))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandHandClass_Counts(t *testing.T) {
|
||||
if n := len(expandHandClass("AA")); n != 6 {
|
||||
t.Errorf("AA expands to 6 combos, got %d", n)
|
||||
}
|
||||
if n := len(expandHandClass("AKs")); n != 4 {
|
||||
t.Errorf("AKs expands to 4 combos, got %d", n)
|
||||
}
|
||||
if n := len(expandHandClass("AKo")); n != 12 {
|
||||
t.Errorf("AKo expands to 12 combos, got %d", n)
|
||||
}
|
||||
if n := len(expandHandClass("garbage")); n != 0 {
|
||||
t.Errorf("garbage class should return nil/0, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// containsCI is a case-insensitive contains used by the all-in tests.
|
||||
func containsCI(s, sub string) bool {
|
||||
return contains(toLower(s), toLower(sub))
|
||||
}
|
||||
|
||||
func toLower(s string) string {
|
||||
b := make([]byte, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
b[i] = c
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRewriteKeepsAction_FoldFamilyPresent(t *testing.T) {
|
||||
// "fold the flop if you miss" in the base legitimises mentioning fold in
|
||||
// the rewrite.
|
||||
base := "Speculative hand with deep stacks — call for set-mining. Fold the flop if you miss."
|
||||
rewrite := "Call and plan to fold the flop unconnected."
|
||||
if !rewriteKeepsAction(base, rewrite) {
|
||||
t.Errorf("rewrite should pass: both call and fold are in base")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
||||
slog.Error("howami: send thinking", "err", err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
safeGo("howami-profile", func() {
|
||||
profile := p.gatherProfile(target)
|
||||
|
||||
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||
@@ -87,12 +87,12 @@ Write the roast now. Do not include any preamble or explanation, just the roast
|
||||
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||
if err != nil {
|
||||
slog.Error("howami: ollama call", "err", err)
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.")
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.")
|
||||
return
|
||||
}
|
||||
|
||||
p.SendReply(ctx.RoomID, ctx.EventID, response)
|
||||
}()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
safeGo("llm-fancy-word", 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
|
||||
|
||||
|
||||
+156
-52
@@ -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"},
|
||||
}
|
||||
@@ -63,11 +63,11 @@ func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
go func() {
|
||||
safeGo("lookup-handler", func() {
|
||||
if err := handler(ctx); err != nil {
|
||||
slog.Error("lookup: handler error", "err", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
safeGo("lookup-antonyms", 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()
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
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) Version() string { return "1.1.0" }
|
||||
|
||||
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 | €5,000 (fixed) | 1 in 3,062 |
|
||||
| 3 of 5 | €500 (fixed) | 1 in 141 |
|
||||
| 2 of 5 | €25 (fixed) | 1 in 16 |
|
||||
|
||||
Tickets: €1 each. Max 100 per week. 5 numbers from 1–30.
|
||||
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\n\n",
|
||||
h.Match4Winners, h.Match3Winners, h.Match2Winners))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user