diff --git a/README.md b/README.md index 96b0620..8360554 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a - **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device. - **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want. - **50 plugins** with dependency injection and ordered registration -- **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), all with channel restriction +- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 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 and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members @@ -475,7 +475,7 @@ 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, fish, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players. +A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, fish, forage, visit the shop, 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` (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. @@ -493,9 +493,15 @@ Characters auto-create on first `!adventure` (or `!adv`) command. All gameplay h | `!adventure revive @user` | Revive a dead player (admin) | | `!adventure respond ` | 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\|]` | 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 @@ -514,16 +520,55 @@ Four activity types across 5 tiers of locations. Higher tiers require higher cha - **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 for 6 hours (or until midnight, whichever comes first). Natural respawn happens automatically. Use `!hospital` for same-day revival at a cost. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. Dead players' streaks are preserved with a grace period — if you die and can't act on revival day, your streak won't reset. +- **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. + +#### 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 ` 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 `, `!thom pay `, `!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 `). + +#### 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. 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. +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 | |---------|-------------| @@ -544,6 +589,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 []` | Join an open invite (defaults to most recent) | +| `!coop fund ` | Set today's funding (`none` / `minimal` / `standard` / `aggressive` / `all_in`) | +| `!coop vote ` | Vote on the day's TwinBee-narrated floor event | +| `!coop bet ` | Spectator parimutuel bet (10% rake) | +| `!coop gift ` | Send a gift (1 harvest action) | +| `!coop giftvote ` | 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 [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 | |---------|-------------| diff --git a/cmd/fetch-esteemed/main.go b/cmd/fetch-esteemed/main.go index 9680063..984a221 100644 --- a/cmd/fetch-esteemed/main.go +++ b/cmd/fetch-esteemed/main.go @@ -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 diff --git a/cmd/gensolver/main.go b/cmd/gensolver/main.go new file mode 100644 index 0000000..e136a59 --- /dev/null +++ b/cmd/gensolver/main.go @@ -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 "] 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) + } +} diff --git a/fix-holdem-tips.md b/fix-holdem-tips.md new file mode 100644 index 0000000..ccc1273 --- /dev/null +++ b/fix-holdem-tips.md @@ -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: +Your hand: +Board: +Equity vs opponents: Win x% | Tie y% | Loss z% +Pot odds to call: x% +SPR: x | Position: | Active players: +``` + +**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 `...` 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 `...` 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 ... block if present + // Qwen3 may use or depending on version + re := regexp.MustCompile(`(?s).*?`) + 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. diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..eaf0960 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -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") + } +} diff --git a/internal/db/db.go b/internal/db/db.go index 6598154..b55e7a5 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 @@ -83,6 +97,50 @@ func runMigrations(d *sql.DB) error { `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`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { @@ -115,6 +173,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 { @@ -139,6 +241,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() { @@ -695,6 +838,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, @@ -1027,6 +1172,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, @@ -1187,6 +1334,126 @@ CREATE TABLE IF NOT EXISTS lottery_history ( 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. diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..e1149b0 --- /dev/null +++ b/internal/db/db_test.go @@ -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 +} diff --git a/internal/plugin/achievements.go b/internal/plugin/achievements.go index 68a07f1..4c15656 100644 --- a/internal/plugin/achievements.go +++ b/internal/plugin/achievements.go @@ -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 + }, + }, } } diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index cf2c454..609334a 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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,8 +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 } @@ -39,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. @@ -67,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) { @@ -88,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"}, } } @@ -110,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) @@ -118,10 +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() @@ -154,6 +177,9 @@ 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) } @@ -163,6 +189,11 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { 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] @@ -172,7 +203,19 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { return p.handleDMReply(ctx) } - // 3. Command dispatch + // 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 } @@ -222,6 +265,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleRepairAllCmd(ctx) case strings.HasPrefix(lower, "repair "): return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:])) + case lower == "boost": + return p.handleBoostCmd(ctx) } return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") @@ -241,10 +286,13 @@ const advHelpText = `**Adventure Commands** ` + "`!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 `" + ` — Repair a specific slot +` + "`!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:** @@ -256,6 +304,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 ───────────────────────────────────────────────────────── @@ -285,25 +337,15 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error { } } - 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) - 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`", @@ -315,7 +357,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) @@ -369,7 +410,7 @@ func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error { p.shopSessionStart(ctx.Sender) if category == "" { - text := luigiShopGreeting(ctx.Sender, equip, balance, showAll) + 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}, @@ -399,12 +440,16 @@ func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error { 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 `. 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)) @@ -417,7 +462,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.") @@ -497,10 +542,15 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error { // Skip if it looks like a command for another plugin lower := strings.ToLower(body) - if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") { + if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") && !strings.HasPrefix(lower, "!thom") { return nil } + // 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) @@ -548,10 +598,20 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact 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 } @@ -613,30 +673,26 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) } } - 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) @@ -728,6 +784,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.") @@ -759,12 +823,25 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m)) } - // Resolve the action - result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone) + // 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": @@ -783,30 +860,34 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha 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.Kill() - char.GrudgeLocation = loc.Name } } else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) { // Clear grudge on successful return @@ -818,22 +899,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 @@ -844,6 +925,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 { @@ -864,8 +955,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") } } } @@ -882,13 +973,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) - } - // Send hospital ad on death (delayed, arrives after resolution DM) - if result.Outcome == AdvOutcomeDeath && !deathReprieved { - p.sendHospitalAd(ctx.Sender, char) + // 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 @@ -897,34 +1013,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.") @@ -954,25 +1076,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 } @@ -1008,7 +1118,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) @@ -1174,7 +1284,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 @@ -1200,3 +1315,44 @@ 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) 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.") +} + diff --git a/internal/plugin/adventure_activities.go b/internal/plugin/adventure_activities.go index 1013d21..8934f43 100644 --- a/internal/plugin/adventure_activities.go +++ b/internal/plugin/adventure_activities.go @@ -130,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}}, @@ -138,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}}, @@ -146,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}}, @@ -154,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}}, @@ -376,10 +376,9 @@ func advLocationCooldown(userID id.UserID, location string) time.Duration { // 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 } @@ -397,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: @@ -535,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 } @@ -547,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 @@ -559,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. @@ -615,11 +603,22 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS // 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%. -func advOverlevelMultiplier(effectiveLevel, minLevel int) float64 { - gap := effectiveLevel - minLevel +// 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) } @@ -632,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 @@ -642,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 { @@ -654,7 +655,7 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui // Overlevel penalty — reduces loot and XP for farming low-tier content skillLevel := advEffectiveSkill(char, loc.Activity, bonuses) - overlevelMult := advOverlevelMultiplier(skillLevel, loc.MinLevel) + overlevelMult := advOverlevelMultiplier(skillLevel, loc) // Roll outcome roll := rand.Float64() * 100 @@ -699,24 +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) - } - // Apply overlevel penalty to XP - if overlevelMult < 1.0 { - xp = max(1, int(float64(xp)*overlevelMult)) - } - 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 || @@ -797,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 } diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 99ac7ba..6ebfb14 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -23,7 +23,9 @@ type ArenaRun struct { Tier int Round int Status string // "active", "awaiting", "completed", "dead", "cashed_out" - Earnings int64 + Earnings int64 // session total (multiplied euros accumulated across completed tiers) + TierEarnings int64 // current tier's raw earnings (reset each tier) + XPAccumulated int // session XP accumulator (raw, multiplied at payout) RoundsSurvived int LastMonster string StartedAt time.Time @@ -39,14 +41,8 @@ func (p *AdventurePlugin) dispatchArenaCommand(ctx MessageContext) error { switch { case args == "" || lower == "menu": return p.handleArenaMenu(ctx) - case strings.HasPrefix(lower, "tier "): - return p.handleArenaTier(ctx, strings.TrimSpace(args[5:])) case lower == "fight" || lower == "f": return p.handleArenaFight(ctx) - case lower == "descend": - return p.handleArenaDescend(ctx) - case lower == "cashout": - return p.handleArenaCashout(ctx) case lower == "cancel": return p.handleArenaCancel(ctx) case lower == "status": @@ -64,18 +60,16 @@ func (p *AdventurePlugin) dispatchArenaCommand(ctx MessageContext) error { const arenaHelpText = `**Arena Commands** -` + "`!arena`" + ` — Show tier menu and eligible tiers -` + "`!arena tier <1-5>`" + ` — Preview a tier (requires confirmation) +` + "`!arena`" + ` — Enter the arena or view your current run ` + "`!arena fight`" + ` — Confirm entry or fight current round -` + "`!arena cancel`" + ` — Cancel pending tier entry -` + "`!arena descend`" + ` — Descend to next tier after clearing (earnings stay at risk) -` + "`!arena cashout`" + ` — Take your earnings and leave +` + "`!arena cancel`" + ` — Cancel pending entry +` + "`!bail`" + ` — Opt out between tiers and collect your rewards ` + "`!arena status`" + ` — Current run state ` + "`!arena stats`" + ` — Your personal arena stats ` + "`!arena leaderboard`" + ` — Top arena players ` + "`!arena help`" + ` — This message -The Arena is independent of your daily adventure action. You can run both on the same day. Death in the arena locks you out of both arena and adventure until midnight UTC.` +The Arena is a streak. You start at Tier 1 and fight your way down. After each tier, you have 30 seconds to bail or you auto-advance. Death forfeits all accumulated rewards. The Arena is independent of your daily adventure actions.` // ── Handlers ──────────────────────────────────────────────────────────────── @@ -89,7 +83,7 @@ func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error { return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) } - // Clear any pending tier selection when viewing menu + // Clear any pending entry when viewing menu p.arenaPending.Delete(string(ctx.Sender)) // Check for active run @@ -98,46 +92,15 @@ func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error { return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(run)) } + // Always start at Tier 1 + tier := arenaGetTier(1) + + // Store pending T1 entry — run is NOT created yet + p.arenaPending.Store(string(ctx.Sender), 1) + stats := loadArenaPersonalStats(ctx.Sender) - return p.SendDM(ctx.Sender, renderArenaTierMenu(char, stats)) -} - -func (p *AdventurePlugin) handleArenaTier(ctx MessageContext, arg string) error { - tierNum, err := strconv.Atoi(arg) - if err != nil || tierNum < 1 || tierNum > 5 { - return p.SendDM(ctx.Sender, "Invalid tier. Use `!arena tier <1-5>`.") - } - - char, err := loadAdvCharacter(ctx.Sender) - if err != nil { - return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one first.") - } - - if !char.Alive { - return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) - } - - // Check active run - existing, _ := loadActiveArenaRun(ctx.Sender) - if existing != nil { - return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(existing)) - } - - tier := arenaGetTier(tierNum) - if tier == nil { - return p.SendDM(ctx.Sender, "Invalid tier.") - } - - // Level gate - if char.CombatLevel < tier.MinLevel { - return p.SendDM(ctx.Sender, renderArenaLevelGate(tier, char.CombatLevel)) - } - - // Store pending tier selection — run is NOT created yet - p.arenaPending.Store(string(ctx.Sender), tierNum) - - monster := arenaGetMonster(tierNum, 1) - text := renderArenaTierConfirm(tier, monster) + monster := arenaGetMonster(1, 1) + text := renderArenaStreakEntry(char, stats, tier, monster) return p.SendDM(ctx.Sender, text) } @@ -146,20 +109,19 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error { userMu.Lock() defer userMu.Unlock() - // Check for pending tier confirmation first - if val, ok := p.arenaPending.LoadAndDelete(string(ctx.Sender)); ok { - tierNum := val.(int) - return p.confirmAndStartArenaRun(ctx, tierNum) + // Check for pending entry confirmation first + if _, ok := p.arenaPending.LoadAndDelete(string(ctx.Sender)); ok { + return p.confirmAndStartArenaRun(ctx) } run, err := loadActiveArenaRun(ctx.Sender) if err != nil || run == nil { - return p.SendDM(ctx.Sender, "You don't have an active arena run. Start one with `!arena tier <1-5>`.") + return p.SendDM(ctx.Sender, "You don't have an active arena run. Type `!arena` to enter.") } if run.Status != "active" { if run.Status == "awaiting" { - return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(run)) + return p.SendDM(ctx.Sender, "Your next tier is loading. Type `!bail` to cash out instead.") } return p.SendDM(ctx.Sender, "Your arena run is not in a fightable state.") } @@ -168,6 +130,9 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error { if err != nil { return p.SendDM(ctx.Sender, "Failed to load character.") } + if !char.Alive { + return p.SendDM(ctx.Sender, "You're dead. The arena requires living participants.") + } equip, _ := loadAdvEquipment(ctx.Sender) @@ -177,23 +142,10 @@ func (p *AdventurePlugin) handleArenaFight(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Arena data error. This shouldn't happen.") } - // Resolve combat - deathChance := arenaDeathChance(monster, char, equip) - roll := rand.Float64() - died := roll < deathChance - - // Generate combat log (cosmetic — outcome already determined) - closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance) - combatLog := generateArenaCombatLog(!died, closeness) - - if died { - return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog) - } - - return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog) + return p.resolveArenaRound(ctx, run, char, equip, tier, monster) } -func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum int) error { +func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error { char, err := loadAdvCharacter(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Failed to load character.") @@ -203,26 +155,23 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum in return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) } - // Re-check active run (could have changed since tier selection) + // Re-check active run (could have changed since entry prompt) existing, _ := loadActiveArenaRun(ctx.Sender) if existing != nil { return p.SendDM(ctx.Sender, renderArenaAlreadyInRun(existing)) } - tier := arenaGetTier(tierNum) - if tier == nil || char.CombatLevel < tier.MinLevel { - return p.SendDM(ctx.Sender, "You are no longer eligible for this tier.") - } + // Always start at Tier 1 + tier := arenaGetTier(1) // NOW create the run run := &ArenaRun{ UserID: ctx.Sender, RoomID: ctx.RoomID, - StartTier: tierNum, - Tier: tierNum, + StartTier: 1, + Tier: 1, Round: 1, Status: "active", - Earnings: 0, StartedAt: time.Now().UTC(), } @@ -233,20 +182,44 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext, tierNum in // Resolve round 1 immediately equip, _ := loadAdvEquipment(ctx.Sender) - monster := arenaGetMonster(tierNum, 1) + monster := arenaGetMonster(1, 1) - deathChance := arenaDeathChance(monster, char, equip) - roll := rand.Float64() - died := roll < deathChance + return p.resolveArenaRound(ctx, run, char, equip, tier, monster) +} - closeness := 1.0 - math.Abs(roll-deathChance)/math.Max(deathChance, 1-deathChance) - combatLog := generateArenaCombatLog(!died, closeness) +// resolveArenaRound runs a single arena round through the combat engine and +// dispatches to survival or death handlers. +func (p *AdventurePlugin) resolveArenaRound(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, tier *ArenaTier, monster *ArenaMonster) error { + result, condRepair := p.runArenaCombat(ctx.Sender, char, equip, monster, run.Round, run.Tier) - if died { - return p.resolveArenaDeath(ctx, run, char, tier, monster, combatLog) + // Apply event-based equipment degradation + degradation := combatDegradation(result, equip) + for slot, eq := range equip { + if d, ok := degradation[slot]; ok && d > 0 { + _ = saveAdvEquipment(ctx.Sender, eq) + } } - return p.resolveArenaSurvival(ctx, run, char, tier, monster, combatLog) + deathSaved := checkDeathSaveEvent(result.Events) + + if deathSaved { + now := time.Now().UTC() + char.DeathReprieveLast = &now + if err := saveAdvCharacter(char); err != nil { + slog.Error("arena: failed to save character after reprieve", "user", ctx.Sender, "err", err) + } + } + + if !result.PlayerWon { + return p.resolveArenaDeath(ctx, run, char, tier, monster, result) + } + + // Misty condition repair (post-combat) + if condRepair > 0 { + npcRepairMostDamaged(ctx.Sender, equip, condRepair) + } + + return p.resolveArenaSurvival(ctx, run, char, tier, monster, result) } func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error { @@ -256,67 +229,33 @@ func (p *AdventurePlugin) handleArenaCancel(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Nothing to cancel.") } -func (p *AdventurePlugin) handleArenaDescend(ctx MessageContext) error { +func (p *AdventurePlugin) handleArenaBail(ctx MessageContext) error { + // Signal the countdown goroutine if one is running + if ch, ok := p.arenaBailCh.LoadAndDelete(string(ctx.Sender)); ok { + close(ch.(chan struct{})) + // The countdown goroutine handles the actual payout + return nil + } + + // No countdown running — check if there's an awaiting run (stale state) userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() run, err := loadActiveArenaRun(ctx.Sender) - if err != nil || run == nil || run.Status != "awaiting" { - return p.SendDM(ctx.Sender, "You don't have a pending tier decision.") + if err != nil || run == nil { + return p.SendDM(ctx.Sender, "You don't have an active arena session to bail from.") } - if run.Tier >= 5 { - return p.SendDM(ctx.Sender, "There is nothing deeper. You've reached the bottom.") + if run.Status == "awaiting" { + return p.arenaProcessBail(ctx.Sender, run) } - char, err := loadAdvCharacter(ctx.Sender) - if err != nil { - return p.SendDM(ctx.Sender, "Failed to load character.") + if run.Status == "active" { + return p.SendDM(ctx.Sender, "You're mid-fight. Finish the current tier first — you can only bail between tiers.") } - nextTier := arenaGetTier(run.Tier + 1) - if nextTier == nil { - return p.SendDM(ctx.Sender, "Invalid next tier.") - } - - // Level gate for next tier - if char.CombatLevel < nextTier.MinLevel { - return p.SendDM(ctx.Sender, renderArenaLevelGate(nextTier, char.CombatLevel)) - } - - // Clear auto-cashout deadline - p.arenaDeadlines.Delete(string(ctx.Sender)) - - // Update run: advance to next tier - run.Tier = nextTier.Number - run.Round = 1 - run.Status = "active" - if err := saveArenaRun(run); err != nil { - return p.SendDM(ctx.Sender, "Failed to update arena run.") - } - - // Grant descend achievement - if p.achievements != nil { - p.achievements.GrantAchievement(ctx.Sender, "arena_descend") - } - - monster := arenaGetMonster(nextTier.Number, 1) - text := renderArenaRoundStart(nextTier, 1, monster, run) - return p.SendDM(ctx.Sender, text) -} - -func (p *AdventurePlugin) handleArenaCashout(ctx MessageContext) error { - userMu := p.advUserLock(ctx.Sender) - userMu.Lock() - defer userMu.Unlock() - - run, err := loadActiveArenaRun(ctx.Sender) - if err != nil || run == nil || run.Status != "awaiting" { - return p.SendDM(ctx.Sender, "You don't have a pending tier decision.") - } - - return p.arenaCompleteCashout(ctx.Sender, run, false) + return p.SendDM(ctx.Sender, "You don't have an active arena session to bail from.") } func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error { @@ -327,7 +266,7 @@ func (p *AdventurePlugin) handleArenaStatus(ctx MessageContext) error { run, err := loadActiveArenaRun(ctx.Sender) if err != nil || run == nil { - return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena tier <1-5>`.") + return p.SendDM(ctx.Sender, "No active arena run. Start one with `!arena`.") } return p.SendDM(ctx.Sender, renderArenaStatus(run, char)) @@ -354,34 +293,40 @@ func (p *AdventurePlugin) handleArenaLeaderboard(ctx MessageContext) error { // ── Combat Resolution ─────────────────────────────────────────────────────── -func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog) error { - // Calculate reward +// ── Streak Multipliers ────────────────────────────────────────────────────── + +var arenaStreakEuroMultiplier = [6]float64{0, 1.0, 1.5, 2.0, 2.75, 4.0} // index = tier +var arenaStreakXPMultiplier = [6]float64{0, 1.0, 1.2, 1.5, 1.85, 2.5} // index = tiers won + +func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error { + // Calculate reward — accumulate in tier earnings, not credited yet reward := arenaRoundReward(tier, run.Round, char.CombatLevel) - run.Earnings += reward + run.TierEarnings += reward run.RoundsSurvived++ run.LastMonster = monster.Name - // Award battle XP (Ironclad set: Battle-Hardened — +5% XP) + // Accumulate battle XP (Ironclad set: Battle-Hardened — +5% XP, chat level bonus) battleXP := tier.BattleXP equip, _ := loadAdvEquipment(ctx.Sender) if advEquippedArenaSets(equip)["ironclad"] { battleXP = int(float64(battleXP) * 1.05) } - char.CombatXP += battleXP - leveled, newLevel := checkAdvLevelUp(char, "combat") - if leveled { - p.checkRivalPoolUnlock(char) - } - if err := saveAdvCharacter(char); err != nil { - slog.Error("arena: failed to save character after survival", "user", ctx.Sender, "err", err) + if bonus := chatLevelXPBonus(p.chatLevel(ctx.Sender)); bonus > 0 { + battleXP = int(float64(battleXP) * (1.0 + bonus)) } + run.XPAccumulated += battleXP - // Build survival message with combat log - closer := arenaWinCloser(monster.Name, len(combatLog.Rounds)) - text := renderArenaCombatLog(combatLog, monster, true, reward, tier.BattleXP, closer) - text += fmt.Sprintf("\nRun total: €%d\n", run.Earnings) - if leveled { - text += fmt.Sprintf("\n🎉 **Combat Level %d!**", newLevel) + // Render combat log as phased messages + final outcome + phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name) + phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages) + finalMessage := renderArenaCombatFinalMessage(result, monster, reward, battleXP, run.Round) + + // Suppress the "(at risk)" line on the tier-completing round — those earnings + // are about to be locked in by the tier-complete branch below, so labelling + // them as at-risk is stale the moment it's written. + if run.Round < 4 { + finalMessage += fmt.Sprintf("\nTier earnings: %s | Session total: %s (at risk)\n", + fmtEuro(run.TierEarnings), fmtEuro(run.Earnings+run.TierEarnings)) } // Achievement: first blood @@ -396,37 +341,53 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun // Check if tier is complete (4 rounds) if run.Round >= 4 { - // Add completion bonus - run.Earnings += tier.CompletionBonus - run.Round = 4 // keep at 4 for clarity + run.Round = 4 + + tierRaw := run.TierEarnings + run.TierEarnings += tier.CompletionBonus + + multiplier := arenaStreakEuroMultiplier[run.Tier] + run.Earnings += int64(float64(run.TierEarnings) * multiplier) + run.TierEarnings = 0 + + p.grantArenaTierAchievement(ctx.Sender, run.Tier) if run.Tier >= 5 { - // Tier 5 complete — full payout, run ends - return p.arenaCompleteTier5(ctx, run, char, tier, text) + run.Status = "completed" + now := time.Now().UTC() + run.EndedAt = &now + if err := saveArenaRun(run); err != nil { + slog.Error("arena: failed to save run after T5", "user", ctx.Sender, "err", err) + } + + finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: €%d + completion bonus: €%d (×%.1f streak)\n", + tier.Number, tierRaw, tier.CompletionBonus, multiplier) + + done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage) + <-done + return p.arenaCompleteSession(ctx.Sender, run, char, "") } - // Tier complete, awaiting decision run.Status = "awaiting" if err := saveArenaRun(run); err != nil { slog.Error("arena: failed to save run after tier complete", "user", ctx.Sender, "err", err) } - // Set auto-cashout deadline - deadline := time.Now().UTC().Add(10 * time.Minute) - p.arenaDeadlines.Store(string(ctx.Sender), deadline) + finalMessage += fmt.Sprintf("\n🏆 **Tier %d cleared!** Round earnings: %s + completion bonus: %s (×%.1f streak)\n"+ + "Session total: %s (at risk)\n", + tier.Number, fmtEuro(tierRaw), fmtEuro(tier.CompletionBonus), multiplier, fmtEuro(run.Earnings)) - text += "\n\n" + renderArenaTierComplete(tier, tier.CompletionBonus, run.Earnings) - - // Check for arena helmet drop if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil { - text += "\n\n" + renderArenaHelmetDrop(dropped) + finalMessage += "\n" + renderArenaHelmetDrop(dropped) p.postArenaDropAnnouncement(char.DisplayName, dropped) } - // Grant tier achievement - p.grantArenaTierAchievement(ctx.Sender, run.Tier) - - return p.SendDM(ctx.Sender, text) + done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage) + go func() { + <-done + p.arenaCountdown(ctx.Sender, run) + }() + return nil } // Advance to next round @@ -435,112 +396,28 @@ func (p *AdventurePlugin) resolveArenaSurvival(ctx MessageContext, run *ArenaRun slog.Error("arena: failed to save run after round", "user", ctx.Sender, "err", err) } - // Reveal next monster nextMonster := arenaGetMonster(run.Tier, run.Round) if nextMonster != nil { - text += fmt.Sprintf("\n\n─────────────────────────────\n\n") - text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name) - text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor) - text += "`!arena fight` — Face this opponent" + finalMessage += fmt.Sprintf("\n\n─────────────────────────────\n\n") + finalMessage += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name) + finalMessage += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor) + finalMessage += "`!arena fight` — Face this opponent" } - return p.SendDM(ctx.Sender, text) + p.sendCombatMessages(ctx.Sender, phaseMessages, finalMessage) + return nil } -func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, combatLog *ArenaCombatLog) error { +func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, monster *ArenaMonster, result CombatResult) error { run.LastMonster = monster.Name + lostEarnings := run.Earnings + run.TierEarnings - // Sovereign set: Death's Reprieve — survive lethal arena outcome - equip, _ := loadAdvEquipment(ctx.Sender) - if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() { - now := time.Now().UTC() - char.DeathReprieveLast = &now - if err := saveAdvCharacter(char); err != nil { - slog.Error("arena: failed to save character after reprieve", "user", ctx.Sender, "err", err) - } + phaseMessages := RenderCombatLogArena(result, char.DisplayName, monster.Name) - // Gear absorbs the blow — all equipment set to 1 condition - for _, slot := range allSlots { - if eq, ok := equip[slot]; ok { - eq.Condition = 1 - saveAdvEquipment(ctx.Sender, eq) - } - } + dt := transitionDeath(DeathTransitionParams{Char: char}) - // Run continues — not dead, earnings preserved - nextWindow := now.Add(168 * time.Hour) - gr := gamesRoom() - if gr != "" { - p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, monster.Name, nextWindow)) - } - - // Show combat log (player "lost" but was saved by reprieve) - closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds)) - text := renderArenaCombatLog(combatLog, monster, false, 0, 0, closer) - text += fmt.Sprintf("\n💀→⚔️ **%s nearly killed you.**\n\n"+ - "Your Sovereign gear activated **Death's Reprieve**. You survived — barely.\n"+ - "All equipment set to 1 condition.\n\n"+ - "Next reprieve window: %s\n", - monster.Name, nextWindow.Format("2006-01-02 15:04 UTC")) - - run.RoundsSurvived++ - - // Check if this was round 4 — tier completion via reprieve - if run.Round >= 4 { - run.Earnings += tier.CompletionBonus - run.Round = 4 - - if run.Tier >= 5 { - // Tier 5 complete — hand off to the normal T5 completion path - return p.arenaCompleteTier5(ctx, run, char, tier, text) - } - - // Tier complete, awaiting descend/cashout - run.Status = "awaiting" - if err := saveArenaRun(run); err != nil { - slog.Error("arena: failed to save run after reprieve tier complete", "user", ctx.Sender, "err", err) - } - - deadline := time.Now().UTC().Add(10 * time.Minute) - p.arenaDeadlines.Store(string(ctx.Sender), deadline) - - text += "\n" + renderArenaTierComplete(tier, tier.CompletionBonus, run.Earnings) - - // Check for arena helmet drop - if dropped := p.arenaRollHelmetDrop(ctx.Sender, run.Tier); dropped != nil { - text += "\n\n" + renderArenaHelmetDrop(dropped) - p.postArenaDropAnnouncement(char.DisplayName, dropped) - } - - p.grantArenaTierAchievement(ctx.Sender, run.Tier) - return p.SendDM(ctx.Sender, text) - } - - // Not round 4 — advance to next round - run.Round++ - if err := saveArenaRun(run); err != nil { - slog.Error("arena: failed to save run after reprieve", "user", ctx.Sender, "err", err) - } - - text += fmt.Sprintf("\nRun earnings: €%d (still at risk)\n", run.Earnings) - - nextMonster := arenaGetMonster(run.Tier, run.Round) - if nextMonster != nil { - text += fmt.Sprintf("\n─────────────────────────────\n\n") - text += fmt.Sprintf("**Round %d/4 — %s**\n", run.Round, nextMonster.Name) - text += fmt.Sprintf("_%s_\n\n", nextMonster.Flavor) - text += "`!arena fight` — Face this opponent" - } - - return p.SendDM(ctx.Sender, text) - } - - lostEarnings := run.Earnings - - char.Kill() - now := time.Now().UTC() char.ArenaLosses++ - char.CombatXP += arenaParticipationXP // +60 flat participation XP + char.CombatXP += arenaParticipationXP if leveled, _ := checkAdvLevelUp(char, "combat"); leveled { p.checkRivalPoolUnlock(char) } @@ -548,120 +425,171 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c slog.Error("arena: failed to save character after death", "user", ctx.Sender, "err", err) } - // End the run + now := time.Now().UTC() run.Status = "dead" run.Earnings = 0 + run.TierEarnings = 0 + run.XPAccumulated = 0 run.EndedAt = &now if err := saveArenaRun(run); err != nil { slog.Error("arena: failed to end arena run", "user", ctx.Sender, "err", err) } - - // Insert history insertArenaHistory(run.UserID, run.StartTier, run.Tier, run.RoundsSurvived, 0, "dead", monster.Name) - - // Update stats upsertArenaStats(run.UserID, 0, true, run.Tier) - // Achievement: death in T5 - if run.Tier == 5 && p.achievements != nil { - p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5") + finalMsg := renderArenaCombatFinalMessage(result, monster, 0, arenaParticipationXP, run.Round) + if dt.PetRecovered { + finalMsg += fmt.Sprintf("\n\nYour pet dragged you out of the arena. Death timer reduced. All session earnings forfeited.") + } else { + if run.Tier == 5 && p.achievements != nil { + p.achievements.GrantAchievement(ctx.Sender, "arena_death_t5") + } + finalMsg += fmt.Sprintf("\nYou died in Tier %d. Everything you were carrying goes with you. The arena keeps its own ledger.\n", run.Tier) + if lostEarnings > 0 { + finalMsg += fmt.Sprintf("Forfeited: %s\n", fmtEuro(lostEarnings)) + } } - // Build death message with combat log - closer := arenaLoseCloser(monster.Name, len(combatLog.Rounds)) - text := renderArenaCombatLog(combatLog, monster, false, 0, arenaParticipationXP, closer) - text += fmt.Sprintf("\nLost earnings: €%d\n", lostEarnings) + done := p.sendCombatMessages(ctx.Sender, phaseMessages, finalMsg) - if err := p.SendDM(ctx.Sender, text); err != nil { - slog.Error("arena: failed to send death DM", "user", ctx.Sender, "err", err) + if dt.PetRecovered { + gr := gamesRoom() + if gr != "" { + _ = p.SendMessage(gr, petDitchRecoveryGameRoom(char.DisplayName, char.PetName, true)) + } } - // Send hospital ad (delayed) - p.sendHospitalAd(ctx.Sender, char) + go func() { + <-done + p.sendHospitalAd(ctx.Sender, char) + }() return nil } -func (p *AdventurePlugin) arenaCompleteTier5(ctx MessageContext, run *ArenaRun, char *AdventureCharacter, tier *ArenaTier, prefixText string) error { - // Credit earnings - p.euro.Credit(run.UserID, float64(run.Earnings), "arena_tier5_complete") +// arenaCompleteSession handles session payout for both T5 completion and bail. +// prefixText is the combat log / tier-complete text to prepend to the summary. +func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun, char *AdventureCharacter, prefixText string) error { + tiersWon := run.Tier // tiers cleared in this session (always starts at 1) + + // Apply XP streak multiplier + xpMult := 1.0 + if tiersWon >= 1 && tiersWon <= 5 { + xpMult = arenaStreakXPMultiplier[tiersWon] + } + totalXP := int(float64(run.XPAccumulated) * xpMult) + + // Arena tax: 10% of earnings to community pot. + arenaTax := int64(math.Round(float64(run.Earnings) * 0.1)) + arenaNet := run.Earnings - arenaTax + if arenaTax > 0 { + communityPotAdd(int(arenaTax)) + trackTaxPaid(userID, int(arenaTax)) + } + p.euro.Credit(userID, float64(arenaNet), "arena_streak_payout") + + // Credit XP + char.CombatXP += totalXP char.ArenaWins++ + leveled, newLevel := checkAdvLevelUp(char, "combat") + if leveled { + p.checkRivalPoolUnlock(char) + } if err := saveAdvCharacter(char); err != nil { - slog.Error("arena: failed to save character after T5 complete", "user", ctx.Sender, "err", err) + slog.Error("arena: failed to save character after session complete", "user", userID, "err", err) } - // End run - now := time.Now().UTC() - run.Status = "completed" - run.EndedAt = &now - if err := saveArenaRun(run); err != nil { - slog.Error("arena: failed to end arena run after T5", "user", ctx.Sender, "err", err) + // End run if not already ended + if run.Status != "completed" && run.Status != "cashed_out" { + now := time.Now().UTC() + run.Status = "cashed_out" + run.EndedAt = &now + if err := saveArenaRun(run); err != nil { + slog.Error("arena: failed to end arena run on session complete", "user", userID, "err", err) + } + } else { + if err := saveArenaRun(run); err != nil { + slog.Error("arena: failed to save arena run on session complete", "user", userID, "err", err) + } } // History and stats - insertArenaHistory(run.UserID, run.StartTier, 5, run.RoundsSurvived, run.Earnings, "completed", "That Which Has Always Been") - upsertArenaStats(run.UserID, run.Earnings, false, 5) - - // Grant achievements - p.grantArenaTierAchievement(ctx.Sender, 5) - if run.StartTier == 1 && p.achievements != nil { - p.achievements.GrantAchievement(ctx.Sender, "arena_full_run") + outcome := "cashed_out" + lastMonster := run.LastMonster + if run.Tier >= 5 && run.Status == "completed" { + outcome = "completed" + lastMonster = "That Which Has Always Been" } - - // Room announcement - gr := gamesRoom() - if gr != "" { - announce := fmt.Sprintf("🏆 **%s has conquered the Arena.** Tier 5 cleared. €%d earned. That Which Has Always Been has fallen.", - char.DisplayName, run.Earnings) - p.SendMessage(id.RoomID(gr), announce) - } - - text := prefixText + "\n\n" + renderArenaTier5Complete(run.Earnings, run.StartTier) - - // Check for arena helmet drop - if dropped := p.arenaRollHelmetDrop(ctx.Sender, 5); dropped != nil { - text += "\n\n" + renderArenaHelmetDrop(dropped) - p.postArenaDropAnnouncement(char.DisplayName, dropped) - } - - return p.SendDM(ctx.Sender, text) -} - -func (p *AdventurePlugin) arenaCompleteCashout(userID id.UserID, run *ArenaRun, isAuto bool) error { - // Clear deadline - p.arenaDeadlines.Delete(string(userID)) - - // Credit earnings - p.euro.Credit(userID, float64(run.Earnings), "arena_cashout") - - // Load char to update wins - char, err := loadAdvCharacter(userID) - if err == nil { - char.ArenaWins++ - saveAdvCharacter(char) - } - - // End run - now := time.Now().UTC() - run.Status = "cashed_out" - run.EndedAt = &now - if err := saveArenaRun(run); err != nil { - slog.Error("arena: failed to end arena run on cashout", "user", userID, "err", err) - } - - // History and stats - insertArenaHistory(userID, run.StartTier, run.Tier, run.RoundsSurvived, run.Earnings, "cashed_out", run.LastMonster) + insertArenaHistory(userID, run.StartTier, run.Tier, run.RoundsSurvived, run.Earnings, outcome, lastMonster) upsertArenaStats(userID, run.Earnings, false, run.Tier) - // Achievement: big cashout + // Achievements if run.Earnings >= 10000 && p.achievements != nil { p.achievements.GrantAchievement(userID, "arena_cashout_big") } - - if isAuto { - return p.SendDM(userID, renderArenaAutoCashout(run.Earnings)) + if run.Tier >= 5 { + if p.achievements != nil { + p.achievements.GrantAchievement(userID, "arena_full_run") + } + // Room announcement for T5 + gr := gamesRoom() + if gr != "" { + announce := fmt.Sprintf("🏆 **%s has conquered the Arena.** Tier 5 streak. €%d earned. That Which Has Always Been has fallen.", + char.DisplayName, run.Earnings) + p.SendMessage(id.RoomID(gr), announce) + } } - return p.SendDM(userID, renderArenaCashout(run.Earnings, run.Tier)) + + // Gladiator's Helm roll (Step 9) + helmText := "" + if tiersWon >= 4 { + helmDrop := p.arenaRollGladiatorHelm(userID, tiersWon) + if helmDrop != "" { + helmText = helmDrop + // Game room announcement + gr := gamesRoom() + if gr != "" { + p.SendMessage(id.RoomID(gr), fmt.Sprintf( + "🏆 %s walked out of the arena with the Gladiator's Helm. Tier %d streak. They had the option to stop. They did not stop.", + char.DisplayName, tiersWon)) + } + } + } + + // Build payout summary DM + text := "" + if prefixText != "" { + text = prefixText + "\n\n" + } + text += fmt.Sprintf("⚔️ **Arena Session Complete — %d tiers cleared**\n\n", tiersWon) + text += fmt.Sprintf("Euros: +%s (%s after 10%% arena tax → community pot)\n", fmtEuro(run.Earnings), fmtEuro(arenaNet)) + text += fmt.Sprintf("XP: +%d (%.1f× streak bonus)\n", totalXP, xpMult) + if helmText != "" { + text += fmt.Sprintf("Helm drop: %s\n", helmText) + } else { + text += "Helm drop: —\n" + } + text += "\nYour rewards have been applied." + if leveled { + text += fmt.Sprintf("\n\n🎉 **Combat Level %d!**", newLevel) + } + + return p.SendDM(userID, text) +} + +// arenaProcessBail handles bail payout (called from handleArenaBail or countdown). +func (p *AdventurePlugin) arenaProcessBail(userID id.UserID, run *ArenaRun) error { + char, err := loadAdvCharacter(userID) + if err != nil { + slog.Error("arena: failed to load character for bail", "user", userID, "err", err) + return p.SendDM(userID, "Failed to process bail. Your session state is preserved.") + } + + now := time.Now().UTC() + run.Status = "cashed_out" + run.EndedAt = &now + + return p.arenaCompleteSession(userID, run, char, "") } // ── Combat Math ───────────────────────────────────────────────────────────── @@ -686,7 +614,16 @@ func arenaDeathChance(monster *ArenaMonster, char *AdventureCharacter, equip map } equipMod := avgTier * 0.03 // 0 at tier 0, 0.15 at tier 5 - deathChance := baseDeath + levelMod - equipMod + skillMod + // Housing HP bonus reduces death chance + houseMod := char.HouseHPBonus() // 0-20% based on house tier + + // Pet morning defense buff (cat offering / dog smothering) + petDefMod := 0.0 + if char.PetMorningDefense { + petDefMod = 0.05 + } + + deathChance := baseDeath + levelMod - equipMod + skillMod - houseMod - petDefMod return math.Max(0.01, math.Min(0.98, deathChance)) } @@ -731,54 +668,128 @@ func arenaPickDeathMessage(monster *ArenaMonster, tier, round int) string { return r.Replace(template) } -// ── Auto-Cashout Ticker ───────────────────────────────────────────────────── +// ── Arena Countdown (30-second opt-out timer) ────────────────────────────── -func (p *AdventurePlugin) arenaAutoCashoutTicker() { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() +func (p *AdventurePlugin) arenaCountdown(userID id.UserID, run *ArenaRun) { + // Create bail channel — ownership is transferred to either the bail handler + // (via LoadAndDelete) or the timer expiry path. The defer is removed to + // avoid racing with those paths. + bailCh := make(chan struct{}) + p.arenaBailCh.Store(string(userID), bailCh) - for range ticker.C { - now := time.Now().UTC() - p.arenaDeadlines.Range(func(key, value any) bool { - userIDStr := key.(string) - deadline := value.(time.Time) - if now.After(deadline) { - p.arenaDeadlines.Delete(userIDStr) - go p.autoCollectArena(id.UserID(userIDStr)) - } - return true - }) + nextTier := arenaGetTier(run.Tier + 1) + nextTierName := "unknown" + if nextTier != nil { + nextTierName = nextTier.Name + } + + // Send countdown DM + countdownText := fmt.Sprintf("⚔️ Tier %d cleared.\n\nEntering Tier %d — %s in 30 seconds. Type `!bail` to stop here and collect your rewards.\n\n▸ 30 seconds", + run.Tier, run.Tier+1, nextTierName) + msgID, err := p.SendDMID(userID, countdownText) + if err != nil { + slog.Error("arena: failed to send countdown DM", "user", userID, "err", err) + // Fall through to auto-advance anyway + } + + // 3 intervals of 10 seconds each + for _, remaining := range []int{20, 10, 0} { + select { + case <-bailCh: + // Player bailed — process payout + userMu := p.advUserLock(userID) + userMu.Lock() + // Re-load run in case state changed + freshRun, err := loadActiveArenaRun(userID) + if err != nil || freshRun == nil || freshRun.Status != "awaiting" { + userMu.Unlock() + return + } + if err := p.arenaProcessBail(userID, freshRun); err != nil { + slog.Error("arena: bail payout failed", "user", userID, "err", err) + } + userMu.Unlock() + return + case <-time.After(10 * time.Second): + if remaining > 0 && msgID != "" { + editText := fmt.Sprintf("⚔️ Tier %d cleared.\n\nEntering Tier %d — %s in %d seconds. Type `!bail` to stop here and collect your rewards.\n\n▸ %d seconds", + run.Tier, run.Tier+1, nextTierName, remaining, remaining) + if err := p.EditDM(userID, msgID, editText); err != nil { + slog.Warn("arena: failed to edit countdown DM", "user", userID, "err", err) + } + } + } + } + + // Timer expired — remove bail channel to prevent late bail from closing it. + // If LoadAndDelete fails, bail already claimed it — exit silently. + if _, ok := p.arenaBailCh.LoadAndDelete(string(userID)); !ok { + return // bail handler already took ownership } -} -func (p *AdventurePlugin) autoCollectArena(userID id.UserID) { userMu := p.advUserLock(userID) userMu.Lock() defer userMu.Unlock() - run, err := loadActiveArenaRun(userID) - if err != nil || run == nil || run.Status != "awaiting" { + freshRun, err := loadActiveArenaRun(userID) + if err != nil || freshRun == nil || freshRun.Status != "awaiting" { return } - if err := p.arenaCompleteCashout(userID, run, true); err != nil { - slog.Error("arena: auto-cashout failed", "user", userID, "err", err) + if nextTier == nil { + // Shouldn't happen — T5 is handled before countdown starts + p.arenaProcessBail(userID, freshRun) + return } + + char, err := loadAdvCharacter(userID) + if err != nil { + slog.Error("arena: failed to load character for auto-advance", "user", userID, "err", err) + p.arenaProcessBail(userID, freshRun) + return + } + + // Level gate for next tier + if char.CombatLevel < nextTier.MinLevel { + p.SendDM(userID, renderArenaLevelGate(nextTier, char.CombatLevel)+"\n\nYour accumulated rewards have been paid out.") + p.arenaProcessBail(userID, freshRun) + return + } + + // Advance to next tier + freshRun.Tier = nextTier.Number + freshRun.Round = 1 + freshRun.Status = "active" + if err := saveArenaRun(freshRun); err != nil { + slog.Error("arena: failed to advance to next tier", "user", userID, "err", err) + return + } + + // Grant descend achievement + if p.achievements != nil { + p.achievements.GrantAchievement(userID, "arena_descend") + } + + monster := arenaGetMonster(nextTier.Number, 1) + text := renderArenaRoundStart(nextTier, 1, monster, freshRun) + p.SendDM(userID, text) } -// arenaCleanupStaleRuns auto-cashes out any runs left in 'awaiting' status -// (e.g. from a bot restart). Called during Init(). +// ── Restart Recovery ──────────────────────────────────────────────────────── + +// arenaCleanupStaleRuns auto-bails any active/awaiting runs on restart. +// Per spec: do not forfeit due to infrastructure reasons. func (p *AdventurePlugin) arenaCleanupStaleRuns() { - runs, err := loadAwaitingArenaRuns() + runs, err := loadStaleArenaRuns() if err != nil { slog.Error("arena: failed to load stale runs", "err", err) return } for _, run := range runs { run := run - slog.Info("arena: auto-cashing out stale awaiting run", "user", run.UserID, "earnings", run.Earnings) - if err := p.arenaCompleteCashout(run.UserID, &run, true); err != nil { - slog.Error("arena: stale run cashout failed", "user", run.UserID, "err", err) + slog.Info("arena: auto-bailing stale run on restart", "user", run.UserID, "tier", run.Tier, "earnings", run.Earnings) + if err := p.arenaProcessBail(run.UserID, &run); err != nil { + slog.Error("arena: stale run bail failed", "user", run.UserID, "err", err) } } } @@ -792,12 +803,14 @@ func loadActiveArenaRun(userID id.UserID) (*ArenaRun, error) { var endedAt *int64 err := d.QueryRow(` SELECT id, user_id, room_id, start_tier, tier, round, status, earnings, + tier_earnings, xp_accumulated, rounds_survived, last_monster, started_at, ended_at FROM arena_runs WHERE user_id = ? AND status IN ('active', 'awaiting') ORDER BY id DESC LIMIT 1`, string(userID)).Scan( &run.ID, &run.UserID, &run.RoomID, &run.StartTier, &run.Tier, &run.Round, - &run.Status, &run.Earnings, &run.RoundsSurvived, &run.LastMonster, + &run.Status, &run.Earnings, &run.TierEarnings, &run.XPAccumulated, + &run.RoundsSurvived, &run.LastMonster, &startedAt, &endedAt, ) if err != nil { @@ -811,12 +824,13 @@ func loadActiveArenaRun(userID id.UserID) (*ArenaRun, error) { return run, nil } -func loadAwaitingArenaRuns() ([]ArenaRun, error) { +func loadStaleArenaRuns() ([]ArenaRun, error) { d := db.Get() rows, err := d.Query(` SELECT id, user_id, room_id, start_tier, tier, round, status, earnings, + tier_earnings, xp_accumulated, rounds_survived, last_monster, started_at, ended_at - FROM arena_runs WHERE status = 'awaiting'`) + FROM arena_runs WHERE status IN ('awaiting', 'active')`) if err != nil { return nil, err } @@ -829,7 +843,8 @@ func loadAwaitingArenaRuns() ([]ArenaRun, error) { var endedAt *int64 if err := rows.Scan( &r.ID, &r.UserID, &r.RoomID, &r.StartTier, &r.Tier, &r.Round, - &r.Status, &r.Earnings, &r.RoundsSurvived, &r.LastMonster, + &r.Status, &r.Earnings, &r.TierEarnings, &r.XPAccumulated, + &r.RoundsSurvived, &r.LastMonster, &startedAt, &endedAt, ); err != nil { return nil, err @@ -848,10 +863,12 @@ func createArenaRun(run *ArenaRun) error { d := db.Get() result, err := d.Exec(` INSERT INTO arena_runs (user_id, room_id, start_tier, tier, round, status, earnings, + tier_earnings, xp_accumulated, rounds_survived, last_monster, started_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, string(run.UserID), string(run.RoomID), run.StartTier, run.Tier, run.Round, - run.Status, run.Earnings, run.RoundsSurvived, run.LastMonster, + run.Status, run.Earnings, run.TierEarnings, run.XPAccumulated, + run.RoundsSurvived, run.LastMonster, run.StartedAt.Unix(), ) if err != nil { @@ -870,9 +887,11 @@ func saveArenaRun(run *ArenaRun) error { _, err := d.Exec(` UPDATE arena_runs SET tier = ?, round = ?, status = ?, earnings = ?, + tier_earnings = ?, xp_accumulated = ?, rounds_survived = ?, last_monster = ?, ended_at = ? WHERE id = ?`, run.Tier, run.Round, run.Status, run.Earnings, + run.TierEarnings, run.XPAccumulated, run.RoundsSurvived, run.LastMonster, endedAt, run.ID, ) @@ -1021,6 +1040,15 @@ func arenaGearByTier(tier int) *ArenaGearSet { return &arenaGearSets[tier-1] } +func arenaGearByName(name string) *ArenaGearSet { + for i := range arenaGearSets { + if arenaGearSets[i].HelmetName == name { + return &arenaGearSets[i] + } + } + return nil +} + // arenaRollHelmetDrop checks if a helmet should drop and equips it if the player // doesn't already have an arena helmet at this tier or higher. If they do, the // drop is silently discarded (no duplicate drops). @@ -1044,14 +1072,49 @@ func (p *AdventurePlugin) arenaRollHelmetDrop(userID id.UserID, tier int) *Arena } helmet, hasHelmet := equip[SlotHelmet] - if hasHelmet && helmet.ArenaTier >= tier { - // Already has same or better arena helmet — silent discard + hasRealHelmet := hasHelmet && helmet.Tier > 0 + + // Discard only if existing is a same-or-better arena helmet (duplicate suppression). + if hasRealHelmet && helmet.ArenaTier >= tier { return nil } - // Equip the arena helmet + // If existing is a strictly better non-arena helmet, stash the drop in inventory + // so the player can equip it later via !adventure equip rather than overwriting. + if hasRealHelmet && helmet.ArenaTier == 0 && helmet.Tier > tier { + if err := addAdvInventoryItem(userID, AdvItem{ + Name: gear.HelmetName, + Type: "ArenaGear", + Tier: tier, + Slot: SlotHelmet, + }); err != nil { + slog.Error("arena: failed to stash arena helmet drop", "user", userID, "err", err) + return nil + } + return gear + } + + // Auto-equip: move the old helmet to inventory first (preserving its flavor) + // so the player doesn't silently lose it. + if hasRealHelmet { + oldType := "ShopGear" + if helmet.Masterwork { + oldType = "MasterworkGear" + } else if helmet.ArenaTier > 0 { + oldType = "ArenaGear" + } + if err := addAdvInventoryItem(userID, AdvItem{ + Name: helmet.Name, + Type: oldType, + Tier: helmet.Tier, + Slot: SlotHelmet, + SkillSource: helmet.SkillSource, + }); err != nil { + slog.Error("arena: failed to move old helmet to inventory", "user", userID, "err", err) + } + } + if !hasHelmet { - // Shouldn't happen (all slots created at character creation), but be safe helmet = &AdvEquipment{Slot: SlotHelmet} } helmet.Tier = tier @@ -1060,6 +1123,8 @@ func (p *AdventurePlugin) arenaRollHelmetDrop(userID id.UserID, tier int) *Arena helmet.ActionsUsed = 0 helmet.ArenaTier = tier helmet.ArenaSet = gear.SetKey + helmet.Masterwork = false + helmet.SkillSource = "" if err := saveAdvEquipment(userID, helmet); err != nil { slog.Error("arena: failed to save arena helmet drop", "user", userID, "err", err) @@ -1084,3 +1149,60 @@ func (p *AdventurePlugin) postArenaDropAnnouncement(playerName string, gear *Are } p.SendMessage(id.RoomID(gr), announce) } + +// ── Gladiator's Helm (Streak-Exclusive Drop) ────────────────────────────── + +const ( + gladiatorHelmDropT4 = 0.08 + gladiatorHelmDropT5 = 0.18 + gladiatorHelmTier = 5 // equipment tier level +) + +// arenaRollGladiatorHelm checks for the streak-exclusive Gladiator's Helm drop. +// Returns the DM text for the drop, or empty string if no drop. +func (p *AdventurePlugin) arenaRollGladiatorHelm(userID id.UserID, maxTierCleared int) string { + if maxTierCleared < 4 { + return "" + } + + dropRate := gladiatorHelmDropT4 + if maxTierCleared >= 5 { + dropRate = gladiatorHelmDropT5 + } + + if rand.Float64() >= dropRate { + return "" + } + + // Check if player already owns a Gladiator's Helm at full condition + equip, err := loadAdvEquipment(userID) + if err != nil { + slog.Error("arena: failed to load equipment for gladiator helm check", "user", userID, "err", err) + return "" + } + + helmet, hasHelmet := equip[SlotHelmet] + if hasHelmet && helmet.Name == "Gladiator's Helm" && helmet.Condition == 100 { + // Already owns at full condition — suppress silently + return "" + } + + // Equip the Gladiator's Helm (1.75x tier = effective tier 5 with bonus) + if !hasHelmet { + helmet = &AdvEquipment{Slot: SlotHelmet} + } + helmet.Tier = gladiatorHelmTier + helmet.Condition = 100 + helmet.Name = "Gladiator's Helm" + helmet.ActionsUsed = 0 + helmet.ArenaTier = gladiatorHelmTier + helmet.ArenaSet = "gladiator" + helmet.Masterwork = true // Masterwork-tier item + + if err := saveAdvEquipment(userID, helmet); err != nil { + slog.Error("arena: failed to save gladiator helm", "user", userID, "err", err) + return "" + } + + return "The Gladiator's Helm" +} diff --git a/internal/plugin/adventure_arena_combat.go b/internal/plugin/adventure_arena_combat.go index 479bfec..bc8436a 100644 --- a/internal/plugin/adventure_arena_combat.go +++ b/internal/plugin/adventure_arena_combat.go @@ -3,383 +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 - } - // Mix in player-miss actions (~30% of enemy_hit rounds) - if rand.IntN(100) < 30 { - r.Text = pickFrom(arenaPlayerMissActions, picker.playerMiss, dmg) - } else { - r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg) - } - - case "block": - r.Text = pickFromNoFmt(arenaBlockActions, picker.block) - - 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 - 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), - } -} - -// 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 ─────────────────────────────────────────────────────────── @@ -409,96 +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. They do not go with that top on this planet nor any other.", - "The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity of the move hurts more than the hit itself. %d damage.", - "The enemy questions your life choices. You pause to genuinely reflect. They hit you during the pause. %d damage.", - "The enemy delivers a full monologue. You listen to the whole thing. It was actually pretty good. %d damage from the time lost.", - "The enemy compliments you unexpectedly. You thank them. They snicker because you actually believed them and revealed to everyone that you're somehow a bigger buffoon than previously known. %d damage.", - "The enemy points at something behind you. You don't fall for it. They throw a projectile which bounces off the wall and hits you in the back of the head. What an amazing trick shot. %d damage. The crowd roars in laughter at the spectacle. But mostly at you.", - "The enemy pulls out their phone and starts filming. You perform for the camera. This was a mistake. %d damage.", - "The enemy sneezes directly in your face. You lose your turn being disgusted. %d damage while you process this.", - "The enemy whispers something. You lean in to hear it. %d damage. There was nothing worth hearing.", - "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. This was the plan. %d damage.", - "The enemy sighs before hitting you. Like they had somewhere better to be. %d damage.", - "The enemy recounts a mildly interesting story mid-fight. You get drawn in. %d damage before the ending, which was not worth it.", - "The enemy raises one eyebrow at you and then attacks. The eyebrow did more damage than the hit. %d damage total.", - "The enemy adjusts their grip, rolls their shoulders, and hits you with what is technically the bare minimum of effort. %d damage. You gave it everything. They did not.", -} - -// Player actions — hit the enemy. %d is damage to enemy. -var arenaPlayerHitActions = []string{ - "You make a joke using a painfully dated reference. While the enemy stands there pondering what on earth you could possibly be referring to, you seize the opportunity and land a critical hit. %d damage. Your jokes are always great at leaving people dazed and confused.", - "You attempt a battle cry. It comes out as a question. The enemy is briefly confused. You hit them for %d damage before they recover.", - "You wind up for a big hit and connect for %d damage. You pulled something. The enemy doesn't know this yet.", - "You hit the enemy for %d damage. They seem fine. You are less fine about this than they are.", - "You connect cleanly for %d damage and immediately look at your hand like you're surprised it worked. You were.", - "You score a clean hit for %d damage and immediately start explaining to no one in particular how you did that. Nobody asked. The fight is still happening.", - "You land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.", -} - -// Player actions — player's turn goes wrong. %d is damage to player. -var arenaPlayerMissActions = []string{ - "You reach for your weapon and grab the wrong item. You are holding a receipt. The enemy hits you for %d damage. You find this receipt later and it's actually useful.", - "You make prolonged eye contact with a spectator. It goes on too long. The enemy hits you for %d damage. The spectator looks away first.", - "Your shoelace comes untied. You are wearing boots. You address this. The enemy does not wait. %d damage.", - "You sneeze at a critical moment. The enemy respectfully waits. Then hits you for %d damage. There was no respect involved actually.", - "You perform a move you saw in a film once. It does not work like in the film. %d damage. The physics were always wrong in that film.", - "You get distracted by a food vendor passing the arena perimeter. So does the enemy. You recover second. %d damage.", - "You attempt to intimidate the enemy. They laugh. Genuinely. This is worse than if they hadn't. You take %d damage from the experience.", - "You slip on something. There is nothing to slip on. %d damage. The arena floor is flat and dry. You will be thinking about this.", - "You decide mid-swing to do something different. The original plan was better. %d damage.", - "You attempt a combo you've been mentally rehearsing for weeks. It goes fine until the third move. %d damage.", - "You feint left. The enemy doesn't move. You feint right. They still don't move. You just stand there feinting at someone who is not playing along. The enemy hits you. %d damage.", - "You remember reading something about fighting once. You implement it. It was about chess. %d damage.", - "You close your eyes for the strike because it feels more dramatic. You miss. The enemy doesn't. %d damage.", - "You decide this is the moment for something new. It is not the moment for something new. %d damage. File this under lessons.", -} - -// Block/dodge actions — no damage. -var arenaBlockActions = []string{ - "You swing with conviction. The enemy sidesteps it with the energy of someone who has somewhere else to be. Nothing happens. You both reset.", - "The enemy lunges. You step aside. They continue past you for several feet and have to walk back. The pause is awkward for everyone.", - "You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally. You don't know their relationship so it probably did, but also you were faster.", - "The enemy's attack grazes you but doesn't connect. They seem more annoyed by this than you are relieved.", - "You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was. Then the fight continues.", - "The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked. You will be thinking about how unnecessary it was.", - "You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it. It was still a good parry.", - "The enemy blocks your hilariously poor-timed strike with their forearm. This speaks less about the strength of their forearm and much more so about the pathetic nature of your striking abilities.", - "You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The enemy didn't do that. The pillar gets no credit either.", - "The enemy telegraphs the attack so clearly that you block it before they've finished committing to it. They look briefly embarrassed. They recover. The fight continues.", - "You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional. The enemy hesitates, which was also not intentional. Nothing lands.", - "The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena. They retrieve a backup weapon from somewhere. Nobody asks where they got it.", - "You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other. Someone has to move first. It's them. The fight continues.", - "The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects. You land. The fight continues.", - "You sidestep a strike that wasn't aimed at you. The enemy had already redirected. You both end up slightly confused about where the other one is. The round resolves without damage.", - "A referee walks through the arena on the way to somewhere else. Eye contact is made with both fighters. They keep walking. There is a beat. The fight resumes.", -} - -// Environmental actions — damage to player. %d is damage. -var arenaEnvironmentalActions = []string{ - "Your mother calls in the middle of battle asking when you're giving her grandchildren. The enemy hits you for %d damage while you work out how to answer that on speakerphone.", - "A bird lands between you and the enemy. Both combatants stop. The bird leaves. The enemy recovers first. %d damage.", - "A spectator in the front row is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.", - "The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage. The announcer mispronounces it again.", - "An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. Mutual acknowledgment. The enemy hits you for %d damage during this social transaction.", - "Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.", - "A cloud passes in front of the sun at the wrong moment. %d damage. The cloud did not mean anything by it.", - "The arena's background music cuts out unexpectedly. The silence is louder than the fight. The enemy hits you for %d damage in the disorientation.", - "The arena PA crackles and announces something completely unrelated to your fight. You both look up. The enemy looks back down first. %d damage.", - "Something falls from the spectator area. Nobody claims it. You both look at it. The enemy decides faster. %d damage.", - "A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. You both needed that break more than you'd like to admit. The enemy uses the reset better. %d damage.", - "The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round trying to work out if that changes anything. It does not. %d damage while you calculate.", - "The arena sells a limited merch item at exactly this moment. The announcement is enthusiastic. You are briefly curious. %d damage.", - "The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear from the enemy's corner. %d damage.", -} diff --git a/internal/plugin/adventure_arena_monsters.go b/internal/plugin/adventure_arena_monsters.go index 18067ee..0229fb4 100644 --- a/internal/plugin/adventure_arena_monsters.go +++ b/internal/plugin/adventure_arena_monsters.go @@ -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"}, }, }, }, diff --git a/internal/plugin/adventure_arena_render.go b/internal/plugin/adventure_arena_render.go index f45683c..f8efbf4 100644 --- a/internal/plugin/adventure_arena_render.go +++ b/internal/plugin/adventure_arena_render.go @@ -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) } diff --git a/internal/plugin/adventure_arena_test.go b/internal/plugin/adventure_arena_test.go index b7fad7e..e169d3d 100644 --- a/internal/plugin/adventure_arena_test.go +++ b/internal/plugin/adventure_arena_test.go @@ -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) + } +} diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 862196d..d28c6dd 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -72,15 +72,41 @@ func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) erro 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() @@ -103,7 +129,7 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er totalCost := daily * days balance := p.euro.GetBalance(char.UserID) if balance < float64(totalCost) { - return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs €%d for %d days. You have €%.0f. The service has standards. Not many, but some.", totalCost, days, balance)) + 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 @@ -111,6 +137,9 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er 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) @@ -148,8 +177,13 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error { return p.SendDM(ctx.Sender, "No adventurer found.") } + autoLabel := "OFF" + if char.AutoBabysit { + autoLabel = "ON" + } + if !char.BabysitActive { - return p.SendDM(ctx.Sender, "🍼 No active babysitting service. Use `!adventure babysit week` or `!adventure babysit month` to start.") + 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" @@ -176,8 +210,9 @@ func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error { "Gold earned: €%d\n"+ "XP gained: %d\n"+ "Items claimed by babysitter: %d\n"+ - "Rivals declined: %d", - remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused) + "Rivals declined: %d\n"+ + "Auto-babysit: %s", + remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel) return p.SendDM(ctx.Sender, text) } @@ -211,38 +246,68 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error { slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err) } - // Clear logs - clearBabysitLogs(char.UserID) - return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary) } // ── Daily Auto-Resolution ─────────────────────────────────────────────────── func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) { - activity := skillToActivity(char.BabysitSkillFocus) - equip, err := loadAdvEquipment(char.UserID) if err != nil { slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err) return } - // Pick highest-tier eligible location for the focus skill + 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 { - slog.Warn("babysit: no eligible locations", "user", char.UserID, "skill", char.BabysitSkillFocus) - return + return 0, 0, nil } - // Pick the last one (highest tier since they're returned in order) loc := eligible[len(eligible)-1].Location inPenalty := eligible[len(eligible)-1].InPenaltyZone - // Resolve action result := resolveAdvAction(char, equip, loc, bonuses, inPenalty) - // Babysitter never lets the adventurer die — reroll death to empty + // Babysitter never lets the adventurer die if result.Outcome == AdvOutcomeDeath { result.Outcome = AdvOutcomeEmpty result.LootItems = nil @@ -251,8 +316,13 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) { 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": @@ -262,35 +332,61 @@ func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) { } checkAdvLevelUp(char, result.XPSkill) - // Credit gold to player + // Credit gold if result.TotalLootValue > 0 { - p.euro.Credit(char.UserID, float64(result.TotalLootValue), "babysit_haul") - } - - // Items are claimed by the babysitter (not added to player inventory) - var itemNames []string - for _, item := range result.LootItems { - itemNames = append(itemNames, item.Name) + 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 - // Mark action taken + 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") - if err := saveAdvCharacter(char); err != nil { - slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err) - } - - // Log to babysit table itemsJSON := "" - if len(itemNames) > 0 { - itemsJSON = strings.Join(itemNames, ", ") + if len(allItems) > 0 { + itemsJSON = strings.Join(allItems, ", ") } - logBabysitActivity(char.UserID, string(activity), string(result.Outcome), - int(result.TotalLootValue), result.XPGained, itemsJSON) + logBabysitActivity(char.UserID, string(activity), "auto_babysit_daily", + totalGold, totalXP, itemsJSON) } // ── Expiry Check ──────────────────────────────────────────────────────────── @@ -320,8 +416,6 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) { continue } - clearBabysitLogs(char.UserID) - if err := p.SendDM(char.UserID, summary); err != nil { slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err) } diff --git a/internal/plugin/adventure_blacksmith.go b/internal/plugin/adventure_blacksmith.go index 6dd16f0..41140ca 100644 --- a/internal/plugin/adventure_blacksmith.go +++ b/internal/plugin/adventure_blacksmith.go @@ -12,7 +12,7 @@ import ( // ── Pricing ───────────────────────────────────────────────────────────────── -var blacksmithBaseRates = [6]int{1, 3, 8, 20, 55, 150} +var blacksmithBaseRates = [6]int{1, 2, 5, 12, 30, 80} func blacksmithRepairCost(eq *AdvEquipment) int { if eq == nil || eq.Condition >= 100 { @@ -34,7 +34,7 @@ func blacksmithRepairCost(eq *AdvEquipment) int { } baseRate := float64(blacksmithBaseRates[tier]) damage := float64(100 - eq.Condition) - condMult := 1.0 + damage/100.0 + condMult := 1.0 + damage/200.0 costPerPoint := baseRate * condMult return int(math.Ceil(costPerPoint * damage)) } diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index ef6ac12..515ba42 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -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 @@ -59,6 +61,40 @@ type AdventureCharacter struct { 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 } type AdvEquipment struct { @@ -203,6 +239,15 @@ 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 @@ -211,6 +256,68 @@ func (c *AdventureCharacter) Kill() { 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) float64 { @@ -307,7 +414,12 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { d := db.Get() c := &AdventureCharacter{} var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int - var deadUntil, reprieveLast, babysitExp sql.NullTime + 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, @@ -320,7 +432,20 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { masterwork_drops_received, rival_pool, rival_unlocked_notified, babysit_active, babysit_expires_at, babysit_skill_focus, - hospital_visits, robbie_visit_count, last_death_date + 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 FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan( &c.UserID, &c.DisplayName, &c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill, @@ -333,6 +458,19 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { &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, ) if err != nil { return nil, err @@ -342,6 +480,9 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { 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 } @@ -351,6 +492,31 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { 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 } @@ -405,6 +571,46 @@ func saveAdvCharacter(char *AdventureCharacter) error { 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 @@ -417,7 +623,22 @@ func saveAdvCharacter(char *AdventureCharacter) error { masterwork_drops_received = ?, rival_pool = ?, rival_unlocked_notified = ?, babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, - hospital_visits = ?, robbie_visit_count = ?, last_death_date = ? + 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 = ? WHERE user_id = ?`, char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, @@ -428,6 +649,21 @@ func saveAdvCharacter(char *AdventureCharacter) error { 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, string(char.UserID), ) return err @@ -544,7 +780,21 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { created_at, last_active_at, death_reprieve_last, masterwork_drops_received, rival_pool, rival_unlocked_notified, - babysit_active, babysit_expires_at, babysit_skill_focus + 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 FROM adventure_characters`) if err != nil { return nil, err @@ -555,7 +805,11 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { for rows.Next() { c := AdventureCharacter{} var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int - var deadUntil, reprieveLast, babysitExp sql.NullTime + 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, @@ -567,6 +821,20 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { &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, ); err != nil { return nil, err } @@ -575,6 +843,16 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { 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 } @@ -584,6 +862,24 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) { 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() @@ -594,7 +890,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 } @@ -655,14 +951,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 } diff --git a/internal/plugin/adventure_chat_perks.go b/internal/plugin/adventure_chat_perks.go new file mode 100644 index 0000000..3aaa551 --- /dev/null +++ b/internal/plugin/adventure_chat_perks.go @@ -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?" + } +} diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go new file mode 100644 index 0000000..970bb51 --- /dev/null +++ b/internal/plugin/adventure_consumables.go @@ -0,0 +1,481 @@ +package plugin + +import ( + "log/slog" + "math/rand/v2" + + "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 +} + +// 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. +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++ + } else { + // Failure: all ingredients destroyed + for _, id := range ingredientIDs { + removeAdvInventoryItem(id) + } + remaining = removeItemsByIDs(remaining, ingredientIDs) + results = append(results, CraftResult{Recipe: bestRecipe, Success: false}) + } + } + + // 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 +} diff --git a/internal/plugin/adventure_consumables_test.go b/internal/plugin/adventure_consumables_test.go new file mode 100644 index 0000000..fd2dcc5 --- /dev/null +++ b/internal/plugin/adventure_consumables_test.go @@ -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) + } + } +} diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index c7a9e9b..2812dfe 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -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 diff --git a/internal/plugin/adventure_flavor_events.go b/internal/plugin/adventure_flavor_events.go index 29946b1..f4295ec 100644 --- a/internal/plugin/adventure_flavor_events.go +++ b/internal/plugin/adventure_flavor_events.go @@ -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", }, diff --git a/internal/plugin/adventure_flavor_fishing.go b/internal/plugin/adventure_flavor_fishing.go index 5d93171..aad66ce 100644 --- a/internal/plugin/adventure_flavor_fishing.go +++ b/internal/plugin/adventure_flavor_fishing.go @@ -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 diff --git a/internal/plugin/adventure_flavor_mining.go b/internal/plugin/adventure_flavor_mining.go index af00906..73a958d 100644 --- a/internal/plugin/adventure_flavor_mining.go +++ b/internal/plugin/adventure_flavor_mining.go @@ -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.", }, } diff --git a/internal/plugin/adventure_flavor_npcs.go b/internal/plugin/adventure_flavor_npcs.go new file mode 100644 index 0000000..c937189 --- /dev/null +++ b/internal/plugin/adventure_flavor_npcs.go @@ -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!", +} diff --git a/internal/plugin/adventure_flavor_pets.go b/internal/plugin/adventure_flavor_pets.go new file mode 100644 index 0000000..5e7484c --- /dev/null +++ b/internal/plugin/adventure_flavor_pets.go @@ -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.", +} diff --git a/internal/plugin/adventure_holidays_test.go b/internal/plugin/adventure_holidays_test.go index fc005a4..7c099db 100644 --- a/internal/plugin/adventure_holidays_test.go +++ b/internal/plugin/adventure_holidays_test.go @@ -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}, diff --git a/internal/plugin/adventure_hospital.go b/internal/plugin/adventure_hospital.go index d631ac5..cdb090b 100644 --- a/internal/plugin/adventure_hospital.go +++ b/internal/plugin/adventure_hospital.go @@ -3,6 +3,7 @@ package plugin import ( "fmt" "log/slog" + "math" "strings" "time" @@ -96,7 +97,7 @@ func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error { sb.WriteString("\n\n") // Payment prompt - sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **€%d**\n\n", afterInsurance)) + 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.*") @@ -162,6 +163,12 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad 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 @@ -176,10 +183,10 @@ func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *ad // Discharge DM p.SendDM(ctx.Sender, fmt.Sprintf( "✅ **Discharged — you're alive!**\n\n"+ - "€%d deducted. Nurse Joy wishes you the best. "+ + "%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.", - cost)) + fmtEuro(cost))) // Room announcement gr := gamesRoom() @@ -220,9 +227,9 @@ func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharac "🏥 **St. Guildmore's Memorial Hospital**\n\n"+ "Nurse Joy has been notified. A bed is being prepared.\n\n"+ "Type `!hospital` to check in for same-day revival.\n"+ - "Estimated bill: €%d (Guild insurance covers €%d → you pay €%d)\n\n"+ + "Estimated bill: %s (Guild insurance covers %s → you pay %s)\n\n"+ "Or rest up — natural respawn at %s UTC.", - beforeInsurance, beforeInsurance-afterInsurance, afterInsurance, respawnTime) + fmtEuro(beforeInsurance), fmtEuro(beforeInsurance-afterInsurance), fmtEuro(afterInsurance), respawnTime) time.AfterFunc(10*time.Second, func() { if err := p.SendDM(userID, text); err != nil { diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go new file mode 100644 index 0000000..f7db09d --- /dev/null +++ b/internal/plugin/adventure_housing.go @@ -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 ` 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 ` — 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 ` (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*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)) +} diff --git a/internal/plugin/adventure_housing_test.go b/internal/plugin/adventure_housing_test.go new file mode 100644 index 0000000..ebf56f2 --- /dev/null +++ b/internal/plugin/adventure_housing_test.go @@ -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) + } + } +} diff --git a/internal/plugin/adventure_masterwork.go b/internal/plugin/adventure_masterwork.go index 37ad8d0..b0fc9df 100644 --- a/internal/plugin/adventure_masterwork.go +++ b/internal/plugin/adventure_masterwork.go @@ -169,8 +169,9 @@ 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 } @@ -332,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) @@ -351,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] @@ -365,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\".") @@ -374,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()) @@ -429,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 { @@ -487,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.") @@ -501,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. diff --git a/internal/plugin/adventure_mortgage.go b/internal/plugin/adventure_mortgage.go new file mode 100644 index 0000000..9f89cb3 --- /dev/null +++ b/internal/plugin/adventure_mortgage.go @@ -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)) +} diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go new file mode 100644 index 0000000..0f2ba94 --- /dev/null +++ b/internal/plugin/adventure_npcs.go @@ -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) + } +} diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go new file mode 100644 index 0000000..fc568cb --- /dev/null +++ b/internal/plugin/adventure_pets.go @@ -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 +} diff --git a/internal/plugin/adventure_pets_test.go b/internal/plugin/adventure_pets_test.go new file mode 100644 index 0000000..5f618f7 --- /dev/null +++ b/internal/plugin/adventure_pets_test.go @@ -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) + } + } +} diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index e2c703c..9fe96c6 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -201,11 +201,26 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* sb.WriteString(" — `!adventure rivals` for details\n") } - // Today's action - if char.ActionTakenToday { - sb.WriteString("\n📅 Today: Action taken") + // 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() @@ -218,7 +233,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 @@ -259,46 +274,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️⃣ Blacksmith** — repair damaged equipment\n") - sb.WriteString("**7️⃣ Rest** — skip today, bank your luck\n\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.") @@ -306,57 +359,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️⃣ Blacksmith** — repair damaged equipment\n") - sb.WriteString("**7️⃣ Rest** — skip the second action\n\n") - sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`") - - return sb.String() -} - // ── Resolution DM ──────────────────────────────────────────────────────────── func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string { @@ -380,13 +382,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)) } @@ -619,8 +624,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 @@ -737,11 +746,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, }) } diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index 4c153fd..c9b924f 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "log/slog" + "math" "math/rand/v2" "strings" "time" @@ -169,6 +170,29 @@ func communityPotAdd(amount int) { ) } +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 @@ -459,8 +483,11 @@ func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction * // Outcome line. outcomePool := rivalRoundOutcomeWin outcome := pickRivalFlavor(outcomePool) - if strings.Contains(outcome, "%s") { + 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)) @@ -471,8 +498,11 @@ func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction * challenge.RivalScore++ outcomePool := rivalRoundOutcomeLoss outcome := pickRivalFlavor(outcomePool) - if strings.Contains(outcome, "%s") { + 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)) @@ -550,13 +580,14 @@ func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playe } } - winnerShare := stake / 2 + 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). @@ -666,13 +697,14 @@ func (p *AdventurePlugin) expireRivalChallenges() { } } - winnerShare := stake / 2 + 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) diff --git a/internal/plugin/adventure_robbie.go b/internal/plugin/adventure_robbie.go index e93478d..50f9221 100644 --- a/internal/plugin/adventure_robbie.go +++ b/internal/plugin/adventure_robbie.go @@ -42,7 +42,7 @@ func (p *AdventurePlugin) robbieTicker() { slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey) } - if now.Hour() != robbieTargetHour || now.Minute() != 0 { + if now.Hour() < robbieTargetHour { continue } @@ -122,7 +122,11 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string continue } takenItems = append(takenItems, item) - totalPayout += 50 + payout := item.Value / 4 + if payout < 1 { + payout = 1 + } + totalPayout += payout communityTotal += item.Value if item.Type == "MasterworkGear" { masterworkTaken = true @@ -180,15 +184,18 @@ func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem { var result []AdvItem for _, item := range inv { - // Only gear with a slot (skip ores, wood, fruit, gems, fish, etc.) - if item.Slot == "" { - continue - } // Never touch Arena gear or cards if item.Type == "ArenaGear" || item.Type == "card" { continue } + // 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 @@ -243,14 +250,18 @@ func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gav 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) → €50", emoji, item.Name, item.Tier)) + 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) → €50", emoji, item.Name, item.Tier)) + sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €%d", emoji, item.Name, item.Tier, payout)) } sb.WriteByte('\n') } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 09f0d40..dd9bae3 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -4,6 +4,8 @@ import ( "fmt" "log/slog" "math/rand/v2" + "os" + "strings" "time" "gogobee/internal/db" @@ -96,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 { @@ -118,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) @@ -170,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 { @@ -225,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)] @@ -276,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 } @@ -294,6 +320,7 @@ func (p *AdventurePlugin) midnightTicker() { continue } db.MarkJobCompleted(jobName, dateKey) + lastRanDate = dateKey } } @@ -308,19 +335,46 @@ func (p *AdventurePlugin) midnightReset() error { dmsSent := 0 for _, char := range chars { - // Dead players freeze their streak — death is involuntary, don't punish it. - if !char.Alive { - continue - } - - if !char.ActionTakenToday { - // If the player died today (or yesterday — covering late-night deaths - // that span midnight), grant a grace period: no shame, no streak reset. + if !char.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 + } + } + } + // Jitter between DMs to avoid Matrix rate limits if dmsSent > 0 { time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond) @@ -329,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") @@ -364,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) } @@ -373,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() @@ -389,9 +457,39 @@ func (p *AdventurePlugin) midnightReset() error { // 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) { diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index 0bd722e..1fcaebd 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -3,6 +3,7 @@ package plugin import ( "fmt" "log/slog" + "math" "math/rand/v2" "strings" "time" @@ -101,7 +102,7 @@ func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) { p.shopSessions.Store(string(userID)+":nudge_gen", gen) capturedGen := gen - go func() { + 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 { @@ -117,12 +118,12 @@ func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) { } flavor, _ := advPickFlavor(luigiBrowseTimeout, userID, "luigi_browse") p.SendDM(userID, fmt.Sprintf("*%s*", flavor)) - }() + }) } // ── Display: Luigi Greeting + Category Menu ───────────────────────────────── -func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string { +func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool, chatLevel int) string { var sb strings.Builder // Check if fully maxed out. @@ -141,13 +142,18 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, return sb.String() } - greet, _ := advPickFlavor(luigiGreetings, userID, "luigi_greet") + 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\n\n") + sb.WriteString("⛏️ Tools 🧪 Supplies\n\n") if showAll { flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall") @@ -319,11 +325,25 @@ func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interact 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, or tools.") + 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) @@ -359,7 +379,7 @@ func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction } balance := p.euro.GetBalance(ctx.Sender) - text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll) + 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}, @@ -397,10 +417,10 @@ func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction // 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 { + 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) in that slot. A shop item can't replace that. You earned it.\n\nPick something else, or \"back\" to return.", - current.Name)) + 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) @@ -459,8 +479,8 @@ func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *ad current := equip[data.Slot] // Re-check Arena/Masterwork block. - if current != nil && current.ArenaTier > 0 { - return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** is Arena gear. Luigi won't overwrite it.", current.Name)) + if current != nil && current.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)) @@ -477,6 +497,12 @@ func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *ad 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" @@ -484,6 +510,8 @@ func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *ad 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) } @@ -656,10 +684,9 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, } // Block shop purchases that would overwrite arena gear (always) or // masterwork gear (only if shop item isn't clearly better). - if current != nil && current.ArenaTier > 0 { - return fmt.Sprintf("You already have **%s** (Arena gear). A shop item cannot replace this. "+ - "You earned that. Don't throw it away.", - current.Name) + 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.", @@ -676,12 +703,20 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, 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) } @@ -762,11 +797,17 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { 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) } @@ -788,8 +829,14 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string 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)) } } @@ -828,3 +875,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)) +} diff --git a/internal/plugin/adventure_shop_test.go b/internal/plugin/adventure_shop_test.go index 7d09cce..79f327a 100644 --- a/internal/plugin/adventure_shop_test.go +++ b/internal/plugin/adventure_shop_test.go @@ -11,7 +11,7 @@ func TestLuigiShopGreeting_Basic(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{ SlotWeapon: {Tier: 2, Condition: 100}, } - text := luigiShopGreeting("@user:test", equip, 5000, false) + text := luigiShopGreeting("@user:test", equip, 5000, false, 0) if !strings.Contains(text, "Luigi's") { t.Error("greeting should contain Luigi's") @@ -35,7 +35,7 @@ func TestLuigiShopGreeting_MaxedOut(t *testing.T) { SlotBoots: {Tier: 5, Condition: 100}, SlotTool: {Tier: 5, Condition: 100}, } - text := luigiShopGreeting("@user:test", equip, 99999, false) + text := luigiShopGreeting("@user:test", equip, 99999, false, 0) // Should NOT show category grid. if strings.Contains(text, "Reply with a category") { @@ -49,7 +49,7 @@ func TestLuigiShopGreeting_MaxedOut(t *testing.T) { func TestLuigiShopGreeting_ShowAll(t *testing.T) { equip := map[EquipmentSlot]*AdvEquipment{} - text := luigiShopGreeting("@user:test", equip, 1000, true) + text := luigiShopGreeting("@user:test", equip, 1000, true, 0) // Should contain a show-all comment. if !strings.Contains(text, "judgment") && !strings.Contains(text, "thoroughness") { diff --git a/internal/plugin/adventure_test.go b/internal/plugin/adventure_test.go index bc3e48c..81c3527 100644 --- a/internal/plugin/adventure_test.go +++ b/internal/plugin/adventure_test.go @@ -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{ diff --git a/internal/plugin/adventure_treasure.go b/internal/plugin/adventure_treasure.go index 18506ef..885187b 100644 --- a/internal/plugin/adventure_treasure.go +++ b/internal/plugin/adventure_treasure.go @@ -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 diff --git a/internal/plugin/adventure_twinbee.go b/internal/plugin/adventure_twinbee.go index 909a137..ef92009 100644 --- a/internal/plugin/adventure_twinbee.go +++ b/internal/plugin/adventure_twinbee.go @@ -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++ diff --git a/internal/plugin/anime.go b/internal/plugin/anime.go index d7cfb60..3b2eb01 100644 --- a/internal/plugin/anime.go +++ b/internal/plugin/anime.go @@ -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 } diff --git a/internal/plugin/audit_fixes_test.go b/internal/plugin/audit_fixes_test.go new file mode 100644 index 0000000..5b87d46 --- /dev/null +++ b/internal/plugin/audit_fixes_test.go @@ -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") + } +} diff --git a/internal/plugin/blackjack.go b/internal/plugin/blackjack.go index 04ed291..7c6596e 100644 --- a/internal/plugin/blackjack.go +++ b/internal/plugin/blackjack.go @@ -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" diff --git a/internal/plugin/botinfo.go b/internal/plugin/botinfo.go index 2368ee2..deffe34 100644 --- a/internal/plugin/botinfo.go +++ b/internal/plugin/botinfo.go @@ -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()) } diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go new file mode 100644 index 0000000..1de75d4 --- /dev/null +++ b/internal/plugin/combat_bridge.go @@ -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) +} diff --git a/internal/plugin/combat_bridge_test.go b/internal/plugin/combat_bridge_test.go new file mode 100644 index 0000000..a6131a0 --- /dev/null +++ b/internal/plugin/combat_bridge_test.go @@ -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) + } +} diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go new file mode 100644 index 0000000..8862e7f --- /dev/null +++ b/internal/plugin/combat_engine.go @@ -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 +} diff --git a/internal/plugin/combat_engine_test.go b/internal/plugin/combat_engine_test.go new file mode 100644 index 0000000..f24532b --- /dev/null +++ b/internal/plugin/combat_engine_test.go @@ -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") + } +} diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go new file mode 100644 index 0000000..91d87f6 --- /dev/null +++ b/internal/plugin/combat_narrative.go @@ -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.", +} diff --git a/internal/plugin/combat_narrative_test.go b/internal/plugin/combat_narrative_test.go new file mode 100644 index 0000000..d83229e --- /dev/null +++ b/internal/plugin/combat_narrative_test.go @@ -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") + } +} diff --git a/internal/plugin/combat_stats.go b/internal/plugin/combat_stats.go new file mode 100644 index 0000000..2cd397f --- /dev/null +++ b/internal/plugin/combat_stats.go @@ -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 +} diff --git a/internal/plugin/combat_stats_test.go b/internal/plugin/combat_stats_test.go new file mode 100644 index 0000000..949b3b8 --- /dev/null +++ b/internal/plugin/combat_stats_test.go @@ -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) + } + } +} diff --git a/internal/plugin/concerts.go b/internal/plugin/concerts.go index 66f3e5a..708ac26 100644 --- a/internal/plugin/concerts.go +++ b/internal/plugin/concerts.go @@ -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 } diff --git a/internal/plugin/coop_dungeon.go b/internal/plugin/coop_dungeon.go new file mode 100644 index 0000000..45a39f2 --- /dev/null +++ b/internal/plugin/coop_dungeon.go @@ -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 `" + ` — Open an invite for a co-op dungeon (tier 1-5) +` + "`!coop join []`" + ` — Join an open invite (defaults to most recent) +` + "`!coop fund `" + ` — Set today's funding (none/minimal/standard/aggressive/all_in) +` + "`!coop vote `" + ` — Vote on the day's floor event +` + "`!coop bet `" + ` — Spectator bet (parimutuel, 10% rake) +` + "`!coop gift `" + ` — Send a gift (1 harvest action) +` + "`!coop giftvote `" + ` — 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 `.") + } + 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 ` 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 `.") + } + 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 [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 [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 +} diff --git a/internal/plugin/coop_dungeon_balance_test.go b/internal/plugin/coop_dungeon_balance_test.go new file mode 100644 index 0000000..e91c1b5 --- /dev/null +++ b/internal/plugin/coop_dungeon_balance_test.go @@ -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() +} diff --git a/internal/plugin/coop_dungeon_betting.go b/internal/plugin/coop_dungeon_betting.go new file mode 100644 index 0000000..af2023d --- /dev/null +++ b/internal/plugin/coop_dungeon_betting.go @@ -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 ` or `!coop bet ` 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 success|failure`.", + pSuccess*100, total, successPool, failurePool, coopBetRake*100) +} + diff --git a/internal/plugin/coop_dungeon_db.go b/internal/plugin/coop_dungeon_db.go new file mode 100644 index 0000000..3543f63 --- /dev/null +++ b/internal/plugin/coop_dungeon_db.go @@ -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 +} diff --git a/internal/plugin/coop_dungeon_gifts.go b/internal/plugin/coop_dungeon_gifts.go new file mode 100644 index 0000000..eaf1439 --- /dev/null +++ b/internal/plugin/coop_dungeon_gifts.go @@ -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 `. 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 ` or `!coop giftvote ` 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() +} diff --git a/internal/plugin/coop_dungeon_render.go b/internal/plugin/coop_dungeon_render.go new file mode 100644 index 0000000..6ec96be --- /dev/null +++ b/internal/plugin/coop_dungeon_render.go @@ -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 `.\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() +} diff --git a/internal/plugin/coop_dungeon_scheduler.go b/internal/plugin/coop_dungeon_scheduler.go new file mode 100644 index 0000000..5792071 --- /dev/null +++ b/internal/plugin/coop_dungeon_scheduler.go @@ -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 +} diff --git a/internal/plugin/coop_dungeon_stats.go b/internal/plugin/coop_dungeon_stats.go new file mode 100644 index 0000000..35096f5 --- /dev/null +++ b/internal/plugin/coop_dungeon_stats.go @@ -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() +} diff --git a/internal/plugin/coop_dungeon_test.go b/internal/plugin/coop_dungeon_test.go new file mode 100644 index 0000000..2a76c0f --- /dev/null +++ b/internal/plugin/coop_dungeon_test.go @@ -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 +} diff --git a/internal/plugin/coop_event_meta.go b/internal/plugin/coop_event_meta.go new file mode 100644 index 0000000..142efbb --- /dev/null +++ b/internal/plugin/coop_event_meta.go @@ -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 +} diff --git a/internal/plugin/coop_flavor_twinbee.go b/internal/plugin/coop_flavor_twinbee.go new file mode 100644 index 0000000..60286fb --- /dev/null +++ b/internal/plugin/coop_flavor_twinbee.go @@ -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!", +} diff --git a/internal/plugin/dictionary.go b/internal/plugin/dictionary.go index 252dc7d..f2b2433 100644 --- a/internal/plugin/dictionary.go +++ b/internal/plugin/dictionary.go @@ -55,11 +55,11 @@ func (p *DictionaryPlugin) OnMessage(ctx MessageContext) error { default: return nil } - go func() { + safeGo("dictionary-handler", func() { if err := handler(ctx); err != nil { slog.Error("dictionary: handler error", "err", err) } - }() + }) return nil } diff --git a/internal/plugin/esteemed.go b/internal/plugin/esteemed.go index d1798a8..0bdca6c 100644 --- a/internal/plugin/esteemed.go +++ b/internal/plugin/esteemed.go @@ -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) diff --git a/internal/plugin/euro.go b/internal/plugin/euro.go index 6efb624..5aad6d9 100644 --- a/internal/plugin/euro.go +++ b/internal/plugin/euro.go @@ -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 = 20.00 + baseMsg = 20.00 case words >= 26: - amount = 10.00 + baseMsg = 10.00 case words >= 11: - amount = 5.00 + baseMsg = 5.00 case words >= 4: - amount = 2.50 + baseMsg = 2.50 default: - amount = 1.00 + 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) } diff --git a/internal/plugin/forex.go b/internal/plugin/forex.go index ab3c19d..da5da67 100644 --- a/internal/plugin/forex.go +++ b/internal/plugin/forex.go @@ -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 diff --git a/internal/plugin/gaming.go b/internal/plugin/gaming.go index f8cd9ba..1ddce91 100644 --- a/internal/plugin/gaming.go +++ b/internal/plugin/gaming.go @@ -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") { diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go index 5086f89..40d53a0 100644 --- a/internal/plugin/hangman.go +++ b/internal/plugin/hangman.go @@ -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{ @@ -820,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)) } } diff --git a/internal/plugin/helpers_test.go b/internal/plugin/helpers_test.go new file mode 100644 index 0000000..682dcef --- /dev/null +++ b/internal/plugin/helpers_test.go @@ -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) + } + } +} diff --git a/internal/plugin/holdem.go b/internal/plugin/holdem.go index 1ee238d..c3c73da 100644 --- a/internal/plugin/holdem.go +++ b/internal/plugin/holdem.go @@ -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 `") + return p.reply(ctx, "Usage: `!holdem raise `") } 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 `") + return p.reply(ctx, "Invalid amount. Usage: `!holdem raise `") } 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 { diff --git a/internal/plugin/holdem_equity_range.go b/internal/plugin/holdem_equity_range.go new file mode 100644 index 0000000..f528ab9 --- /dev/null +++ b/internal/plugin/holdem_equity_range.go @@ -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 +} diff --git a/internal/plugin/holdem_tip_cfr_test.go b/internal/plugin/holdem_tip_cfr_test.go new file mode 100644 index 0000000..d40271c --- /dev/null +++ b/internal/plugin/holdem_tip_cfr_test.go @@ -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 + } +} diff --git a/internal/plugin/holdem_tip_scenarios.go b/internal/plugin/holdem_tip_scenarios.go new file mode 100644 index 0000000..2c08e90 --- /dev/null +++ b/internal/plugin/holdem_tip_scenarios.go @@ -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"}, + }, +} diff --git a/internal/plugin/holdem_tip_scenarios_test.go b/internal/plugin/holdem_tip_scenarios_test.go new file mode 100644 index 0000000..e42ef34 --- /dev/null +++ b/internal/plugin/holdem_tip_scenarios_test.go @@ -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) + } + }) + } +} diff --git a/internal/plugin/holdem_tips.go b/internal/plugin/holdem_tips.go index 479aee9..019c60c 100644 --- a/internal/plugin/holdem_tips.go +++ b/internal/plugin/holdem_tips.go @@ -61,6 +61,24 @@ type holdemTipContext struct { HandCategory string // from poker.RankString(), e.g. "Pair", "Flush" Draw DrawInfo // flush/straight draw analysis HeadsUp bool + + // IsAllIn is true when at least one opponent is already all-in, meaning + // this is a terminal decision — no future streets, no position leverage. + IsAllIn bool + // EquityVsShove is hero's equity vs a realistic facing-all-in shove range. + // Only computed when IsAllIn; zero-valued otherwise. Use this instead of + // Equity for terminal decisions, since "vs random" systematically inflates + // high-card equity against narrow shove ranges. + EquityVsShove EquityResult + + // Derived fields for the rules engine. + HoleRanks [2]int // 0-12, for preflop classification + HoleSuited bool + PreflopTier string // "premium", "strong", "speculative", "playable", "trash" + BoardPaired bool + BoardMaxSuit int // largest same-suit count on the board + BoardStraightRisk int // 0-4, rough straight-possibility score + BoardHighRank int // highest rank on board, -1 preflop } // tipSnapshot holds game data captured under lock for async tip generation. @@ -79,6 +97,7 @@ type tipSnapshot struct { position string headsUp bool isDealer bool + isAllIn bool // at least one opponent is already all-in } // snapshotForTip captures game state under lock. Cheap — no MC here. @@ -113,6 +132,19 @@ func snapshotForTip(g *HoldemGame, playerIdx int) tipSnapshot { headsUp := numActive == 2 isDealer := playerIdx == g.DealerIdx + // Facing an all-in? Any opponent (not hero) who is still in the hand + // and marked AllIn turns this into a terminal decision. + isAllIn := false + for i, pp := range g.Players { + if i == playerIdx { + continue + } + if pp.State == PlayerAllIn { + isAllIn = true + break + } + } + // Compute position with heads-up street awareness. position := g.positionLabel(playerIdx) if headsUp { @@ -134,6 +166,7 @@ func snapshotForTip(g *HoldemGame, playerIdx int) tipSnapshot { position: position, headsUp: headsUp, isDealer: isDealer, + isAllIn: isAllIn, } } @@ -164,6 +197,11 @@ func buildTipContext(snap tipSnapshot) holdemTipContext { } eq := Equity(snap.hole, snap.community, snap.numOpp, iterations) + var eqShove EquityResult + if snap.isAllIn { + eqShove = EquityVsRange(snap.hole, snap.community, facingAllInRangeFor(snap.street), iterations) + } + spr := 0.0 if snap.totalPot > 0 { spr = float64(snap.stack) / float64(snap.totalPot) @@ -174,49 +212,298 @@ func buildTipContext(snap tipSnapshot) holdemTipContext { potOdds = float64(snap.toCall) / float64(snap.totalPot+snap.toCall) * 100 } - // Compute hand category from current best 5-card hand. + // Compute hand category from current best 5-card hand, enriched with + // which ranks actually make the hand so the LLM can't hallucinate a + // different pair / set / kicker. handCategory := "" if len(snap.community) >= 3 { - _, handCategory = handRank(snap.hole, snap.community) + _, raw := handRank(snap.hole, snap.community) + handCategory = describeMadeHand(raw, snap.hole, snap.community) } // Compute draw info (meaningful on flop/turn only). draw := computeDraws(snap.hole, snap.community) + holeRanks := [2]int{cardRankIndex(snap.hole[0]), cardRankIndex(snap.hole[1])} + holeSuited := snap.hole[0].Suit() == snap.hole[1].Suit() + preflopTier := classifyPreflopHand(holeRanks, holeSuited, snap.headsUp) + paired, maxSuit, straightRisk, boardHigh := analyzeBoardTexture(snap.community) + return holdemTipContext{ - PlayerName: snap.playerName, - Hole: snap.holeStr, - Community: snap.communityS, - Equity: eq, - SPR: spr, - PotOddsPct: potOdds, - ToCall: snap.toCall, - Pot: snap.totalPot, - Stack: snap.stack, - Street: snap.street, - Position: snap.position, - NumActive: snap.numActive, - HandCategory: handCategory, - Draw: draw, - HeadsUp: snap.headsUp, + PlayerName: snap.playerName, + Hole: snap.holeStr, + Community: snap.communityS, + Equity: eq, + SPR: spr, + PotOddsPct: potOdds, + ToCall: snap.toCall, + Pot: snap.totalPot, + Stack: snap.stack, + Street: snap.street, + Position: snap.position, + NumActive: snap.numActive, + HandCategory: handCategory, + Draw: draw, + HeadsUp: snap.headsUp, + HoleRanks: holeRanks, + HoleSuited: holeSuited, + PreflopTier: preflopTier, + BoardPaired: paired, + BoardMaxSuit: maxSuit, + BoardStraightRisk: straightRisk, + BoardHighRank: boardHigh, + IsAllIn: snap.isAllIn, + EquityVsShove: eqShove, } } -// generateTip generates a coaching tip, trying LLM first then falling back to rules. +// classifyPreflopHand buckets a starting hand into one of five tiers. +// Sklansky-lite for full ring; significantly wider for heads-up where +// ranges are ~70% open from BTN and ~55% defend from BB. +func classifyPreflopHand(ranks [2]int, suited, headsUp bool) string { + hi, lo := ranks[0], ranks[1] + if lo > hi { + hi, lo = lo, hi + } + pair := hi == lo + gap := hi - lo + + // Rank indices: 2=0, 3=1, ..., 9=7, T=8, J=9, Q=10, K=11, A=12. + + if headsUp { + return classifyPreflopHU(hi, lo, pair, gap, suited) + } + + // --- Full-ring / 6-max tiers --- + + // Pocket pairs + if pair { + switch { + case hi >= 9: // JJ+ + return "premium" + case hi == 8: // TT + return "strong" + default: // 22-99 + return "speculative" + } + } + + // AK + if hi == 12 && lo == 11 { + return "premium" + } + // AQ, AJs, KQs + if hi == 12 && lo == 10 { + return "strong" + } + if suited && hi == 12 && lo == 9 { + return "strong" + } + if suited && hi == 11 && lo == 10 { + return "strong" + } + // AJo, KQo, KJs, QJs, JTs, ATs + if hi == 12 && lo == 9 { + return "playable" + } + if suited && hi == 12 && lo == 8 { + return "playable" + } + if hi == 11 && lo == 10 { + return "playable" + } + if suited && hi == 11 && lo >= 8 { + return "playable" + } + if suited && hi == 10 && lo >= 7 { + return "playable" + } + if suited && hi == 9 && lo == 8 { + return "playable" + } + // Suited connectors & one-gappers down to 43s + if suited && gap <= 2 && lo >= 2 { + return "speculative" + } + // Suited aces (any) + if suited && hi == 12 { + return "speculative" + } + return "trash" +} + +// classifyPreflopHU uses much wider tiers appropriate for heads-up play. +// BTN opens ~70%, BB defends ~55%. Any King, any Ace, any pair, most suited +// hands, and connected hands are all playable. +func classifyPreflopHU(hi, lo int, pair bool, gap int, suited bool) string { + // Premium: AA-QQ, AKs, AKo + if pair && hi >= 10 { + return "premium" + } + if hi == 12 && lo == 11 { + return "premium" + } + + // Strong: JJ-88, AQ-ATs, KQs, AQo-AJo, KQo + if pair && hi >= 6 { // 88+ + return "strong" + } + if hi == 12 && lo >= 8 { // AT+ + return "strong" + } + if hi == 11 && lo == 10 { // KQ + return "strong" + } + if suited && hi == 11 && lo >= 9 { // KJs + return "strong" + } + + // Playable: 77-22, any Ax, Kx (x>=5), Q9+, J9+, T8+, suited connectors + if pair { + return "playable" + } + if hi == 12 { // any Ace + return "playable" + } + if hi == 11 && lo >= 3 { // K5+ + return "playable" + } + if hi == 10 && lo >= 7 { // Q9+ + return "playable" + } + if hi == 9 && lo >= 7 { // J9+ + return "playable" + } + if suited && gap <= 3 { // suited connectors/gappers + return "playable" + } + + // Speculative: Kx (any), suited broadway/connectors, T7+, 97+ + if hi == 11 { // any remaining King + return "speculative" + } + if suited && hi >= 8 { // any suited T+ hand + return "speculative" + } + if hi == 8 && lo >= 5 { // T7+ + return "speculative" + } + if hi == 7 && lo >= 5 { // 97+ + return "speculative" + } + if gap <= 2 { // offsuit connectors/gappers + return "speculative" + } + + return "trash" +} + +// analyzeBoardTexture returns (paired, maxSuitCount, straightRisk, highRank). +// straightRisk is a 0-4 heuristic: higher means more straight possibilities. +// highRank is -1 when there are no community cards. +func analyzeBoardTexture(community []poker.Card) (bool, int, int, int) { + if len(community) == 0 { + return false, 0, 0, -1 + } + var rankCounts [13]int + var suitCounts [4]int + high := -1 + for _, c := range community { + r := cardRankIndex(c) + rankCounts[r]++ + if r > high { + high = r + } + suitCounts[cardSuitIndex(c)]++ + } + + paired := false + for _, n := range rankCounts { + if n >= 2 { + paired = true + break + } + } + + maxSuit := 0 + for _, n := range suitCounts { + if n > maxSuit { + maxSuit = n + } + } + + // Straight risk: count cards within a 5-rank window, take the max. + // (Ace low also considered via wrap.) + bestWindow := 0 + for start := 0; start <= 12; start++ { + count := 0 + for r := start; r < start+5 && r <= 12; r++ { + if rankCounts[r] > 0 { + count++ + } + } + if count > bestWindow { + bestWindow = count + } + } + // Ace-low wheel window (A-2-3-4-5) + wheel := 0 + if rankCounts[12] > 0 { + wheel++ + } + for r := 0; r < 4; r++ { + if rankCounts[r] > 0 { + wheel++ + } + } + if wheel > bestWindow { + bestWindow = wheel + } + + return paired, maxSuit, bestWindow, high +} + +// cardSuitIndex maps a card suit to 0-3. Uses the string form rather than the +// library's bitfield encoding to stay consistent with cardRankIndex. +func cardSuitIndex(c poker.Card) int { + s := c.String() + if len(s) < 2 { + return 0 + } + switch s[1] { + case 'c': + return 0 + case 'd': + return 1 + case 'h': + return 2 + case 's': + return 3 + } + return 0 +} + +// generateTip generates a coaching tip. The rules engine is the source of truth +// for the recommended action and reasoning; an optional LLM step rewrites that +// tip for prose variety without introducing new facts or changing the action. +// If the LLM is unavailable or its rewrite looks wrong, we fall back to the +// rules tip verbatim. func generateTip(ctx holdemTipContext) string { + base := generateRulesTip(ctx) + host := os.Getenv("OLLAMA_HOST") model := os.Getenv("OLLAMA_MODEL") if host != "" && model != "" { - tip, err := generateLLMTip(host, model, ctx) + rewritten, err := rewriteTipWithLLM(host, model, ctx, base) if err != nil { - slog.Warn("holdem: LLM tip failed, using fallback", "err", err) - } else if tip != "" { - return "💡 " + tip + slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err) + } else if rewritten != "" { + return "💡 " + rewritten } } - return "💡 " + generateRulesTip(ctx) + return "💡 " + base } type chatMessage struct { @@ -244,27 +531,52 @@ func extractTipFromResponse(raw string) string { return strings.TrimSpace(cleaned) } -// buildTipSystemPrompt returns the system prompt for poker tip generation. +// buildTipSystemPrompt returns the system prompt for poker tip rewriting. +// The LLM is a stylist, not a coach: it rewrites a tip produced by the rules +// engine and must not change the recommended action or invent facts. func buildTipSystemPrompt() string { - return `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: + return `You are rewriting a Texas Hold'em tip so it reads naturally in a private +message to a single player. You are NOT a coach — the recommended action and +the reasoning are already decided by a rules engine. Your only job is to make +the existing tip read well. -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? +You will receive: +- CONTEXT: the game state (cards, board, equity, pot odds, position, etc.) +- TIP: the rules-engine tip to rewrite -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.` +STRICT RULES — do not violate these: +- Keep the same recommended action (fold / check / call / bet / raise). If TIP + says "call", your rewrite must also say "call", not "raise" or "bet". +- Do NOT add a second action on top of what TIP recommends. If TIP says + "call", do NOT say "but raising is even better" or "you could also raise". + The set of actions mentioned in your rewrite must be a subset of the actions + TIP mentions. +- Do not invent bet sizing. Phrases like "3-4× the pot", "pot-sized bet", + "half pot", "raise to X" are forbidden unless TIP uses that exact sizing. +- Do not introduce new facts, new numbers, new equity claims, or new board + reads. Every fact in your rewrite must appear verbatim in TIP or CONTEXT. +- Do not restate or rename the hole cards. If you mention them, copy them + exactly from CONTEXT. "Pair" does NOT mean pocket pair — the pair can + come from a hole card matching the board. Do not infer pocket pairs. +- Do not reference actions that are not currently legal. If CONTEXT says + "Available actions: check / bet", do NOT mention "call", "raise", "fold", + or "facing a bet" — there is no bet to face. If CONTEXT says "Available + actions: call / raise / fold", do NOT mention "check" or "free card". +- On the river there are no future streets. Do NOT use language like "later + streets", "next card", "turn", "see another card", "stay in the pot to see", + "if they commit later", "later in the hand", or "on future streets" when + Street is River. The hand ends after this action. +- Output 1 to 3 sentences, no lists, no headings, no meta-commentary, no + "here is the rewrite", just the rewritten tip text. +- If you are unsure how to rewrite it, output TIP unchanged.` } -// buildTipUserPrompt builds the structured user prompt with hand context. +// buildTipUserPrompt builds the structured context block that accompanies the +// rules tip sent to the LLM rewriter. The text here is informational only — +// the rules engine has already decided the action. func buildTipUserPrompt(ctx holdemTipContext) string { var b strings.Builder + b.WriteString("CONTEXT:\n") b.WriteString(fmt.Sprintf("Street: %s\n", ctx.Street.String())) if ctx.HandCategory != "" { @@ -288,8 +600,10 @@ func buildTipUserPrompt(ctx holdemTipContext) string { exceeds = "falls short of" } b.WriteString(fmt.Sprintf("Pot odds to call: %.0f%% — equity %s price\n", ctx.PotOddsPct, exceeds)) + b.WriteString("Available actions: call / raise / fold (facing a bet — do NOT say 'bet' or 'check')\n") } else { b.WriteString("Free card available — no bet to call\n") + b.WriteString("Available actions: check / bet (no bet to face — do NOT say 'call' or 'raise')\n") } headsUp := "no" @@ -299,15 +613,26 @@ func buildTipUserPrompt(ctx holdemTipContext) string { b.WriteString(fmt.Sprintf("SPR: %.1f | Position: %s | Heads-up: %s | Active players: %d\n", ctx.SPR, ctx.Position, headsUp, ctx.NumActive)) + if ctx.IsAllIn { + b.WriteString("FACING ALL-IN: this is a terminal decision. There are no future streets, no position leverage, and no \"fold later\" option. Do not mention \"the turn\", \"the river\", \"later streets\", \"see another card\", \"position advantage\", or \"act after\".\n") + shoveEq := (ctx.EquityVsShove.Win + ctx.EquityVsShove.Tie*0.5) * 100 + b.WriteString(fmt.Sprintf("Equity vs a realistic shove range: %.0f%% (use this number, not the vs-random equity above)\n", shoveEq)) + } + return b.String() } -func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) { +// rewriteTipWithLLM asks the LLM to rephrase the rules-engine tip for prose +// variety. The rules tip is the source of truth — if the rewrite diverges +// (empty, action vocabulary changed, etc.) we reject it and the caller falls +// back to the original. +func rewriteTipWithLLM(host, model string, ctx holdemTipContext, base string) (string, error) { + userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n" req := ollamaChatRequest{ Model: model, Messages: []chatMessage{ {Role: "system", Content: buildTipSystemPrompt()}, - {Role: "user", Content: buildTipUserPrompt(ctx)}, + {Role: "user", Content: userMsg}, }, Stream: false, } @@ -338,63 +663,730 @@ func generateLLMTip(host, model string, ctx holdemTipContext) (string, error) { if tip == "" { return "", fmt.Errorf("empty response") } - + if !rewriteKeepsAction(base, tip) { + return "", fmt.Errorf("rewrite changed action vocabulary") + } + if reason := rewriteSemanticProblem(ctx, tip); reason != "" { + return "", fmt.Errorf("rewrite semantic drift: %s", reason) + } return tip, nil } -func generateRulesTip(ctx holdemTipContext) string { - equity := ctx.Equity.Win + ctx.Equity.Tie*0.5 +// rewriteSemanticProblem returns a non-empty reason when the rewrite contains +// language that is wrong for the current game state — e.g. future-street talk +// on the river, or referencing a bet-to-face when checking is free. This is a +// cheap, high-signal filter layered on top of rewriteKeepsAction, which only +// validates action verbs. +func rewriteSemanticProblem(ctx holdemTipContext, rewrite string) string { + r := " " + strings.ToLower(rewrite) + " " - var tip string + // Facing an all-in is terminal: no future streets, no position leverage. + // Reject forward-looking language and position advice that presumes more + // actions to come. + if ctx.IsAllIn { + allInBadPhrases := []string{ + "see the turn", "see the river", "see another card", "see the next", + "next card", "next street", "later street", "later streets", + "future street", "future streets", "on later", "on future", + "fold later", "raise later", "fold on the turn", "fold on the river", + "position advantage", "act after", "acts after", + "you can fold", "you can raise", "give you the advantage", + } + for _, p := range allInBadPhrases { + if strings.Contains(r, p) { + return "rewrite presumes future action but facing all-in: " + p + } + } + } + + // River: no future streets exist. Reject any forward-looking language. + if ctx.Street == StreetRiver { + riverBadPhrases := []string{ + "later street", "later streets", "next card", "next street", + "future street", "future streets", "on later", "on future", + "see another card", "see the next", "see what comes", + "stay in the pot to see", "stay in to see", "see if they commit", + "if they commit later", "later in the hand", "later in the pot", + "turn card", "river card", // referring to "the turn" / "the river" as upcoming + "wait for the", "coming street", + } + for _, p := range riverBadPhrases { + if strings.Contains(r, p) { + return "river rewrite mentions future streets: " + p + } + } + } + + // No bet to face: reject "facing a bet" / "folding after a bet" language, + // which implies the player is currently under pressure they aren't. + if ctx.ToCall == 0 { + noBetBadPhrases := []string{ + "facing a bet", "facing the bet", "facing their bet", + "folding after a bet", "fold after a bet", + "call this bet", "call their bet", + "under pressure to call", + } + for _, p := range noBetBadPhrases { + if strings.Contains(r, p) { + return "rewrite implies a bet to face but ToCall=0: " + p + } + } + } + + return "" +} + +// rewriteKeepsAction checks that the LLM rewrite uses the same action verbs as +// the rules tip. If the rules tip recommends "call" but the rewrite says +// "raise" or "bet" without mentioning "call", we reject the rewrite rather +// than misleading the player. +// actionFamilies groups interchangeable action words. The rewrite must mention +// at least one word from every family the base tip used. "bet" and "raise" are +// separate because recommending a raise when the base said bet (or vice-versa) +// is a real action change. +var actionFamilies = [][]string{ + {"fold"}, + {"check"}, + {"call"}, + {"raise", "3-bet", "three-bet", "re-raise", "reraise"}, + {"bet"}, + {"shove", "jam", "all-in", "allin", "all in"}, +} + +// rewriteKeepsAction checks that the LLM rewrite is a faithful restatement of +// the base tip's recommendation. Two rules apply: +// +// 1. Primary action preserved: the first action family that appears in the +// base must also appear in the rewrite. This catches rewrites that +// silently flip "call" → "fold". +// 2. No new action families: any action family the rewrite mentions must +// have been present in the base too. This catches rewrites that add a +// raise/bet recommendation on top of a call, which is the most common +// way the LLM smuggles in unwanted aggression. +// +// The contains check uses word boundaries so "call this bet" (base) followed +// by a rewrite that drops the noun "bet" is not penalised. +func rewriteKeepsAction(base, rewrite string) bool { + b := " " + strings.ToLower(base) + " " + r := " " + strings.ToLower(rewrite) + " " + + containsWord := func(s, word string) bool { + idx := 0 + for { + i := strings.Index(s[idx:], word) + if i < 0 { + return false + } + i += idx + if !isWordChar(s[i-1]) && !isWordChar(s[i+len(word)]) { + return true + } + idx = i + 1 + } + } + familyInText := func(s string, family []string) bool { + for _, w := range family { + if containsWord(s, w) { + return true + } + } + return false + } + + primaryFamily := -1 + earliest := len(b) + 1 + for i, family := range actionFamilies { + for _, w := range family { + idx := strings.Index(b, " "+w+" ") + if idx >= 0 && idx < earliest { + earliest = idx + primaryFamily = i + } + } + } + if primaryFamily >= 0 { + if !familyInText(r, actionFamilies[primaryFamily]) { + return false + } + } + + // Subset rule (narrowed to aggressive families): if the rewrite mentions + // raise/bet/shove and the base does not, reject. This catches the common + // failure mode where the LLM injects "but raising is even better" on top + // of a call. We don't apply this to passive families (fold/check/call) + // because rewrites legitimately say things like "this is a fold, don't + // call" or "check — don't bet into a wet board". + aggressiveNames := map[string]bool{"raise": true, "bet": true, "shove": true} + for _, family := range actionFamilies { + if !aggressiveNames[family[0]] { + continue + } + if familyInText(r, family) && !familyInText(b, family) { + return false + } + } + return true +} + +func isWordChar(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || b == '_' +} + +var rankNames = [13]string{"2", "3", "4", "5", "6", "7", "8", "9", "10", "Jack", "Queen", "King", "Ace"} +var rankNamesPlural = [13]string{"2s", "3s", "4s", "5s", "6s", "7s", "8s", "9s", "10s", "Jacks", "Queens", "Kings", "Aces"} + +// describeMadeHand enriches a poker.RankString label ("Pair", "Two Pair", +// "Three of a Kind", etc.) with the specific ranks that form the hand and +// where those ranks come from (hole vs board). This removes the ambiguity +// that lets the LLM hallucinate "pocket 9s" when the pair is a 9 in hand +// matching a 9 on the board. +func describeMadeHand(category string, hole [2]poker.Card, community []poker.Card) string { + var holeRanks [2]int + holeRanks[0] = cardRankIndex(hole[0]) + holeRanks[1] = cardRankIndex(hole[1]) + + var boardCounts [13]int + for _, c := range community { + boardCounts[cardRankIndex(c)]++ + } + totalCounts := boardCounts + totalCounts[holeRanks[0]]++ + totalCounts[holeRanks[1]]++ + + holeHas := func(r int) bool { return holeRanks[0] == r || holeRanks[1] == r } + pocketPair := holeRanks[0] == holeRanks[1] + + // Helper: where the group of a given rank "comes from". + source := func(r int) string { + inHole := 0 + if holeRanks[0] == r { + inHole++ + } + if holeRanks[1] == r { + inHole++ + } + onBoard := boardCounts[r] + switch { + case inHole == 2 && onBoard == 0: + return "pocket pair" + case inHole == 2 && onBoard >= 1: + return "set (pocket pair + board)" + case inHole == 1 && onBoard >= 2: + return "trips (one in hand)" + case inHole == 0 && onBoard >= 2: + return "board" + case inHole == 1 && onBoard >= 1: + return "board-paired" + } + return "" + } + + // Highest rank ≥ threshold count, scanning from Ace down. + highestWithCount := func(counts [13]int, n int) int { + for r := 12; r >= 0; r-- { + if counts[r] >= n { + return r + } + } + return -1 + } + + switch category { + case "High Card": + best := holeRanks[0] + if holeRanks[1] > best { + best = holeRanks[1] + } + return fmt.Sprintf("High Card — best in hand: %s", rankNames[best]) + + case "Pair": + r := highestWithCount(totalCounts, 2) + if r < 0 { + return category + } + src := source(r) + // Kicker: best of the top-3 remaining cards across hole+board, + // excluding the paired rank. Report only if it comes from the hole. + remaining := make([]int, 0, 5) + for _, hr := range holeRanks { + if hr != r { + remaining = append(remaining, hr) + } + } + for _, c := range community { + br := cardRankIndex(c) + if br != r { + remaining = append(remaining, br) + } + } + // Pick the single top kicker (first of the best 3 is the only one we name). + best := -1 + for _, v := range remaining { + if v > best { + best = v + } + } + holeKicker := best >= 0 && (holeRanks[0] == best || holeRanks[1] == best) && best != r + if holeKicker && !pocketPair { + return fmt.Sprintf("Pair of %s (%s, kicker %s)", rankNamesPlural[r], src, rankNames[best]) + } + return fmt.Sprintf("Pair of %s (%s)", rankNamesPlural[r], src) + + case "Two Pair": + high := highestWithCount(totalCounts, 2) + low := -1 + for r := high - 1; r >= 0; r-- { + if totalCounts[r] >= 2 { + low = r + break + } + } + if high < 0 || low < 0 { + return category + } + note := "" + if !holeHas(high) && !holeHas(low) { + note = " (both on board — playing the board)" + } else if holeHas(high) && holeHas(low) { + note = " (both pairs use hole cards)" + } + return fmt.Sprintf("Two Pair — %s and %s%s", rankNamesPlural[high], rankNamesPlural[low], note) + + case "Three of a Kind": + r := highestWithCount(totalCounts, 3) + if r < 0 { + return category + } + return fmt.Sprintf("Three of a Kind — %s (%s)", rankNamesPlural[r], source(r)) + + case "Straight": + // Top of the highest run of 5 consecutive ranks. Ace plays high (A-high + // straight) or low (wheel 5-4-3-2-A, reported as 5-high). + present := func(r int) bool { return totalCounts[r] >= 1 } + top := -1 + for r := 12; r >= 4; r-- { + if present(r) && present(r-1) && present(r-2) && present(r-3) && present(r-4) { + top = r + break + } + } + if top < 0 && present(3) && present(2) && present(1) && present(0) && present(12) { + top = 3 // 5-high wheel + } + if top < 0 { + return category + } + return fmt.Sprintf("Straight — high card %s", rankNames[top]) + + case "Flush": + // Find the flush suit (5+ of same suit across hole+board), then report + // the actual high card of that suit. + var suitRanks [4][]int + for i := 0; i < 2; i++ { + suitRanks[cardSuitIndex(hole[i])] = append(suitRanks[cardSuitIndex(hole[i])], holeRanks[i]) + } + boardSuit := [4][]int{} + for _, c := range community { + s := cardSuitIndex(c) + suitRanks[s] = append(suitRanks[s], cardRankIndex(c)) + boardSuit[s] = append(boardSuit[s], cardRankIndex(c)) + } + flushSuit := -1 + for s := 0; s < 4; s++ { + if len(suitRanks[s]) >= 5 { + flushSuit = s + break + } + } + if flushSuit < 0 { + return category + } + top := -1 + for _, r := range suitRanks[flushSuit] { + if r > top { + top = r + } + } + note := "" + holeInFlush := cardSuitIndex(hole[0]) == flushSuit || cardSuitIndex(hole[1]) == flushSuit + if !holeInFlush { + note = " (playing the board)" + } + return fmt.Sprintf("Flush — %s-high%s", rankNames[top], note) + + case "Full House": + trips := highestWithCount(totalCounts, 3) + pair := -1 + for r := 12; r >= 0; r-- { + if r != trips && totalCounts[r] >= 2 { + pair = r + break + } + } + if trips < 0 || pair < 0 { + return category + } + return fmt.Sprintf("Full House — %s full of %s", rankNamesPlural[trips], rankNamesPlural[pair]) + + case "Four of a Kind": + r := highestWithCount(totalCounts, 4) + if r < 0 { + return category + } + return fmt.Sprintf("Four of a Kind — %s", rankNamesPlural[r]) + + case "Straight Flush": + return "Straight Flush" + } + return category +} + +// generateRulesTip is the deterministic source of truth for poker tips. +// It returns a complete tip string with a recommended action and reasoning +// tied to the specific equity, hand type, draw, board texture, position and +// stack depth of the current spot. +func generateRulesTip(ctx holdemTipContext) string { + // Facing an all-in is a terminal decision: no future streets, no position + // leverage, and "vs random" equity is systematically wrong. Hand off to a + // dedicated branch and do not append the usual position/multiway notes, + // which all assume there are more actions to come. + if ctx.IsAllIn { + return generateAllInFacingTip(ctx) + } + + var body string + if ctx.Street == StreetPreFlop { + body = generatePreflopTip(ctx) + } else { + body = generatePostflopTip(ctx) + } + + if !ctx.HeadsUp { + switch ctx.Position { + case "BTN", "CO": + body += " You have positional advantage — use it." + case "UTG": + body += " Early position — you need a strong range here." + case "SB", "BB": + if ctx.Street != StreetPreFlop { + body += " Out of position — play tighter on later streets." + } + } + if ctx.NumActive >= 4 { + body += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value." + } + } else if ctx.Street != StreetPreFlop { + if ctx.Position == "BB" || ctx.Position == "SB" { + body += " Out of position heads-up — play cautiously." + } + } + + return body +} + +// generateAllInFacingTip is the terminal-decision branch. We compare hero's +// equity against a realistic shove range (not random) to the pot odds and +// recommend call/fold with a generous margin of safety. No mention of future +// streets, position, or "later". +func generateAllInFacingTip(ctx holdemTipContext) string { + heroEq := (ctx.EquityVsShove.Win + ctx.EquityVsShove.Tie*0.5) * 100 + potOdds := ctx.PotOddsPct + hand := ctx.HandCategory + if hand == "" { + hand = "your hand" + } switch { - case equity > 0.80: - tip = fmt.Sprintf("Strong hand (%.0f%% equity). Size your bet for value — you want to get paid off.", equity*100) + case heroEq >= potOdds+8: + return fmt.Sprintf( + "Facing an all-in — terminal decision. Vs a realistic shove range, %s has about %.0f%% equity against %.0f%% pot odds, so calling is clearly +EV. The hand ends on this call.", + hand, heroEq, potOdds) - case ctx.Draw.IsDraw && ctx.ToCall == 0: - tip = fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description) - - case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct: - tip = fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equity*100, ctx.PotOddsPct) - - case ctx.Draw.IsDraw && ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct: - tip = fmt.Sprintf("Drawing hand (%s) but equity %.0f%% falls short of pot odds %.0f%% — consider folding unless implied odds justify calling.", ctx.Draw.Description, equity*100, ctx.PotOddsPct) - - case ctx.ToCall > 0 && equity*100 > ctx.PotOddsPct: - tip = fmt.Sprintf("Equity %.0f%% exceeds pot odds %.0f%% — calling is +EV here.", equity*100, ctx.PotOddsPct) - - case ctx.ToCall > 0 && equity*100 <= ctx.PotOddsPct: - tip = fmt.Sprintf("Equity %.0f%% falls short of pot odds %.0f%% — consider folding.", equity*100, ctx.PotOddsPct) - - case ctx.ToCall == 0 && equity > 0.65: - tip = fmt.Sprintf("%.0f%% equity with check available — bet for value and deny free cards to draws.", equity*100) - - case ctx.ToCall == 0 && equity < 0.40: - tip = fmt.Sprintf("%.0f%% equity — check to control pot size.", equity*100) - - case ctx.SPR < 1: - tip = "Shallow stack (SPR < 1) — commit or fold. No room to maneuver." - - case ctx.SPR > 10 && ctx.Street == StreetPreFlop: - tip = "Deep stacked preflop — implied odds outweigh raw equity. Speculative hands gain value." + case heroEq >= potOdds-2: + return fmt.Sprintf( + "Facing an all-in — terminal decision. Vs a realistic shove range, %s has about %.0f%% equity against %.0f%% pot odds. This is borderline; if you think their range is tighter than a standard stackoff range, fold.", + hand, heroEq, potOdds) default: - tip = fmt.Sprintf("%.0f%% equity. Evaluate your position and pot odds before acting.", equity*100) + return fmt.Sprintf( + "Facing an all-in — fold. Vs a realistic shove range, %s only has about %.0f%% equity while you need %.0f%%. The 'vs random' equity number is misleading here because nobody shoves random cards. The hand ends on this decision.", + hand, heroEq, potOdds) } - - // Position note. - switch ctx.Position { - case "BTN", "CO": - tip += " You have positional advantage — use it." - case "SB", "BB": - tip += " Out of position — play tighter." - case "UTG": - tip += " Early position — you need a strong range here." - } - - if ctx.NumActive >= 4 { - tip += " Multiway pot — hand values shift; drawing hands improve, bluffs lose value." - } - - return tip +} + +func generatePreflopTip(ctx holdemTipContext) string { + if ctx.HeadsUp { + return generatePreflopTipHU(ctx) + } + return generatePreflopTipRing(ctx) +} + +func generatePreflopTipHU(ctx holdemTipContext) string { + facing := ctx.ToCall > 0 + isBTN := ctx.Position == "BTN" || ctx.Position == "SB" + deep := ctx.SPR > 10 + bigRaise := facing && ctx.ToCall > 0 && float64(ctx.ToCall)/float64(ctx.Pot) > 0.6 + + switch ctx.PreflopTier { + case "premium": + if facing { + return "Premium hand heads-up — raise or re-raise. You want to build the pot with this hand against any opponent range." + } + return "Premium hand heads-up — raise for value. Build the pot now." + + case "strong": + if facing { + return "Strong hand heads-up — raise. This hand plays well in a bigger pot against one opponent." + } + return "Strong hand heads-up — raise to put pressure on the BB." + + case "playable": + if facing && bigRaise && !deep { + return "Playable heads-up hand, but the raise is too large relative to your stack — fold. You need deeper stacks to speculate with this." + } + if facing && isBTN { + return "Solid heads-up hand — complete or raise. You have position postflop, which makes this profitable." + } + if facing { + return "Decent heads-up hand — call to see a flop. You're out of position, so keep the pot manageable." + } + return "Decent heads-up hand — raise to take the initiative." + + case "speculative": + if facing && !deep { + return "Speculative hand without deep stacks to support it heads-up — fold. You need implied odds to play these." + } + if facing && isBTN { + return "Marginal heads-up hand — completing is fine with position. You'll have the advantage of acting last on every street." + } + if facing { + return "Marginal heads-up hand — calling is borderline. Being out of position makes it harder to realize your equity." + } + return "Marginal heads-up hand — a small raise can take it down, but don't overcommit." + + case "trash": + if facing && isBTN { + return "Weak hand, but heads-up from the button you can consider completing cheaply — position makes many hands playable." + } + if facing { + return "Weak hand out of position heads-up — fold. Without position advantage, this hand won't be profitable." + } + return "Weak hand — check and see a free flop if possible." + } + + return fmt.Sprintf("Preflop %.0f%% equity heads-up — evaluate before committing.", (ctx.Equity.Win+ctx.Equity.Tie*0.5)*100) +} + +func generatePreflopTipRing(ctx holdemTipContext) string { + facing := ctx.ToCall > 0 + deep := ctx.SPR > 10 + + switch ctx.PreflopTier { + case "premium": + if facing { + return "Premium hand — raise for value. Build the pot now; you want to be all-in by the river against most opponents." + } + return "Premium hand — open for a raise. Don't limp these; build the pot and charge weaker hands to see a flop." + + case "strong": + if facing { + return "Strong hand — call or 3-bet depending on opener's range. Don't fold pre, but avoid getting it all-in against tight ranges." + } + return "Strong hand — raise to open. Good equity against most hands that call you." + + case "playable": + if facing { + return "Playable but not strong enough to 3-bet — call if the price is right and you're in position, fold if you're out of position against tight opens." + } + return "Playable from late position — raise or fold to take control; don't limp." + + case "speculative": + if facing && !deep { + return "Speculative hand without deep stacks to support it — fold. You need implied odds to play these." + } + if facing && deep { + return "Speculative hand with deep stacks — call for set-mining or flopping a big draw. Fold the flop if you miss." + } + return "Speculative hand — limp or raise small from late position; aim to see a cheap flop." + + case "trash": + if facing { + return "Weak starting hand — fold. Don't defend against a raise with this." + } + return "Weak starting hand — check if you can, otherwise fold. Not worth playing from this position." + } + + return fmt.Sprintf("Preflop %.0f%% equity — evaluate position and stack depth before committing.", (ctx.Equity.Win+ctx.Equity.Tie*0.5)*100) +} + +func generatePostflopTip(ctx holdemTipContext) string { + equity := ctx.Equity.Win + ctx.Equity.Tie*0.5 + equityPct := equity * 100 + facing := ctx.ToCall > 0 + wetBoard := ctx.BoardMaxSuit >= 2 || ctx.BoardStraightRisk >= 3 + monotoneFlop := ctx.BoardMaxSuit >= 3 + pairedBoard := ctx.BoardPaired + lowSPR := ctx.SPR < 1.5 + deepSPR := ctx.SPR > 6 + multiway := ctx.NumActive >= 3 + + // Equity thresholds shift with opponent count. "vs random" equity of 55% + // against 3 opponents is much stronger than 55% against 1 — surviving + // against multiple random ranges means you likely have a real hand. + // Lower the thresholds so we don't undervalue multiway equity. + monsterThresh := 0.85 + strongThresh := 0.70 + decentThresh := 0.55 + marginalThresh := 0.40 + if multiway { + monsterThresh = 0.70 + strongThresh = 0.55 + decentThresh = 0.40 + marginalThresh = 0.28 + } + + // --- Monster / nuts range --------------------------------------------- + if equity > monsterThresh { + switch { + case lowSPR: + return fmt.Sprintf("Monster hand (%.0f%% equity) with a shallow stack — get it in. Shove or call any bet; you're way ahead.", equityPct) + case multiway && facing: + return fmt.Sprintf("Monster hand (%.0f%% equity) multiway — raise for value. Multiple opponents means a bigger pot when you're this far ahead.", equityPct) + case multiway: + return fmt.Sprintf("Monster hand (%.0f%% equity) multiway — bet for value. Size up (3/4 pot or more) since someone is likely to call with this many players.", equityPct) + case facing && wetBoard: + return fmt.Sprintf("Monster hand (%.0f%% equity) but the board is wet — raise for value and to deny equity to draws.", equityPct) + case facing: + return fmt.Sprintf("Monster hand (%.0f%% equity) — raise for value, or slow-play only if you're confident villain will keep firing.", equityPct) + case wetBoard: + return fmt.Sprintf("Monster hand (%.0f%% equity) on a wet board — bet big (3/4 pot or more) to charge draws and protect your equity.", equityPct) + default: + return fmt.Sprintf("Monster hand (%.0f%% equity) on a dry board — bet for thin value. A smaller size (1/3 to 1/2 pot) keeps weaker hands in.", equityPct) + } + } + + // --- Strong made hand -------------------------------------------------- + if equity > strongThresh { + switch { + case pairedBoard && ctx.HandCategory != "" && strings.HasPrefix(ctx.HandCategory, "Full House"): + return fmt.Sprintf("Strong hand (%.0f%% equity) — %s. Bet for value; only be cautious if a higher full house is possible.", equityPct, ctx.HandCategory) + case pairedBoard: + return fmt.Sprintf("Strong hand (%.0f%% equity) but the board is paired — bet for value with caution. Slow down if villain raises big.", equityPct) + case multiway && facing: + return fmt.Sprintf("Strong hand (%.0f%% equity) multiway — call. Raising bloats the pot against multiple opponents who could have you beat. Re-evaluate on the next street.", equityPct) + case multiway: + return fmt.Sprintf("Strong hand (%.0f%% equity) multiway — bet for value but size conservatively. Multiple opponents increase the chance someone has a better hand.", equityPct) + case facing && monotoneFlop: + return fmt.Sprintf("Strong hand (%.0f%% equity) on a monotone board — call and see how villain reacts. Don't bloat the pot without a card in the flush suit.", equityPct) + case facing: + return fmt.Sprintf("Strong hand (%.0f%% equity) — raise for value. You're ahead of most hands that will continue.", equityPct) + default: + return fmt.Sprintf("Strong hand (%.0f%% equity) — bet 2/3 pot for value. Get paid by weaker hands and deny equity to draws.", equityPct) + } + } + + // --- Decent made hand (ahead but vulnerable) --------------------------- + if equity > decentThresh { + switch { + case multiway && facing: + if equity*100 > ctx.PotOddsPct { + return fmt.Sprintf("Decent hand (%.0f%% equity) multiway — call. You're priced in, but don't raise into multiple opponents with a vulnerable hand.", equityPct) + } + return fmt.Sprintf("Decent hand (%.0f%% equity) multiway vs %.0f%% pot odds — fold. Against multiple opponents who've shown interest, you're likely behind.", equityPct, ctx.PotOddsPct) + case multiway: + return fmt.Sprintf("Decent hand (%.0f%% equity) multiway — check to control the pot. Betting into multiple opponents with a medium-strength hand is risky.", equityPct) + case facing && equity*100 > ctx.PotOddsPct && wetBoard: + return fmt.Sprintf("Decent hand (%.0f%% equity) on a wet board — call and reassess. Don't raise and bloat the pot out of position.", equityPct) + case facing && equity*100 > ctx.PotOddsPct: + return fmt.Sprintf("Decent hand (%.0f%% equity) — call. You're priced in and ahead of villain's continuing range more often than not.", equityPct) + case facing: + return fmt.Sprintf("Decent hand but the price is wrong (%.0f%% equity vs %.0f%% pot odds) — fold unless you have a read that villain is bluffing.", equityPct, ctx.PotOddsPct) + case wetBoard: + return fmt.Sprintf("Decent hand (%.0f%% equity) on a wet board — bet small for protection, or check to pot-control if the turn could get ugly.", equityPct) + default: + return fmt.Sprintf("Decent hand (%.0f%% equity) — bet small for thin value. Be ready to slow down if villain raises.", equityPct) + } + } + + // --- Drawing hand (check before marginal — draws with low "vs random" + // equity in multiway pots are still worth playing for implied odds) ------ + if ctx.Draw.IsDraw { + switch { + case !facing && multiway: + return fmt.Sprintf("You have a %s. Check for a free card — if you hit, the multiway pot gives you great implied odds.", ctx.Draw.Description) + case !facing: + return fmt.Sprintf("You have a %s. With a free card available, check and see the next card without risk.", ctx.Draw.Description) + case equity*100 > ctx.PotOddsPct: + return fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — the price is right to call.", ctx.Draw.Description, equityPct, ctx.PotOddsPct) + case multiway && deepSPR && ctx.Draw.TotalOuts >= 8: + return fmt.Sprintf("Drawing hand (%s) — pot odds are short but the multiway pot gives you excellent implied odds when you hit. Call.", ctx.Draw.Description) + case deepSPR && ctx.Draw.TotalOuts >= 8: + return fmt.Sprintf("Drawing hand (%s) — pot odds don't quite justify the call, but deep stacks give you implied odds. Call if you'll get paid when you hit.", ctx.Draw.Description) + default: + return fmt.Sprintf("Drawing hand (%s) with %.0f%% equity vs %.0f%% pot odds — fold. The price is wrong and the implied odds aren't there.", ctx.Draw.Description, equityPct, ctx.PotOddsPct) + } + } + + // --- Marginal / bluffcatcher ------------------------------------------- + if equity > marginalThresh { + switch { + case multiway && facing: + return fmt.Sprintf("Marginal hand (%.0f%% equity) multiway — fold. With multiple opponents still in, someone likely has you beat.", equityPct) + case multiway: + return fmt.Sprintf("Marginal hand (%.0f%% equity) multiway — check. Don't bluff or bet for thin value into multiple opponents.", equityPct) + case facing && equity*100 > ctx.PotOddsPct: + return fmt.Sprintf("Marginal hand (%.0f%% equity) — you're barely priced in. Call this bet but don't build the pot further.", equityPct) + case facing: + return fmt.Sprintf("Marginal hand (%.0f%% equity) vs %.0f%% pot odds — fold. This is the kind of spot where hero-calling bleeds your stack.", equityPct, ctx.PotOddsPct) + default: + return fmt.Sprintf("Marginal hand (%.0f%% equity) — check to control the pot. Don't turn your hand into a bluff with weak showdown value.", equityPct) + } + } + + // --- Weak / air -------------------------------------------------------- + if facing { + return fmt.Sprintf("Weak hand (%.0f%% equity) — fold. You don't have the equity or the draw to continue.", equityPct) + } + if ctx.HeadsUp { + return fmt.Sprintf("Weak hand (%.0f%% equity) — check. You could bluff here, but only if the board favors your perceived range.", equityPct) + } + return fmt.Sprintf("Weak hand (%.0f%% equity) — check and give up. Don't bluff into multiple opponents or a board you don't represent.", equityPct) +} + +// extractTipAction pulls the recommended action verb from a rules tip for audit logging. +func extractTipAction(tip string) string { + lower := strings.ToLower(tip) + for _, action := range []string{"fold", "raise", "bet", "call", "check", "shove"} { + if strings.Contains(lower, action) { + return action + } + } + return "unknown" +} + +// logTipAudit records a tip with full context to the audit table. +func logTipAudit(userID string, ctx holdemTipContext, tip string) { + eqPct := (ctx.Equity.Win + ctx.Equity.Tie*0.5) * 100 + drawDesc := "" + if ctx.Draw.IsDraw { + drawDesc = ctx.Draw.Description + } + db.RecordTipAudit( + userID, + ctx.Hole[0]+" "+ctx.Hole[1], + ctx.Community, + ctx.Street.String(), + ctx.Position, + ctx.NumActive, + eqPct, + ctx.Pot, + ctx.ToCall, + ctx.Stack, + ctx.SPR, + ctx.HandCategory, + drawDesc, + ctx.PreflopTier, + extractTipAction(tip), + tip, + ) } diff --git a/internal/plugin/holdem_tips_test.go b/internal/plugin/holdem_tips_test.go index e10a34d..0afa934 100644 --- a/internal/plugin/holdem_tips_test.go +++ b/internal/plugin/holdem_tips_test.go @@ -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") + } +} diff --git a/internal/plugin/howami.go b/internal/plugin/howami.go index a015d31..85c14f7 100644 --- a/internal/plugin/howami.go +++ b/internal/plugin/howami.go @@ -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 } diff --git a/internal/plugin/llm_passive.go b/internal/plugin/llm_passive.go index 6247599..21851a2 100644 --- a/internal/plugin/llm_passive.go +++ b/internal/plugin/llm_passive.go @@ -159,14 +159,14 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error { // Check for fancy words on every message (independent of LLM sampling) if p.dict != nil { - go func() { + 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 diff --git a/internal/plugin/lookup.go b/internal/plugin/lookup.go index a64dc33..cae81c0 100644 --- a/internal/plugin/lookup.go +++ b/internal/plugin/lookup.go @@ -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 } @@ -210,14 +210,14 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error { if !antStarted { antStarted = true antLang := lang - go func() { + safeGo("lookup-antonyms", func() { ants, err := p.dict.Antonyms(wordLower, antLang) if err != nil { antCh <- antResult{} return } antCh <- antResult{ants: ants} - }() + }) } } diff --git a/internal/plugin/lottery.go b/internal/plugin/lottery.go index 6852294..f694327 100644 --- a/internal/plugin/lottery.go +++ b/internal/plugin/lottery.go @@ -25,7 +25,8 @@ func NewLotteryPlugin(client *mautrix.Client, euro *EuroPlugin) *LotteryPlugin { } } -func (p *LotteryPlugin) Name() string { return "lottery" } +func (p *LotteryPlugin) Name() string { return "lottery" } +func (p *LotteryPlugin) Version() string { return "1.1.0" } func (p *LotteryPlugin) Commands() []CommandDef { return []CommandDef{ @@ -198,11 +199,9 @@ func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error { | Match | Prize | Odds (approx.) | |-------|-------|----------------| | 5 of 5 | Jackpot (split among winners) | 1 in 142,506 | -| 4 of 5 | €1,000 (fixed) | 1 in 3,062 | -| 3 of 5 | €100 (fixed) | 1 in 141 | -| 2 of 5 | €10 (fixed) | 1 in 16 | -| 1 of 5 | €2 (fixed) | 1 in 4 | -| 0 of 5 | Nothing | — | +| 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.` @@ -228,8 +227,8 @@ func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error { if h.RolledOver > 0 { sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver)) } - sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d | 1-match: %d\n\n", - h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners)) + 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()) diff --git a/internal/plugin/lottery_db.go b/internal/plugin/lottery_db.go index 2496655..3dc0d28 100644 --- a/internal/plugin/lottery_db.go +++ b/internal/plugin/lottery_db.go @@ -2,6 +2,7 @@ package plugin import ( "encoding/json" + "fmt" "log/slog" "time" @@ -52,15 +53,19 @@ func lotteryCurrentWeekStart() string { func lotteryTicketCount(userID id.UserID, weekStart string) int { d := db.Get() var count int - _ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`, - string(userID), weekStart).Scan(&count) + if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`, + string(userID), weekStart).Scan(&count); err != nil { + slog.Error("lottery: ticket count query failed", "user", userID, "err", err) + } return count } func lotteryTotalTicketCount(weekStart string) int { d := db.Get() var count int - _ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count) + if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count); err != nil { + slog.Error("lottery: total ticket count query failed", "err", err) + } return count } @@ -73,6 +78,16 @@ func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) e } defer tx.Rollback() + // Re-check ticket count inside transaction to prevent TOCTOU race. + var existing int + if err := tx.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`, + string(userID), weekStart).Scan(&existing); err != nil { + return fmt.Errorf("lottery: count tickets in tx: %w", err) + } + if existing+len(tickets) > 100 { + return fmt.Errorf("lottery: ticket limit exceeded (have %d, buying %d)", existing, len(tickets)) + } + for _, nums := range tickets { data, _ := json.Marshal(nums) _, err := tx.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`, @@ -167,7 +182,9 @@ func lotteryLoadHistory(limit int) ([]lotteryHistoryRow, error) { &h.PotTotal, &h.RolledOver); err != nil { return nil, err } - _ = json.Unmarshal([]byte(winJSON), &h.WinningNumbers) + if err := json.Unmarshal([]byte(winJSON), &h.WinningNumbers); err != nil { + slog.Warn("lottery: corrupt winning_numbers JSON", "draw", h.DrawDate, "err", err) + } history = append(history, h) } return history, rows.Err() @@ -200,7 +217,9 @@ func scanLotteryTickets(rows lotteryRows) ([]lotteryTicket, error) { if err := rows.Scan(&t.ID, &t.UserID, &t.WeekStart, &numsJSON, &matchCount, &prize); err != nil { return nil, err } - _ = json.Unmarshal([]byte(numsJSON), &t.Numbers) + if err := json.Unmarshal([]byte(numsJSON), &t.Numbers); err != nil { + slog.Warn("lottery: corrupt ticket numbers JSON", "id", t.ID, "err", err) + } t.MatchCount = matchCount t.Prize = prize tickets = append(tickets, t) diff --git a/internal/plugin/lottery_draw.go b/internal/plugin/lottery_draw.go index 9a25b77..7001bf7 100644 --- a/internal/plugin/lottery_draw.go +++ b/internal/plugin/lottery_draw.go @@ -14,10 +14,9 @@ import ( // ── Prize Tiers (fixed payouts) ───────────────────────────────────────────── var lotteryFixedPrizes = map[int]int{ - 4: 1000, - 3: 100, - 2: 10, - 1: 2, + 4: 5000, + 3: 500, + 2: 25, } // ── Draw Ticker ───────────────────────────────────────────────────────────── @@ -66,9 +65,9 @@ func (p *LotteryPlugin) reminderTicker() { if gr != "" { pot := communityPotBalance() p.SendMessage(gr, fmt.Sprintf( - "🎟️ Lottery draw tomorrow. 23:59 UTC. Current pot: €%d. "+ + "🎟️ Lottery draw tomorrow. 23:59 UTC. Current pot: %s. "+ "Tickets €1 each. Max 100 per player. `!lottery buy [N]` to enter.", - pot)) + fmtEuro(pot))) } db.MarkJobCompleted(jobName, weekKey) @@ -101,7 +100,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { tickets[i].MatchCount = &mc prize := 0 - if mc >= 1 && mc <= 4 { + if mc >= 2 && mc <= 4 { prize = lotteryFixedPrizes[mc] } tickets[i].Prize = &prize @@ -192,7 +191,7 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { Match4Winners: len(matchBuckets[4]), Match3Winners: len(matchBuckets[3]), Match2Winners: len(matchBuckets[2]), - Match1Winners: len(matchBuckets[1]), + Match1Winners: 0, PotTotal: initialPot, RolledOver: rolledOver, } @@ -205,6 +204,9 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { p.SendMessage(gr, announcement) } + // DM each winner with their tickets and results. + p.dmWinners(winning, tickets) + // Cleanup old tickets. lotteryCleanupOldTickets() @@ -215,6 +217,58 @@ func (p *LotteryPlugin) executeDraw(weekStart string) { "jackpot_winners", len(jackpotWinners)) } +func (p *LotteryPlugin) dmWinners(winning []int, tickets []lotteryTicket) { + // Group winning tickets by user. + byUser := make(map[id.UserID][]lotteryTicket) + for _, t := range tickets { + if t.MatchCount == nil || *t.MatchCount < 2 { + continue + } + byUser[t.UserID] = append(byUser[t.UserID], t) + } + + for uid, userTickets := range byUser { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🎟️ **Lottery Results** — Winning numbers: **%s**\n\n", formatLotteryNumbers(winning))) + + totalWon := 0 + for _, t := range userTickets { + matches := *t.MatchCount + prize := 0 + if t.Prize != nil { + prize = *t.Prize + } + totalWon += prize + + // Show ticket numbers with matches highlighted + sb.WriteString(fmt.Sprintf("🎫 %s — **%d match** → %s\n", + formatTicketWithMatches(t.Numbers, winning), matches, fmtEuro(prize))) + } + + sb.WriteString(fmt.Sprintf("\n💰 **Total: %s**", fmtEuro(totalWon))) + + if err := p.SendDM(uid, sb.String()); err != nil { + slog.Error("lottery: failed to DM winner", "user", uid, "err", err) + } + } +} + +func formatTicketWithMatches(ticket, winning []int) string { + winSet := make(map[int]bool, len(winning)) + for _, n := range winning { + winSet[n] = true + } + parts := make([]string, len(ticket)) + for i, n := range ticket { + if winSet[n] { + parts[i] = fmt.Sprintf("**%d**", n) + } else { + parts[i] = fmt.Sprintf("%d", n) + } + } + return strings.Join(parts, " · ") +} + // ── Announcement Builder ──────────────────────────────────────────────────── func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRow, jackpotWinners []lotteryTicket) string { @@ -227,10 +281,10 @@ func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRo if len(jackpotWinners) > 0 && h.JackpotAmount > 0 { names := p.resolveWinnerNames(jackpotWinners) if len(jackpotWinners) == 1 { - sb.WriteString(fmt.Sprintf("Jackpot (5 match): **%s** — €%d 🎉\n", names[0], h.JackpotAmount)) + sb.WriteString(fmt.Sprintf("Jackpot (5 match): **%s** — %s 🎉\n", names[0], fmtEuro(h.JackpotAmount))) } else { - sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. €%d each. 🎉\n", - joinNames(names), h.JackpotAmount)) + sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. %s each. 🎉\n", + joinNames(names), fmtEuro(h.JackpotAmount))) } } else if len(jackpotWinners) > 0 && h.JackpotAmount == 0 { sb.WriteString("Jackpot withheld — pot insufficient. Rolls to next week. Fixed tiers paid as normal.\n") @@ -239,13 +293,12 @@ func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRo } // Fixed tiers. - sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €1,000 each\n", h.Match4Winners)) - sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €100 each\n", h.Match3Winners)) - sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €10 each\n", h.Match2Winners)) - sb.WriteString(fmt.Sprintf("1 match: %d winner(s) — €2 each\n", h.Match1Winners)) + sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €5,000 each\n", h.Match4Winners)) + sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €500 each\n", h.Match3Winners)) + sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €25 each\n", h.Match2Winners)) distributed := h.PotTotal - h.RolledOver - sb.WriteString(fmt.Sprintf("\nPot distributed: €%d. Next draw: Friday. Tickets on sale now.", distributed)) + sb.WriteString(fmt.Sprintf("\nPot distributed: %s. Next draw: Friday. Tickets on sale now.", fmtEuro(distributed))) return sb.String() } diff --git a/internal/plugin/miniflux.go b/internal/plugin/miniflux.go index 264b07d..7cd1a47 100644 --- a/internal/plugin/miniflux.go +++ b/internal/plugin/miniflux.go @@ -116,11 +116,11 @@ func (p *MinifluxPlugin) OnMessage(ctx MessageContext) error { switch sub { case "feeds": - go func() { + safeGo("miniflux-feeds", func() { if err := p.cmdFeeds(ctx); err != nil { slog.Error("miniflux: feeds error", "err", err) } - }() + }) return nil case "subscribe": if !p.IsAdmin(ctx.Sender) { @@ -135,11 +135,11 @@ func (p *MinifluxPlugin) OnMessage(ctx MessageContext) error { case "subscriptions": return p.cmdSubscriptions(ctx) case "latest": - go func() { + safeGo("miniflux-latest", func() { if err := p.cmdLatest(ctx, parts[1:]); err != nil { slog.Error("miniflux: latest error", "err", err) } - }() + }) return nil case "pause": if !p.IsAdmin(ctx.Sender) { @@ -586,11 +586,11 @@ func (p *MinifluxPlugin) poll() { } p.mu.Lock() - if p.pollingDisabled { - p.mu.Unlock() + disabled := p.pollingDisabled + p.mu.Unlock() + if disabled { return } - p.mu.Unlock() // Get all active (non-paused) subscriptions d := db.Get() diff --git a/internal/plugin/movies.go b/internal/plugin/movies.go index 065b78d..fd3fc7e 100644 --- a/internal/plugin/movies.go +++ b/internal/plugin/movies.go @@ -134,11 +134,11 @@ func (p *MoviesPlugin) OnMessage(ctx MessageContext) error { if handler == nil { return nil } - go func() { + safeGo("movies-handler", func() { if err := handler(ctx); err != nil { slog.Error("movies: handler error", "err", err) } - }() + }) return nil } diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index c0eeb7f..1638101 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -13,6 +13,7 @@ import ( "gogobee/internal/db" "gogobee/internal/util" + "gogobee/internal/version" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" @@ -36,6 +37,10 @@ type MessageContext struct { Body string IsCommand bool // true if the message starts with the command prefix Event *event.Event + // OriginRoomID is set by plugins that rewrite RoomID for dispatch (e.g. holdem + // routes DM commands to the player's game room). When set, sender-private + // replies should go here instead of the rewritten RoomID. + OriginRoomID id.RoomID } // ReactionContext holds the context for a reaction event. @@ -57,6 +62,19 @@ type Plugin interface { Init() error } +// Versioned is an optional interface plugins can implement to declare their version. +type Versioned interface { + Version() string +} + +// PluginVersion returns the version for a plugin, or "1.0.0" if it doesn't implement Versioned. +func PluginVersion(p Plugin) string { + if v, ok := p.(Versioned); ok { + return v.Version() + } + return "1.0.0" +} + // dmCache maps user IDs to their DM room IDs to avoid creating duplicate rooms. var ( dmCache = make(map[id.UserID]id.RoomID) @@ -576,10 +594,12 @@ func (b *Base) SendNotice(roomID id.RoomID, text string) error { // SendReply sends a reply to a specific event. func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error { content := textContent(text) - content.RelatesTo = &event.RelatesTo{ - InReplyTo: &event.InReplyTo{ - EventID: eventID, - }, + if eventID != "" { + content.RelatesTo = &event.RelatesTo{ + InReplyTo: &event.InReplyTo{ + EventID: eventID, + }, + } } _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) if err != nil { @@ -678,6 +698,107 @@ func (b *Base) SendDM(userID id.UserID, text string) error { return b.SendMessage(roomID, text) } +// SendDMID sends a direct message and returns the event ID (for later editing). +func (b *Base) SendDMID(userID id.UserID, text string) (id.EventID, error) { + roomID, err := b.GetDMRoom(userID) + if err != nil { + return "", err + } + return b.SendMessageID(roomID, text) +} + +// PinEvent appends the given event ID to the room's m.room.pinned_events +// state. No-op if already pinned. Requires the bot to have power level for +// state events; failures are logged and returned without retry. +func (b *Base) PinEvent(roomID id.RoomID, eventID id.EventID) error { + var content event.PinnedEventsEventContent + err := b.Client.StateEvent(context.Background(), roomID, event.StatePinnedEvents, "", &content) + if err != nil && !strings.Contains(err.Error(), "M_NOT_FOUND") { + slog.Error("pin: read state", "room", roomID, "err", err) + return err + } + for _, id := range content.Pinned { + if id == eventID { + return nil // already pinned + } + } + content.Pinned = append(content.Pinned, eventID) + _, err = b.Client.SendStateEvent(context.Background(), roomID, event.StatePinnedEvents, "", content) + if err != nil { + slog.Error("pin: write state", "room", roomID, "event", eventID, "err", err) + } + return err +} + +// UnpinEvent removes the given event ID from the room's pinned events. +// No-op if not currently pinned. +func (b *Base) UnpinEvent(roomID id.RoomID, eventID id.EventID) error { + var content event.PinnedEventsEventContent + err := b.Client.StateEvent(context.Background(), roomID, event.StatePinnedEvents, "", &content) + if err != nil { + if strings.Contains(err.Error(), "M_NOT_FOUND") { + return nil + } + slog.Error("unpin: read state", "room", roomID, "err", err) + return err + } + out := content.Pinned[:0] + found := false + for _, id := range content.Pinned { + if id == eventID { + found = true + continue + } + out = append(out, id) + } + if !found { + return nil + } + content.Pinned = out + _, err = b.Client.SendStateEvent(context.Background(), roomID, event.StatePinnedEvents, "", content) + if err != nil { + slog.Error("unpin: write state", "room", roomID, "event", eventID, "err", err) + } + return err +} + +// EditMessage edits an existing message using Matrix m.replace relation. +func (b *Base) EditMessage(roomID id.RoomID, eventID id.EventID, newText string) error { + newContent := textContent(newText) + content := &event.MessageEventContent{ + MsgType: event.MsgText, + Body: "* " + newContent.Body, + NewContent: &event.MessageEventContent{ + MsgType: newContent.MsgType, + Body: newContent.Body, + Format: newContent.Format, + FormattedBody: newContent.FormattedBody, + }, + RelatesTo: &event.RelatesTo{ + Type: event.RelReplace, + EventID: eventID, + }, + } + if newContent.Format == event.FormatHTML { + content.Format = event.FormatHTML + content.FormattedBody = "* " + newContent.FormattedBody + } + _, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) + if err != nil { + slog.Error("failed to edit message", "room", roomID, "event", eventID, "err", err) + } + return err +} + +// EditDM edits a message in a user's DM room. +func (b *Base) EditDM(userID id.UserID, eventID id.EventID, newText string) error { + roomID, err := b.GetDMRoom(userID) + if err != nil { + return err + } + return b.EditMessage(roomID, eventID, newText) +} + // UploadContent uploads data to the Matrix content repository and returns the MXC URI. func (b *Base) UploadContent(data []byte, contentType, filename string) (id.ContentURI, error) { resp, err := b.Client.UploadBytesWithName(context.Background(), data, contentType, filename) @@ -708,3 +829,17 @@ func (b *Base) SendImage(roomID id.RoomID, imgData []byte, filename, caption str _, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content) return err } + +// safeGo runs fn in a goroutine with panic recovery. +// Use instead of bare `go func()` to prevent daemon crashes. +func safeGo(label string, fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("goroutine panic recovered", "label", label, "panic", r) + db.RecordCrash(version.Short(), label, fmt.Sprintf("%v", r)) + } + }() + fn() + }() +} diff --git a/internal/plugin/retro.go b/internal/plugin/retro.go index 0656593..03294f7 100644 --- a/internal/plugin/retro.go +++ b/internal/plugin/retro.go @@ -96,11 +96,11 @@ func (p *RetroPlugin) OnMessage(ctx MessageContext) error { default: return nil } - go func() { + safeGo("retro-search", func() { if err := p.handleSearch(ctx, query); err != nil { slog.Error("retro: handler error", "err", err) } - }() + }) return nil } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index 5a6f516..c497176 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -30,7 +30,8 @@ func NewStatsPlugin(client *mautrix.Client) *StatsPlugin { } } -func (p *StatsPlugin) Name() string { return "stats" } +func (p *StatsPlugin) Name() string { return "stats" } +func (p *StatsPlugin) Version() string { return "1.1.0" } func (p *StatsPlugin) Commands() []CommandDef { return []CommandDef{ @@ -199,6 +200,9 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error { avgWords = totalWords / totalMsg } + var taxPaid int + _ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, string(target)).Scan(&taxPaid) + msg := fmt.Sprintf( "📊 Stats for %s\n"+ "Messages: %s | Words: %s | Chars: %s\n"+ @@ -213,6 +217,9 @@ func (p *StatsPlugin) handleStats(ctx MessageContext) error { formatNumber(nightMsg), formatNumber(morningMsg), avgWords, ) + if taxPaid > 0 { + msg += fmt.Sprintf("\n🏛️ Community tax paid: %s", fmtEuro(taxPaid)) + } return p.SendReply(ctx.RoomID, ctx.EventID, msg) } @@ -322,8 +329,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { var achievementCount int _ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount) - sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements\n\n", - level, formatNumber(xp), balance, achievementCount)) + var taxPaid int + _ = d.QueryRow(`SELECT total_paid FROM tax_ledger WHERE user_id = ?`, uid).Scan(&taxPaid) + + taxLine := "" + if taxPaid > 0 { + taxLine = fmt.Sprintf(" · 🏛️ %s tax paid", fmtEuro(taxPaid)) + } + sb.WriteString(fmt.Sprintf("⭐ **Level %d** · %s XP · 💰 €%.0f · 🏅 %d achievements%s\n\n", + level, formatNumber(xp), balance, achievementCount, taxLine)) // ── Chat Stats ── var totalMsg, totalWords, totalLinks, totalEmojis, totalQuestions int diff --git a/internal/plugin/stocks.go b/internal/plugin/stocks.go index b51d9b0..e5a3936 100644 --- a/internal/plugin/stocks.go +++ b/internal/plugin/stocks.go @@ -76,11 +76,11 @@ func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *StocksPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "stock") { - go func() { + safeGo("stocks-handler", func() { if err := p.handleStock(ctx); err != nil { slog.Error("stocks: handler error", "err", err) } - }() + }) return nil } if p.IsCommand(ctx.Body, "stockwatch") { diff --git a/internal/plugin/testdata/solver_freqs.json b/internal/plugin/testdata/solver_freqs.json new file mode 100644 index 0000000..0668b81 --- /dev/null +++ b/internal/plugin/testdata/solver_freqs.json @@ -0,0 +1,53 @@ +{ + "flop/OESD facing small bet priced in": { + "call": 0.9594823122024536, + "fold": 0.000014199958059180062, + "raise": 0.04050343251947197 + }, + "flop/bottom pair facing big bet": { + "call": 1.662783510880672e-8, + "fold": 0.9999967813491821, + "raise": 0.000003206112978659803 + }, + "flop/flush draw with free card": { + "bet": 0.0002895280642860598, + "check": 0.9997105002403259 + }, + "flop/overpair on wet board facing bet": { + "call": 0.9996392726898193, + "fold": 0.0000029239877221698407, + "raise": 0.0003577816241886467 + }, + "flop/top set on dry board checked to": { + "bet": 0.9198929914534801, + "check": 0.08010699599981308 + }, + "river/bluffcatcher facing overbet": { + "call": 0, + "fold": 0.9697164297103882, + "raise": 0.03028361313045025 + }, + "river/nut flush checked to us": { + "bet": 1.0000000298023224, + "check": 0 + }, + "river/second pair facing bet": { + "call": 0.00007920627831481397, + "fold": 0.9981566071510315, + "raise": 0.0017642288767092396 + }, + "river/weak hand check available": { + "bet": 0.018236066796816885, + "check": 0.9817639589309692 + }, + "turn/combo draw facing half-pot bet": { + "call": 0.6370915174484253, + "fold": 0.00001531363341200631, + "raise": 0.3628932002466172 + }, + "turn/top set facing bet": { + "call": 0.6166651248931885, + "fold": 0, + "raise": 0.3833348592670518 + } +} diff --git a/internal/plugin/thom_krooke_mortgage_flavor.go b/internal/plugin/thom_krooke_mortgage_flavor.go new file mode 100644 index 0000000..568876c --- /dev/null +++ b/internal/plugin/thom_krooke_mortgage_flavor.go @@ -0,0 +1,125 @@ +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. +// +// ── THOM KROOKE — MORTGAGE RATE CHANGE NOTIFICATIONS ───────────────────────── +// +// Pulled weekly from FRED API (series: MORTGAGE5US). +// Rate change DM sent to player if rate differs from previous week. +// No change: no DM. Thom does not send neutral news. +// +// Pre-pet: terse, professional, zero warmth. +// Post-pet: still about the money, immediately about the pet. +// The pet is mentioned before you are. You are not mentioned. + +// ── RATE INCREASE (NO PET) ──────────────────────────────────────────────────── + +var ThomRateIncrease = []string{ + "Rates adjusted. Your new weekly payment is €{amount}. Market conditions. You understand.", + + "Weekly update. Rates went up. Payment is now €{amount}. " + + "This is not personal. It is also not not personal. Pay on time.", + + "The Fed moved. Your payment moved. €{amount} weekly going forward. " + + "I don't make the rates. I just apply them promptly and without sympathy.", + + "Rate adjustment. €{amount} per week. " + + "I'd say I'm sorry but the contract was quite clear about this possibility. " + + "You signed it.", + + "Rates up. €{amount}. " + + "If you'd like to discuss this I am available during business hours. " + + "Business hours are whenever I feel like it.", +} + +// ── RATE DECREASE (NO PET) ──────────────────────────────────────────────────── + +var ThomRateDecrease = []string{ + "Rates adjusted down. Your new weekly payment is €{amount}. Don't get used to it.", + + "Good news, technically. Rates dropped. €{amount} weekly going forward. " + + "The market giveth. The market will taketh back. Enjoy it briefly.", + + "Rate adjustment in your favour this week. €{amount}. " + + "I want to be clear that this has nothing to do with me. " + + "The Fed did this. I merely informed you.", + + "Rates down. €{amount} per week. " + + "You're welcome, even though I did nothing. Thom Krooke, Krooke Realty.", + + "Weekly update. Rates dropped. Your payment is €{amount}. " + + "I've noted your relief. I've also noted that rates can go back up. " + + "Good week.", +} + +// ── RATE INCREASE (WITH PET) ────────────────────────────────────────────────── + +var ThomRateIncreasePet = []string{ + "Rates went up. Weekly payment is now €{amount}. " + + "You might need to skip a dinner or two and give yours to {pet_name} for a while. " + + "Whether you eat or not is unimportant. Only {pet_name}'s stomach matters here.", + + "Rate adjustment. €{amount} per week. " + + "I'd tighten the belt if I were you. " + + "{pet_name} should not feel this. You should feel this. Adjust accordingly.", + + "Weekly update. Rates up. €{amount}. " + + "I've been thinking about {pet_name}'s next armor upgrade. " + + "You should also be thinking about that. After you figure out the payment. " + + "Priority order is: {pet_name}, payment, everything else.", + + "Rates moved. Not in your favour. €{amount} weekly going forward. " + + "{pet_name} doesn't need to know about this. Keep things normal for {pet_name}. " + + "You'll figure out the rest.", + + "The Fed raised rates. Your payment is €{amount}. " + + "Before you panic: {pet_name} is fine. {pet_name} will continue to be fine. " + + "You may need to make some adjustments. {pet_name} will not.", + + "Rate increase. €{amount} per week. " + + "I've seen people in your position make difficult choices. " + + "Make sure none of those choices involve {pet_name}'s meals. " + + "I'm watching.", +} + +// ── RATE DECREASE (WITH PET) ────────────────────────────────────────────────── + +var ThomRateDecreasePet = []string{ + "Rates went down. Weekly payment is now €{amount}. " + + "You can buy better things for {pet_name} now. " + + "I'd start with the shop. I may have restocked recently. For {pet_name}.", + + "Good news. Rates dropped. €{amount} going forward. " + + "The difference between what you were paying and what you're paying now " + + "should go directly toward {pet_name}. " + + "I'm not telling you what to do. I'm telling you what to do.", + + "Rate adjustment in your favour. €{amount} weekly. " + + "I thought of {pet_name} immediately when I saw the numbers. " + + "You probably thought of yourself. " + + "Think about what that says. Then go buy {pet_name} something.", + + "Rates down this week. Your payment is €{amount}. " + + "I've already set aside some things in the shop that {pet_name} would benefit from. " + + "The timing is not a coincidence. Come in when you're ready.", + + "Weekly update. Rates dropped. €{amount} per week going forward. " + + "{pet_name} deserves something nice this week. " + + "You've been holding back on the shop. I've noticed. " + + "The rate drop is a sign. Take it.", + + "The Fed cut rates. Your payment is €{amount}. " + + "I'm genuinely pleased about this, which is unusual for me. " + + "{pet_name} is the reason I'm genuinely pleased about this. " + + "You can draw your own conclusions.", +} + +// ── NO CHANGE (NEVER SENT) ──────────────────────────────────────────────────── +// Thom does not send neutral news. +// If rate is unchanged week over week: no DM. No notification. Nothing. diff --git a/internal/plugin/trivia.go b/internal/plugin/trivia.go index a9a73b2..a15b86c 100644 --- a/internal/plugin/trivia.go +++ b/internal/plugin/trivia.go @@ -404,7 +404,7 @@ func (p *TriviaPlugin) startQuestion(ctx MessageContext, args string) error { p.mu.Unlock() // Auto-expire after 30 seconds - go func() { + safeGo("trivia-expire", func() { time.Sleep(30 * time.Second) p.mu.Lock() current, ok := p.sessions[ctx.RoomID] @@ -420,7 +420,7 @@ func (p *TriviaPlugin) startQuestion(ctx MessageContext, args string) error { } else { p.mu.Unlock() } - }() + }) return nil } diff --git a/internal/plugin/uno.go b/internal/plugin/uno.go index 6bd3053..305cc17 100644 --- a/internal/plugin/uno.go +++ b/internal/plugin/uno.go @@ -411,7 +411,8 @@ func NewUnoPlugin(client *mautrix.Client, euro *EuroPlugin) *UnoPlugin { } } -func (p *UnoPlugin) Name() string { return "uno" } +func (p *UnoPlugin) Name() string { return "uno" } +func (p *UnoPlugin) Version() string { return "1.2.0" } func (p *UnoPlugin) Commands() []CommandDef { return []CommandDef{ @@ -1689,7 +1690,8 @@ func (p *UnoPlugin) suddenDeathWinner(game *unoGame) { if playerWins { payout := p.claimFromPot(game.wager) totalPayout := game.wager + payout - p.euro.Credit(game.playerID, totalPayout, "uno_win") + net, _ := communityTax(game.playerID, totalPayout, 0.05) + p.euro.Credit(game.playerID, net, "uno_win") newPot := p.getPot() p.SendMessage(game.dmRoomID, "🎉 **You win on points!**") @@ -1885,7 +1887,8 @@ func (p *UnoPlugin) playerWins(game *unoGame) error { potBefore := p.getPot() payout := p.claimFromPot(game.wager) totalPayout := game.wager + payout - p.euro.Credit(game.playerID, totalPayout, "uno_win") + net, _ := communityTax(game.playerID, totalPayout, 0.05) + p.euro.Credit(game.playerID, net, "uno_win") newPot := p.getPot() playerName := p.DisplayName(game.playerID) diff --git a/internal/plugin/uno_multi.go b/internal/plugin/uno_multi.go index b4a18df..e309299 100644 --- a/internal/plugin/uno_multi.go +++ b/internal/plugin/uno_multi.go @@ -2564,7 +2564,8 @@ func (p *UnoPlugin) multiPlayerWins2(game *unoMultiGame, winner *unoMultiPlayer) } } totalPot := game.ante * float64(humanCount+game.botAntes) - p.euro.Credit(winner.userID, totalPot, "uno_multi_win") + net, _ := communityTax(winner.userID, totalPot, 0.05) + p.euro.Credit(winner.userID, net, "uno_multi_win") p.recordMultiGame(game, winner.userID, "sudden_death_win") p.cleanupMultiGame(game) @@ -2614,9 +2615,10 @@ func (p *UnoPlugin) multiPlayerWins(game *unoMultiGame, winner *unoMultiPlayer) } } totalPot := game.ante * float64(humanCount+game.botAntes) + net, _ := communityTax(winner.userID, totalPot, 0.05) name := p.DisplayName(winner.userID) - p.euro.Credit(winner.userID, totalPot, "uno_multi_win") + p.euro.Credit(winner.userID, net, "uno_multi_win") p.SendMessage(game.roomID, fmt.Sprintf( "🎉 **%s wins Multiplayer UNO!**\nPot: €%d | Turns: %d\n\n%s", diff --git a/internal/plugin/urls.go b/internal/plugin/urls.go index 79287ad..1366666 100644 --- a/internal/plugin/urls.go +++ b/internal/plugin/urls.go @@ -85,7 +85,7 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error { return nil } - go p.previewURL(ctx, urls[0]) + safeGo("url-preview", func() { p.previewURL(ctx, urls[0]) }) return nil } diff --git a/internal/plugin/user.go b/internal/plugin/user.go index 1480b88..e81c703 100644 --- a/internal/plugin/user.go +++ b/internal/plugin/user.go @@ -385,11 +385,11 @@ func (p *UserPlugin) checkKeywordWatches(ctx MessageContext) { keyword, string(ctx.Sender), string(ctx.RoomID), preview) // Use a goroutine to avoid blocking message dispatch - go func(uid id.UserID, msg string) { - if err := p.SendDM(uid, msg); err != nil { - slog.Error("user: send watch DM", "err", err, "user", uid) + safeGo("user-watch-dm", func() { + if err := p.SendDM(id.UserID(watcherID), dmMsg); err != nil { + slog.Error("user: send watch DM", "err", err, "user", watcherID) } - }(id.UserID(watcherID), dmMsg) + }) } } diff --git a/internal/plugin/vibe.go b/internal/plugin/vibe.go index 938e83a..50a5307 100644 --- a/internal/plugin/vibe.go +++ b/internal/plugin/vibe.go @@ -158,7 +158,7 @@ Describe the room's current vibe:`, botName, transcript) if err != nil { slog.Error("vibe: ollama call", "err", err) p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read the vibe. LLM might be offline.") + return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read the vibe. Thanks, Ollama.") } return p.SendMessage(ctx.RoomID, response) @@ -203,7 +203,7 @@ Summary:`, tldrBotName, transcript) if err != nil { slog.Error("vibe: ollama call", "err", err) p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure - return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to summarize. LLM might be offline.") + return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't summarize. Thanks, Ollama.") } return p.SendMessage(ctx.RoomID, response) diff --git a/internal/plugin/wordle.go b/internal/plugin/wordle.go index d7d70f6..cc938a1 100644 --- a/internal/plugin/wordle.go +++ b/internal/plugin/wordle.go @@ -1,7 +1,6 @@ package plugin import ( - "database/sql" "fmt" "log/slog" "os" @@ -50,21 +49,18 @@ func NewWordlePlugin(client *mautrix.Client, euro *EuroPlugin, dict *dreamclient } } -func (p *WordlePlugin) Name() string { return "wordle" } +func (p *WordlePlugin) Name() string { return "wordle" } +func (p *WordlePlugin) Version() string { return "2.0.0" } func (p *WordlePlugin) Commands() []CommandDef { return []CommandDef{ - {Name: "wordle", Description: "Guess today's Wordle", Usage: "!wordle ", Category: "Games"}, + {Name: "wordle", Description: "Guess the current Wordle puzzle", Usage: "!wordle ", Category: "Games"}, } } func (p *WordlePlugin) Init() error { - // Rehydrate today's puzzle from DB if it exists. + // Rehydrate any active puzzle from DB if it exists. p.rehydratePuzzles() - - // Start the midnight ticker for auto-posting. - go p.midnightTicker() - return nil } @@ -113,26 +109,28 @@ func (p *WordlePlugin) handleHelp(ctx MessageContext) error { "`!wordle new <5-20>` — New puzzle with specific length (admin)\n"+ "`!wordle new pt` — Portuguese puzzle (admin)\n"+ "`!wordle new fr` — French puzzle (admin)\n"+ - "`!wordle skip` — Reveal answer and end puzzle (admin)") + "`!wordle skip` — Reveal answer and end puzzle (admin)\n\n"+ + "A new puzzle starts automatically after each solve or failure.") } func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { guess = strings.ToUpper(strings.TrimSpace(guess)) p.mu.Lock() - defer p.mu.Unlock() puzzle := p.puzzles[ctx.RoomID] - if puzzle == nil { - return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle. An admin can start one with `!wordle new`.") - } - - if puzzle.Solved || puzzle.Failed { - return p.SendReply(ctx.RoomID, ctx.EventID, "Today's puzzle is already over. A new one starts at midnight UTC!") + if puzzle == nil || puzzle.Solved || puzzle.Failed { + p.mu.Unlock() + // Auto-start a new puzzle. + if err := p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN); err != nil { + return err + } + return p.SendReply(ctx.RoomID, ctx.EventID, "A new puzzle just started! Try your guess again.") } // Check length. if len([]rune(guess)) != puzzle.WordLength { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Guesses must be %d letters.", puzzle.WordLength)) } @@ -140,6 +138,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { // Check for non-alphabetic characters. for _, r := range guess { if r < 'A' || r > 'Z' { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Guesses must contain only letters.") } } @@ -147,6 +146,7 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { // Check duplicate guess. for _, g := range puzzle.Guesses { if g.Word == guess { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("**%s** has already been tried.", guess)) } @@ -155,10 +155,12 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { // Validate word via DreamDict (with caching). valid, apiErr := p.isValidWord(puzzle.PuzzleID, guess, puzzle.Category) if apiErr { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, "Word validation is temporarily unavailable. Try again in a moment.") } if !valid { + p.mu.Unlock() return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("❌ **%s** is not a valid word.", guess)) } @@ -196,8 +198,14 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { payouts := p.awardPrize(puzzle) p.updateStats(puzzle, payouts) p.markPuzzleDone(puzzle) + p.mu.Unlock() - return p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts)) + if err := p.SendMessage(ctx.RoomID, renderSolvedAnnouncement(puzzle, definition, payouts)); err != nil { + return err + } + + // Auto-start next puzzle. + return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN) } // Check for failure (all guesses used). @@ -207,11 +215,18 @@ func (p *WordlePlugin) handleGuess(ctx MessageContext, guess string) error { definition := p.fetchDefinition(puzzle.Answer) p.updateStats(puzzle, nil) p.markPuzzleDone(puzzle) + p.mu.Unlock() - return p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition)) + if err := p.SendMessage(ctx.RoomID, renderFailedAnnouncement(puzzle, definition)); err != nil { + return err + } + + // Auto-start next puzzle. + return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN) } // Post updated grid. + p.mu.Unlock() return p.SendMessage(ctx.RoomID, renderWordleGrid(puzzle)) } @@ -220,8 +235,12 @@ func (p *WordlePlugin) handleGrid(ctx MessageContext) error { puzzle := p.puzzles[ctx.RoomID] p.mu.Unlock() - if puzzle == nil { - return p.SendReply(ctx.RoomID, ctx.EventID, "No active puzzle.") + if puzzle == nil || puzzle.Solved || puzzle.Failed { + // Auto-start a new puzzle. + if err := p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN); err != nil { + return err + } + return nil } if len(puzzle.Guesses) == 0 { @@ -290,8 +309,13 @@ func (p *WordlePlugin) handleSkip(ctx MessageContext) error { defLine = fmt.Sprintf("\n📖 *%s*\n", definition) } - return p.SendMessage(ctx.RoomID, - fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine)) + if err := p.SendMessage(ctx.RoomID, + fmt.Sprintf("⏭️ **Puzzle skipped.**\nThe word was **%s**.%s", puzzle.Answer, defLine)); err != nil { + return err + } + + // Auto-start next puzzle. + return p.createAndPostPuzzle(ctx.RoomID, p.defaultLength, WordleCategoryEN) } func (p *WordlePlugin) handleStats(ctx MessageContext) error { @@ -337,11 +361,11 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat } puzzleNumber := p.nextPuzzleNumber() - today := time.Now().UTC().Format("2006-01-02") + puzzleID := fmt.Sprintf("%d", puzzleNumber) now := time.Now().UTC() puzzle := &WordlePuzzle{ - PuzzleID: today, + PuzzleID: puzzleID, PuzzleNumber: puzzleNumber, RoomID: roomID, Answer: word, @@ -355,48 +379,42 @@ func (p *WordlePlugin) createAndPostPuzzle(roomID id.RoomID, wordLength int, cat // Persist to DB. db.Exec("wordle: persist puzzle", `INSERT INTO wordle_puzzles (puzzle_id, room_id, answer, word_length, category, solved, guess_count, started_at) - VALUES (?, ?, ?, ?, ?, 0, 0, ?) - ON CONFLICT(puzzle_id, room_id) DO UPDATE SET answer = ?, word_length = ?, category = ?, solved = 0, guess_count = 0, started_at = ?`, - today, string(roomID), word, wordLength, string(category), now, - word, wordLength, string(category), now, - ) - // Clear any stale guesses from a previous puzzle on the same day (e.g. after skip+new). - db.Exec("wordle: clear old guesses", - `DELETE FROM wordle_guesses WHERE puzzle_id = ? AND room_id = ?`, - today, string(roomID), + VALUES (?, ?, ?, ?, ?, 0, 0, ?)`, + puzzleID, string(roomID), word, wordLength, string(category), now, ) p.mu.Lock() p.puzzles[roomID] = puzzle - // Evict stale cache entries from previous days. + // Evict old cache entries — keep only current puzzle. for key := range p.validCache { - if key != today { + if key != puzzleID { delete(p.validCache, key) } } - p.validCache[today] = make(map[string]bool) + p.validCache[puzzleID] = make(map[string]bool) p.mu.Unlock() hint := wordleCategoryHint(category) return p.SendMessage(roomID, renderWordleStartAnnouncement(puzzleNumber, wordLength, hint)) } -// pickWord tries DreamDict's RandomWord API first, falling back to the local -// word pool if DreamDict is unavailable or only returns recently-used words. +// pickWord tries DreamDict's RandomWord API first, gathering multiple candidates +// and selecting the most common one by frequency. Falls back to the local word +// pool if DreamDict is unavailable. func (p *WordlePlugin) pickWord(length int, category WordleCategory) string { if p.dict != nil { recent := loadRecentWordleAnswers(500) lang := categoryLang(category) - // Try a few times to get a word that hasn't been used recently. - for range 10 { + // Gather candidate words, then pick the highest-frequency one. + var candidates []string + for range 15 { word, err := p.dict.RandomWord(lang, "", length, length, 500) if err != nil { slog.Warn("wordle: DreamDict random word failed, falling back", "err", err) break } word = strings.ToUpper(word) - // Verify the word is exactly the right length and contains only letters. runes := []rune(word) if len(runes) != length { continue @@ -411,9 +429,31 @@ func (p *WordlePlugin) pickWord(length int, category WordleCategory) string { if !clean { continue } - if !recent[word] { - return word + if recent[word] { + continue } + candidates = append(candidates, word) + if len(candidates) >= 5 { + break + } + } + + if len(candidates) > 0 { + // Look up frequencies in batch and pick the most common word. + freqs, err := p.dict.FrequencyBatch(candidates, lang) + if err == nil && len(freqs) > 0 { + best := candidates[0] + bestFreq := 0 + for _, w := range candidates { + if f, ok := freqs[strings.ToLower(w)]; ok && f > bestFreq { + best = w + bestFreq = f + } + } + return best + } + // FrequencyBatch failed — just use the first candidate. + return candidates[0] } } @@ -444,47 +484,7 @@ func wordleCategoryHint(category WordleCategory) string { return "" } -// expireUnsolved marks an active puzzle as failed and posts an announcement. -// Must be called without holding p.mu. -func (p *WordlePlugin) expireUnsolved(roomID id.RoomID) { - p.mu.Lock() - existing := p.puzzles[roomID] - if existing == nil || existing.Solved || existing.Failed { - p.mu.Unlock() - return - } - existing.Failed = true - p.markPuzzleDone(existing) - p.updateStats(existing, nil) - p.mu.Unlock() - definition := p.fetchDefinition(existing.Answer) - defLine := "" - if definition != "" { - defLine = fmt.Sprintf("\n📖 *%s*\n", definition) - } - p.SendMessage(roomID, - fmt.Sprintf("⏰ **Time's up!** Yesterday's puzzle expired.\nThe word was **%s**.%s", existing.Answer, defLine)) -} - -// PostDailyPuzzle is called by the scheduler to post today's puzzle. -func (p *WordlePlugin) PostDailyPuzzle(roomID id.RoomID) error { - today := time.Now().UTC().Format("2006-01-02") - - // Check if already posted today. - p.mu.Lock() - existing := p.puzzles[roomID] - if existing != nil && existing.PuzzleID == today { - p.mu.Unlock() - return nil // already posted - } - p.mu.Unlock() - - // Announce expiry of yesterday's unsolved puzzle before creating the new one. - p.expireUnsolved(roomID) - - return p.createAndPostPuzzle(roomID, p.defaultLength, WordleCategoryEN) -} // isValidWord checks if a word is valid, using the in-memory cache first, // then the custom allow-list, then DreamDict. @@ -696,8 +696,9 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout { if c.solver { amount += solverBonus } - p.euro.Credit(uid, float64(amount), "wordle_win") - payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: amount, Solver: c.solver}) + net, _ := communityTax(uid, float64(amount), 0.05) + p.euro.Credit(uid, net, "wordle_win") + payouts = append(payouts, WordlePayout{UserID: uid, Name: c.name, Amount: int(net), Solver: c.solver}) } return payouts } @@ -705,7 +706,7 @@ func (p *WordlePlugin) awardPrize(puzzle *WordlePuzzle) []WordlePayout { func (p *WordlePlugin) communityStreak() int { d := db.Get() rows, err := d.Query( - `SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY puzzle_id DESC LIMIT 100`) + `SELECT puzzle_id, solved FROM wordle_puzzles ORDER BY started_at DESC LIMIT 100`) if err != nil { return 0 } @@ -728,7 +729,6 @@ func (p *WordlePlugin) communityStreak() int { } func (p *WordlePlugin) rehydratePuzzles() { - today := time.Now().UTC().Format("2006-01-02") d := db.Get() // Collect rows first, then close the cursor before doing any nested queries. @@ -743,14 +743,16 @@ func (p *WordlePlugin) rehydratePuzzles() { startedAt time.Time } + // Find the most recent unsolved puzzle per room. rows, err := d.Query( `SELECT puzzle_id, room_id, answer, word_length, COALESCE(category, ''), solved, guess_count, started_at - FROM wordle_puzzles WHERE puzzle_id = ?`, today) + FROM wordle_puzzles WHERE solved = 0 ORDER BY started_at DESC`) if err != nil { slog.Warn("wordle: rehydrate query failed", "err", err) return } + seen := make(map[string]bool) // track rooms already handled var pending []puzzleRow for rows.Next() { var pid, roomStr, answer, category string @@ -760,10 +762,16 @@ func (p *WordlePlugin) rehydratePuzzles() { continue } - if solved == 1 || guessCount >= wordleMaxGuesses(wordLength) { + if guessCount >= wordleMaxGuesses(wordLength) { continue // already done } + // Only take the most recent unsolved puzzle per room. + if seen[roomStr] { + continue + } + seen[roomStr] = true + pending = append(pending, puzzleRow{pid, roomStr, answer, wordLength, category, startedAt}) } rows.Close() @@ -835,52 +843,3 @@ func (p *WordlePlugin) rehydratePuzzles() { } } -// midnightTicker checks every minute if it's time to post a new daily puzzle. -func (p *WordlePlugin) midnightTicker() { - // Check immediately on startup. - p.checkAndPostDaily() - - ticker := time.NewTicker(1 * time.Minute) - defer ticker.Stop() - - lastDate := time.Now().UTC().Format("2006-01-02") - for range ticker.C { - today := time.Now().UTC().Format("2006-01-02") - if today != lastDate { - lastDate = today - p.checkAndPostDaily() - } - } -} - -func (p *WordlePlugin) checkAndPostDaily() { - gr := gamesRoom() - if gr == "" { - return - } - - today := time.Now().UTC().Format("2006-01-02") - d := db.Get() - - // Check if today's puzzle already exists for this room. - var exists int - err := d.QueryRow( - `SELECT 1 FROM wordle_puzzles WHERE puzzle_id = ? AND room_id = ?`, - today, string(gr), - ).Scan(&exists) - if err == nil { - return // already exists - } - if err != sql.ErrNoRows { - slog.Error("wordle: check daily puzzle", "err", err) - return - } - - // Announce expiry of yesterday's unsolved puzzle before creating the new one. - p.expireUnsolved(gr) - - slog.Info("wordle: posting daily puzzle", "room", gr) - if err := p.createAndPostPuzzle(gr, p.defaultLength, WordleCategoryEN); err != nil { - slog.Error("wordle: daily puzzle failed", "err", err) - } -} diff --git a/internal/plugin/wordle_render.go b/internal/plugin/wordle_render.go index 769c613..f7bdeaf 100644 --- a/internal/plugin/wordle_render.go +++ b/internal/plugin/wordle_render.go @@ -74,7 +74,7 @@ func renderKeyboard(states map[rune]LetterResult) string { // renderWordleStartAnnouncement renders the puzzle start message. func renderWordleStartAnnouncement(puzzleNumber, wordLength int, hint string) string { base := fmt.Sprintf( - "🟩 **Daily Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.", + "🟩 **Wordle #%d**\nA new %d-letter puzzle is ready! Work together — %d guesses shared.", puzzleNumber, wordLength, wordleMaxGuesses(wordLength), ) if hint != "" { diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go index 2d761cd..be50a29 100644 --- a/internal/plugin/wotd.go +++ b/internal/plugin/wotd.go @@ -47,7 +47,7 @@ func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *WOTDPlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "wotd") { - go func() { + safeGo("wotd-handler", func() { args := strings.TrimSpace(p.GetArgs(ctx.Body, "wotd")) if strings.ToLower(args) == "force" { if err := p.handleWOTDForce(ctx); err != nil { @@ -58,7 +58,7 @@ func (p *WOTDPlugin) OnMessage(ctx MessageContext) error { slog.Error("wotd: handler error", "err", err) } } - }() + }) return nil } diff --git a/internal/plugin/xp.go b/internal/plugin/xp.go index d42f966..68ace92 100644 --- a/internal/plugin/xp.go +++ b/internal/plugin/xp.go @@ -106,6 +106,16 @@ func (p *XPPlugin) OnMessage(ctx MessageContext) error { } // GrantXP awards XP to a user from an external plugin and returns (newXP, leveledUp, newLevel). +// GetLevel returns the chat level for the given user, or 0 if unknown. +func (p *XPPlugin) GetLevel(userID id.UserID) int { + d := db.Get() + var level int + if err := d.QueryRow(`SELECT level FROM users WHERE user_id = ?`, string(userID)).Scan(&level); err != nil { + return 0 + } + return level +} + func (p *XPPlugin) GrantXP(userID id.UserID, amount int, reason string) (int, bool, int) { return p.grantXPInternal(userID, amount, reason) } diff --git a/internal/util/parser_test.go b/internal/util/parser_test.go new file mode 100644 index 0000000..6bc7862 --- /dev/null +++ b/internal/util/parser_test.go @@ -0,0 +1,255 @@ +package util + +import ( + "testing" +) + +// ── XPForLevel ───────────────────────────────────────────────────────────── + +func TestXPForLevel_Zero(t *testing.T) { + if got := XPForLevel(0); got != 0 { + t.Errorf("XPForLevel(0) = %d, want 0", got) + } +} + +func TestXPForLevel_KnownValues(t *testing.T) { + cases := []struct { + level int + want int + }{ + {1, 100}, + {2, 400}, + {5, 2500}, + {10, 10000}, + {20, 40000}, + {50, 250000}, + } + for _, tc := range cases { + if got := XPForLevel(tc.level); got != tc.want { + t.Errorf("XPForLevel(%d) = %d, want %d", tc.level, got, tc.want) + } + } +} + +// ── LevelFromXP ──────────────────────────────────────────────────────────── + +func TestLevelFromXP_Zero(t *testing.T) { + if got := LevelFromXP(0); got != 0 { + t.Errorf("LevelFromXP(0) = %d, want 0", got) + } +} + +func TestLevelFromXP_Negative(t *testing.T) { + if got := LevelFromXP(-100); got != 0 { + t.Errorf("LevelFromXP(-100) = %d, want 0", got) + } +} + +func TestLevelFromXP_ExactBoundaries(t *testing.T) { + // At exactly the XP threshold for a level, you should be that level. + cases := []struct { + xp int + want int + }{ + {99, 0}, + {100, 1}, + {399, 1}, + {400, 2}, + {2500, 5}, + {10000, 10}, + } + for _, tc := range cases { + if got := LevelFromXP(tc.xp); got != tc.want { + t.Errorf("LevelFromXP(%d) = %d, want %d", tc.xp, got, tc.want) + } + } +} + +func TestLevelFromXP_RoundTrip(t *testing.T) { + for level := 0; level <= 100; level++ { + xp := XPForLevel(level) + got := LevelFromXP(xp) + if got != level { + t.Errorf("LevelFromXP(XPForLevel(%d)) = %d, want %d", level, got, level) + } + } +} + +// ── ProgressBar ──────────────────────────────────────────────────────────── + +func TestProgressBar_Empty(t *testing.T) { + bar := ProgressBar(0, 100, 10) + want := "[----------] 0%" + if bar != want { + t.Errorf("ProgressBar(0,100,10) = %q, want %q", bar, want) + } +} + +func TestProgressBar_Full(t *testing.T) { + bar := ProgressBar(100, 100, 10) + want := "[##########] 100%" + if bar != want { + t.Errorf("ProgressBar(100,100,10) = %q, want %q", bar, want) + } +} + +func TestProgressBar_Half(t *testing.T) { + bar := ProgressBar(50, 100, 10) + want := "[#####-----] 50%" + if bar != want { + t.Errorf("ProgressBar(50,100,10) = %q, want %q", bar, want) + } +} + +func TestProgressBar_Overflow(t *testing.T) { + bar := ProgressBar(200, 100, 10) + want := "[##########] 100%" + if bar != want { + t.Errorf("ProgressBar(200,100,10) = %q, want %q", bar, want) + } +} + +func TestProgressBar_ZeroMax(t *testing.T) { + bar := ProgressBar(50, 0, 10) + want := "----------" + if bar != want { + t.Errorf("ProgressBar(50,0,10) = %q, want %q", bar, want) + } +} + +// ── IsCommand ────────────────────────────────────────────────────────────── + +func TestIsCommand_ExactMatch(t *testing.T) { + if !IsCommand("!rank", "!", "rank") { + t.Error("IsCommand should match exact command") + } +} + +func TestIsCommand_WithArgs(t *testing.T) { + if !IsCommand("!rank @someone", "!", "rank") { + t.Error("IsCommand should match command with args") + } +} + +func TestIsCommand_CaseInsensitive(t *testing.T) { + if !IsCommand("!RANK", "!", "rank") { + t.Error("IsCommand should be case-insensitive") + } +} + +func TestIsCommand_NoMatch(t *testing.T) { + if IsCommand("!leaderboard", "!", "rank") { + t.Error("IsCommand should not match different command") + } +} + +func TestIsCommand_PartialNoMatch(t *testing.T) { + if IsCommand("!rankings", "!", "rank") { + t.Error("IsCommand should not match partial prefix without space") + } +} + +func TestIsCommand_LeadingWhitespace(t *testing.T) { + if !IsCommand(" !rank", "!", "rank") { + t.Error("IsCommand should handle leading whitespace") + } +} + +// ── GetArgs ──────────────────────────────────────────────────────────────── + +func TestGetArgs_NoArgs(t *testing.T) { + if got := GetArgs("!rank", "!", "rank"); got != "" { + t.Errorf("GetArgs should return empty for no args, got %q", got) + } +} + +func TestGetArgs_WithArgs(t *testing.T) { + if got := GetArgs("!rank @someone", "!", "rank"); got != "@someone" { + t.Errorf("GetArgs = %q, want %q", got, "@someone") + } +} + +func TestGetArgs_MultipleArgs(t *testing.T) { + got := GetArgs("!calc 2 + 2", "!", "calc") + if got != "2 + 2" { + t.Errorf("GetArgs = %q, want %q", got, "2 + 2") + } +} + +func TestGetArgs_WrongCommand(t *testing.T) { + if got := GetArgs("!other stuff", "!", "rank"); got != "" { + t.Errorf("GetArgs for wrong command should return empty, got %q", got) + } +} + +// ── HasNonASCII ──────────────────────────────────────────────────────────── + +func TestHasNonASCII_ASCII(t *testing.T) { + if HasNonASCII("hello world 123") { + t.Error("pure ASCII should return false") + } +} + +func TestHasNonASCII_Emoji(t *testing.T) { + if !HasNonASCII("hello 🌍") { + t.Error("emoji should return true") + } +} + +func TestHasNonASCII_Unicode(t *testing.T) { + if !HasNonASCII("café") { + t.Error("accented chars should return true") + } +} + +func TestHasNonASCII_Empty(t *testing.T) { + if HasNonASCII("") { + t.Error("empty string should return false") + } +} + +// ── ParseMessage ─────────────────────────────────────────────────────────── + +func TestParseMessage_Plain(t *testing.T) { + stats := ParseMessage("hello world") + if stats.Words != 2 { + t.Errorf("Words = %d, want 2", stats.Words) + } + if stats.Chars != 11 { + t.Errorf("Chars = %d, want 11", stats.Chars) + } +} + +func TestParseMessage_WithLinks(t *testing.T) { + stats := ParseMessage("check https://example.com and https://other.org") + if stats.Links != 2 { + t.Errorf("Links = %d, want 2", stats.Links) + } +} + +func TestParseMessage_WithImages(t *testing.T) { + stats := ParseMessage("look https://img.example.com/cat.png here") + if stats.Images != 1 { + t.Errorf("Images = %d, want 1", stats.Images) + } + if stats.Links != 1 { + t.Errorf("Links = %d, want 1", stats.Links) + } +} + +func TestParseMessage_Questions(t *testing.T) { + stats := ParseMessage("what? really? yes!") + if stats.Questions != 2 { + t.Errorf("Questions = %d, want 2", stats.Questions) + } + if stats.Exclamations != 1 { + t.Errorf("Exclamations = %d, want 1", stats.Exclamations) + } +} + +func TestParseMessage_Empty(t *testing.T) { + stats := ParseMessage("") + if stats.Words != 0 || stats.Chars != 0 { + t.Errorf("empty message should have 0 words and 0 chars") + } +} diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..01ab2e5 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,28 @@ +package version + +import ( + "fmt" + "runtime" +) + +const Version = "1.0.0" + +var ( + Commit = "dev" + BuildDate = "unknown" +) + +func Full() string { + return fmt.Sprintf("GogoBee v%s (%s, %s, %s)", Version, Commit, BuildDate, runtime.Version()) +} + +func Short() string { + return fmt.Sprintf("v%s-%s", Version, shortCommit()) +} + +func shortCommit() string { + if len(Commit) > 7 { + return Commit[:7] + } + return Commit +} diff --git a/main.go b/main.go index f7603b7..48dc9a4 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "gogobee/internal/dreamclient" "gogobee/internal/plugin" "gogobee/internal/util" + "gogobee/internal/version" "github.com/joho/godotenv" "github.com/robfig/cron/v3" @@ -35,7 +36,7 @@ func main() { logLevel = "info" } util.InitLogger(logLevel) - slog.Info("logger initialized", "level", logLevel, + slog.Info(version.Full(), "level", logLevel, "ollama_host", os.Getenv("OLLAMA_HOST"), "ollama_model", os.Getenv("OLLAMA_MODEL")) @@ -52,6 +53,7 @@ func main() { if err := db.SeedSchedulerDefaults(db.Get()); err != nil { slog.Warn("seed scheduler defaults failed", "err", err) } + db.RecordStartup(version.Version, version.Commit) // Create Matrix client cfg := bot.Config{ @@ -132,7 +134,7 @@ func main() { registry.Register(plugin.NewBlackjackPlugin(client, euroPlugin)) registry.Register(plugin.NewUnoPlugin(client, euroPlugin)) registry.Register(plugin.NewHoldemPlugin(client, euroPlugin)) - adventurePlugin := plugin.NewAdventurePlugin(client, euroPlugin) + adventurePlugin := plugin.NewAdventurePlugin(client, euroPlugin, xpPlugin) registry.Register(adventurePlugin) wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient) registry.Register(wordlePlugin) @@ -207,6 +209,7 @@ func main() { defer func() { if rec := recover(); rec != nil { slog.Error("member event handler panic", "panic", rec, "room", evt.RoomID) + db.RecordCrash(version.Short(), "member_handler", fmt.Sprintf("%v", rec)) } }() @@ -239,6 +242,7 @@ func main() { if rec := recover(); rec != nil { slog.Error("message event handler panic", "panic", rec, "sender", evt.Sender, "room", evt.RoomID) + db.RecordCrash(version.Short(), "message_handler", fmt.Sprintf("%v", rec)) } }() @@ -283,6 +287,7 @@ func main() { if rec := recover(); rec != nil { slog.Error("reaction event handler panic", "panic", rec, "sender", evt.Sender, "room", evt.RoomID) + db.RecordCrash(version.Short(), "reaction_handler", fmt.Sprintf("%v", rec)) } }() @@ -349,6 +354,7 @@ syncLoop: } } + db.Close() slog.Info("GogoBee stopped") }