45 Commits

Author SHA1 Message Date
prosolis
c66c32e698 .gitignore: skip cmd/ binaries (gensolver, holdem-train)
Both are 16MB Go binaries built from cmd/gensolver and cmd/holdem-train.
They were showing up as untracked on every status check. Also drops a
stray 0-byte internal/db/gogobee.db that someone created at the wrong
path (real DB lives in data/, already gitignored).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 07:49:40 -07:00
prosolis
d9ac998458 Adventure: surface crafts in !adventure status + document crafting
- Status sheet now shows lifetime craft count + a hint pointing at
  !adventure recipes (only displayed once the player has crafted at
  least once — keeps the line absent for non-foragers).
- README gains a Crafting subsection documenting the auto-craft flow,
  XP rewards, level gates, the recipes command, and the consumable
  protection (Robbie + sell-all both skip them).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:26:06 -07:00
prosolis
0bebcb56cd Adventure: !adventure recipes — list known crafting recipes
Players had no way to see what they could craft without grinding
ingredients and watching combat narrative. New command lists every recipe
unlocked at the player's current Foraging level, grouped by tier, with:

- Result name + ingredient list (so players know what to gather)
- Per-recipe success rate at the player's current level
- Locked-recipe count + next-unlock threshold
- Max auto-crafts per combat (1 + (level-10)/10)

Sub-Foraging-10 players see only the unlock hint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:23:53 -07:00
prosolis
8e3b8377c0 Adventure crafting: grant foraging XP per attempt, track lifetime crafts
Crafting was a downstream consumer of Foraging skill but never gave any
XP back, so the feedback loop was one-directional — only gathering
contributed to the level that gates better recipes.

Now: each auto-craft attempt grants Foraging XP (success > failure).
Per-tier values:
  tier 1:  +12 / +3
  tier 2:  +25 / +5
  tier 3:  +40 / +8
  tier 4:  +60 / +12
  tier 5:  +90 / +18

Successful T1 ≈ 30% of a Foraging Success haul; T5 ≈ 40%. Failures get a
small consolation grant — the attempt still produced practical knowledge
(and lost ingredients).

Schema: adventure_characters.crafts_succeeded INT for lifetime tracking.
Migration entry included.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:19:06 -07:00
prosolis
671773abd5 Adventure: protect consumables from Robbie and 'sell all'
Consumables (auto-crafted from foraged ingredients or dropped from T2+
activities) are a curated stockpile — selling them should be an explicit
choice, not a side effect.

Two surfaces tightened:
- Robbie's qualifying-items filter now skips Type == "consumable" the
  same way it skips Arena gear and cards.
- !adventure sell all leaves consumables in inventory and reports them
  in the kept-items tally. The merchant message points players at
  !adventure sell <name> for explicit consumable sales.

!adventure sell <name> still works fine on consumables — that path is
already explicit by definition.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 23:13:09 -07:00
prosolis
e22d4f8362 Hold'em: route sender-private replies to DM origin + jitter broadcasts
Two changes that were running in prod but hadn't been committed:

1. Sender-private DM routing. When a player invokes a holdem command from
   their DM, the dispatcher rewrites ctx.RoomID to the game room so
   actions affect the right table. But sender-private replies (errors,
   help, status, "unknown command") would then post to the public games
   room instead of back to the DM that asked. Now: the dispatcher saves
   ctx.OriginRoomID = the DM, and a new reply() helper routes sender-
   private messages to OriginRoomID when set, falling back to in-thread
   reply otherwise. (OriginRoomID was added to MessageContext earlier.)

2. broadcastDM jitter. 150-400ms sleep between sends to avoid Matrix
   rate-limit bursts when a game has 4+ players each in their own DM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:37:19 -07:00
prosolis
4826b5e466 Adventure: scale random-event gold rewards 100× to match current economy
Random event gold ranges (€20-250) were authored against an earlier
economy and have been near-trivial against current per-action haul values
(thousands at higher tiers). Scaled the GoldMin/GoldMax across all event
entries by 100× so the rewards feel like real moments instead of
rounding errors.

Pure flavor-table numeric rebalance — no logic changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:37:04 -07:00
prosolis
7878ce0999 Adventure: defer random-event roll to 1-3h after a player acts
Previous scheduler built one fixed roll-minute per player per UTC day in
the 10:00-16:00 window, regardless of when (or whether) the player
actually played. Players who acted late in the day might miss their roll
entirely; players who acted early still had to wait for an arbitrary
mid-day window.

New scheduler:
- Each ticker minute, scan for players who have acted today but don't
  yet have a scheduled roll. Assign each a one-shot roll-minute 60-180
  minutes in the future (capped to 23:50 UTC).
- At the assigned minute, fire the 0.5% trigger roll. Mark advEventRolled
  so it won't fire again that day.
- New day rebuilds both maps fresh.

Result: events trigger relative to the player's actual activity, not
clock time. Late-day actors get their chance; players who skip a day
don't get a roll.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:36:52 -07:00
prosolis
6eadb09c1e Adventure: extend NPC encounter expiry to end of UTC day
NPC encounter pending interactions were using advDMResponseWindow
(the short DM-reply window), so a player who didn't get to their DMs
quickly would lose the encounter prompt. Bumped to "until midnight UTC"
so the encounter is available throughout the day the NPC arrived.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:36:36 -07:00
prosolis
d93dc4faa4 Adventure: skip overlevel penalty when no higher tier is accessible
Previously, advOverlevelMultiplier penalized every action where the
player's effective level exceeded the location's MinLevel by 4+, with
no escape valve. That punished players grinding at their max-available
tier (e.g., a level 50 character at the highest mining location they
qualify for, with nothing higher unlocked).

Now: the multiplier short-circuits to 1.0 when no accessible higher-tier
location of the same activity exists for that player. Signature changed
from (effectiveLevel, minLevel) to (effectiveLevel, *AdvLocation) so the
helper can scan the activity's location list. combat_bridge.go updated
to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:36:17 -07:00
prosolis
07ca5288c3 Coop: stack same-type gifts on the same day into a single vote post
Multiple baskets (or multiple mimics) sent during the same day now share
one game-room post, one vote, one resolution. First-in becomes the stack
"lead"; subsequent same-type sends become followers that inherit the
lead's deadline and votes.

Behavior:
- Send a basket → new lead, new post, 6h timer starts
- Send another basket within the window → silently joins the stack,
  edits lead's post to bump count
- At stack size 2, TwinBee adds a "this gift looks REALLY special" line
  (one of 5 variants picked deterministically by lead id). No further
  escalation as the stack grows
- One !coop giftvote on the lead's id covers the whole stack
- Resolution applies the same outcome to every gift in the stack;
  modifier is N × ±6
- End-of-run gift log groups by stack with all senders attributed

Schema: coop_dungeon_gifts.stack_lead_id INTEGER (NULL for lead/standalone,
otherwise points at lead's id). Migration entry included.

Why first-in deadline (vs extending on each follower): exploitability.
Saboteurs could spam-extend a stack to delay resolution. Locked deadline
keeps senders honest about timing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:28:23 -07:00
prosolis
48e5000745 Coop: lock combat actions for the full duration of an active run
Players in a co-op were getting their combat action back every midnight
reset and offered the dungeon option in the morning DM. Per spec they
should be "off in the Co-op" and unable to also solo-grind dungeons.

Fix at the DB layer (so all CanDoCombat() paths agree):
- New helper lockCoopCombatActions() sets combat_actions_used=99 for any
  user in an active coop run. Called immediately after every
  resetAllAdvDailyActions() — at startup and at midnight.
- Render layer: morning DM now shows "combat locked (in Co-op #N, day
  X/Y)" and replaces the dungeon section with an explanatory line
  pointing them at !coop status.

Combat returns to normal automatically once the run completes/wipes —
the next midnight reset finds status != 'active' and skips the lock.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:38:00 -07:00
prosolis
c060e13b41 Coop: surface per-gift countdowns in !coop status
Each gift now has its own 6h voting window, but the only places the
remaining time was visible were the original arrival post and any
edit-triggered re-render after a vote. Mid-window the displayed
countdown was stale.

!coop status now lists pending gifts (anything with vote_result still
NULL on this run) with current open/leave tally and a fresh per-gift
countdown derived from expires_at. The party can poll this any time to
see what's about to close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:20:00 -07:00
prosolis
b7077eeea1 Coop: substitute {count} and {leader} placeholders in gift flavor
The TwinBeeGiftArrival pool entries each end with a templated footer
authored to the spec format ("Open: {count} · Leave it: {count}\nMajority
rules. Ties go to {leader}."), but the placeholders were never being
substituted — players saw the literal "{count}" and "{leader}" strings.

Substitute in renderCoopGiftPost: ordered Replace for the two {count}
positions (opens, then leaves), ReplaceAll for {leader}. Also dropped
the redundant "Current votes:" line my code was appending — the flavor's
own tally line covers it after substitution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 18:14:49 -07:00
prosolis
9f094549b7 Coop: per-gift voting timers (6h) — gifts resolve throughout the day
Replace the single daily-tick gift resolution with independent per-gift
expiries. Each gift now has its own 6h voting window; once that elapses,
the votes are tallied, the sender gets DM feedback, and the live game-room
post is edited to reveal the type and resolved modifier. The modifier
sits on the run waiting for the next floor resolution to merge it in.

Effect:
- Gifts fire continuously throughout the day rather than all at once at
  08:00 UTC, surfacing sender activity in real time.
- Senders get fast feedback (~6h instead of waiting for the next daily
  tick).
- Floor resolution becomes purely "sum pending modifiers from already-
  resolved gifts" — cleaner separation of concerns.
- Atomic per-gift apply via markCoopGiftApplied (UPDATE...WHERE applied_at
  IS NULL) prevents double-application on retry.

Schema additions:
- coop_dungeon_gifts.expires_at (when voting closes)
- coop_dungeon_gifts.applied_at (when modifier merged into a floor)
- Migration entries provided.

Defensive backstop: floor resolution still force-resolves any gifts whose
expiry was missed (e.g., bot down during the timer window).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:55:14 -07:00
prosolis
4bc541ca85 Coop: shorten first-day window — require 12h since lock, not date change
Previous guard skipped resolution if lock_date == today_date, pushing
first-day windows to 24-32h regardless of when in the UTC day the lock
fired. With per-minute lock checks, the original same-tick race no longer
exists, so the date guard was just adding latency.

New guard: at least 12h since lock. Late-night locks still get >12h
funding/voting windows; early-morning locks get 24h+ as before. No effect
on the existing run #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:50:24 -07:00
prosolis
5e4de7b4c3 README: document !coop admgift admin debug command
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:25:07 -07:00
prosolis
90edb27147 Coop: add !coop admgift admin debug path
Admin-only command that creates a gift without the party-member or
harvest-action checks. Lets a solo tester exercise the basket/mimic
mechanic without recruiting a non-party spectator.

Usage: !coop admgift <run_id> <basket|mimic> [sender_user_id]
       (sender defaults to the caller)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:15:26 -07:00
prosolis
ecca66da58 Coop: split lock checks (per-minute) from resolution (daily)
Locks were gated behind the same once-per-day 08:00 UTC tick as floor
resolution. A run created after morningHour would wait until next day's
tick to be eligible — meaning actual lock latency could be 24-48h, not the
24h advertised. Worse, a run created late on Day N missed the Day N+1 tick
(too early) AND the Day N+2 tick happens 48h after creation.

Fix: lock checks fire on every ticker minute (cheap timestamp scan over
typically 0-5 open runs). Resolution stays daily, gated by JobCompleted.

Real symptom that prompted this: Run #1 created 17:17 UTC, locked never
fired because the morning tick at 08:00 UTC the next day saw the run as
not-yet-eligible (only 15h elapsed), then marked the daily job complete
before the actual 24h elapsed at 17:17 UTC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 17:35:37 -07:00
prosolis
9d949dc649 Hold'em: deduplicate broadcast in solo-vs-bot games
Win/end announcements were sent twice in solo-vs-bot: once via
SendMessage(game.RoomID, ...) for the public room, once via broadcastDM
which iterated the human player's DMRoomID — but in solo-vs-bot the
game.RoomID IS the player's DM. Skip players whose DMRoomID matches
game.RoomID in broadcastDM so the room post isn't echoed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:38:16 -07:00
prosolis
a1ede53fa9 Equalize co-op gift magnitudes to kill 'always leave' dominant strategy
Old values (open ±7, leave ±4) had matching EV but asymmetric variance;
risk-averse parties had a strict preference to always leave gifts, which
collapsed the dilemma. New values (all ±6) keep EV=0 for both strategies
AND equalize variance. Choice now depends entirely on inferring sender
intent.

Adds TestCoopGiftVarianceSymmetric — fails loud if anyone reintroduces
asymmetric magnitudes. EV test was already in place but only checked the
mean; missed this on first pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:12:22 -07:00
prosolis
1ca9e77fa0 Update README with co-op dungeons and revised blacksmith rates
- New Co-op Dungeons subsection covering tier table, all !coop commands,
  funding mechanics, floor events, party strength factors, newbie liability,
  spectator betting, gift system, loot/masterwork rules, wipe semantics, and
  pinning behavior
- Refresh the Adventure feature bullet to mention co-op
- Update Blacksmith section with the post-tuning base rates (T0-T5: 1, 2,
  5, 12, 30, 80) and the softened convexity formula

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 19:06:58 -07:00
prosolis
f3d1e65bf1 Pin co-op invite posts; unpin on lock/cancel
Adds Base.PinEvent / Base.UnpinEvent helpers (m.room.pinned_events state)
that read-modify-write the existing pin list — idempotent on both sides.
The bot needs power level for state events; failures are logged but not
fatal.

Schema: add coop_dungeon_runs.invite_post_id to remember which event to
unpin. Migration entry included.

Wired:
- !coop start: pin the invite post in the games room
- !coop cancel: unpin before posting cancellation
- coopProcessLocks (auto-lock or auto-cancel): unpin before lock/expiry post

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:24:21 -07:00
prosolis
91af4f1e83 Thanks, Ollama.
Replace flat 'LLM might be offline' error strings in vibe and howami with
the deadpan 'Thanks, Ollama.' callback. Skipped tarot — its 'union-mandated
vacation' line was already the right register and shouldn't be displaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 10:09:32 -07:00
prosolis
73f3362400 Fix misleading 'combat action refunded' DM on coop invite expiry
Combat actions are only deducted at lock, never at start or join, so
there's nothing to refund when an unjoined invite expires. Just say so.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:25:08 -07:00
prosolis
9639a86268 Add !coop stats — public telemetry for runs, gold flow, betting, gifts
Aggregates that surface community-level patterns:
- Per-tier outcomes table (open/active/won/wiped/cancelled, win%, avg party size)
- Gold flow: rewards distributed vs funding forfeited to wipes
- Betting: bets placed, distinct bettors, total wagered, paid to winners,
  estimated house cut
- Gift activity: basket/mimic sent, open rates by type
- TwinBee helpfulness: last 30 events vs lifetime

Public command (no admin gate) so the community can debate strategy with
shared context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:14:45 -07:00
prosolis
e8a3b8b35d Co-op audit fixes: lock scheduler, idempotent payouts, deterministic ties
Audit pass addressed concurrency races, crash-recovery double-pay risks, and
non-determinism in tie tallying.

CRITICAL — concurrency
- coopResolveFloor now acquires advUserLock for every party member (sorted by
  UserID) before mutating state, so concurrent !coop fund / vote / giftvote
  commands serialize against the scheduler.

CRITICAL — crash idempotency
- Add coop_dungeon_runs.last_resolved_day and coop_dungeon_members.member_payout
  columns (with migration entries) for crash-resume tracking.
- Skip resolution only if last_resolved_day >= day AND status is terminal;
  otherwise re-enter and finish via idempotent operations.
- On resume detection (event already has outcome), reuse the saved roll
  instead of re-rolling — prevents different outcomes on retry.
- claimCoopMemberPayout / claimCoopBetPayout: atomic UPDATE...WHERE col IS NULL
  pattern. Each member/bet can only be paid once; retries are silent no-ops.
- createCoopEvent now INSERT OR IGNORE — idempotent on retry.
- last_resolved_day is set as the FINAL write per floor.

HIGH — bugs
- Lazy-create floor event in resolver if missing — covers crash window
  between lockCoopRun and the original createCoopEvent.
- coopTallyVote sorts tied winners alphabetically before fallback (was
  non-deterministic via Go map iteration).
- Removed dead day-1 wipe combat-action refund block — refund check was
  always false because midnight reset clears state before resolution.

LOW
- Log slog.Error on malformed JSON in parseCoopVotes / parseCoopFundingMap
  instead of silently dropping data.

Tests
- Tally test extended to assert determinism across 50 runs (catches map-iteration
  leaks).
- Added coverage for new tie-with-leader-vote-outside-winners case.
- Idempotency-guard sanity check on LastResolvedDay vs CurrentDay.

Race detector: clean. Full suite: green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 09:01:29 -07:00
prosolis
8ad31a0009 Add co-op dungeon system (party runs, voting, betting, gifts, items)
Multi-day party runs with funding decisions, TwinBee-narrated floor events,
spectator parimutuel betting, basket/mimic gift system, and weighted-roll
item distribution including masterwork drops at T4-T5.

Schema (5 tables, 2 fewer than spec by computing helpfulness on-the-fly and
skipping the loot-pending state):
- coop_dungeon_runs / _members (daily funding as JSON column)
- coop_dungeon_events (votes as JSON, used to derive TwinBee helpfulness)
- coop_dungeon_bets / _gifts

Mechanics:
- 2-4 player parties, 24h invite window, locks consume one combat action
- Per-floor success roll: base + sum(funding) + level/pet bonuses + event
  vote modifier + active gift modifiers, clamped to 5..95
- Funding tiers (none/min/std/agg/all_in) with liability cap at +8% for
  under-leveled players
- TwinBee narrates events from existing flavor pool; 20 authored events with
  per-option modifiers and embedded recommendation
- Parimutuel betting with 10% rake; odds line shown on lock/daily/status posts
- Gift modifiers symmetric at 50/50 sender mix so no dominant strategy
- Item drops on success via weight-by-contribution roll; T4 25% / T5 100%
  masterwork chance (random pick from existing T4/T5 defs)

Balance via Monte Carlo (coop_dungeon_balance_test.go):
- All tiers exceed 1.5x solo daily income at average party profile
- Optimal funding strategy walks up tiers correctly (Min/Std/Std/Mixed/Agg)
- All-In stays -EV at every tier (boost lever, not optimal play)

Tests: parsing, liability cap, JSON roundtrips, vote tally with leader
tiebreak, event meta consistency, parimutuel payouts, gift EV symmetry,
weighted-roll distribution (Monte Carlo), masterwork tier gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 07:58:13 -07:00
prosolis
16d64323d9 Fix babysit logging, achievement table name, and blacksmith repair costs
- runAutoBabysitDay now logs daily totals (was silently skipping logBabysitActivity)
- Stop wiping babysit_log on expiry/cancel; clear at next purchase instead so history persists between services
- Fix achievement query referencing wrong table name (babysit_log -> adventure_babysit_log)
- Tune blacksmith baseRates ([1,3,8,20,55,150] -> [1,2,5,12,30,80]) and soften the convexity (damage/100 -> damage/200) so mid-game repair drag isn't punitive

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 07:57:31 -07:00
prosolis
be76973fd2 Fix arena gear lockout, extend DM window, scrub mining flavor
Arena helmet auto-equip now preserves the existing helmet to inventory
(or stashes the new drop if the existing piece is strictly higher tier),
and arena gear can be re-equipped via !adventure equip. Shop only blocks
swaps that aren't an upgrade over current arena tier. DM response window
bumped from 15m to 3h so NPC/treasure/shop prompts don't expire while
players are AFK. MiningSuccess flavor pools no longer name specific ores
in prose — uses {ore}/{location}/{tool} placeholders so the narration
matches the actual loot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 22:23:58 -07:00
prosolis
d77c2ebbbb Add activity combo system with +200% per-step multiplier
Messages, links, and images each have independent combo streaks
that multiply passive euro earning. Combo grows 3x per step
(+200%), resets after 10 min idle, with daily caps (50 msg,
10 links, 15 images). !combo command shows current streaks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 16:06:28 -07:00
prosolis
148b8d20f2 Add tip audit logging, CFR alignment tests, and multiway scenarios
Three confidence features for the poker tip system:
- Tip audit table (holdem_tip_audit) logs every tip with full context
  for bulk review after real sessions
- CFR alignment test validates all 32 scenarios against the trained
  5M-iteration policy — rules engine never contradicts the bot's AI
- 8 new multiway test scenarios covering all streets and key decisions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 14:11:59 -07:00
prosolis
202056e802 Update README with blacksmith, babysit, rivals, housing, pets, NPC docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:07:41 -07:00
prosolis
b15c13cde7 Add version system, tax tracking, lottery winner DMs, fmtEuro, audit fixes
- Version system: per-plugin versions via Versioned interface, crash_log and
  version_history DB tables, !version command, crash stats in !botinfo
- Tax tracking: tax_ledger table, trackTaxPaid across all communityTax and
  communityPotAdd sites, tax paid display in !stats and !superstatsexplusalpha
- Lottery: DM winners with winning ticket details and matched numbers bolded
- fmtEuro: generic currency formatter with thousand separators across all
  economy display paths
- Audit fixes: streak_decayed column for Streak Survivor achievement,
  combat_narrative empty-phase guard, crafting success rate integer division,
  RecordCrash nil-DB guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:07:25 -07:00
prosolis
42e6e23900 Overhaul hold'em tips: solver-backed scenarios, equity ranges, validation suite
Replaces hardcoded tip scenarios with solver-frequency-backed decisions, adds
equity range display, fixes bet-size matching tolerance (25% threshold), and
adds comprehensive test coverage for scenario validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:06:50 -07:00
prosolis
7c450aaefb Add forward-simulating combat engine with consumables, monster abilities, and narrative rendering
Replaces the single-roll probability system for dungeon and arena combat with
a multi-phase simulation engine where gear, buffs, pets, and NPCs are meaningful
mechanical inputs. Adds consumable items (auto-crafted from forage/mine/fish drops),
monster abilities for T2+ enemies, and Dragon Quest-style combat narrative with
phased message delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:05:22 -07:00
prosolis
08e9925d8f Wordle: prefer common words via DreamDict frequency batch lookup
Gather up to 5 candidate words from DreamDict RandomWord, then use
FrequencyBatch to pick the most commonly used one. Players get
recognizable everyday words instead of obscure ones that just clear
the min_freq floor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 17:15:16 -07:00
prosolis
76110f61ca Add housing/pets/Thom Krooke, wordle overhaul, boost system, backup strategy
Part 3 (Housing, Thom Krooke & Pets):
- Housing system with tiered upgrades, mortgage loans, FRED API rates
- Thom Krooke realtor NPC with !thom commands and !thom pay extra principal
- Pet system with arrival, naming, leveling, combat, morning defense
- Pet ditch recovery scales with level, name validation

Wordle overhaul:
- Remove daily expiration — puzzles persist until solved/failed
- Auto-start new puzzle after solve, fail, or skip
- Sequential puzzle IDs instead of date-based
- Auto-create puzzle on first guess if none active

Adventure fixes:
- Double XP/money boost toggle (!adv boost, admin only)
- Fix ensureCharacter wiping existing data on query errors
- Fix rival RPS format string bug
- Fix daily summary empty data (load today+yesterday logs)
- Fix holdem DM-to-room reply routing
- Fix Robbie payout to 25% of item value
- Add fishing to leaderboard and TwinBee calculations
- Add Thom to morning menu
- Persist mortgage rate across restarts via db.CacheGet/Set

Infrastructure:
- Daily database backup via VACUUM INTO with 7-day retention
- Admin DM notification on backup failure (async)
- Daily NPC house balance reset for holdem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 16:50:49 -07:00
prosolis
90865d1c51 Fix NPC audit findings: race conditions, debit checks, sniper mechanics
Concurrency:
- npcTrackMessage now acquires advUserLock to prevent lost updates
  on message count and double encounter triggers from rapid messages
- Lock released before spawning encounter goroutine (avoids deadlock)
- Only one NPC encounter fires per message

Security:
- Debit() return value checked for both Misty and Arina payments
- Buff only applied on successful debit; failed debit falls through
  to decline/debuff path
- NPC encounter won't overwrite existing pending interactions (shop,
  treasure discard, etc.)

Arena sniper rework:
- Sniper checked independently before combat roll, not after
- If sniper fires, combat roll is skipped entirely (not a death save)
- Sniper kill renders as standalone narrative with no combat log
- Sniper flavor text rewritten: reads as random arena chaos with no
  hint of Arina's involvement

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 23:01:39 -07:00
prosolis
2ef7a29f02 Add Misty & Arina NPCs, fix tier gating, babysit actions, Robbie filter, streak/scheduler bugs
Misty & Arina: random encounter NPCs triggered by chat message counting
(5-10 msg threshold, 7-day cooldown, 7.5% roll). Misty asks for €100 —
accept gives 7-day arena buff (20% gourmet food heals gear + 5 enemy dmg),
decline gives 7-day debuff (20% crowd revenge damage). Arina asks for €5000 —
accept gives 7-day sniper buff (8% instant kill in arena). Effects integrate
into arena round resolution after combat roll; sniper overrides death.

Other fixes:
- Tier gating now uses base skill only — buffs improve success, not access
- Babysitter uses all harvest actions (3/day, 4 on holidays) instead of 1
- Robbie collects all non-equipped items, not just slotted gear
- Robbie timing: fire on first tick >= target hour, not exact minute match
- Streak: dead players who acted still get streak credit
- Streak: dead players skip shame DM (use LastDeathDate, not Alive flag)
- Midnight ticker: in-memory date cache prevents 1439 wasted DB checks/day
- T1 loot values ~3x across all activities to close T1/T2 gap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 22:41:21 -07:00
prosolis
1b825498bd Fix harvest actions remaining to use consistent harvestMax pattern
The nudge message after an action was computing harvest remaining
inline with maxHarvestActions + 1 for holidays instead of using the
harvestMax++ pattern used everywhere else. Functionally equivalent
but inconsistent — would break if the holiday bonus ever changed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:22:04 -07:00
prosolis
e459b6e78d Full codebase audit: 21 security/robustness fixes, 328 tests across 4 packages
Security & economy:
- Credit()/Debit() reject non-positive amounts (closes infinite-money exploit)
- Lottery ticket purchase uses in-transaction count check (closes TOCTOU race)
- Arena bail channel ownership prevents double-close panic
- Entry.ID validated before exec.Command in fetch-esteemed
- Internal errors no longer leaked to users (forex, esteemed)

Robustness:
- safeGo() panic recovery added to 17 goroutine launch sites across 14 plugins
- db.Close() added and called on shutdown
- Miniflux mutex pattern fixed (snapshot-under-lock)
- Silently ignored DB/JSON errors now logged (lottery_db)

Performance:
- Added indexes: idx_arena_runs_user(user_id, status), idx_euro_bal_user(user_id)

UX:
- Empty !buy args shows usage instead of "No item matching ''"
- "Failed to load character" now suggests !adventure

Test coverage:
- internal/util/parser_test.go (19 tests: XP, levels, progress bar, commands, parsing)
- internal/crypto/crypto_test.go (12 tests: encrypt/decrypt, HMAC, ParseKey)
- internal/plugin/helpers_test.go (30 tests: formatNumber, calc, lottery, chat perks)
- internal/plugin/audit_fixes_test.go (25 tests: safeGo, bail channels, error leaks, overflow)
- internal/db/db_test.go (4 tests: Init, Close, schema indexes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 17:04:40 -07:00
prosolis
68b2f8b7a5 Add chat level perks: XP bonus, shop flavor, rare drop nudge, death pardon (Adventure 2.5 steps 3-6)
- XP bonus: +5% per 10 chat levels (cap +25% at 50+), applies to all
  adventure and arena XP
- Shop flavor: shopkeeper greeting changes at chat levels 10/30/50,
  replacing random flavor for recognized players
- Rare drop nudge: +0.5% per 10 chat levels (cap +2.5%) applied
  additively to treasure and masterwork drop rates
- Death pardon: chat level 20+ gets 33% chance to survive death once
  per 7 days (converts to bad-failure, quiet DM). Does not apply in
  arena. Fires before Sovereign Death's Reprieve check.
- New adventure_chat_perks.go with perk calculation helpers
- LastPardonUsed field + migration on adventure_characters

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:50:28 -07:00
prosolis
a44a9d9234 Fix character sheet holiday actions, remove dead holiday prompt, cap babysit counter
- Character sheet now checks isHolidayToday() for correct remaining
  action counts on holidays
- Remove renderAdvHolidaySecondPrompt (dead code after economy split)
  and its test
- Babysit harvest counter clamped to max to prevent exceeding budget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:31:25 -07:00
prosolis
7e4fbe5ec8 Add action economy split and cross-plugin chat level lookup (Adventure 2.5 steps 1-2)
Action economy: replace single daily action with 1 combat + 3 harvest
actions per day. Holidays grant +1 to each bucket. Rest consumes all
remaining actions. Arena remains outside both buckets.

- Add CombatActionsUsed/HarvestActionsUsed counters to AdventureCharacter
- Add CanDoCombat/CanDoHarvest/AllActionsUsed/HasActedToday helpers
- Update all 14 ActionTakenToday check sites across adventure, scheduler,
  babysit, twinbee, events, and render
- Morning DM shows action budget and grays out exhausted categories
- Activity resolution checks per-bucket availability before proceeding
- Midnight reset zeros both counters
- Post-action nudge shows remaining actions instead of holiday prompt

Cross-plugin lookup: export XPPlugin.GetLevel(), wire into AdventurePlugin
via p.chatLevel(userID) for upcoming chat level perks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:24:49 -07:00
111 changed files with 20673 additions and 1952 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ data/
.env.*
!.env.example
gogobee
gensolver
holdem-train

124
README.md
View File

@@ -30,7 +30,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
- **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 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure (daily idle RPG via DMs — dungeon, mine, forage, fish, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions, hospital revival system, Robbie the Friendly Bandit automated inventory cleaner), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), all with channel restriction
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure (daily idle RPG via DMs — dungeon, mine, forage, fish, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions, hospital revival system, Robbie the Friendly Bandit automated inventory cleaner), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), Co-op Dungeons (27 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 T4T5), 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 <choice>` | Reply to today's action prompt (alternative to DM reply) |
| `!adventure summary` | Force daily summary post (admin) |
| `!adventure boost` | Double XP/money for all adventurers for a day (admin) |
| `!adventure babysit [week\|month\|status\|cancel]` | Hire TwinBee to run your daily action (trains your weakest gathering skill) |
| `!adventure blacksmith` | Show repair quotes for damaged gear |
| `!adventure repair [all\|<slot>]` | Repair one slot or all damaged gear at the blacksmith |
| `!adventure rivals` | Show the rival pool and any open challenges |
| `!thom [shop\|buy\|pay\|payoff\|autopay\|petbuy]` | Thom Krooke — housing, mortgage, and pet adoption broker |
| `!hospital` | Check in to St. Guildmore's Memorial Hospital for same-day revival (costs €25k × combat level) |
**DM replies:** Reply to the morning prompt with a number (`1``5`) or activity name (`dungeon`, `mine`, `forage`, `shop`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
**DM replies:** Reply to the morning prompt with a number (`1``7`) or activity name (`dungeon`, `mine`, `fish`, `forage`, `shop`, `blacksmith`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
#### Activities
@@ -514,16 +520,69 @@ 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:0021:00 UTC). Robbie takes sub-tier gear from your inventory (shop gear below your equipped tier, masterwork gear you've outgrown), leaves €50 per item as a "handling fee," and donates everything to the community pot. If he takes masterwork gear and you don't already have one, he drops a "Get Out of Medical Debt Free" card. No player command — Robbie comes to you.
- **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters.
- **Chat level perks** — active chat participation boosts your adventurer. +5% XP per 10 chat levels (capped at +25% at level 50+), plus +0.5% rare drop chance per 10 levels.
#### Crafting
Auto-crafting kicks in at Foraging Lv.10. Before each combat action, the system scans your inventory for matching ingredients and assembles the highest-tier recipe you qualify for. 12 recipes spanning T1T5, gated at Foraging 10/15/20/25/30. Per-recipe success rate is 50% at the unlock level, +3% per 5 levels above (capped 95%). Failed crafts destroy the ingredients — you tried.
Per attempt:
- **Successful crafts** grant Foraging XP (T1: +12, T2: +25, T3: +40, T4: +60, T5: +90) and bump a lifetime `CraftsSucceeded` counter shown on `!adventure status`.
- **Failed crafts** grant a token Foraging XP (1/4 of success).
Max auto-crafts per combat scales with level: 1 at Foraging 10, 2 at 20, 3 at 30+.
`!adventure recipes` lists every recipe unlocked at your current Foraging level with ingredients and per-recipe success rate, plus a teaser for the next unlock threshold.
**Consumable protection:** crafted/dropped consumables (Type `consumable`) are excluded from `!adventure sell all` and Robbie the Friendly Bandit's pickup filter. Selling consumables requires explicit `!adventure sell <name>` — no accidental mass-sells, no surprise donations to the community pot.
#### Blacksmith & Repair
Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair <slot>` commits. Visiting the blacksmith counts as your daily action.
#### Babysitting Service
Busy days? Hire TwinBee to run your daily action for you. Daily cost is `€100 + combatLevel × €20`. TwinBee trains your weakest gathering skill (mining/fishing/foraging) so the service doubles as catch-up for neglected tracks. Subscribe by the week or month, or check/cancel anytime: `!adventure babysit week|month|status|cancel`.
#### Rival Duels
At combat level 5 and above you're entered into the rival pool. Every 34 days a random eligible player is matched as your rival and challenges you to a rock-paper-scissors duel via DM. You have 24 hours to accept and play. Stake is `(combatLevel / 5) × €1,000`, winner-take-all. Use `!adventure rivals` to see the pool and open challenges.
#### Housing & Mortgage (Thom Krooke)
Thom Krooke is the guild's housing broker. Four tiers are available:
| Tier | Name | Price |
|------|------|-------|
| 1 | Base | €75,000 |
| 2 | Livable | €150,000 |
| 3 | Comfortable | €300,000 |
| 4 | Established | €600,000 |
Buy outright or finance with a mortgage. Rates track the real-world US 5/1 ARM via the FRED API (`MORTGAGE5US` series; requires `FRED_API_KEY`, defaults to 6.5% if unset). Autopay pulls from your euro balance each day. Commands: `!thom shop`, `!thom buy <tier>`, `!thom pay <amount>`, `!thom payoff`, `!thom autopay on|off`, `!thom petbuy`.
#### Pets
Once you reach the Livable tier (HouseTier ≥ 2) a stray pet may show up at your door — 15% daily chance. When a pet arrives you'll get a DM prompt to `chase` it away or `feed` it to adopt. Each pet interaction grants +1.5 XP to a random skill. At pet Level 10, Thom unlocks pet armor for purchase (`!thom petbuy <tier>`).
#### Guest NPCs — Misty & Arina
Two rotating guest adventurers can be hired to join you on encounters:
- **Misty** — €100 hire fee, 7-day cooldown.
- **Arina** — €5,000 hire fee, 7-day cooldown.
After hiring, there's a 7.5% chance per action over the next 7 days that your NPC shows up mid-adventure and applies a buff to the outcome.
#### Arena (`!arena`)
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC. 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 +603,61 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
- **Auto-cashout** — 10 minutes to decide after clearing a tier. GogoBee cashes out on your behalf if you're slow.
- **Tier entry confirmation** — `!arena tier N` previews the first opponent; `!arena fight` commits.
#### Co-op Dungeons (`!coop`)
Multi-day party runs separate from solo. 24 players, 27 days depending on tier, scaled rewards, and a public game-room thread the whole community watches. The combat action is consumed once at lock; subsequent days only ask for a daily funding decision (harvest activities continue normally).
| Tier | Days | Difficulty | Reward Pool | Optimal Funding |
|------|------|------------|-------------|-----------------|
| 1 | 2 | Moderate | €22,500 | Minimal/Standard |
| 2 | 3 | Hard | €40,000 | Standard |
| 3 | 4 | Very Hard | €72,500 | Standard |
| 4 | 5 | Brutal | €120,000 | Standard or Aggressive |
| 5 | 7 | Murderous | €600,000 | Aggressive (must commit) |
| Command | Description |
|---------|-------------|
| `!coop list` | Show open invites |
| `!coop start <1-5>` | Open an invite (24h window, pinned in the games room) |
| `!coop join [<id>]` | Join an open invite (defaults to most recent) |
| `!coop fund <tier>` | Set today's funding (`none` / `minimal` / `standard` / `aggressive` / `all_in`) |
| `!coop vote <A\|B\|C>` | Vote on the day's TwinBee-narrated floor event |
| `!coop bet <amount> <success\|failure>` | Spectator parimutuel bet (10% rake) |
| `!coop gift <run_id> <basket\|mimic>` | Send a gift (1 harvest action) |
| `!coop giftvote <id> <open\|leave>` | Party votes whether to open a received gift |
| `!coop status` | Your current run state |
| `!coop stats` | Community-wide aggregates: outcomes, gold flow, betting, gifts, helpfulness |
| `!coop cancel` | Leader cancels an open invite before lock |
| `!coop admgift <run_id> <basket\|mimic> [sender]` | Drop a gift into an active run, bypassing party-member and harvest-action checks (admin only — debug/testing) |
| `!coop help` | Co-op command list |
**Funding tiers** add to the per-floor success modifier: None 10%, Minimal +0%, Standard +8%, Aggressive +18%, All-In +30%. None is free; Minimal €500/day; Standard €1,500; Aggressive €4,000; All-In €10,000. Funding is non-refundable. Inactive players auto-play None.
**Floor events.** TwinBee narrates 1 of 4 categories per floor (Obstacle / Opportunity / Crisis / Encounter), tier-weighted. Each event has 23 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). 26 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 |
|---------|-------------|

View File

@@ -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

408
cmd/gensolver/main.go Normal file
View File

@@ -0,0 +1,408 @@
// cmd/gensolver drives TexasSolver offline to populate
// internal/plugin/testdata/solver_freqs.json for Layer 2 tip scenario tests.
//
// Usage:
//
// GOGOBEE_SOLVER=/path/to/console_solver \
// GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver-v0.2.0-Linux/resources \
// go run ./cmd/gensolver [scenario-name-substring]
//
// If no positional arg is given, every postflop scenario is solved. Results
// are *merged* into the existing fixture file — re-running one scenario does
// not wipe the others.
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gogobee/internal/plugin"
)
const fixturePath = "internal/plugin/testdata/solver_freqs.json"
// Heads-up default ranges. Postflop only — preflop solving needs a full range
// tree and is out of scope for our Layer 2 validation, so we skip preflop
// scenarios entirely.
//
// These are deliberately coarse: HU BTN opens wide (~70%), BB defends wide
// (~55% vs min-raise). Refine per scenario if solver output looks nonsensical.
// TexasSolver range syntax does NOT support the `22+` / `A2s+` shorthand — it
// requires explicit enumeration. These two ranges are lifted verbatim from the
// solver's own sample input file so we know they parse and produce sensible
// equilibria. Not tuned for heads-up specifically; refine later if needed.
const (
rangeBTNOpen = "AA,KK,QQ,JJ,TT,99:0.75,88:0.75,77:0.5,66:0.25,55:0.25,AK,AQs,AQo:0.75,AJs,AJo:0.5,ATs:0.75,A6s:0.25,A5s:0.75,A4s:0.75,A3s:0.5,A2s:0.5,KQs,KQo:0.5,KJs,KTs:0.75,K5s:0.25,K4s:0.25,QJs:0.75,QTs:0.75,Q9s:0.5,JTs:0.75,J9s:0.75,J8s:0.75,T9s:0.75,T8s:0.75,T7s:0.75,98s:0.75,97s:0.75,96s:0.5,87s:0.75,86s:0.5,85s:0.5,76s:0.75,75s:0.5,65s:0.75,64s:0.5,54s:0.75,53s:0.5,43s:0.5"
rangeBBDefend = "QQ:0.5,JJ:0.75,TT,99,88,77,66,55,44,33,22,AKo:0.25,AQs,AQo:0.75,AJs,AJo:0.75,ATs,ATo:0.75,A9s,A8s,A7s,A6s,A5s,A4s,A3s,A2s,KQ,KJ,KTs,KTo:0.5,K9s,K8s,K7s,K6s,K5s,K4s:0.5,K3s:0.5,K2s:0.5,QJ,QTs,Q9s,Q8s,Q7s,JTs,JTo:0.5,J9s,J8s,T9s,T8s,T7s,98s,97s,96s,87s,86s,76s,75s,65s,64s,54s,53s,43s"
)
// SolverNode mirrors the recursive shape of TexasSolver's dump_result JSON.
// Every action node has: `actions` (the player-to-act's options), `strategy`
// (hand→freq map for those actions), and `childrens` (subtree per action).
type SolverNode struct {
Actions []string `json:"actions"`
Strategy *StrategyBlock `json:"strategy,omitempty"`
Childrens map[string]*SolverNode `json:"childrens,omitempty"`
NodeType string `json:"node_type,omitempty"`
Player int `json:"player,omitempty"`
}
type StrategyBlock struct {
Actions []string `json:"actions"`
Strategy map[string][]float64 `json:"strategy"`
}
func main() {
flag.Parse()
filter := strings.ToLower(flag.Arg(0))
solverBin := os.Getenv("GOGOBEE_SOLVER")
resourceDir := os.Getenv("GOGOBEE_SOLVER_RESOURCES")
if solverBin == "" || resourceDir == "" {
fmt.Fprintln(os.Stderr, "set GOGOBEE_SOLVER and GOGOBEE_SOLVER_RESOURCES")
os.Exit(2)
}
existing := loadFixture()
workDir, err := os.MkdirTemp("", "gensolver-*")
must(err)
defer os.RemoveAll(workDir)
solved := 0
for _, s := range plugin.TipScenarios() {
if s.Street == plugin.StreetPreFlop || len(s.BoardStr) == 0 {
continue
}
if filter != "" && !strings.Contains(strings.ToLower(s.Name), filter) {
continue
}
fmt.Printf("solving: %s\n", s.Name)
freqs, err := solveScenario(s, solverBin, resourceDir, workDir)
if err != nil {
fmt.Fprintf(os.Stderr, " FAILED: %v\n", err)
continue
}
existing[s.Name] = freqs
fmt.Printf(" → %v\n", freqs)
solved++
}
writeFixture(existing)
fmt.Printf("\ndone. %d scenarios solved, fixture written to %s\n", solved, fixturePath)
}
func solveScenario(s plugin.TipScenario, bin, resources, workDir string) (map[string]float64, error) {
input := buildInputFile(s)
inputPath := filepath.Join(workDir, "input.txt")
outputPath := filepath.Join(workDir, "output.json")
if err := os.WriteFile(inputPath, []byte(input), 0o644); err != nil {
return nil, err
}
// TexasSolver writes output_result.json to its CWD, so cd into workDir.
cmd := exec.Command(bin, "-i", inputPath, "-r", resources)
cmd.Dir = workDir
cmd.Stdout = os.Stderr // surface solver logs on stderr so JSON doesn't mix in
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("solver failed: %w", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
return nil, fmt.Errorf("read output: %w", err)
}
return extractHeroFrequencies(data, s)
}
func buildInputFile(s plugin.TipScenario) string {
board := strings.Join(s.BoardStr, ",")
ipRange, oopRange := rangesFor(s)
// Normalize to solver-friendly scale: pot=50, stack=8×pot, preserving
// SPR. TexasSolver segfaults on large chip counts for certain textures
// (suspected internal precision/overflow on some flop trees). Strategic
// equivalence holds because GTO frequencies are scale-invariant; only
// the raw chip values in action labels change. Caps SPR at 8 to keep
// tree build time sane regardless of scenario stack depth.
const normalizedPot = 50
spr := float64(s.Stack) / float64(s.Pot)
if spr > 8 {
spr = 8
}
normalizedStack := int(float64(normalizedPot) * spr)
if normalizedStack < 100 {
normalizedStack = 100
}
var b strings.Builder
fmt.Fprintf(&b, "set_pot %d\n", normalizedPot)
fmt.Fprintf(&b, "set_effective_stack %d\n", normalizedStack)
fmt.Fprintf(&b, "set_board %s\n", board)
fmt.Fprintf(&b, "set_range_ip %s\n", ipRange)
fmt.Fprintf(&b, "set_range_oop %s\n", oopRange)
// Simple bet tree: half-pot + allin each street.
for _, side := range []string{"ip", "oop"} {
for _, street := range []string{"flop", "turn", "river"} {
fmt.Fprintf(&b, "set_bet_sizes %s,%s,bet,50,100\n", side, street)
fmt.Fprintf(&b, "set_bet_sizes %s,%s,raise,60\n", side, street)
fmt.Fprintf(&b, "set_bet_sizes %s,%s,allin\n", side, street)
}
}
b.WriteString("set_allin_threshold 0.67\n")
b.WriteString("build_tree\n")
b.WriteString("set_thread_num 8\n")
// accuracy 1.0 = stop when total exploitability < 1% of pot. Empirically
// this is reached in ~80 iterations on our scenarios vs 120+ for 0.5%,
// cutting per-scenario time roughly in half with no practical loss for
// validation (we only check if rules engine matches a significant-freq
// action, not exact frequencies).
b.WriteString("set_accuracy 1.0\n")
b.WriteString("set_max_iteration 100\n")
b.WriteString("set_print_interval 20\n")
// Isomorphism optimization segfaults on certain flop textures (notably
// paired boards and some two-tone). Disabling costs ~20% extra solve
// time but makes the pipeline reliable.
b.WriteString("set_use_isomorphism 0\n")
b.WriteString("start_solve\n")
b.WriteString("set_dump_rounds 1\n")
b.WriteString("dump_result output.json\n")
return b.String()
}
func rangesFor(s plugin.TipScenario) (ip, oop string) {
// HU: BTN=IP, BB=OOP.
return rangeBTNOpen, rangeBBDefend
}
// extractHeroFrequencies walks the dumped tree to find the node where hero
// is actually to act, then returns hero's action frequencies for their exact
// hole combo, normalized to {check,bet,call,fold,raise}.
//
// HU postflop ordering: OOP acts first on every street. So the root node is
// always OOP's decision.
//
// - hero=OOP, ToCall=0 → root (OOP first to act, no action yet)
// - hero=IP, ToCall=0 → root.childrens["CHECK"] (OOP checked, IP facing check)
// - hero=IP, ToCall>0 → root.childrens["BET <x>"] matching ToCall size
// - hero=OOP, ToCall>0 → not supported yet (check-bet line, 2 levels deep)
func extractHeroFrequencies(data []byte, s plugin.TipScenario) (map[string]float64, error) {
var root SolverNode
if err := json.Unmarshal(data, &root); err != nil {
return nil, fmt.Errorf("parse json: %w", err)
}
heroIP := s.Position == "BTN" || s.Position == "SB"
// Normalize ToCall to the solver's chip scale (pot=50) so bet-child
// matching works after the pot/stack normalization in buildInputFile.
normalizedToCall := float64(s.ToCall) * 50.0 / float64(s.Pot)
node, err := navigateToHero(&root, heroIP, normalizedToCall)
if err != nil {
return nil, err
}
if node.Strategy == nil {
return nil, fmt.Errorf("hero node has no strategy (node_type=%s)", node.NodeType)
}
key := holeKey(s.HoleStr)
raw, ok := node.Strategy.Strategy[key]
if !ok {
raw, ok = node.Strategy.Strategy[flipHole(key)]
}
if !ok {
return nil, fmt.Errorf("hole %q not found in strategy (tried %q)", key, flipHole(key))
}
if len(raw) != len(node.Strategy.Actions) {
return nil, fmt.Errorf("action/freq length mismatch: %d vs %d", len(raw), len(node.Strategy.Actions))
}
out := map[string]float64{}
for i, act := range node.Strategy.Actions {
out[normalizeAction(act)] += raw[i]
}
return out, nil
}
func navigateToHero(root *SolverNode, heroIP bool, toCall float64) (*SolverNode, error) {
// HU postflop: OOP always acts first on each street, so root is OOP's node.
switch {
case !heroIP && toCall == 0:
// OOP first to act, no prior action.
return root, nil
case heroIP && toCall == 0:
// OOP checked → IP facing check.
return childByLabel(root, "CHECK")
case heroIP && toCall > 0:
// OOP donk-bet (or we're mid-street with OOP having bet first). Find
// the BET child whose chip amount is closest to the scenario's ToCall.
return childByBetSize(root, toCall)
case !heroIP && toCall > 0:
// Check-bet line: OOP checks → IP bets → OOP facing bet.
checkNode, err := childByLabel(root, "CHECK")
if err != nil {
return nil, fmt.Errorf("check-bet line: %w", err)
}
return childByBetSize(checkNode, toCall)
}
return nil, fmt.Errorf("unreachable")
}
func childByLabel(node *SolverNode, label string) (*SolverNode, error) {
child, ok := node.Childrens[label]
if !ok {
return nil, fmt.Errorf("no %q child; available: %v", label, keysOf(node.Childrens))
}
return child, nil
}
// childByBetSize picks the BET child whose chip amount is closest to toCall.
// Rejects the match if the nearest bet size differs by more than 25% — that
// usually means the solver wasn't configured with a comparable sizing and the
// returned frequencies would describe a different decision.
func childByBetSize(node *SolverNode, toCall float64) (*SolverNode, error) {
var best *SolverNode
var bestAmt float64
bestDelta := 1e18
for label, child := range node.Childrens {
if !strings.HasPrefix(label, "BET") {
continue
}
amt := parseBetAmount(label)
if amt < 0 {
continue
}
d := amt - toCall
if d < 0 {
d = -d
}
if d < bestDelta {
bestDelta = d
bestAmt = amt
best = child
}
}
if best == nil {
return nil, fmt.Errorf("no BET child matching toCall=%v; available: %v", toCall, keysOf(node.Childrens))
}
if toCall > 0 && bestDelta/toCall > 0.25 {
return nil, fmt.Errorf("nearest BET child %.2f is >25%% off toCall=%.2f; solver sizings don't cover this spot; available: %v", bestAmt, toCall, keysOf(node.Childrens))
}
return best, nil
}
func parseBetAmount(label string) float64 {
// "BET 25.000000" → 25
parts := strings.Fields(label)
if len(parts) != 2 {
return -1
}
var v float64
if _, err := fmt.Sscanf(parts[1], "%f", &v); err != nil {
return -1
}
return v
}
func keysOf(m map[string]*SolverNode) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// holeKey builds TexasSolver's hand key: two cards with higher rank first.
// Ranks: 2..9,T,J,Q,K,A. Suit order for same-rank pairs: s > h > d > c.
func holeKey(hole [2]string) string {
a, b := hole[0], hole[1]
if cardLess(b, a) {
return a + b
}
return b + a
}
func flipHole(k string) string {
if len(k) != 4 {
return k
}
return k[2:] + k[:2]
}
func cardLess(a, b string) bool {
ra := rankIdx(a[0])
rb := rankIdx(b[0])
if ra != rb {
return ra < rb
}
return suitIdx(a[1]) < suitIdx(b[1])
}
func rankIdx(r byte) int {
return strings.IndexByte("23456789TJQKA", r)
}
func suitIdx(s byte) int {
return strings.IndexByte("cdhs", s)
}
func normalizeAction(a string) string {
a = strings.ToLower(a)
switch {
case strings.HasPrefix(a, "check"):
return "check"
case strings.HasPrefix(a, "fold"):
return "fold"
case strings.HasPrefix(a, "call"):
return "call"
case strings.HasPrefix(a, "bet"):
return "bet"
case strings.HasPrefix(a, "raise"):
return "raise"
case strings.Contains(a, "allin"):
return "raise"
default:
return a
}
}
func loadFixture() map[string]map[string]float64 {
out := map[string]map[string]float64{}
data, err := os.ReadFile(fixturePath)
if err != nil {
return out
}
_ = json.Unmarshal(data, &out)
return out
}
func writeFixture(m map[string]map[string]float64) {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
ordered := make(map[string]map[string]float64, len(m))
for _, k := range keys {
ordered[k] = m[k]
}
data, err := json.MarshalIndent(ordered, "", " ")
must(err)
must(os.MkdirAll(filepath.Dir(fixturePath), 0o755))
must(os.WriteFile(fixturePath, append(data, '\n'), 0o644))
}
func must(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

319
fix-holdem-tips.md Normal file
View File

@@ -0,0 +1,319 @@
# Fix: Texas Hold'em LLM Tips
## What's broken
Two confirmed issues observed across multiple tip examples:
### 1. Position label is inverted in heads-up play
The tip says "positional advantage" when the player is acting first post-flop (out of position) and "out of position" when they're acting last. The position label reaching the LLM prompt is wrong.
**Root cause:** the `positionLabel()` function in `tips.go` derives position from `DealerIdx` using the general formula. In heads-up play the dealer posts the small blind and acts first pre-flop but **last** post-flop. The heads-up exception that exists in `PostBlinds()` in `betting.go` is not being reflected in position label calculation.
**Fix:** in `positionLabel()`, gate on `len(g.Players) == 2` before applying any label logic. In heads-up:
- Pre-flop: dealer = BTN/SB (acts first), other player = BB (acts last)
- Post-flop: dealer = BTN (acts last, positional advantage), other player = BB (acts first, out of position)
Check which street it is before assigning the label. `g.Street == PreFlop` needs different position semantics than all other streets in heads-up.
---
### 2. LLM is generating generic concepts instead of hand-specific advice
**Observed:** tips reference equity numbers but then ignore what those numbers mean for the specific hand. A player with 8♥ 7♥ on Q♥ K♠ 10♦ (gutshot + backdoor flush draw, 29% equity, free card available) received "not enough equity to bet" — which ignores the draw entirely and misapplies a made-hand concept to a drawing hand.
**Root cause:** the user prompt is not giving the LLM enough structured context to reason about hand *type*. It sees an equity number but doesn't know whether the hand is a draw, a made hand, a bluff catcher, or air. It pattern-matches on the number alone.
**Fix:** compute and inject the following additional fields into `TipContext` before building the prompt:
```go
type TipContext struct {
// existing fields...
// Add these:
HandCategory string // from poker.RankString() on current 5-card best
IsDraw bool // true if outs > 0 (see below)
FlushDrawOuts int // suited cards matching board suit count
StraightDrawOuts int // connected card gaps to straight
TotalOuts int // combined draw outs (deduped)
IsFreeCard bool // ToCall == 0
HeadsUp bool // len(ActivePlayers) == 2
}
```
Outs calculation (add to `equity.go` or a new `outs.go`):
- Flush draw: count hole cards matching dominant board suit; if 2 hole cards + 2 board cards same suit, FlushDrawOuts = 9
- Open-ended straight draw: 8 outs
- Gutshot: 4 outs
- Backdoor draws: count as 1-2 outs each
- TotalOuts = sum, capped at 15 (avoid double-counting straights and flushes)
---
### 3. System prompt needs to be more directive
**Current system prompt** (paraphrased from blueprint): "be a concise Hold'em coach, 2-4 sentences, cover hand strength, pot odds, position."
This is too open-ended. The LLM fills the space with whatever poker concepts come to mind. Replace with a prompt that forces it to reason about the specific situation before speaking.
**New system prompt:**
```
You are a Texas Hold'em coach giving advice to a single player via private message.
You will receive structured game context. Reason through it in this order:
1. What type of hand do I have — made hand, drawing hand, or air?
2. If drawing: how many outs, and do pot odds justify continuing?
3. If made hand: is it strong enough to bet for value, or weak enough to just pot control?
4. Does position affect what I should do here?
5. Is a free card available, and if so, is taking it correct?
Then write ONE piece of advice — 2 to 3 sentences maximum — that tells the player
what to do and why, using the specific cards and numbers provided.
Do not list concepts. Do not use generic poker vocabulary without connecting it to
this specific hand. If the correct play is obvious (e.g. free card with a draw),
say so plainly and briefly.
```
---
### 4. User prompt needs draw and hand type context injected
**Current user prompt structure** (from blueprint):
```
Street: <street>
Your hand: <cards>
Board: <cards>
Equity vs <n> opponents: Win x% | Tie y% | Loss z%
Pot odds to call: x%
SPR: x | Position: <pos> | Active players: <n>
```
**New user prompt structure** — add the computed fields:
```
Street: {street}
Your hand: {cards} [{hand_category}]
Board: {cards}
Draw outs: {total_outs} ({draw_description}) <- omit line if IsDraw == false
Equity vs {n} opponent(s): Win {x}% | Tie {y}% | Loss {z}%
{if ToCall > 0}: Pot odds to call: {pct}% — equity {exceeds|falls short of} price
{if IsFreeCard}: Free card available — no bet to call
SPR: {spr} | Position: {position} | Heads-up: {yes|no} | Street: {street}
```
`{draw_description}` examples:
- "flush draw (9 outs)"
- "gutshot straight draw (4 outs)"
- "open-ended straight draw (8 outs)"
- "flush draw + gutshot (11 outs)"
- "backdoor flush + backdoor straight (2 outs)"
`{hand_category}` examples from `poker.RankString()`:
- "High Card", "One Pair", "Two Pair", "Three of a Kind", "Straight", "Flush", "Full House", "Four of a Kind", "Straight Flush"
---
## Specific scenario the fix must handle correctly
**Hand:** 8♥ 7♥
**Board:** Q♥ K♠ 10♦
**Street:** Flop
**Equity:** 29%
**To call:** €0 (free card)
**Position:** dealer, heads-up, acting first post-flop (out of position)
Expected tip behaviour after fix:
- Identifies this as a drawing hand (gutshot + backdoor flush)
- Notes the free card is available
- Does NOT say "not enough equity to bet" without acknowledging the draw
- Does NOT say "positional advantage" — player is out of position post-flop heads-up
- Produces something like: "You have a gutshot straight draw with a backdoor flush. With a free card available you can check and see the turn without risk. If a 9 or a third heart comes, you'll be in a strong position — for now, take the free card."
---
## Reasoning mode (Qwen3 thinking)
Poker tips are the only task in GogoBee that should use reasoning mode. All other LLM calls (adventure narrative, etc.) run with thinking disabled. This needs to be a one-off configuration scoped entirely to `tips.go`.
### Why reasoning mode here
The tips failure pattern is not a knowledge gap — Qwen3-32B knows poker. The problem is that it jumps to pattern-matched conclusions without working through the situation in sequence. Reasoning mode forces the model to produce a `<think>...</think>` chain before the final response, which naturally surfaces: hand type, outs, position semantics, and the actual decision. The tip then follows from that chain rather than being assembled from disconnected concepts.
### Request changes in `tips.go`
Add a `enable_thinking` field to the request body and a `thinking_budget` cap to keep latency bounded:
```go
type llmRequest struct {
Model string `json:"model"`
Messages []llmMessage `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
EnableThinking bool `json:"enable_thinking,omitempty"`
ThinkingBudget int `json:"thinking_budget,omitempty"`
}
```
When building the tips request, set:
```go
body := llmRequest{
Model: cfg.Model,
Messages: []llmMessage{...},
MaxTokens: 1000, // increased to accommodate think block + response
Stream: false,
EnableThinking: true,
ThinkingBudget: 512, // cap reasoning tokens; enough for poker, not runaway
}
```
`ThinkingBudget` of 512 tokens is sufficient for a poker hand analysis reasoning chain. Without a cap, complex board textures can produce very long think blocks. 512 keeps worst-case latency reasonable.
Note: the exact field names for Ollama's Qwen3 thinking mode may differ from the above. Check the Ollama API docs for the current `qwen3:32b` thinking parameters — it may be `/think` appended to the model name (`qwen3:32b/think`) rather than a request body field, depending on the Ollama version. Either way, the intent is the same — make this configurable in `TipsConfig` so it can be toggled without a code change:
```go
type TipsConfig struct {
Endpoint string
Model string
APIKey string
Timeout time.Duration
EnableThinking bool // default true for poker tips
ThinkingBudget int // default 512
}
```
### Strip the think block from the response
The `<think>...</think>` content must never reach the player DM. The current response parser takes `choices[0].message.content` directly. Update it to strip thinking content before returning:
```go
func extractTipFromResponse(raw string) string {
// Strip <think>...</think> block if present
// Qwen3 may use <think> or <!--think--> depending on version
re := regexp.MustCompile(`(?s)<think>.*?</think>`)
cleaned := re.ReplaceAllString(raw, "")
// Also strip any leading/trailing whitespace left behind
return strings.TrimSpace(cleaned)
}
```
Call `extractTipFromResponse()` on `llmResp.Choices[0].Message.Content` before returning the tip string. If the result is empty after stripping (model only produced a think block and nothing else), fall back to the rules-based tip.
### Latency expectations
With `ThinkingBudget: 512` and the structured context prompt, expect:
- Typical: 4-8 seconds total (within the existing 10s timeout)
- Complex boards: up to 10 seconds
- Increase `cfg.Timeout` to `12 * time.Second` for tips specifically to give reasoning room without affecting other LLM calls
Tip delivery via DM is already async (goroutine), so even a 10-12 second tip doesn't block the table view or the action loop. Players receive the table view immediately and the tip follows shortly after.
### Config addition
```toml
[holdem]
# ... existing fields ...
tips_enable_thinking = true
tips_thinking_budget = 512
tips_timeout = "12s" # longer than default to accommodate reasoning
```
---
## Files to change
- `tips.go``TipContext` struct, `BuildTipContext()`, `buildPrompt()`, `positionLabel()`, `llmRequest` struct, `GenerateTip()`, new `extractTipFromResponse()` function
- `equity.go` — add outs calculation function
- No schema changes required
- No changes to `game.go`, `betting.go`, or `render.go`
## Test cases to verify before shipping
Write a table-driven test in `tips_test.go` covering:
| Hand | Board | Street | Expected position (HU) | Expected IsDraw | Expected outs |
|------|-------|--------|------------------------|-----------------|---------------|
| 8♥ 7♥ | Q♥ K♠ 10♦ | Flop | Out of position | true | 4 (gutshot) + backdoor |
| A♠ K♠ | — | Pre-Flop | BTN (dealer, acts first) | false | 0 |
| 5♥ 6♥ | 7♥ 8♣ 2♥ | Flop | varies | true | 15 (OESD + flush) |
| Q♣ Q♦ | Q♥ 2♠ 7♣ | Flop | varies | false | 0 |
The position test for heads-up pre-flop vs post-flop is the most important one. Get that right first.
---
## Validation pipeline (shipped 2026-04-13)
The "is the tip actually good?" question is now answered by a two-layer
automated test harness rather than vibes.
**Layer 1 — hand-authored scenarios** (`internal/plugin/holdem_tip_scenarios.go`)
20 canonical spots covering preflop tier/facing-bet branches and postflop
equity tiers × board textures × SPR depths. Each scenario declares an
expected action verb, required theme keywords, and forbidden substrings.
`TestTipScenarios_Layer1` runs the full rules-engine pipeline
(equity MC, draw detection, hand category, board texture, preflop
classification) against each scenario and asserts the tip contains the
expected action + themes. Fast, cheap, green.
**Layer 2 — solver-derived scenarios** (same scenarios, populated via `cmd/gensolver`)
11 of the 14 postflop scenarios carry real TexasSolver GTO frequencies
committed as a fixture at `internal/plugin/testdata/solver_freqs.json`.
`TestTipScenarios_Layer2` treats any action with solver frequency ≥ 15% as
"significant" and asserts the rules engine's recommended action matches one
of the significant actions — tolerating GTO's legitimately mixed spots
while catching genuinely-wrong recommendations.
**cmd/gensolver**
Offline pipeline that iterates `plugin.TipScenarios()`, shells out to
`console_solver` (TexasSolver CLI), parses the JSON strategy tree,
navigates to hero's decision node (IP/OOP × facing-check/facing-bet ×
check-bet line), extracts hero's action frequencies for their exact hole
combo, and merges them into the fixture file.
Key solver-side knobs worked out the hard way:
- **Scale normalization** to `pot=50, stack=8×pot` (SPR cap 8). TexasSolver
segfaults on deep stacks and on some textures at larger chip counts;
strategic equivalence is preserved because GTO frequencies are
scale-invariant.
- **Bet tree**: 50% + 100% pot sizings, plus allin. Narrower trees build
faster and still give solvable decision points.
- **`set_accuracy 1.0`, `set_max_iteration 100`** — converges in ~2 min
per flop instead of the ~24 min the solver's defaults demanded. 1%
exploitability is plenty for our assertion type.
- **Range syntax**: TexasSolver rejects shorthand like `22+` / `A2s+`
ranges must be explicitly enumerated. Using the solver's own
sample-input ranges verbatim as HU defaults.
Invocation:
```bash
GOGOBEE_SOLVER=/path/to/console_solver \
GOGOBEE_SOLVER_RESOURCES=/path/to/TexasSolver/resources \
go run ./cmd/gensolver [scenario-name-substring]
```
Results merge into the fixture, so regenerating one scenario doesn't wipe
the others.
**Known gaps** — 3 scenarios have no solver frequencies:
- `flop/monster set on paired board facing bet` — TexasSolver segfaults on
paired-board textures (upstream bug, not fixable from our side).
- `turn/weak top pair facing overbet` — hero's hole (63o) isn't in any
reasonable HU range, so the solver never allocates strategy for it.
Scenario still validated by Layer 1.
- Occasional flake on `flop/bottom pair facing big bet` at full-batch time
(succeeds when retried solo). Current fixture entry came from a solo
retry and is valid; if regeneration fails, just re-run that one
scenario with the name filter.
Adding new scenarios: append to `tipScenarios` in
`holdem_tip_scenarios.go`, run `cmd/gensolver` with the name filter,
commit both the code and fixture changes together.

View File

@@ -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")
}
}

View File

@@ -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,51 @@ 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`,
`ALTER TABLE adventure_characters ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -115,6 +174,50 @@ func MarkJobCompleted(jobName, dateKey string) {
)
}
// RecordCrash logs a panic/crash event with version and plugin context.
func RecordCrash(botVersion, plugin, message string) {
if globalDB == nil {
return
}
Exec("crash log",
`INSERT INTO crash_log (bot_version, plugin, message) VALUES (?, ?, ?)`,
botVersion, plugin, message,
)
}
// RecordTipAudit logs a poker tip with full context for bulk review.
func RecordTipAudit(userID, hole, board, street, position string, numActive int, equityPct float64, pot, toCall, stack int64, spr float64, handCat, drawDesc, preflopTier, action, tipText string) {
if globalDB == nil {
return
}
Exec("tip audit",
`INSERT INTO holdem_tip_audit (user_id, hole, board, street, position, num_active, equity_pct, pot, to_call, stack, spr, hand_cat, draw_desc, preflop_tier, action, tip_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
userID, hole, board, street, position, numActive, equityPct, pot, toCall, stack, spr, handCat, drawDesc, preflopTier, action, tipText,
)
}
// RecordStartup logs a version startup event.
func RecordStartup(version, commit string) {
Exec("version history",
`INSERT INTO version_history (version, commit_sha) VALUES (?, ?)`,
version, commit,
)
}
// CrashCount returns the number of crashes for a given bot version.
func CrashCount(botVersion string) int {
var count int
_ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log WHERE bot_version = ?`, botVersion).Scan(&count)
return count
}
// CrashCountAll returns the total number of recorded crashes.
func CrashCountAll() int {
var count int
_ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log`).Scan(&count)
return count
}
// CacheGet returns cached data for the given key if it exists and is within ttlSeconds.
// Returns empty string if not cached or expired.
func CacheGet(key string, ttlSeconds int) string {
@@ -139,6 +242,47 @@ func CacheSet(key, data string) {
)
}
// Backup creates a consistent snapshot of the database using VACUUM INTO.
// Keeps the last 7 daily backups, deleting older ones.
func Backup() error {
backupDir := filepath.Join(dataPath, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
filename := fmt.Sprintf("gogobee_%s.db", time.Now().UTC().Format("2006-01-02"))
backupPath := filepath.Join(backupDir, filename)
_, err := Get().Exec(fmt.Sprintf(`VACUUM INTO '%s'`, backupPath))
if err != nil {
return fmt.Errorf("vacuum into backup: %w", err)
}
slog.Info("database backup created", "path", backupPath)
// Prune backups older than 7 days
entries, err := os.ReadDir(backupDir)
if err != nil {
return nil // backup succeeded, prune failure is non-fatal
}
cutoff := time.Now().UTC().AddDate(0, 0, -7)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(backupDir, e.Name()))
slog.Info("pruned old backup", "file", e.Name())
}
}
return nil
}
// RunMaintenance purges stale data from cache tables, old rate limits,
// expired logs, and runs SQLite optimization. Intended to run daily.
func RunMaintenance() {
@@ -695,6 +839,8 @@ CREATE TABLE IF NOT EXISTS euro_balances (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_euro_bal_user ON euro_balances(user_id);
CREATE TABLE IF NOT EXISTS euro_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1027,6 +1173,8 @@ CREATE TABLE IF NOT EXISTS arena_runs (
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_arena_runs_user ON arena_runs(user_id, status);
CREATE TABLE IF NOT EXISTS arena_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1187,6 +1335,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.

112
internal/db/db_test.go Normal file
View File

@@ -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
}

View File

@@ -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(&current, &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
},
},
}
}

View File

@@ -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,10 @@ 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)
case lower == "recipes":
return p.handleRecipesCmd(ctx)
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -241,10 +288,14 @@ 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 <slot>`" + ` — Repair a specific slot
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `.
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
` + "`!adventure help`" + ` — This message
**Arena:**
@@ -256,6 +307,10 @@ const advHelpText = `**Adventure Commands**
` + "`!arena status`" + ` — Current run state
` + "`!arena leaderboard`" + ` — Top arena players
**Systems:**
• Foraging level 10+ unlocks auto-crafting consumables from loot ingredients before combat.
• A 5% community tax funds the lottery pot. Arena and hospital apply 10%.
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
// ── Command Handlers ─────────────────────────────────────────────────────────
@@ -285,25 +340,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 +360,6 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID)
_, holName := isHolidayToday()
text := renderAdvMorningDM(char, equip, balance, bonuses, holName)
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
@@ -369,7 +413,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 +443,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 <item name>`. Type `!adventure shop` to browse.")
}
slot, def, found := advFindShopItem(itemName)
if !found {
return p.SendDM(ctx.Sender, fmt.Sprintf("No item matching '%s' found in the shop. Type `!adventure shop` to see what's available.", itemName))
@@ -417,7 +465,7 @@ func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) erro
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.")
@@ -497,10 +545,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 +601,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 +676,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 +787,14 @@ func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCha
// ── Activity Resolution ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
isHol, _ := isHolidayToday()
if isCombatActivity(activity) && !char.CanDoCombat(isHol) {
return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.")
}
if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) {
return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.")
}
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
@@ -759,12 +826,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 +863,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 +902,22 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
_ = addAdvInventoryItem(char.UserID, item)
}
// Determine if this is the holiday second action
isAction2 := char.ActionTakenToday // already taken = this is the second
isHol, _ := isHolidayToday()
// Mark action taken and record the date for streak tracking
if !isAction2 {
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
// Roll for consumable drop on success/exceptional at T2+
if (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) && loc.Tier >= 2 {
if drop := RollConsumableDrop(loc.Activity, loc.Tier); drop != nil {
_ = addAdvInventoryItem(char.UserID, *drop)
result.LootItems = append(result.LootItems, *drop)
}
}
// Holiday flags: mark second action done, or mark it done on death during action 1
if isAction2 {
char.HolidayActionTaken = true
} else if isHol && result.Outcome == AdvOutcomeDeath && !deathReprieved {
char.HolidayActionTaken = true // died on action 1 — no second action
// Mark action consumed in the correct bucket
if isCombatActivity(activity) {
char.CombatActionsUsed++
} else if isHarvestActivity(activity) {
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
// Update streak info
result.StreakBonus = char.CurrentStreak
@@ -844,6 +928,16 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
}
// Pet XP
if char.HasPet() && result.Outcome != AdvOutcomeDeath {
if petGrantXP(char) {
_ = saveAdvCharacter(char)
_ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel))
} else {
_ = saveAdvCharacter(char)
}
}
// Save equipment changes
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
@@ -864,8 +958,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
if partyBonus > 0 {
result.TotalLootValue += partyBonus
// Credit the bonus directly
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
net, _ := communityTax(char.UserID, float64(partyBonus), 0.05)
p.euro.Credit(char.UserID, net, "adventure_party_bonus")
}
}
}
@@ -882,13 +976,38 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
if closing != "" {
text += "\n" + closing
}
if err := p.SendDM(ctx.Sender, text); err != nil {
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
}
// 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 +1016,40 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome)
}
// TODO: holiday achievement hooks
// Holiday: offer second action if this was action 1 and player survived
if !isAction2 && isHol && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
equip2, _ := loadAdvEquipment(char.UserID)
treasures2, _ := loadAdvTreasureBonuses(char.UserID)
buffs2, _ := loadAdvActiveBuffs(char.UserID)
bonuses2 := computeAdvBonuses(treasures2, buffs2, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip2, bonuses2)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
// If the player still has actions remaining, nudge them
if !char.AllActionsUsed(isHol) && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
remaining := []string{}
if char.CanDoCombat(isHol) {
remaining = append(remaining, "combat")
}
if char.CanDoHarvest(isHol) {
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
harvestLeft := harvestMax - char.HarvestActionsUsed
remaining = append(remaining, fmt.Sprintf("%d harvest", harvestLeft))
}
p.SendDM(ctx.Sender, fmt.Sprintf("Actions remaining today: %s. Type `!adv` for the menu.", strings.Join(remaining, ", ")))
}
return nil
}
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
isAction2 := char.ActionTakenToday
isHol, _ := isHolidayToday()
if !isAction2 {
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
}
if isAction2 {
char.HolidayActionTaken = true
// Rest consumes all remaining actions
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
char.CombatActionsUsed = combatMax
char.HarvestActionsUsed = harvestMax
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
@@ -954,25 +1079,13 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
}
// Holiday: offer second action if this was action 1
if !isAction2 && isHol {
equip, _ := loadAdvEquipment(char.UserID)
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip, bonuses)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
}
}
return nil
}
// ── Treasure Drop Check ─────────────────────────────────────────────────────
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
drop := rollAdvTreasureDrop(loc.Tier, userID)
drop := rollAdvTreasureDrop(loc.Tier, userID, p.chatLevel(userID))
if drop == nil {
return
}
@@ -1008,7 +1121,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
NewTreasure: drop.Def,
Existing: existing,
},
ExpiresAt: time.Now().Add(5 * time.Minute),
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
text := renderAdvTreasureDiscardPrompt(drop.Def, existing)
@@ -1174,7 +1287,12 @@ func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *Adv
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
char, err := loadAdvCharacter(userID)
if err != nil {
// Auto-create
if !errors.Is(err, sql.ErrNoRows) {
// Query error (e.g. missing column) — do NOT auto-create over existing data
slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
return nil, nil, fmt.Errorf("failed to load character: %w", err)
}
// Genuinely new player — auto-create
displayName := p.DisplayName(userID)
if err := createAdvCharacter(userID, displayName); err != nil {
return nil, nil, err
@@ -1200,3 +1318,52 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
return char, equip, nil
}
// ── Double XP/Money Boost ───────────────────────────────────────────────────
const advBoostCacheKey = "adv_boost_active"
func advBoostActive() bool {
return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
}
func advSetBoost(active bool) {
v := "0"
if active {
v = "1"
}
db.CacheSet(advBoostCacheKey, v)
}
func advApplyBoost(result *AdvActionResult) {
if !advBoostActive() {
return
}
result.XPGained *= 2
result.TotalLootValue = 0
for i := range result.LootItems {
result.LootItems[i].Value *= 2
result.TotalLootValue += result.LootItems[i].Value
}
}
func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill))
}
func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
if advBoostActive() {
advSetBoost(false)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
}
advSetBoost(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
}

View File

@@ -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.051.0) that reduces XP and
// loot when a character's effective level far exceeds the location's minimum.
// Gap 0-3: no penalty. Gap 4+: 15% per level over 3, floor 5%.
func advOverlevelMultiplier(effectiveLevel, minLevel int) float64 {
gap := effectiveLevel - minLevel
// 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
}

File diff suppressed because it is too large Load Diff

View File

@@ -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.",
}

View File

@@ -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"},
},
},
},

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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,41 @@ 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
CraftsSucceeded int
}
type AdvEquipment struct {
@@ -203,6 +240,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 +257,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 +415,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 +433,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, crafts_succeeded
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -333,6 +459,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, &c.CraftsSucceeded,
)
if err != nil {
return nil, err
@@ -342,6 +481,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 +493,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 +572,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 +624,23 @@ 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 = ?,
crafts_succeeded = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
@@ -428,6 +651,22 @@ 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,
char.CraftsSucceeded,
string(char.UserID),
)
return err
@@ -544,7 +783,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, crafts_succeeded
FROM adventure_characters`)
if err != nil {
return nil, err
@@ -555,7 +808,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 +824,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, &c.CraftsSucceeded,
); err != nil {
return nil, err
}
@@ -575,6 +846,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 +865,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 +893,7 @@ func resetAllAdvDailyActions() error {
// Only reset actions taken before today — protects against race if a player
// resolves their action at exactly midnight.
today := time.Now().UTC().Format("2006-01-02")
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0, pet_morning_defense = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
return err
}
@@ -655,14 +954,13 @@ type AdvDayLog struct {
XPGained int
}
func loadAdvTodayLogs() ([]AdvDayLog, error) {
func loadAdvLogsForDate(date string) ([]AdvDayLog, error) {
d := db.Get()
today := time.Now().UTC().Format("2006-01-02")
rows, err := d.Query(`
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
FROM adventure_activity_log
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
ORDER BY logged_at`, today, today)
ORDER BY logged_at`, date, date)
if err != nil {
return nil, err
}

View File

@@ -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?"
}
}

View File

@@ -0,0 +1,561 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"maunium.net/go/mautrix/id"
)
// ── Consumable Definitions ───────────────────────────────────────────────────
// Items that are auto-consumed during combat. Players don't choose when to use
// them — the engine selects up to 2 (1 offensive + 1 defensive) based on threat
// assessment to avoid wasting items on trivial fights.
type ConsumableEffect string
const (
EffectHeal ConsumableEffect = "heal"
EffectDefBoost ConsumableEffect = "def_boost"
EffectAtkBoost ConsumableEffect = "atk_boost"
EffectWard ConsumableEffect = "ward"
EffectSpeedBoost ConsumableEffect = "speed_boost"
EffectCritBoost ConsumableEffect = "crit_boost"
EffectFlatDmg ConsumableEffect = "flat_dmg"
EffectSpore ConsumableEffect = "spore"
EffectReflect ConsumableEffect = "reflect"
EffectAutoCrit ConsumableEffect = "auto_crit"
)
type ConsumableDef struct {
Name string
Effect ConsumableEffect
Value float64 // meaning depends on effect
Tier int
Buyable bool
Price int64
Slot string // "offensive" or "defensive"
}
// ConsumableItem is an AdvItem that has consumable properties.
type ConsumableItem struct {
InventoryID int64
Def *ConsumableDef
}
var consumableDefs = []ConsumableDef{
// T1
{Name: "Berry Poultice", Effect: EffectHeal, Value: 12, Tier: 1, Buyable: true, Price: 400, Slot: "defensive"},
// T2 — drop only (forage, mine, dungeon)
{Name: "Herb Salve", Effect: EffectHeal, Value: 20, Tier: 2, Slot: "defensive"},
{Name: "Mushroom Brew", Effect: EffectDefBoost, Value: 0.20, Tier: 2, Slot: "defensive"},
{Name: "Coal Bomb", Effect: EffectFlatDmg, Value: 8, Tier: 2, Slot: "offensive"},
// T3 — drop only
{Name: "Goblin Grease", Effect: EffectAtkBoost, Value: 0.25, Tier: 3, Slot: "offensive"},
{Name: "Quartz Ward", Effect: EffectWard, Value: 1, Tier: 3, Slot: "defensive"},
{Name: "Blooper Ink Vial", Effect: EffectSpeedBoost, Value: 0.30, Tier: 3, Slot: "offensive"},
{Name: "Spore Cloud", Effect: EffectSpore, Value: 2, Tier: 3, Slot: "defensive"},
// T4
{Name: "Spirit Tonic", Effect: EffectHeal, Value: 40, Tier: 4, Buyable: false, Slot: "defensive"},
{Name: "Sapphire Elixir", Effect: EffectCritBoost, Value: 0.15, Tier: 4, Buyable: false, Slot: "offensive"},
// T5
{Name: "Ancient Artifact Oil", Effect: EffectAutoCrit, Value: 0.35, Tier: 5, Buyable: false, Slot: "offensive"},
{Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"},
}
// consumableDefByName returns the definition for a consumable by name.
func consumableDefByName(name string) *ConsumableDef {
for i := range consumableDefs {
if consumableDefs[i].Name == name {
return &consumableDefs[i]
}
}
return nil
}
// ── Threat Assessment ────────────────────────────────────────────────────────
type threatLevel int
const (
threatTrivial threatLevel = iota // < 0.4
threatEasy // 0.40.7
threatCompetitive // 0.71.0
threatDangerous // > 1.0
)
func assessThreat(player, enemy CombatStats) threatLevel {
playerPower := float64(player.MaxHP + player.Attack*3)
enemyPower := float64(enemy.MaxHP + enemy.Attack*3)
if playerPower == 0 {
return threatDangerous
}
ratio := enemyPower / playerPower
switch {
case ratio < 0.4:
return threatTrivial
case ratio < 0.7:
return threatEasy
case ratio < 1.0:
return threatCompetitive
default:
return threatDangerous
}
}
// ── Selection Logic ──────────────────────────────────────────────────────────
// SelectConsumables picks up to 2 items (1 offensive + 1 defensive) from inventory.
// contentTier caps consumable tier to the content being fought (0 = no cap).
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem {
threat := assessThreat(playerStats, enemyStats)
if threat == threatTrivial {
return nil
}
maxTier := maxConsumableTier(threat, arenaRound)
if contentTier > 0 && contentTier < maxTier {
maxTier = contentTier
}
var bestOffensive, bestDefensive *ConsumableItem
for i := range inventory {
item := &inventory[i]
if item.Def.Tier > maxTier {
continue
}
switch item.Def.Slot {
case "offensive":
if bestOffensive == nil || betterOffensive(item, bestOffensive, threat) {
bestOffensive = item
}
case "defensive":
if bestDefensive == nil || betterDefensive(item, bestDefensive, threat, playerStats, enemyStats) {
bestDefensive = item
}
}
}
var selected []ConsumableItem
if bestOffensive != nil {
selected = append(selected, *bestOffensive)
}
if bestDefensive != nil {
selected = append(selected, *bestDefensive)
}
return selected
}
func maxConsumableTier(threat threatLevel, arenaRound int) int {
base := 1
switch threat {
case threatEasy:
base = 2
case threatCompetitive:
base = 3
case threatDangerous:
base = 5
}
// Arena: spend freely on later rounds
if arenaRound >= 3 {
base = min(5, base+1)
}
return base
}
// betterOffensive prefers lowest tier that's appropriate for the threat.
func betterOffensive(candidate, current *ConsumableItem, threat threatLevel) bool {
if threat == threatDangerous {
return candidate.Def.Tier > current.Def.Tier
}
return candidate.Def.Tier < current.Def.Tier
}
// betterDefensive picks heal items for longer fights, wards for burst threats.
func betterDefensive(candidate, current *ConsumableItem, threat threatLevel, player, enemy CombatStats) bool {
if threat == threatDangerous {
return candidate.Def.Tier > current.Def.Tier
}
// Prefer wards against high-attack enemies
if enemy.Attack > player.MaxHP/4 && candidate.Def.Effect == EffectWard {
return true
}
return candidate.Def.Tier < current.Def.Tier
}
// ── Modifier Application ─────────────────────────────────────────────────────
// consumableDropTable maps activity types to consumable names available at each tier.
// Drop chance is 15% per activity completion at T2+.
var consumableDropTable = map[AdvActivityType]map[int][]string{
AdvActivityForaging: {
2: {"Herb Salve"},
3: {"Spore Cloud"},
4: {"Spirit Tonic"},
5: {"Spirit Tonic", "Voidstone Shard"},
},
AdvActivityMining: {
2: {"Coal Bomb"},
3: {"Quartz Ward"},
4: {"Sapphire Elixir"},
5: {"Voidstone Shard"},
},
AdvActivityFishing: {
2: {"Herb Salve"},
3: {"Blooper Ink Vial"},
4: {"Blooper Ink Vial", "Spirit Tonic"},
5: {"Blooper Ink Vial", "Voidstone Shard"},
},
AdvActivityDungeon: {
2: {"Herb Salve", "Coal Bomb"},
3: {"Goblin Grease", "Quartz Ward"},
4: {"Sapphire Elixir", "Spirit Tonic"},
5: {"Ancient Artifact Oil", "Voidstone Shard"},
},
}
// RollConsumableDrop checks whether a consumable item drops from an activity.
// Returns a consumable AdvItem or nil.
func RollConsumableDrop(activity AdvActivityType, tier int) *AdvItem {
actTable, ok := consumableDropTable[activity]
if !ok {
return nil
}
names, ok := actTable[tier]
if !ok || len(names) == 0 {
return nil
}
if rand.Float64() >= 0.15 {
return nil
}
name := names[rand.IntN(len(names))]
def := consumableDefByName(name)
if def == nil {
return nil
}
sellValue := def.Price / 2
if sellValue == 0 {
tierSellValues := map[int]int64{1: 200, 2: 600, 3: 1500, 4: 3000, 5: 6000}
sellValue = tierSellValues[def.Tier]
}
return &AdvItem{
Name: def.Name,
Type: "consumable",
Tier: def.Tier,
Value: sellValue,
}
}
// BuyableConsumables returns all consumable defs that can be purchased from the shop.
func BuyableConsumables() []ConsumableDef {
var result []ConsumableDef
for _, def := range consumableDefs {
if def.Buyable {
result = append(result, def)
}
}
return result
}
// ApplyConsumableMods adjusts the CombatStats and CombatModifiers for selected consumables.
func ApplyConsumableMods(stats *CombatStats, mods *CombatModifiers, items []ConsumableItem) {
for _, item := range items {
switch item.Def.Effect {
case EffectHeal:
mods.HealItem = int(item.Def.Value)
case EffectDefBoost:
mods.DamageReduct *= (1 - item.Def.Value) // 0.20 → 0.80x damage taken
case EffectAtkBoost:
mods.DamageBonus += item.Def.Value
case EffectWard:
mods.WardCharges = int(item.Def.Value)
case EffectSpeedBoost:
stats.Speed = int(float64(stats.Speed) * (1 + item.Def.Value))
case EffectCritBoost:
stats.CritRate += item.Def.Value
case EffectFlatDmg:
mods.FlatDmgStart = int(item.Def.Value)
case EffectSpore:
mods.SporeCloud = int(item.Def.Value)
case EffectReflect:
mods.ReflectNext = item.Def.Value
case EffectAutoCrit:
mods.AutoCritFirst = true
mods.DamageBonus += item.Def.Value // +35% attack too
}
}
}
// ── Crafting System ─────────────────────────────────────────────────────────
// Auto-crafting happens before combat. If the player has sufficient foraging
// level and the right ingredients in inventory, consumables are assembled
// automatically. Failed crafts consume one ingredient.
type CraftingRecipe struct {
Result string // consumable name to produce
Ingredients []string // required item names (matched by inventory Name)
MinForaging int // foraging level required
Tier int
}
var craftingRecipes = []CraftingRecipe{
// T1 — Foraging 10
{Result: "Berry Poultice", Ingredients: []string{"Berries", "Common Herbs"}, MinForaging: 10, Tier: 1},
// T2 — Foraging 15
{Result: "Herb Salve", Ingredients: []string{"Rare Herbs", "Honey"}, MinForaging: 15, Tier: 2},
{Result: "Mushroom Brew", Ingredients: []string{"Mushrooms", "Hardwood"}, MinForaging: 15, Tier: 2},
{Result: "Coal Bomb", Ingredients: []string{"Coal", "Saltpetre"}, MinForaging: 15, Tier: 2},
// T3 — Foraging 20
{Result: "Goblin Grease", Ingredients: []string{"Goblin Trinket", "Honey"}, MinForaging: 20, Tier: 3},
{Result: "Quartz Ward", Ingredients: []string{"Quartz", "Iron Ore"}, MinForaging: 20, Tier: 3},
{Result: "Blooper Ink Vial", Ingredients: []string{"Blooper Ink", "River Pearl"}, MinForaging: 20, Tier: 3},
{Result: "Spore Cloud", Ingredients: []string{"Spores", "Rare Herbs"}, MinForaging: 20, Tier: 3},
// T4 — Foraging 25
{Result: "Spirit Tonic", Ingredients: []string{"Spirit Herbs", "Starfruit"}, MinForaging: 25, Tier: 4},
{Result: "Sapphire Elixir", Ingredients: []string{"Sapphire", "Dragon Crystal"}, MinForaging: 25, Tier: 4},
// T5 — Foraging 30
{Result: "Ancient Artifact Oil", Ingredients: []string{"Ancient Artifact", "Dragon Scale"}, MinForaging: 30, Tier: 5},
{Result: "Voidstone Shard", Ingredients: []string{"Voidstone", "Mythril Ore"}, MinForaging: 30, Tier: 5},
}
// craftingSuccessRate returns the success chance for a recipe given foraging level.
// Base rate is 50% at minimum level, +3% per 5 levels above minimum, capped at 95%.
func craftingSuccessRate(foragingLevel, minForaging int) float64 {
base := 0.50
levelsAbove := foragingLevel - minForaging
bonus := float64(levelsAbove) / 5.0 * 0.03
rate := base + bonus
if rate > 0.95 {
return 0.95
}
return rate
}
// CraftResult records what happened during auto-crafting for narrative output.
type CraftResult struct {
Recipe *CraftingRecipe
Success bool
}
// renderRecipesKnown returns a player-facing list of recipes available at
// their current foraging level, plus a teaser line for the next unlock
// threshold. Hides exact ingredient lists for recipes the player hasn't
// unlocked — only the count of locked recipes is shown.
func renderRecipesKnown(foragingLevel int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🧪 **Crafting Recipes** — Foraging Lv.%d\n\n", foragingLevel))
if foragingLevel < 10 {
sb.WriteString("_Auto-crafting unlocks at Foraging Lv.10. Keep gathering._")
return sb.String()
}
// Group recipes by tier, list those known.
known := map[int][]CraftingRecipe{}
totalKnown := 0
totalLocked := 0
nextUnlock := 0
for _, r := range craftingRecipes {
if r.MinForaging <= foragingLevel {
known[r.Tier] = append(known[r.Tier], r)
totalKnown++
} else {
totalLocked++
if nextUnlock == 0 || r.MinForaging < nextUnlock {
nextUnlock = r.MinForaging
}
}
}
for tier := 1; tier <= 5; tier++ {
recipes := known[tier]
if len(recipes) == 0 {
continue
}
sb.WriteString(fmt.Sprintf("**Tier %d** (Foraging %d+)\n", tier, recipes[0].MinForaging))
for _, r := range recipes {
rate := craftingSuccessRate(foragingLevel, r.MinForaging) * 100
sb.WriteString(fmt.Sprintf(" • %s — %s (%.0f%% success)\n",
r.Result, strings.Join(r.Ingredients, " + "), rate))
}
}
sb.WriteString(fmt.Sprintf("\nKnown: %d · ", totalKnown))
if totalLocked == 0 {
sb.WriteString("All recipes unlocked. ⭐")
} else {
sb.WriteString(fmt.Sprintf("Locked: %d (next unlock at Foraging Lv.%d)", totalLocked, nextUnlock))
}
maxCrafts := 1 + (foragingLevel-10)/10
sb.WriteString(fmt.Sprintf("\nMax auto-crafts per combat: %d.", maxCrafts))
return sb.String()
}
// craftXPRewards: per-tier foraging XP granted on successful (and tiny on
// failed) auto-crafts. Successful T1 ≈ 30% of a Foraging Success haul, scaling
// up so T5 grants are meaningful. Failures get a token consolation grant —
// you tried.
var craftXPSuccess = map[int]int{1: 12, 2: 25, 3: 40, 4: 60, 5: 90}
var craftXPFailure = map[int]int{1: 3, 2: 5, 3: 8, 4: 12, 5: 18}
// autoCraftConsumables scans the player's inventory for craftable recipes and
// attempts to craft the highest-tier recipe available. Consumes ingredients on
// attempt; on failure one ingredient is lost. Returns crafted items and results.
//
// Side effects: grants foraging XP per attempt (success > failure) and bumps
// the player's CraftsSucceeded counter on each success.
func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) ([]CraftResult, []AdvItem) {
if foragingLevel < 10 {
return nil, items
}
remaining := make([]AdvItem, len(items))
copy(remaining, items)
var results []CraftResult
crafted := 0
maxCrafts := 1 + (foragingLevel-10)/10 // 1 at lv10, 2 at lv20, 3 at lv30
// Try recipes from highest tier down
for attempt := 0; attempt < maxCrafts; attempt++ {
bestRecipe, ingredientIDs := findBestCraftable(remaining, foragingLevel)
if bestRecipe == nil {
break
}
rate := craftingSuccessRate(foragingLevel, bestRecipe.MinForaging)
success := rand.Float64() < rate
if success {
// Remove all ingredients from inventory
for _, id := range ingredientIDs {
removeAdvInventoryItem(id)
}
remaining = removeItemsByIDs(remaining, ingredientIDs)
// Add crafted consumable to inventory
def := consumableDefByName(bestRecipe.Result)
if def == nil {
continue
}
sellValue := def.Price / 2
if sellValue == 0 {
tierSellValues := map[int]int64{1: 200, 2: 600, 3: 1500, 4: 3000, 5: 6000}
sellValue = tierSellValues[def.Tier]
}
craftedItem := AdvItem{
Name: def.Name,
Type: "consumable",
Tier: def.Tier,
Value: sellValue,
}
if err := addAdvInventoryItem(userID, craftedItem); err != nil {
slog.Error("crafting: failed to add crafted item", "item", def.Name, "err", err)
continue
}
results = append(results, CraftResult{Recipe: bestRecipe, Success: true})
crafted++
// Foraging XP + lifetime counter bump.
if char, err := loadAdvCharacter(userID); err == nil {
char.ForagingXP += craftXPSuccess[bestRecipe.Tier]
char.CraftsSucceeded++
checkAdvLevelUp(char, "foraging")
_ = saveAdvCharacter(char)
}
} else {
// Failure: all ingredients destroyed
for _, id := range ingredientIDs {
removeAdvInventoryItem(id)
}
remaining = removeItemsByIDs(remaining, ingredientIDs)
results = append(results, CraftResult{Recipe: bestRecipe, Success: false})
// Token foraging XP for the attempt.
if char, err := loadAdvCharacter(userID); err == nil {
char.ForagingXP += craftXPFailure[bestRecipe.Tier]
checkAdvLevelUp(char, "foraging")
_ = saveAdvCharacter(char)
}
}
}
// Reload remaining if we crafted anything (IDs changed)
if crafted > 0 {
reloaded, err := loadAdvInventory(userID)
if err == nil {
remaining = reloaded
}
}
return results, remaining
}
// findBestCraftable returns the highest-tier recipe the player can craft
// from their current inventory, along with the inventory IDs of the ingredients.
func findBestCraftable(items []AdvItem, foragingLevel int) (*CraftingRecipe, []int64) {
var bestRecipe *CraftingRecipe
var bestIDs []int64
for i := range craftingRecipes {
recipe := &craftingRecipes[i]
if foragingLevel < recipe.MinForaging {
continue
}
ids := matchIngredients(items, recipe.Ingredients)
if ids == nil {
continue
}
if bestRecipe == nil || recipe.Tier > bestRecipe.Tier {
bestRecipe = recipe
bestIDs = ids
}
}
return bestRecipe, bestIDs
}
// matchIngredients checks if inventory contains all ingredients for a recipe.
// Returns the inventory item IDs to consume, or nil if not all found.
func matchIngredients(items []AdvItem, ingredients []string) []int64 {
used := make(map[int]bool)
var ids []int64
for _, need := range ingredients {
found := false
for j, item := range items {
if used[j] {
continue
}
if item.Name == need && item.Type != "consumable" && item.Type != "MasterworkGear" && item.Type != "ArenaGear" && item.Type != "card" {
used[j] = true
ids = append(ids, item.ID)
found = true
break
}
}
if !found {
return nil
}
}
return ids
}
func removeItemsByIDs(items []AdvItem, ids []int64) []AdvItem {
idSet := make(map[int64]bool, len(ids))
for _, id := range ids {
idSet[id] = true
}
var result []AdvItem
for _, item := range items {
if !idSet[item.ID] {
result = append(result, item)
}
}
return result
}

View File

@@ -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)
}
}
}

View File

@@ -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 60180 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 60180 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

View File

@@ -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",
},

View File

@@ -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

View File

@@ -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.",
},
}

View File

@@ -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!",
}

View File

@@ -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.",
}

View File

@@ -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},

View File

@@ -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 {

View File

@@ -0,0 +1,759 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"time"
)
// ── Housing Tier Definitions ───────────────────────────────────────────────
type HouseTierDef struct {
Tier int
Name string
Description string
BasePrice int
}
// HouseTier 0 = no house. Purchasable tiers are 1-4.
var houseTiers = []HouseTierDef{
{Tier: 1, Name: "Base House", Description: "Four walls and a roof. 'Livable' is a strong word.", BasePrice: 75000},
{Tier: 2, Name: "Livable", Description: "A place you can sit down in without questioning your life choices.", BasePrice: 150000},
{Tier: 3, Name: "Comfortable", Description: "Walls that don't leak. A door that locks. Luxuries of the modern age.", BasePrice: 300000},
{Tier: 4, Name: "Established", Description: "This is a house that says things about you. Good things. Mostly.", BasePrice: 600000},
}
// houseTierByTier returns the tier definition for a given tier number, or nil.
func houseTierByTier(tier int) *HouseTierDef {
for i := range houseTiers {
if houseTiers[i].Tier == tier {
return &houseTiers[i]
}
}
return nil
}
// houseNextTier returns the next purchasable tier definition, or nil if maxed.
func houseNextTier(currentTier int) *HouseTierDef {
for i := range houseTiers {
if houseTiers[i].Tier == currentTier+1 {
return &houseTiers[i]
}
}
return nil
}
// houseClosingCosts returns 5% closing + 6% realtor fees = 11% of base price.
func houseClosingCosts(basePrice int) int {
return int(float64(basePrice) * 0.11)
}
// houseTotalCost returns base price + closing costs + realtor fees.
func houseTotalCost(basePrice int) int {
return basePrice + houseClosingCosts(basePrice)
}
// houseWeeklyPayment calculates weekly payment based on balance and rate.
// Uses a simple amortization: interest + fixed principal portion.
// Rate is annual percentage (e.g., 6.5 means 6.5%).
func houseWeeklyPayment(balance int, annualRate float64) int {
if balance <= 0 || annualRate <= 0 {
return 0
}
weeklyRate := annualRate / 100.0 / 52.0
interest := float64(balance) * weeklyRate
// Principal portion: 0.5% of balance per week (~26% of balance per year)
// Ensures loans pay off in roughly 2-4 years of active play.
principal := float64(balance) * 0.005
payment := int(interest + principal)
if payment < 1 {
payment = 1
}
return payment
}
// houseMissedPenalty returns the penalty for a missed payment (5% of weekly payment).
func houseMissedPenalty(weeklyPayment int) int {
penalty := int(float64(weeklyPayment) * 0.05)
if penalty < 1 {
penalty = 1
}
return penalty
}
// ── Pending Interaction Types ──────────────────────────────────────────────
type advPendingHouseConfirm struct {
Tier int
TotalCost int
DownPayment int
}
type advPendingHouseDownPayment struct {
Tier int
TotalCost int
}
type advPendingHouseAutopay struct{}
type advPendingPetArrival struct{}
type advPendingPetType struct{}
type advPendingPetName struct {
PetType string
}
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
var thomGreetingsPreAdoption = []string{
"Welcome in! Great timing. I've got something perfect for you. I say that to everyone and I mean it every time.",
"You don't look like someone ready to make a smart investment. I can work with that. Sit down. Don't touch anything.",
"Thom Krooke, Krooke Realty. No relation to that other guy. Mostly.",
}
var thomGreetingsPostAdoption = []string{
"You're back. Good. I've got things.",
"Oh. You. Sure. What do you need.",
"Come in. Wipe your feet. How's the animal.",
}
var thomGreetingsPostLevel10 = []string{
"Hello, {pet_name}'s caretaker. The usual?",
"Ah. {pet_name}'s caretaker. I've been expecting you. I have things {pet_name} needs.",
"Come in, {pet_name} and the caretaker! Wipe your feet! How are things? *You open your mouth to answer but quickly notice Thom is speaking to your pet.*",
}
var thomHouseSellingLines = []string{
"Charming property. Full of character. 'Character' means different things to different people and that's what makes real estate exciting.",
"Minor structural considerations that a positive attitude will absolutely address. Price reflects the opportunity.",
"Previous owner left in a hurry. Didn't say why. The chalk outlines in the living room are almost certainly decorative. Kids do that now apparently.",
}
const thomAnimalLine = "I heard an animal got in your house. Luckily for you, it's a sweet one that will give you the world in exchange for a tiny bit of kindness. ...that's what I heard anyway."
var thomMissedPaymentNoPet = "Missed payment. Penalty applied. Balance is now €%d. Sort it out."
var thomMissedPaymentPet = "Missed payment. Penalty applied. Balance is now €%d. %s doesn't need to know about this. Keep it together."
var thomFreezeNoPet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it."
var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it. %s is fine. You should work on being fine."
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
// thomGreeting returns an appropriate greeting based on pet state.
func thomGreeting(char *AdventureCharacter) string {
if char.HasPet() && char.PetLevel >= 10 {
line := thomGreetingsPostLevel10[rand.IntN(len(thomGreetingsPostLevel10))]
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
}
if char.HasPet() {
return thomGreetingsPostAdoption[rand.IntN(len(thomGreetingsPostAdoption))]
}
return thomGreetingsPreAdoption[rand.IntN(len(thomGreetingsPreAdoption))]
}
// ── Thom Shop Display ──────────────────────────────────────────────────────
func thomShopView(char *AdventureCharacter, balance float64) string {
var sb strings.Builder
sb.WriteString("🏠 **Krooke Realty**\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomGreeting(char)))
// Check for animal line (fires once after pet appears)
if char.HasPet() && !char.ThomAnimalLineFired {
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomAnimalLine))
}
// Current housing status
if !char.HasHouse() {
sb.WriteString("You don't own a house yet.\n\n")
} else {
tierDef := houseTierByTier(char.HouseTier)
tierName := "Unknown"
if tierDef != nil {
tierName = tierDef.Name
}
sb.WriteString(fmt.Sprintf("🏡 Your house: **%s** (Tier %d)\n", tierName, char.HouseTier))
if char.HouseLoanBalance > 0 {
weekly := houseWeeklyPayment(char.HouseLoanBalance, char.HouseCurrentRate)
if char.HouseAutopay {
weekly = int(float64(weekly) * 0.98) // 2% autopay discount
}
sb.WriteString(fmt.Sprintf("💳 Loan balance: €%d | Weekly: €%d", char.HouseLoanBalance, weekly))
if char.HouseAutopay {
sb.WriteString(" (autopay)")
}
if char.HouseLoanFrozen {
sb.WriteString(" ❄️ FROZEN")
}
sb.WriteString(fmt.Sprintf(" | Rate: %.2f%%\n", char.HouseCurrentRate))
}
sb.WriteString("\n")
}
// Available upgrades
if char.HouseLoanBalance > 0 {
sb.WriteString("Pay off your current loan before upgrading.\n")
sb.WriteString(fmt.Sprintf("\nTo pay off early: `!thom payoff`\n"))
if !char.HouseAutopay {
sb.WriteString("To enable autopay (2% discount): `!thom autopay`\n")
}
} else {
// Can purchase next tier
nextDef := houseNextTier(char.HouseTier)
if nextDef == nil {
sb.WriteString("✨ Max tier reached. There is nothing left to sell you. This has never happened before.\n")
} else {
def := *nextDef
total := houseTotalCost(def.BasePrice)
selling := thomHouseSellingLines[rand.IntN(len(thomHouseSellingLines))]
sb.WriteString(fmt.Sprintf("*%s*\n\n", selling))
sb.WriteString(fmt.Sprintf("📋 **%s** (Tier %d)\n", def.Name, def.Tier))
sb.WriteString(fmt.Sprintf(" Base price: €%d\n", def.BasePrice))
sb.WriteString(fmt.Sprintf(" Closing costs (5%%): €%d\n", int(float64(def.BasePrice)*0.05)))
sb.WriteString(fmt.Sprintf(" Realtor fees (6%%): €%d\n", int(float64(def.BasePrice)*0.06)))
sb.WriteString(fmt.Sprintf(" **Total: €%d**\n\n", total))
sb.WriteString(fmt.Sprintf("`!thom buy` to purchase outright, or `!thom buy <amount>` for a down payment.\n"))
}
}
// Pet supply shop (unlocks 1 week after pet level 10)
if char.PetSupplyShopUnlocked && char.HasPet() {
sb.WriteString("\n---\n")
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char))
}
return sb.String()
}
// ── Thom Shop Command Handler ──────────────────────────────────────────────
func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "thom"))
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
lower := strings.ToLower(args)
switch {
case args == "" || lower == "shop":
text := thomShopView(char, balance)
// Mark animal line as fired if applicable
if char.HasPet() && !char.ThomAnimalLineFired {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
char.ThomAnimalLineFired = true
_ = saveAdvCharacter(char)
userMu.Unlock()
}
return p.SendDM(ctx.Sender, text)
case lower == "payoff":
return p.handleThomPayoff(ctx, char)
case strings.HasPrefix(lower, "pay "):
return p.handleThomPay(ctx, char, strings.TrimSpace(lower[4:]))
case lower == "autopay":
return p.handleThomAutopay(ctx, char)
case lower == "buy" || strings.HasPrefix(lower, "buy "):
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
case strings.HasPrefix(lower, "petbuy "):
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
default:
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
}
}
// ── Buy House ──────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureCharacter, balance float64, downPaymentStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Reload fresh
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance = p.euro.GetBalance(ctx.Sender)
if char.HouseLoanBalance > 0 {
return p.SendDM(ctx.Sender, "You still have an outstanding loan. Pay it off first with `!thom payoff`.")
}
nextDef := houseNextTier(char.HouseTier)
if nextDef == nil {
return p.SendDM(ctx.Sender, "You've reached max tier. There is nothing left to sell you.")
}
def := *nextDef
totalCost := houseTotalCost(def.BasePrice)
// Parse optional down payment
downPayment := 0
if downPaymentStr != "" {
dp := 0
for _, c := range downPaymentStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Down payment must be a number. Example: `!thom buy 10000`")
}
dp = dp*10 + int(c-'0')
}
downPayment = dp
}
if downPayment > 0 && float64(downPayment) > balance {
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford a €%d down payment. Balance: €%.0f.", downPayment, balance))
}
if downPayment >= totalCost {
// Full purchase, no loan
if !p.euro.Debit(ctx.Sender, float64(totalCost), "house_purchase_full") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseTier = def.Tier
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(totalCost), "house_purchase_refund")
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 **%s** purchased outright for €%d.\n\nNo loan. No weekly payments. Thom is visibly disappointed.", def.Name, totalCost))
}
// Loan purchase
loanAmount := totalCost - downPayment
// Get current mortgage rate
rate := getCurrentMortgageRate()
if rate <= 0 {
rate = 6.5 // fallback default
}
// Debit down payment if any
if downPayment > 0 {
if !p.euro.Debit(ctx.Sender, float64(downPayment), "house_down_payment") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
}
char.HouseTier = def.Tier
char.HouseLoanBalance = loanAmount
char.HouseCurrentRate = rate
char.HouseMissedPayments = 0
char.HouseLoanFrozen = false
if err := saveAdvCharacter(char); err != nil {
if downPayment > 0 {
p.euro.Credit(ctx.Sender, float64(downPayment), "house_dp_refund")
}
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
}
weekly := houseWeeklyPayment(loanAmount, rate)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏠 **%s** purchased!\n\n", def.Name))
if downPayment > 0 {
sb.WriteString(fmt.Sprintf("Down payment: €%d\n", downPayment))
}
sb.WriteString(fmt.Sprintf("Loan balance: €%d\n", loanAmount))
sb.WriteString(fmt.Sprintf("Weekly payment: ~€%d (%.2f%% ARM rate)\n", weekly, rate))
sb.WriteString("\nPayments are collected weekly. Enable autopay for a 2% discount: `!thom autopay`")
return p.SendDM(ctx.Sender, sb.String())
}
// ── Payoff ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomPayoff(ctx MessageContext, char *AdventureCharacter) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Reload fresh
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(char.HouseLoanBalance) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d to pay off the loan. Balance: €%.0f.", char.HouseLoanBalance, balance))
}
payoffAmount := float64(char.HouseLoanBalance)
if !p.euro.Debit(ctx.Sender, payoffAmount, "house_payoff") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseLoanBalance = 0
char.HouseLoanFrozen = false
char.HouseMissedPayments = 0
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, payoffAmount, "house_payoff_refund")
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
}
if char.HasPet() {
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, char.PetName)))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
// ── Extra Payment ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomPay(ctx MessageContext, char *AdventureCharacter, amountStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
amount, err := strconv.Atoi(amountStr)
if err != nil || amount <= 0 {
return p.SendDM(ctx.Sender, "Usage: `!thom pay <amount>` — e.g. `!thom pay 5000`")
}
// Cap at remaining balance
if amount > char.HouseLoanBalance {
amount = char.HouseLoanBalance
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(amount) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d but only have €%.0f.", amount, balance))
}
if !p.euro.Debit(ctx.Sender, float64(amount), "house_extra_payment") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseLoanBalance -= amount
if char.HouseLoanBalance <= 0 {
char.HouseLoanBalance = 0
char.HouseLoanFrozen = false
char.HouseMissedPayments = 0
}
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(amount), "house_extra_payment_refund")
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
}
if char.HouseLoanBalance == 0 {
if char.HasPet() {
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, fmt.Sprintf(thomPaidOffPet, char.PetName)))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, thomPaidOffNoPet))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. Remaining balance: €%d.", amount, char.HouseLoanBalance))
}
// ── Autopay Toggle ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomAutopay(ctx MessageContext, char *AdventureCharacter) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
char.HouseAutopay = !char.HouseAutopay
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.")
}
if char.HouseAutopay {
return p.SendDM(ctx.Sender, "✅ Autopay enabled. 2% discount on weekly payments. Thom calls this a favour. It is not.")
}
return p.SendDM(ctx.Sender, "Autopay disabled.")
}
// ── Weekly Mortgage Payment Processing ─────────────────────────────────────
func (p *AdventurePlugin) processMortgagePayments() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("mortgage: failed to load characters", "err", err)
return
}
for _, char := range chars {
if char.HouseLoanBalance <= 0 {
continue
}
if char.HouseLoanFrozen {
continue
}
userMu := p.advUserLock(char.UserID)
userMu.Lock()
// Reload fresh inside lock
freshChar, err := loadAdvCharacter(char.UserID)
if err != nil || freshChar.HouseLoanBalance <= 0 || freshChar.HouseLoanFrozen {
userMu.Unlock()
continue
}
weekly := houseWeeklyPayment(freshChar.HouseLoanBalance, freshChar.HouseCurrentRate)
if freshChar.HouseAutopay {
weekly = int(float64(weekly) * 0.98)
}
if p.euro.Debit(freshChar.UserID, float64(weekly), "mortgage_weekly") {
freshChar.HouseLoanBalance -= weekly
if freshChar.HouseLoanBalance <= 0 {
freshChar.HouseLoanBalance = 0
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
// Paid off!
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
continue
}
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
} else {
// Missed payment
penalty := houseMissedPenalty(weekly)
freshChar.HouseLoanBalance += penalty
freshChar.HouseMissedPayments++
if freshChar.HouseMissedPayments >= 10 {
freshChar.HouseLoanFrozen = true
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomFreezePet, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomFreezeNoPet))
}
} else {
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentPet, freshChar.HouseLoanBalance, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentNoPet, freshChar.HouseLoanBalance)))
}
}
}
}
}
// ── Mortgage Rate Change DMs ───────────────────────────────────────────────
func (p *AdventurePlugin) sendMortgageRateChangeDMs(oldRate, newRate float64) {
if oldRate == newRate {
return
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("mortgage: failed to load characters for rate DMs", "err", err)
return
}
increased := newRate > oldRate
for _, char := range chars {
if char.HouseLoanBalance <= 0 {
continue
}
// Update rate
userMu := p.advUserLock(char.UserID)
userMu.Lock()
freshChar, err := loadAdvCharacter(char.UserID)
if err != nil || freshChar.HouseLoanBalance <= 0 {
userMu.Unlock()
continue
}
freshChar.HouseCurrentRate = newRate
_ = saveAdvCharacter(freshChar)
userMu.Unlock()
newWeekly := houseWeeklyPayment(freshChar.HouseLoanBalance, newRate)
amount := fmt.Sprintf("%d", newWeekly)
var pool []string
if freshChar.HasPet() {
if increased {
pool = ThomRateIncreasePet
} else {
pool = ThomRateDecreasePet
}
} else {
if increased {
pool = ThomRateIncrease
} else {
pool = ThomRateDecrease
}
}
line := pool[rand.IntN(len(pool))]
line = strings.ReplaceAll(line, "{amount}", amount)
if freshChar.HasPet() {
line = strings.ReplaceAll(line, "{pet_name}", freshChar.PetName)
}
_ = p.SendDM(char.UserID, fmt.Sprintf("🏠 %s", line))
// Jitter
time.Sleep(time.Duration(500+rand.IntN(1500)) * time.Millisecond)
}
}
// ── Pet Armor Shop ─────────────────────────────────────────────────────────
type PetArmorDef struct {
Tier int
Name string
DeflectBonus float64
Price int
}
var petDogArmor = []PetArmorDef{
{Tier: 1, Name: "Leather Dog Barding", DeflectBonus: 0.015, Price: 225},
{Tier: 2, Name: "Chain Dog Barding", DeflectBonus: 0.03, Price: 675},
{Tier: 3, Name: "Plate Dog Barding", DeflectBonus: 0.05, Price: 2250},
{Tier: 4, Name: "Enchanted Dog Barding", DeflectBonus: 0.075, Price: 11250},
{Tier: 5, Name: "Dragonscale Dog Barding", DeflectBonus: 0.105, Price: 45000},
}
var petCatArmor = []PetArmorDef{
{Tier: 1, Name: "Leather Cat Armor", DeflectBonus: 0.015, Price: 225},
{Tier: 2, Name: "Chain Cat Armor", DeflectBonus: 0.03, Price: 675},
{Tier: 3, Name: "Plate Cat Armor", DeflectBonus: 0.05, Price: 2250},
{Tier: 4, Name: "Enchanted Cat Armor", DeflectBonus: 0.075, Price: 11250},
{Tier: 5, Name: "Dragonscale Cat Armor", DeflectBonus: 0.105, Price: 45000},
}
func petArmorDefs(petType string) []PetArmorDef {
if petType == "dog" {
return petDogArmor
}
return petCatArmor
}
func petArmorShopView(char *AdventureCharacter) string {
var sb strings.Builder
defs := petArmorDefs(char.PetType)
if char.PetArmorTier >= 5 {
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
return sb.String()
}
for _, def := range defs {
if def.Tier <= char.PetArmorTier {
if def.Tier == char.PetArmorTier {
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
}
continue
}
indicator := "⬆️"
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
}
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
return sb.String()
}
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.HasPet() {
return p.SendDM(ctx.Sender, "You don't have a pet.")
}
if !char.PetSupplyShopUnlocked {
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
}
tier := 0
for _, c := range tierStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
}
tier = tier*10 + int(c-'0')
}
if tier != char.PetArmorTier+1 {
if tier <= char.PetArmorTier {
return p.SendDM(ctx.Sender, "That's not an upgrade.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
}
defs := petArmorDefs(char.PetType)
var def *PetArmorDef
for i := range defs {
if defs[i].Tier == tier {
def = &defs[i]
break
}
}
if def == nil {
return p.SendDM(ctx.Sender, "Invalid tier.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(def.Price) {
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.PetArmorTier = tier
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
}

View File

@@ -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)
}
}
}

View File

@@ -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.

View File

@@ -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))
}

View File

@@ -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) // 510
char.ArinaRollTarget = 5 + rand.IntN(6) // 510
}
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)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -120,6 +120,12 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(fmt.Sprintf(" (best: %d)\n", char.BestStreak))
}
// Crafting (only show once they've actually crafted something)
if char.CraftsSucceeded > 0 {
sb.WriteString(fmt.Sprintf("🧪 Crafts: %d successful (Foraging Lv.%d — `!adventure recipes`)\n",
char.CraftsSucceeded, char.ForagingSkill))
}
// Equipment
sb.WriteString("\n🛡 Equipment:\n")
eqScore := advEquipmentScore(equip)
@@ -201,11 +207,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 +239,7 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
// Holiday notice (before greeting)
if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you are able to take **two actions** today.\n\n", holidayName, holidayName))
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName))
}
// Pick a morning greeting
@@ -259,46 +280,84 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
sb.WriteString("\n\n")
}
// Action budget
isHol := holidayName != ""
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
combatLeft := combatMax - char.CombatActionsUsed
if combatLeft < 0 {
combatLeft = 0
}
harvestLeft := harvestMax - char.HarvestActionsUsed
// Co-op participants have combat locked for the duration of the run.
coopRun, _ := loadCoopRunForUser(char.UserID)
inCoop := coopRun != nil && coopRun.Status == "active"
if inCoop {
sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n",
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax))
} else {
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
}
// Location choices
sb.WriteString("**1⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
if inCoop {
sb.WriteString(fmt.Sprintf("**1⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n"))
} else if char.CanDoCombat(isHol) {
sb.WriteString("**1⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} else {
sb.WriteString("**1⃣ Dungeon:** _(no combat actions remaining)_\n")
}
sb.WriteString("**2⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
if char.CanDoHarvest(isHol) {
sb.WriteString("**2⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
sb.WriteString("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
sb.WriteString("**4⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} else {
sb.WriteString("**2⃣ Mine:** _(no harvest actions remaining)_\n")
sb.WriteString("**3⃣ Forage:** _(no harvest actions remaining)_\n")
sb.WriteString("**4⃣ Fish:** _(no harvest actions remaining)_\n")
}
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6⃣ 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 +365,6 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
return sb.String()
}
// ── Holiday Second Action Prompt ──────────────────────────────────────────────
func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, bonuses *AdvBonusSummary) string {
var sb strings.Builder
sb.WriteString("✅ Action 1 complete.\n\nNow choose your **second action**:\n\n")
sb.WriteString("**1⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**2⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**3⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6⃣ 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 +388,16 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
}
if len(result.LootItems) > 0 {
sb.WriteString(fmt.Sprintf("💰 Loot: €%d total\n", result.TotalLootValue))
sb.WriteString(fmt.Sprintf("💰 Loot: %s total\n", fmtEuro(result.TotalLootValue)))
for _, item := range result.LootItems {
sb.WriteString(fmt.Sprintf(" • %s — €%d\n", item.Name, item.Value))
sb.WriteString(fmt.Sprintf(" • %s — %s\n", item.Name, fmtEuro(item.Value)))
}
}
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
if result.XPBreakdown != "" {
sb.WriteString(fmt.Sprintf(" (%s)", result.XPBreakdown))
}
if result.LeveledUp {
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel))
}
@@ -619,8 +630,12 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n",
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill))
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
if p.Location != "" {
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
} else {
sb.WriteString(" Acted today — no log recorded.\n\n")
}
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
bestPlayer = p
@@ -737,11 +752,11 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
}
var entries []entry
for _, c := range chars {
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill) * 10
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
entries = append(entries, entry{
Name: c.DisplayName,
Score: score,
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill),
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
Streak: c.CurrentStreak,
})
}

View File

@@ -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)

View File

@@ -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,20 @@ 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" {
// Never touch Arena gear, cards, or consumables. Consumables are a
// player-curated stockpile (crafted or dropped); selling them is an
// explicit decision the player must make themselves.
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
continue
}
// Non-gear items (ores, fish, junk, treasure, etc.) — always take
if item.Slot == "" {
result = append(result, item)
continue
}
// Slotted gear — check against equipped piece
eq, hasSlot := equip[item.Slot]
if !hasSlot {
continue
@@ -243,14 +252,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')
}

View File

@@ -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) {

View File

@@ -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)
}
@@ -743,11 +778,16 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
var total float64
var sold int
var keptSpecial int
var keptConsumable int
for _, item := range items {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
keptSpecial++
continue
}
if item.Type == "consumable" {
keptConsumable++
continue
}
if err := removeAdvInventoryItem(item.ID); err != nil {
continue
}
@@ -756,20 +796,34 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
}
if sold == 0 {
if keptSpecial > 0 {
switch {
case keptSpecial > 0 && keptConsumable > 0:
return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell <name>` for individual consumables."
case keptSpecial > 0:
return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead."
case keptConsumable > 0:
return "Your inventory only contains consumables. `sell all` won't touch them — use `!adventure sell <name>` to sell a specific consumable."
}
return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
}
p.euro.Credit(userID, total, "adventure_sell_all")
potCut := math.Round(total * 0.05)
payout := total - potCut
p.euro.Credit(userID, payout, "adventure_sell_all")
if int(potCut) > 0 {
communityPotAdd(int(potCut))
trackTaxPaid(userID, int(potCut))
}
msg := fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
msg := fmt.Sprintf("Sold %d items for **%s** (%s after 5%% broker fee → community pot).\n\nThe merchant took everything without comment. "+
"This is the most respect anyone has shown your loot collection. Take the money.",
sold, total)
sold, fmtEuro(total), fmtEuro(payout))
if keptSpecial > 0 {
msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial)
}
if keptConsumable > 0 {
msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell <name>`.)", keptConsumable)
}
return msg
}
@@ -788,8 +842,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 +888,116 @@ func advInventoryDisplay(userID id.UserID) string {
sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
return sb.String()
}
// ── Supplies (Consumables) ──────────────────────────────────────────────────
func luigiSuppliesView(_ id.UserID, balance float64) string {
var sb strings.Builder
sb.WriteString("🧪 **Supplies** — combat consumables (auto-used)\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
sb.WriteString("Items are used automatically during combat. The engine picks up to 2 per fight based on threat level — it won't waste them on easy fights.\n\n")
defs := BuyableConsumables()
if len(defs) == 0 {
sb.WriteString("_Nothing in stock right now._\n\n")
} else {
for _, def := range defs {
effectDesc := consumableEffectDescription(def.Effect, def.Value)
sb.WriteString(fmt.Sprintf("**%s** (T%d) — €%d\n %s\n\n", def.Name, def.Tier, def.Price, effectDesc))
}
}
sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
return sb.String()
}
func consumableEffectDescription(effect ConsumableEffect, value float64) string {
switch effect {
case EffectHeal:
return fmt.Sprintf("Restores %d HP mid-combat (triggers at <50%% HP)", int(value))
case EffectDefBoost:
return fmt.Sprintf("+%.0f%% Defense for the fight", value*100)
case EffectAtkBoost:
return fmt.Sprintf("+%.0f%% Attack for the fight", value*100)
case EffectWard:
return "Blocks one hit completely"
case EffectSpeedBoost:
return fmt.Sprintf("+%.0f%% Speed for the fight (evasion boost)", value*100)
case EffectCritBoost:
return fmt.Sprintf("+%.0f%% Crit Rate for the fight", value*100)
case EffectFlatDmg:
return fmt.Sprintf("Deals %d damage at fight start", int(value))
case EffectSpore:
return fmt.Sprintf("15%% enemy miss chance for %d rounds", int(value))
case EffectReflect:
return fmt.Sprintf("Reflects %.0f%% of next hit back at enemy", value*100)
case EffectAutoCrit:
return fmt.Sprintf("First hit is auto-crit + %.0f%% Attack", value*100)
}
return ""
}
func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interaction *advPendingInteraction) error {
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "back" {
data := interaction.Data.(*advPendingShopCategory)
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, text)
}
if reply == "exit" || reply == "cancel" {
p.shopSessionEnd(ctx.Sender)
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
}
// Find matching consumable
var match *ConsumableDef
for i := range consumableDefs {
if consumableDefs[i].Buyable && containsFold(consumableDefs[i].Name, reply) {
match = &consumableDefs[i]
break
}
}
if match == nil {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "I don't have that. Reply with an item name from the list, or `back` to return.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(match.Price) {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d for %s but only have €%.0f.", match.Price, match.Name, balance))
}
// Purchase the consumable
p.euro.Debit(ctx.Sender, float64(match.Price), "shop_consumable")
if potCut := int(math.Round(float64(match.Price) * 0.05)); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
item := AdvItem{
Name: match.Name,
Type: "consumable",
Tier: match.Tier,
Value: match.Price / 2,
}
_ = addAdvInventoryItem(ctx.Sender, item)
// Stay in supplies view for more purchases
p.pending.Store(string(ctx.Sender), interaction)
newBalance := p.euro.GetBalance(ctx.Sender)
return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%d. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
match.Name, match.Price, newBalance))
}

View File

@@ -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") {

View File

@@ -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{

View File

@@ -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

View File

@@ -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++

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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"

View File

@@ -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())
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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.",
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,535 @@
package plugin
import (
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Balance Constants ───────────────────────────────────────────────────────
//
// Phase 1 values. Adjust during balance pass. Co-op should feel meaningfully
// harder than solo and reward groups for showing up.
type CoopFundingTier string
const (
CoopFundNone CoopFundingTier = "none"
CoopFundMinimal CoopFundingTier = "minimal"
CoopFundStandard CoopFundingTier = "standard"
CoopFundAggressive CoopFundingTier = "aggressive"
CoopFundAllIn CoopFundingTier = "all_in"
)
type coopFundingDef struct {
cost int
modifier int // % added to per-floor success roll
label string
}
var coopFundingTable = map[CoopFundingTier]coopFundingDef{
CoopFundNone: {0, -10, "None"},
CoopFundMinimal: {500, 0, "Minimal"},
CoopFundStandard: {1500, 8, "Standard"},
CoopFundAggressive: {4000, 18, "Aggressive"},
CoopFundAllIn: {10000, 30, "All In"},
}
// fundingTierOrder for menu display.
var coopFundingOrder = []CoopFundingTier{
CoopFundMinimal, CoopFundStandard, CoopFundAggressive, CoopFundAllIn,
}
type coopTierDef struct {
totalDays int
baseFailurePct int // base % failure per floor at zero modifier
rewardBase int // base reward (gold) before split
difficulty string // label
minLevel int // newbie liability threshold
lootMult float64 // for display only
}
// Reward bases tuned via Monte Carlo (coop_dungeon_balance_test.go) so the
// optimal funding strategy walks up tiers: T1-T2 Minimal/Standard, T3 Standard,
// T4 Standard or Aggressive, T5 Aggressive only. All-In stays -EV at every
// tier — it's the boost lever for liability parties, not the optimal play.
var coopTierTable = map[int]coopTierDef{
1: {2, 25, 22500, "Moderate", 5, 2.0},
2: {3, 32, 40000, "Hard", 12, 3.5},
3: {4, 40, 72500, "Very Hard", 20, 5.5},
4: {5, 48, 120000, "Brutal", 30, 8.0},
5: {7, 58, 600000, "Murderous", 40, 12.0},
}
const (
coopMinPartySize = 2
coopMaxPartySize = 4
coopInviteWindow = 24 * time.Hour
coopLiabilityCap = 8 // liability players' funding modifier capped at +8% regardless of tier
coopAdventureRake = 0.05
)
// ── Command Dispatch ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopCmd(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "coop"))
lower := strings.ToLower(args)
switch {
case args == "" || lower == "help":
return p.SendDM(ctx.Sender, coopHelpText)
case lower == "list":
return p.handleCoopList(ctx)
case lower == "status":
return p.handleCoopStatus(ctx)
case lower == "stats":
return p.SendDM(ctx.Sender, renderCoopStats())
case strings.HasPrefix(lower, "start "):
return p.handleCoopStart(ctx, strings.TrimSpace(args[6:]))
case strings.HasPrefix(lower, "join"):
return p.handleCoopJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join")))
case strings.HasPrefix(lower, "fund "):
return p.handleCoopFund(ctx, strings.TrimSpace(args[5:]))
case lower == "cancel":
return p.handleCoopCancel(ctx)
case strings.HasPrefix(lower, "vote "):
return p.handleCoopVote(ctx, strings.TrimSpace(args[5:]))
case strings.HasPrefix(lower, "bet "):
return p.handleCoopBet(ctx, strings.TrimSpace(args[4:]))
case strings.HasPrefix(lower, "gift "):
return p.handleCoopGift(ctx, strings.TrimSpace(args[5:]))
case strings.HasPrefix(lower, "giftvote "):
return p.handleCoopGiftVote(ctx, strings.TrimSpace(args[9:]))
case strings.HasPrefix(lower, "admgift "):
return p.handleCoopAdmGift(ctx, strings.TrimSpace(args[8:]))
}
return p.SendDM(ctx.Sender, "Unknown coop command. Try `!coop help`.")
}
const coopHelpText = `**Co-op Dungeon Commands**
` + "`!coop list`" + ` — Show open invites
` + "`!coop start <tier>`" + ` — Open an invite for a co-op dungeon (tier 1-5)
` + "`!coop join [<run_id>]`" + ` — Join an open invite (defaults to most recent)
` + "`!coop fund <tier>`" + ` — Set today's funding (none/minimal/standard/aggressive/all_in)
` + "`!coop vote <A|B|C>`" + ` — Vote on the day's floor event
` + "`!coop bet <amount> <success|failure>`" + ` — Spectator bet (parimutuel, 10% rake)
` + "`!coop gift <run_id> <basket|mimic>`" + ` — Send a gift (1 harvest action)
` + "`!coop giftvote <id> <open|leave>`" + ` — Party votes whether to open a gift
` + "`!coop status`" + ` — Show your current run
` + "`!coop stats`" + ` — Aggregate stats: outcomes, gold flow, betting, gifts, TwinBee helpfulness
` + "`!coop cancel`" + ` — Cancel an open invite (leader only, before lock)
**How it runs:**
- 24h invite window. Run locks automatically when window closes.
- 2-4 players. 2 minimum or invite cancels.
- Locking the party consumes the day's combat action for every member.
- Each subsequent day, every player picks a funding tier. Funding modifies
the party's per-floor success chance.
- Inactive players (no funding decision) auto-play at None (-10%).
- Wipes refund the day's combat action only. Funding is gone.
- On success, the reward is split evenly. Tax applies.`
// ── Handlers ────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopStart(ctx MessageContext, tierArg string) error {
tier, err := strconv.Atoi(tierArg)
if err != nil || tier < 1 || tier > 5 {
return p.SendDM(ctx.Sender, "Tier must be 1-5. Example: `!coop start 3`.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. Co-op dungeons require a living adventurer.")
}
if !char.CanDoCombat(false) {
return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.")
}
// One active run per player.
if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in a co-op run (#%d, %s). Use `!coop status`.", existing.ID, existing.Status))
}
def := coopTierTable[tier]
run, err := createCoopRun(tier, ctx.Sender, def.totalDays, def.baseFailurePct)
if err != nil {
slog.Error("coop: create run", "err", err)
return p.SendDM(ctx.Sender, "Couldn't open an invite. Try again.")
}
if err := addCoopMember(run.ID, ctx.Sender, 0, char.CombatLevel < def.minLevel); err != nil {
slog.Error("coop: add leader", "err", err)
return p.SendDM(ctx.Sender, "Couldn't add you to the run.")
}
gr := gamesRoom()
if gr != "" {
postID, err := p.SendMessageID(gr, renderCoopInvite(run, []CoopMember{{UserID: ctx.Sender, TurnOrder: 0}}, char.DisplayName))
if err == nil {
_ = setCoopRunInvitePostID(run.ID, postID)
_ = p.PinEvent(gr, postID)
}
}
return p.SendDM(ctx.Sender, fmt.Sprintf("⚔️ Tier %d co-op invite opened (#%d). Locks in 24h. Recruit your party.", tier, run.ID))
}
func (p *AdventurePlugin) handleCoopJoin(ctx MessageContext, runIDArg string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. The dungeon does not accept corpses.")
}
if !char.CanDoCombat(false) {
return p.SendDM(ctx.Sender, "You've already used your combat action today. Try tomorrow.")
}
if existing, _ := loadCoopRunForUser(ctx.Sender); existing != nil {
return p.SendDM(ctx.Sender, fmt.Sprintf("You're already in run #%d.", existing.ID))
}
var run *CoopRun
if runIDArg == "" {
run, err = loadMostRecentOpenCoopRun()
} else {
id, perr := strconv.Atoi(runIDArg)
if perr != nil {
return p.SendDM(ctx.Sender, "Run ID must be a number. Try `!coop join 7` or `!coop join`.")
}
run, err = loadCoopRun(id)
}
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "No open invite found. Use `!coop list` to see open runs.")
}
if run.Status != "open" {
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s. Can't join.", run.ID, run.Status))
}
members, err := loadCoopMembers(run.ID)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load the party.")
}
if len(members) >= coopMaxPartySize {
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is full (%d/%d).", run.ID, len(members), coopMaxPartySize))
}
def := coopTierTable[run.Tier]
liability := char.CombatLevel < def.minLevel
if err := addCoopMember(run.ID, ctx.Sender, len(members), liability); err != nil {
slog.Error("coop: join", "err", err)
return p.SendDM(ctx.Sender, "Couldn't join.")
}
gr := gamesRoom()
if gr != "" {
warn := ""
if liability {
warn = " ⚠️ liability"
}
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ %s joined Tier %d Co-op #%d (%d/%d)%s.",
char.DisplayName, run.Tier, run.ID, len(members)+1, coopMaxPartySize, warn))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Joined run #%d. Wait for the lock; the run starts then.", run.ID))
}
func (p *AdventurePlugin) handleCoopList(ctx MessageContext) error {
runs, err := loadOpenCoopRuns()
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load the list.")
}
if len(runs) == 0 {
return p.SendDM(ctx.Sender, "No open invites right now. Start one with `!coop start <tier>`.")
}
var sb strings.Builder
sb.WriteString("**Open Co-op Invites**\n\n")
for _, r := range runs {
members, _ := loadCoopMembers(r.ID)
closesIn := time.Until(r.CreatedAt.Add(coopInviteWindow)).Truncate(time.Minute)
sb.WriteString(fmt.Sprintf(" #%d T%d (%s) %d/%d closes in %s\n",
r.ID, r.Tier, coopTierTable[r.Tier].difficulty, len(members), coopMaxPartySize, closesIn))
}
sb.WriteString("\nJoin with `!coop join <id>` or just `!coop join` for the most recent.")
return p.SendDM(ctx.Sender, sb.String())
}
func (p *AdventurePlugin) handleCoopStatus(ctx MessageContext) error {
run, err := loadCoopRunForUser(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "You're not in a co-op run. Use `!coop list` or `!coop start <tier>`.")
}
members, _ := loadCoopMembers(run.ID)
return p.SendDM(ctx.Sender, renderCoopStatus(p, run, members))
}
func (p *AdventurePlugin) handleCoopFund(ctx MessageContext, tierArg string) error {
tier := parseCoopFundingTier(tierArg)
if tier == "" {
return p.SendDM(ctx.Sender, "Funding must be one of: none, minimal, standard, aggressive, all_in.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadCoopRunForUser(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
}
if run.Status != "active" {
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — funding decisions don't apply.", run.ID, run.Status))
}
day := run.CurrentDay
if day < 1 {
return p.SendDM(ctx.Sender, "The run hasn't started yet. Wait for the lock tick.")
}
member, err := loadCoopMember(run.ID, ctx.Sender)
if err != nil || member == nil {
return p.SendDM(ctx.Sender, "You're not on the party roster for this run.")
}
if _, alreadySet := member.DailyFunding[day]; alreadySet {
return p.SendDM(ctx.Sender, fmt.Sprintf("You've already locked in funding for day %d. Decisions are final until the daily tick.", day))
}
cost := coopFundingTable[tier].cost
if cost > 0 {
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(cost) {
return p.SendDM(ctx.Sender, fmt.Sprintf("That tier costs €%d. You have €%.0f.", cost, balance))
}
if !p.euro.Debit(ctx.Sender, float64(cost), "coop_funding") {
return p.SendDM(ctx.Sender, "Payment failed.")
}
}
member.DailyFunding[day] = tier
member.TotalContributed += cost
if err := saveCoopMemberFunding(member); err != nil {
// Refund and surface
if cost > 0 {
p.euro.Credit(ctx.Sender, float64(cost), "coop_funding_refund")
}
return p.SendDM(ctx.Sender, "Couldn't save your decision. Gold refunded if any was charged.")
}
if cost > 0 {
if err := addCoopRunPool(run.ID, cost); err != nil {
slog.Error("coop: pool update", "err", err)
}
}
mod := coopFundingTable[tier].modifier
return p.SendDM(ctx.Sender, fmt.Sprintf("Funding for day %d locked: %s (€%d, %+d%%). Tomorrow's tick resolves the floor.",
day, coopFundingTable[tier].label, cost, mod))
}
func (p *AdventurePlugin) handleCoopCancel(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadCoopRunForUser(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "No co-op run to cancel.")
}
if run.LeaderID != ctx.Sender {
return p.SendDM(ctx.Sender, "Only the party leader can cancel.")
}
if run.Status != "open" {
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d already %s — too late.", run.ID, run.Status))
}
if err := setCoopRunStatus(run.ID, "cancelled"); err != nil {
return p.SendDM(ctx.Sender, "Couldn't cancel.")
}
gr := gamesRoom()
if gr != "" {
if run.InvitePostID != "" {
_ = p.UnpinEvent(gr, run.InvitePostID)
}
_ = p.SendMessage(gr, fmt.Sprintf("⚔️ Tier %d Co-op #%d cancelled by the leader.", run.Tier, run.ID))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d cancelled.", run.ID))
}
// handleCoopAdmGift is an admin-only debug path that creates a gift on
// behalf of the caller (or a specified sender) without the harvest-action
// or party-membership checks. Useful for testing the gift mechanic without
// recruiting a non-party spectator.
//
// Usage: !coop admgift <run_id> <basket|mimic> [sender_user_id]
func (p *AdventurePlugin) handleCoopAdmGift(ctx MessageContext, args string) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendDM(ctx.Sender, "Usage: `!coop admgift <run_id> <basket|mimic> [sender_user_id]`. Admin-only — bypasses party-member and harvest checks.")
}
runID, err := strconv.Atoi(parts[0])
if err != nil {
return p.SendDM(ctx.Sender, "Run ID must be a number.")
}
giftType := strings.ToLower(parts[1])
if giftType != coopGiftBasket && giftType != coopGiftMimic {
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
}
sender := ctx.Sender
if len(parts) >= 3 {
sender = id.UserID(parts[2])
}
run, err := loadCoopRun(runID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Run not found.")
}
if run.Status != "active" {
return p.SendDM(ctx.Sender, fmt.Sprintf("Run #%d is %s — gifts only land during active runs.", runID, run.Status))
}
giftID, leadID, isNewLead, err := createCoopGift(runID, sender, run.CurrentDay, giftType)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't create gift.")
}
gr := gamesRoom()
if gr != "" {
members, _ := loadCoopMembers(runID)
if isNewLead {
gift, _ := loadCoopGift(giftID)
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
if perr == nil {
_ = saveCoopGiftPostID(giftID, postID)
}
} else {
lead, _ := loadCoopGift(leadID)
if lead != nil && lead.PostEventID != "" {
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
}
}
}
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Admin gift dispatched: %s from %s to run #%d (day %d). Gift id #%d (stack lead %d). No harvest action consumed.",
giftType, sender, runID, run.CurrentDay, giftID, leadID))
}
func (p *AdventurePlugin) handleCoopVote(ctx MessageContext, voteArg string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadCoopRunForUser(ctx.Sender)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
}
if run.Status != "active" {
return p.SendDM(ctx.Sender, "No active floor event to vote on.")
}
event, err := loadCoopEvent(run.ID, run.CurrentDay)
if err != nil || event == nil {
return p.SendDM(ctx.Sender, "No floor event found for the current day.")
}
if event.Outcome != "" {
return p.SendDM(ctx.Sender, "Voting closed for this floor.")
}
label, ok := validateCoopVote(event.Category, event.EventIndex, voteArg)
if !ok {
return p.SendDM(ctx.Sender, fmt.Sprintf("Vote one of: %s.",
strings.Join(coopEventOptionLabels(event.Category, event.EventIndex), ", ")))
}
if event.Votes == nil {
event.Votes = map[id.UserID]string{}
}
event.Votes[ctx.Sender] = label
if err := saveCoopEventVotes(event); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
}
// Update the game-room post in place to show the running tally.
gr := gamesRoom()
if gr != "" && event.PostEventID != "" {
members, _ := loadCoopMembers(run.ID)
_ = p.EditMessage(gr, event.PostEventID, renderCoopEventPost(run, members, event))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Vote recorded: %s.", label))
}
// ── Helpers ─────────────────────────────────────────────────────────────────
func parseCoopFundingTier(s string) CoopFundingTier {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "_")
s = strings.ReplaceAll(s, " ", "_")
switch s {
case "none", "skip", "pass":
return CoopFundNone
case "minimal", "min":
return CoopFundMinimal
case "standard", "std":
return CoopFundStandard
case "aggressive", "agg":
return CoopFundAggressive
case "all_in", "allin", "all":
return CoopFundAllIn
}
return ""
}
// effectiveModifier returns a member's per-day funding modifier, respecting the
// liability cap.
func coopMemberModifier(m *CoopMember, day int) (CoopFundingTier, int) {
tier, ok := m.DailyFunding[day]
if !ok {
tier = CoopFundNone
}
mod := coopFundingTable[tier].modifier
if m.IsLiability && mod > coopLiabilityCap {
mod = coopLiabilityCap
}
return tier, mod
}
// coopLevelBonus returns the per-floor success bonus from a player's combat
// level relative to the tier's minimum. Levels above the floor count up
// (capped); levels below count down. Encourages tier-appropriate parties.
func coopLevelBonus(combatLevel, tierMinLevel int) int {
delta := combatLevel - tierMinLevel
bonus := delta * 4 / 10 // 0.4× scale, integer math
if bonus > 8 {
bonus = 8
}
if bonus < -8 {
bonus = -8
}
return bonus
}
// coopPetBonus returns the per-floor success bonus from a player's pet. Worth
// roughly a quarter of an additional baseline party member at pet level 10.
// Returns 0 if the player has no pet.
func coopPetBonus(char *AdventureCharacter) int {
if !char.HasPet() || char.PetLevel <= 0 {
return 0
}
bonus := char.PetLevel * 25 / 100 // 0.25× scale
if bonus > 2 {
bonus = 2
}
return bonus
}

View File

@@ -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()
}

View File

@@ -0,0 +1,345 @@
package plugin
import (
"database/sql"
"fmt"
"math"
"strconv"
"strings"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Betting Constants ───────────────────────────────────────────────────────
const (
coopBetMin = 100
coopBetMax = 1_000_000
coopBetRake = 0.10 // 10% house cut, per spec
coopOddsWindow = 30 // helpfulness rolling-window size
coopOddsHelpInf = 0.20 // helpfulness contribution to estimated success
)
// ── Bet Types ───────────────────────────────────────────────────────────────
type CoopBet struct {
RunID int
PlayerID id.UserID
Position string // "success" | "failure"
Amount int
Payout sql.NullInt64
}
// ── Command Handler ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopBet(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendDM(ctx.Sender, "Usage: `!coop bet <run_id> <amount> <success|failure>` or `!coop bet <amount> <success|failure>` for the most recent open/active run.")
}
// Two forms: with or without explicit run id.
var runID, amount int
var position string
var err error
if len(parts) >= 3 {
runID, err = strconv.Atoi(parts[0])
if err != nil {
return p.SendDM(ctx.Sender, "First arg must be the run id.")
}
amount, err = strconv.Atoi(parts[1])
if err != nil {
return p.SendDM(ctx.Sender, "Amount must be a number.")
}
position = strings.ToLower(parts[2])
} else {
amount, err = strconv.Atoi(parts[0])
if err != nil {
return p.SendDM(ctx.Sender, "Amount must be a number.")
}
position = strings.ToLower(parts[1])
run, _ := loadMostRecentBettableCoopRun()
if run == nil {
return p.SendDM(ctx.Sender, "No co-op runs accept bets right now.")
}
runID = run.ID
}
if position != "success" && position != "failure" {
return p.SendDM(ctx.Sender, "Position must be `success` or `failure`.")
}
if amount < coopBetMin || amount > coopBetMax {
return p.SendDM(ctx.Sender, fmt.Sprintf("Bet must be between €%d and €%d.", coopBetMin, coopBetMax))
}
run, err := loadCoopRun(runID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Run not found.")
}
if run.Status != "open" && run.Status != "active" {
return p.SendDM(ctx.Sender, "Bets are closed on this run.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(amount) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You have €%.0f. Bet was €%d.", balance, amount))
}
existing, _ := loadCoopBet(runID, ctx.Sender)
if existing != nil && existing.Position != position {
return p.SendDM(ctx.Sender, fmt.Sprintf("You already bet %s on this run. Positions can't be switched.", existing.Position))
}
if !p.euro.Debit(ctx.Sender, float64(amount), "coop_bet") {
return p.SendDM(ctx.Sender, "Payment failed.")
}
if existing == nil {
err = createCoopBet(runID, ctx.Sender, position, amount)
} else {
err = increaseCoopBet(runID, ctx.Sender, amount)
}
if err != nil {
p.euro.Credit(ctx.Sender, float64(amount), "coop_bet_refund")
return p.SendDM(ctx.Sender, "Couldn't save bet. Refunded.")
}
total := amount
if existing != nil {
total += existing.Amount
}
return p.SendDM(ctx.Sender, fmt.Sprintf("📊 Bet placed: €%d on %s (run #%d). Total stake: €%d.",
amount, position, runID, total))
}
// ── Odds Calculation ────────────────────────────────────────────────────────
// coopEstimatedRunSuccess returns the estimated P(success) for the rest of the
// run given current party state. Used for spectator odds display.
func coopEstimatedRunSuccess(run *CoopRun, members []CoopMember) float64 {
if run.Status != "open" && run.Status != "active" {
return 0
}
if len(members) == 0 {
return 0
}
tierDef := coopTierTable[run.Tier]
// Per-floor success at "Standard" funding by all members (a neutral
// baseline — actual funding varies day to day).
mod := len(members) * coopFundingTable[CoopFundStandard].modifier
for _, m := range members {
char, err := loadAdvCharacter(m.UserID)
if err != nil {
continue
}
mod += coopLevelBonus(char.CombatLevel, tierDef.minLevel)
mod += coopPetBonus(char)
}
successPct := 100 - run.BaseDifficulty + mod
// TwinBee helpfulness shifts the line: a "helpful" history (close to 1)
// bumps odds; an "unhelpful" history pulls them down.
help := coopTwinBeeHelpfulness(coopOddsWindow)
successPct += int(math.Round((help - 0.5) * 2 * coopOddsHelpInf * 100))
if successPct < 5 {
successPct = 5
}
if successPct > 95 {
successPct = 95
}
floorsRemaining := run.TotalDays - run.CurrentDay
if floorsRemaining < 0 {
floorsRemaining = 0
}
if run.Status == "open" {
floorsRemaining = run.TotalDays
}
return math.Pow(float64(successPct)/100.0, float64(floorsRemaining))
}
// coopParimutuelPayouts returns winning side, losers' side, rake, and
// per-player payout map (winning bettors only).
func coopParimutuelPayouts(bets []CoopBet, winningPosition string) (winners []CoopBet, totalPool, rake int, payouts map[id.UserID]int) {
for _, b := range bets {
totalPool += b.Amount
}
rake = int(math.Floor(float64(totalPool) * coopBetRake))
netPool := totalPool - rake
winningPool := 0
for _, b := range bets {
if b.Position == winningPosition {
winners = append(winners, b)
winningPool += b.Amount
}
}
payouts = map[id.UserID]int{}
if winningPool == 0 {
// No winners — house keeps everything (this happens if e.g. all bets
// were on the wrong side).
return
}
for _, b := range winners {
share := float64(b.Amount) / float64(winningPool)
payouts[b.PlayerID] = int(math.Floor(share * float64(netPool)))
}
return
}
// ── DB ──────────────────────────────────────────────────────────────────────
func createCoopBet(runID int, userID id.UserID, position string, amount int) error {
d := db.Get()
_, err := d.Exec(`INSERT INTO coop_dungeon_bets (run_id, player_id, position, amount)
VALUES (?, ?, ?, ?)`, runID, string(userID), position, amount)
return err
}
func increaseCoopBet(runID int, userID id.UserID, delta int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_bets SET amount = amount + ?
WHERE run_id = ? AND player_id = ?`, delta, runID, string(userID))
return err
}
func loadCoopBet(runID int, userID id.UserID) (*CoopBet, error) {
d := db.Get()
b := &CoopBet{}
var uid string
err := d.QueryRow(`SELECT run_id, player_id, position, amount, payout
FROM coop_dungeon_bets WHERE run_id = ? AND player_id = ?`, runID, string(userID)).Scan(
&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
b.PlayerID = id.UserID(uid)
return b, nil
}
func loadCoopBets(runID int) ([]CoopBet, error) {
d := db.Get()
rows, err := d.Query(`SELECT run_id, player_id, position, amount, payout
FROM coop_dungeon_bets WHERE run_id = ?`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopBet
for rows.Next() {
b := CoopBet{}
var uid string
if err := rows.Scan(&b.RunID, &uid, &b.Position, &b.Amount, &b.Payout); err != nil {
return nil, err
}
b.PlayerID = id.UserID(uid)
out = append(out, b)
}
return out, rows.Err()
}
func setCoopBetPayout(runID int, userID id.UserID, payout int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
WHERE run_id = ? AND player_id = ?`, payout, runID, string(userID))
return err
}
// claimCoopBetPayout atomically claims a bet for payout: returns true only if
// this call wins the race (the row had payout IS NULL and we set it). Used to
// prevent double-credit on retry after a mid-resolution crash.
func claimCoopBetPayout(runID int, userID id.UserID, payout int) (bool, error) {
d := db.Get()
res, err := d.Exec(`UPDATE coop_dungeon_bets SET payout = ?
WHERE run_id = ? AND player_id = ? AND payout IS NULL`,
payout, runID, string(userID))
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
func loadMostRecentBettableCoopRun() (*CoopRun, error) {
runs, err := queryCoopRuns(`status IN ('open','active') ORDER BY id DESC LIMIT 1`)
if err != nil || len(runs) == 0 {
return nil, err
}
return runs[0], nil
}
// ── Resolution ──────────────────────────────────────────────────────────────
// coopResolveBets is called when a run completes. It computes payouts,
// credits winners, persists payout values, and posts a summary line.
func (p *AdventurePlugin) coopResolveBets(run *CoopRun, won bool) string {
bets, err := loadCoopBets(run.ID)
if err != nil || len(bets) == 0 {
return ""
}
winningPosition := "failure"
if won {
winningPosition = "success"
}
winners, total, rake, payouts := coopParimutuelPayouts(bets, winningPosition)
// Idempotent: claim each bet's payout slot (UPDATE...WHERE payout IS NULL)
// before crediting. If the claim fails, this bet was already paid by a
// prior resolution attempt — skip the credit. Prevents double-pay on
// crash-restart.
for _, b := range winners {
payout := payouts[b.PlayerID]
claimed, err := claimCoopBetPayout(run.ID, b.PlayerID, payout)
if err != nil || !claimed {
continue
}
if payout > 0 {
p.euro.Credit(b.PlayerID, float64(payout), "coop_bet_payout")
}
}
for _, b := range bets {
if b.Position != winningPosition && !b.Payout.Valid {
_ = setCoopBetPayout(run.ID, b.PlayerID, 0)
}
}
if len(winners) == 0 {
return fmt.Sprintf("📊 Parimutuel pool: €%d. No winners — house (TwinBee) keeps the pool.", total)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📊 Parimutuel pool: €%d. Rake: €%d. Net: €%d.\n", total, rake, total-rake))
sb.WriteString(fmt.Sprintf("Winners (bet on %s):\n", winningPosition))
for _, b := range winners {
sb.WriteString(fmt.Sprintf(" %s: €%d → €%d\n", coopDisplayName(p, b.PlayerID), b.Amount, payouts[b.PlayerID]))
}
return sb.String()
}
// ── Render ──────────────────────────────────────────────────────────────────
func renderCoopOddsLine(run *CoopRun, members []CoopMember, bets []CoopBet) string {
pSuccess := coopEstimatedRunSuccess(run, members)
successPool, failurePool := 0, 0
for _, b := range bets {
if b.Position == "success" {
successPool += b.Amount
} else {
failurePool += b.Amount
}
}
total := successPool + failurePool
return fmt.Sprintf("📊 Estimated success: %.0f%%. Pool €%d (success €%d / failure €%d). Rake %.0f%%. `!coop bet <amount> success|failure`.",
pSuccess*100, total, successPool, failurePool, coopBetRake*100)
}

View File

@@ -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
}

View File

@@ -0,0 +1,913 @@
package plugin
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Gift Constants ──────────────────────────────────────────────────────────
//
// Magnitudes equalized across all four outcomes so neither "always open" nor
// "always leave" is variance-dominant at a 50/50 sender mix. Asymmetric
// magnitudes (e.g., open=±7, leave=±4) gave leave the same EV with lower
// variance, making it a dominant strategy for risk-averse parties — which
// collapses the dilemma into "always leave."
//
// basket opened: +6 basket unopened: -6
// mimic opened: -6 mimic unopened: +6
//
// Both strategies: EV=0, σ=6. The choice becomes "read sender intent" only.
const (
coopGiftBasketOpened = 6
coopGiftBasketUnopened = -6
coopGiftMimicOpened = -6
coopGiftMimicUnopened = 6
)
const (
coopGiftBasket = "basket"
coopGiftMimic = "mimic"
// Per-gift voting window. After this elapses, the gift's votes are
// tallied independently of the daily floor tick. Senders get DM feedback
// at expiry; the modifier sits on the run until the next floor resolves.
coopGiftWindow = 6 * time.Hour
)
type CoopGift struct {
ID int
RunID int
SenderID id.UserID
Day int
GiftType string
Votes map[id.UserID]string // "open" or "leave" — only set on lead
VoteResult string // "opened" / "left" / ""
Outcome string // "boost" / "reduction" / ""
Modifier int
PostEventID id.EventID
ExpiresAt *time.Time // voting closes — only set on lead
AppliedAt *time.Time // modifier merged into a floor roll
StackLeadID *int // NULL = lead/standalone; otherwise points at lead's id
}
// ── Command: send a gift ────────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopGift(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendDM(ctx.Sender, "Usage: `!coop gift <run_id> <basket|mimic>`. Costs one harvest action; party never knows what you sent.")
}
runID, err := strconv.Atoi(parts[0])
if err != nil {
return p.SendDM(ctx.Sender, "Run ID must be a number.")
}
giftType := strings.ToLower(parts[1])
if giftType != coopGiftBasket && giftType != coopGiftMimic {
return p.SendDM(ctx.Sender, "Gift type must be `basket` or `mimic`.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. The post office does not deliver from the ditch.")
}
if !char.CanDoHarvest(false) {
return p.SendDM(ctx.Sender, "You're out of harvest actions today.")
}
run, err := loadCoopRun(runID)
if err != nil || run == nil {
return p.SendDM(ctx.Sender, "Run not found.")
}
if run.Status != "active" {
return p.SendDM(ctx.Sender, "Run is not active. Gifts can only be sent during a running co-op.")
}
// Senders cannot be party members.
if existing, _ := loadCoopMember(runID, ctx.Sender); existing != nil {
return p.SendDM(ctx.Sender, "You're in the party. You can't send gifts to yourself.")
}
giftID, leadID, isNewLead, err := createCoopGift(runID, ctx.Sender, run.CurrentDay, giftType)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't send the gift.")
}
// Charge the harvest action and persist.
char.HarvestActionsUsed++
if err := saveAdvCharacter(char); err != nil {
slog.Error("coop: save sender after gift", "err", err)
}
// Post (new lead) or edit (added to existing stack).
gr := gamesRoom()
if gr != "" {
members, _ := loadCoopMembers(runID)
if isNewLead {
gift, _ := loadCoopGift(giftID)
postID, perr := p.SendMessageID(gr, renderCoopGiftPost(p, run, gift, members))
if perr == nil {
_ = saveCoopGiftPostID(giftID, postID)
}
} else {
lead, _ := loadCoopGift(leadID)
if lead != nil && lead.PostEventID != "" {
_ = p.EditMessage(gr, lead.PostEventID, renderCoopGiftPost(p, run, lead, members))
}
}
}
return p.SendDM(ctx.Sender, fmt.Sprintf("📦 Gift dispatched (run #%d, day %d). One harvest action consumed. The party doesn't know what you sent.", runID, run.CurrentDay))
}
// ── Command: vote on a gift ─────────────────────────────────────────────────
func (p *AdventurePlugin) handleCoopGiftVote(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 1 {
return p.SendDM(ctx.Sender, "Usage: `!coop giftvote <gift_id> <open|leave>` or `!coop giftvote <open|leave>` for the most recent unresolved gift.")
}
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
run, err := loadCoopRunForUser(ctx.Sender)
if err != nil || run == nil || run.Status != "active" {
return p.SendDM(ctx.Sender, "You're not in an active co-op run.")
}
var gift *CoopGift
var choice string
if len(parts) >= 2 {
giftID, perr := strconv.Atoi(parts[0])
if perr != nil {
return p.SendDM(ctx.Sender, "First arg must be the gift id, or just `open`/`leave` for the most recent.")
}
gift, _ = loadCoopGift(giftID)
choice = strings.ToLower(parts[1])
} else {
gift, _ = loadMostRecentUnresolvedGift(run.ID, run.CurrentDay)
choice = strings.ToLower(parts[0])
}
if gift == nil {
return p.SendDM(ctx.Sender, "No unresolved gift found.")
}
// If the user voted on a follower, redirect to its lead — votes always
// live on the lead.
if gift.StackLeadID != nil {
lead, _ := loadCoopGift(*gift.StackLeadID)
if lead == nil {
return p.SendDM(ctx.Sender, "Couldn't find the gift's stack lead.")
}
gift = lead
}
if gift.RunID != run.ID || gift.Day != run.CurrentDay {
return p.SendDM(ctx.Sender, "That gift isn't for your current run/day.")
}
if gift.VoteResult != "" {
return p.SendDM(ctx.Sender, "That gift has already resolved.")
}
if choice != "open" && choice != "leave" {
return p.SendDM(ctx.Sender, "Vote `open` or `leave`.")
}
if gift.Votes == nil {
gift.Votes = map[id.UserID]string{}
}
gift.Votes[ctx.Sender] = choice
if err := saveCoopGiftVotes(gift); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save your vote.")
}
gr := gamesRoom()
if gr != "" && gift.PostEventID != "" {
members, _ := loadCoopMembers(run.ID)
_ = p.EditMessage(gr, gift.PostEventID, renderCoopGiftPost(p, run, gift, members))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Gift #%d vote recorded: %s.", gift.ID, choice))
}
// ── Resolution ──────────────────────────────────────────────────────────────
// resolveSingleGift tallies a stack lead's votes, persists the outcome, DMs
// every sender in the stack, and edits the game-room post to show the
// resolved state. Used by both the per-minute expiry ticker and the
// floor-resolution catchall.
//
// Followers passed in here will be redirected to their lead. Idempotent:
// always re-loads the lead from DB before checking vote_result, so an
// in-memory snapshot from earlier in the same loop can't trigger a double
// resolve.
func (p *AdventurePlugin) resolveSingleGift(g *CoopGift, leader id.UserID) {
// Redirect follower → lead, then re-fetch fresh state.
leadID := g.ID
if g.StackLeadID != nil {
leadID = *g.StackLeadID
}
fresh, _ := loadCoopGift(leadID)
if fresh == nil {
return
}
g = fresh
if g.VoteResult != "" {
return
}
opens, leaves := 0, 0
var leaderVote string
for u, v := range g.Votes {
if v == "open" {
opens++
} else if v == "leave" {
leaves++
}
if u == leader {
leaderVote = v
}
}
var voteResult string
switch {
case opens > leaves:
voteResult = "opened"
case leaves > opens:
voteResult = "left"
case leaderVote == "open":
voteResult = "opened"
case leaderVote == "leave":
voteResult = "left"
default:
voteResult = "left"
}
// Per-gift modifier (single ±6 unit). Stack-wide modifier is N × this,
// applied at floor resolution by summing the rows individually.
var perMod int
var outcome string
switch g.GiftType {
case coopGiftBasket:
if voteResult == "opened" {
perMod = coopGiftBasketOpened
outcome = "boost"
} else {
perMod = coopGiftBasketUnopened
outcome = "reduction"
}
case coopGiftMimic:
if voteResult == "opened" {
perMod = coopGiftMimicOpened
outcome = "reduction"
} else {
perMod = coopGiftMimicUnopened
outcome = "boost"
}
}
stack, _ := loadCoopGiftStack(g.ID)
totalMod := 0
for _, m := range stack {
_ = resolveCoopGift(m.ID, voteResult, outcome, perMod)
_ = p.SendDM(m.SenderID, renderCoopGiftSenderDM(&m, voteResult, perMod))
totalMod += perMod
}
g.VoteResult = voteResult
g.Outcome = outcome
g.Modifier = perMod
// Edit the live game-room post to show the resolved state — total swing
// reflects the full stack.
gr := gamesRoom()
if gr != "" && g.PostEventID != "" {
_ = p.EditMessage(gr, g.PostEventID, renderCoopGiftResolvedPost(g, len(stack), totalMod))
}
}
// coopProcessGiftExpiries runs each ticker minute, resolving any gifts whose
// voting window has elapsed. Modifiers are recorded but not yet applied to a
// floor — they wait for the next floor resolution.
func (p *AdventurePlugin) coopProcessGiftExpiries() {
gifts, err := loadExpiredUnresolvedGifts()
if err != nil {
slog.Error("coop: load expired gifts", "err", err)
return
}
for i := range gifts {
g := &gifts[i]
run, _ := loadCoopRun(g.RunID)
if run == nil || run.Status != "active" {
// Run gone or already terminal — still mark the gift resolved so
// it stops cluttering the expiry query.
_ = resolveCoopGift(g.ID, "left", "reduction", 0)
continue
}
p.resolveSingleGift(g, run.LeaderID)
}
}
// coopResolveGifts is called inside coopResolveFloor. Two responsibilities:
//
// 1. Catchall: force-resolve any gifts on this day that still have no
// vote_result (the timer ticker should normally have caught them, but
// this is the defensive backstop, e.g., bot was down).
// 2. Apply: sum modifiers from any resolved-but-not-yet-applied gifts on
// this run and atomically claim them via markCoopGiftApplied so the
// same modifier is never double-applied across retries.
//
// Returns total modifier and a summary line for the daily-result post.
func (p *AdventurePlugin) coopResolveGifts(run *CoopRun, leader id.UserID, day int) (int, string) {
// Catchall: any unresolved leads for this day get force-resolved now.
// Iterate leads only — resolveSingleGift propagates to followers, so
// touching followers here would just be redundant work.
dayGifts, _ := loadDayCoopGifts(run.ID, day)
for i := range dayGifts {
if dayGifts[i].StackLeadID != nil {
continue
}
if dayGifts[i].VoteResult == "" {
p.resolveSingleGift(&dayGifts[i], leader)
}
}
// Apply: pending resolved gifts (any day, this run) get their modifier
// merged into the floor roll.
pending, _ := loadResolvedUnappliedGifts(run.ID)
totalMod := 0
var lines []string
for _, g := range pending {
claimed, err := markCoopGiftApplied(g.ID)
if err != nil || !claimed {
continue
}
totalMod += g.Modifier
lines = append(lines, fmt.Sprintf(" %s gift from %s — %s → %+d%%",
titleCaseWord(g.GiftType), coopDisplayName(p, g.SenderID), g.VoteResult, g.Modifier))
}
if len(lines) == 0 {
return 0, ""
}
return totalMod, "Gifts:\n" + strings.Join(lines, "\n")
}
// ── DB ──────────────────────────────────────────────────────────────────────
// findCoopGiftStackLead returns the active lead gift for a (run, day, type)
// stack, or nil if no unresolved lead exists. Lead = oldest unresolved gift
// of that type with stack_lead_id IS NULL.
func findCoopGiftStackLead(runID, day int, giftType string) (*CoopGift, error) {
d := db.Get()
var leadID int
err := d.QueryRow(`SELECT id FROM coop_dungeon_gifts
WHERE run_id = ? AND day = ? AND gift_type = ?
AND vote_result IS NULL AND stack_lead_id IS NULL
ORDER BY id ASC LIMIT 1`, runID, day, giftType).Scan(&leadID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return loadCoopGift(leadID)
}
// loadCoopGiftStack returns the lead and all followers (in id order) for a
// stack identified by lead_id. Used to propagate resolution + render.
func loadCoopGiftStack(leadID int) ([]CoopGift, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts
WHERE id = ? OR stack_lead_id = ?
ORDER BY id ASC`, leadID, leadID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if voteResult.Valid {
g.VoteResult = voteResult.String
}
if outcome.Valid {
g.Outcome = outcome.String
}
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if appliedAt.Valid {
t := appliedAt.Time
g.AppliedAt = &t
}
if stackLeadID.Valid {
v := int(stackLeadID.Int64)
g.StackLeadID = &v
}
out = append(out, g)
}
return out, rows.Err()
}
// createCoopGift inserts a new gift. If an unresolved same-type stack exists
// for the run+day, the new gift becomes a follower (no expires_at, no own
// post — lead's post is edited to bump the count). Otherwise a new lead is
// created with its own 6h expiry.
//
// Returns (giftID, leadID, isNewLead, error). leadID is the gift's effective
// stack lead — either itself (new lead) or the existing lead it joined.
func createCoopGift(runID int, sender id.UserID, day int, giftType string) (giftID int, leadID int, isNewLead bool, err error) {
d := db.Get()
existing, lerr := findCoopGiftStackLead(runID, day, giftType)
if lerr != nil {
return 0, 0, false, lerr
}
if existing != nil {
// Follower: no expires_at, no post. Inherits lead's deadline.
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
(run_id, sender_id, day, gift_type, stack_lead_id) VALUES (?, ?, ?, ?, ?)`,
runID, string(sender), day, giftType, existing.ID)
if ierr != nil {
return 0, 0, false, ierr
}
id64, _ := res.LastInsertId()
return int(id64), existing.ID, false, nil
}
// New lead: own expiry, will get its own post.
expires := time.Now().UTC().Add(coopGiftWindow)
res, ierr := d.Exec(`INSERT INTO coop_dungeon_gifts
(run_id, sender_id, day, gift_type, expires_at) VALUES (?, ?, ?, ?, ?)`,
runID, string(sender), day, giftType, expires)
if ierr != nil {
return 0, 0, false, ierr
}
id64, _ := res.LastInsertId()
return int(id64), int(id64), true, nil
}
func loadCoopGift(giftID int) (*CoopGift, error) {
d := db.Get()
g := &CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
err := d.QueryRow(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts WHERE id = ?`, giftID).Scan(
&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if voteResult.Valid {
g.VoteResult = voteResult.String
}
if outcome.Valid {
g.Outcome = outcome.String
}
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
if appliedAt.Valid {
t := appliedAt.Time
g.AppliedAt = &t
}
return g, nil
}
func loadDayCoopGifts(runID, day int) ([]CoopGift, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts WHERE run_id = ? AND day = ? ORDER BY id`, runID, day)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if voteResult.Valid {
g.VoteResult = voteResult.String
}
if outcome.Valid {
g.Outcome = outcome.String
}
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
if appliedAt.Valid {
t := appliedAt.Time
g.AppliedAt = &t
}
out = append(out, g)
}
return out, rows.Err()
}
func loadAllCoopGifts(runID int) ([]CoopGift, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts WHERE run_id = ? ORDER BY id`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if voteResult.Valid {
g.VoteResult = voteResult.String
}
if outcome.Valid {
g.Outcome = outcome.String
}
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if stackLeadID.Valid { v := int(stackLeadID.Int64); g.StackLeadID = &v }
if appliedAt.Valid {
t := appliedAt.Time
g.AppliedAt = &t
}
out = append(out, g)
}
return out, rows.Err()
}
func loadMostRecentUnresolvedGift(runID, day int) (*CoopGift, error) {
gifts, err := loadDayCoopGifts(runID, day)
if err != nil {
return nil, err
}
for i := len(gifts) - 1; i >= 0; i-- {
if gifts[i].VoteResult == "" {
g := gifts[i]
return &g, nil
}
}
return nil, nil
}
func saveCoopGiftVotes(g *CoopGift) error {
d := db.Get()
jsonBytes, err := json.Marshal(serializeCoopVotes(g.Votes))
if err != nil {
return err
}
_, err = d.Exec(`UPDATE coop_dungeon_gifts SET votes = ? WHERE id = ?`,
string(jsonBytes), g.ID)
return err
}
func saveCoopGiftPostID(giftID int, eventID id.EventID) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_gifts SET post_event_id = ? WHERE id = ?`,
string(eventID), giftID)
return err
}
func resolveCoopGift(giftID int, voteResult, outcome string, modifier int) error {
d := db.Get()
_, err := d.Exec(`UPDATE coop_dungeon_gifts
SET vote_result = ?, outcome = ?, modifier = ?, resolved_at = CURRENT_TIMESTAMP
WHERE id = ?`, voteResult, outcome, modifier, giftID)
return err
}
// loadExpiredUnresolvedGifts returns gifts whose voting window has elapsed
// but whose votes haven't yet been tallied. Driven by the per-minute
// expiry ticker.
func loadExpiredUnresolvedGifts() ([]CoopGift, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts
WHERE vote_result IS NULL AND expires_at IS NOT NULL AND expires_at <= CURRENT_TIMESTAMP
AND stack_lead_id IS NULL
ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if postID.Valid {
g.PostEventID = id.EventID(postID.String)
}
if expiresAt.Valid {
t := expiresAt.Time
g.ExpiresAt = &t
}
if stackLeadID.Valid {
v := int(stackLeadID.Int64)
g.StackLeadID = &v
}
out = append(out, g)
}
return out, rows.Err()
}
// loadResolvedUnappliedGifts returns gifts that have been tallied but whose
// modifiers have not yet been merged into a floor success roll. Used by
// floor resolution to sum and apply pending gift modifiers.
func loadResolvedUnappliedGifts(runID int) ([]CoopGift, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, run_id, sender_id, day, gift_type,
votes, vote_result, outcome, modifier, post_event_id, expires_at, applied_at, stack_lead_id
FROM coop_dungeon_gifts
WHERE run_id = ? AND vote_result IS NOT NULL AND applied_at IS NULL
ORDER BY id`, runID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CoopGift
for rows.Next() {
g := CoopGift{}
var sender, votesJSON string
var voteResult, outcome, postID sql.NullString
var expiresAt, appliedAt sql.NullTime
var stackLeadID sql.NullInt64
if err := rows.Scan(&g.ID, &g.RunID, &sender, &g.Day, &g.GiftType,
&votesJSON, &voteResult, &outcome, &g.Modifier, &postID, &expiresAt, &appliedAt, &stackLeadID); err != nil {
return nil, err
}
g.SenderID = id.UserID(sender)
g.Votes = parseCoopVotes(votesJSON)
if voteResult.Valid {
g.VoteResult = voteResult.String
}
if outcome.Valid {
g.Outcome = outcome.String
}
if stackLeadID.Valid {
v := int(stackLeadID.Int64)
g.StackLeadID = &v
}
out = append(out, g)
}
return out, rows.Err()
}
// markCoopGiftApplied claims a gift's modifier as merged into a floor roll.
// Idempotent — only the first call sets applied_at; subsequent calls return
// false (claimed=false) so the same modifier is never double-applied.
func markCoopGiftApplied(giftID int) (bool, error) {
d := db.Get()
res, err := d.Exec(`UPDATE coop_dungeon_gifts SET applied_at = CURRENT_TIMESTAMP
WHERE id = ? AND applied_at IS NULL`, giftID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// ── Render ──────────────────────────────────────────────────────────────────
// renderCoopGiftPost is called only for stack leads. Stack count is derived
// from how many followers point at this lead. Flavor is picked deterministically
// by lead id so re-renders don't rotate.
func renderCoopGiftPost(p *AdventurePlugin, run *CoopRun, gift *CoopGift, members []CoopMember) string {
var sb strings.Builder
// Stack size = lead + followers
stack, _ := loadCoopGiftStack(gift.ID)
stackSize := len(stack)
if stackSize == 0 {
stackSize = 1
}
// Tally on the lead's votes
opens, leaves := 0, 0
for _, v := range gift.Votes {
if v == "open" {
opens++
} else if v == "leave" {
leaves++
}
}
leaderName := coopDisplayName(p, run.LeaderID)
header := fmt.Sprintf("📦 **Gift #%d arrived — Co-op #%d, Day %d**", gift.ID, run.ID, gift.Day)
if stackSize > 1 {
header = fmt.Sprintf("📦 **Gift Stack #%d (×%d) — Co-op #%d, Day %d**", gift.ID, stackSize, run.ID, gift.Day)
}
sb.WriteString(header)
sb.WriteString("\n\n")
// Pick flavor deterministically so edits don't rotate the joke.
if len(TwinBeeGiftArrival) > 0 {
flavor := TwinBeeGiftArrival[gift.ID%len(TwinBeeGiftArrival)]
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(opens), 1)
flavor = strings.Replace(flavor, "{count}", strconv.Itoa(leaves), 1)
flavor = strings.ReplaceAll(flavor, "{leader}", leaderName)
sb.WriteString(flavor)
sb.WriteString("\n\n")
}
// "REALLY special" line, picked once per stack lead, shown for size 2+.
if stackSize >= 2 && len(TwinBeeGiftStackEscalation) > 0 {
line := TwinBeeGiftStackEscalation[gift.ID%len(TwinBeeGiftStackEscalation)]
sb.WriteString("_")
sb.WriteString(line)
sb.WriteString("_\n\n")
}
closesIn := ""
if gift.ExpiresAt != nil {
remaining := time.Until(*gift.ExpiresAt).Truncate(time.Minute)
if remaining > 0 {
closesIn = fmt.Sprintf(" · voting closes in %s", remaining)
} else {
closesIn = " · voting closing"
}
}
if stackSize > 1 {
sb.WriteString(fmt.Sprintf("One vote covers the whole stack of %d. ", stackSize))
}
sb.WriteString("Vote with `!coop giftvote " + strconv.Itoa(gift.ID) + " open|leave`" + closesIn + ".")
awaiting := 0
for _, m := range members {
if _, ok := gift.Votes[m.UserID]; !ok {
awaiting++
}
}
if awaiting > 0 && awaiting < len(members) {
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", awaiting, len(members)))
}
return sb.String()
}
// renderCoopGiftResolvedPost replaces the live vote post when a gift's
// voting window closes. Total modifier = stackSize × per-gift mod.
func renderCoopGiftResolvedPost(g *CoopGift, stackSize, totalMod int) string {
verb := "left"
if g.VoteResult == "opened" {
verb = "opened"
}
if stackSize > 1 {
return fmt.Sprintf("📦 **Gift Stack #%d (×%d) resolved** — %s, %s → **%+d%%** to next floor.",
g.ID, stackSize, titleCaseWord(g.GiftType), verb, totalMod)
}
return fmt.Sprintf("📦 **Gift #%d resolved** — %s, %s → **%+d%%** to next floor.",
g.ID, titleCaseWord(g.GiftType), verb, totalMod)
}
func renderCoopGiftSenderDM(g *CoopGift, voteResult string, mod int) string {
pickFrom := func(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}
switch {
case g.GiftType == coopGiftBasket && voteResult == "opened":
return fmt.Sprintf("📦 Your care basket was opened. %s (%+d%% to today's floor)", pickFrom(TwinBeeGiftBasketOpened), mod)
case g.GiftType == coopGiftBasket && voteResult == "left":
return fmt.Sprintf("📦 Your care basket was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftBasketUnopened), mod)
case g.GiftType == coopGiftMimic && voteResult == "opened":
return fmt.Sprintf("📦 Your mimic was opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicOpened), mod)
case g.GiftType == coopGiftMimic && voteResult == "left":
return fmt.Sprintf("📦 Your mimic was not opened. %s (%+d%%)", pickFrom(TwinBeeGiftMimicUnopened), mod)
}
return "📦 Your gift resolved."
}
// renderCoopGiftLog returns the public end-of-run gift log, grouped by stack.
// One line per stack with all senders listed and the total swing.
func renderCoopGiftLog(p *AdventurePlugin, runID int) string {
gifts, err := loadAllCoopGifts(runID)
if err != nil || len(gifts) == 0 {
return ""
}
// Group gifts by their effective lead id.
type groupKey struct {
day int
giftType string
leadID int
}
groups := map[groupKey][]CoopGift{}
order := []groupKey{}
for _, g := range gifts {
leadID := g.ID
if g.StackLeadID != nil {
leadID = *g.StackLeadID
}
k := groupKey{day: g.Day, giftType: g.GiftType, leadID: leadID}
if _, seen := groups[k]; !seen {
order = append(order, k)
}
groups[k] = append(groups[k], g)
}
var sb strings.Builder
sb.WriteString("📦 Gift Log:\n")
for _, k := range order {
stack := groups[k]
// Senders for the line — ordered by id for stable display.
names := make([]string, 0, len(stack))
totalMod := 0
voteResult := stack[0].VoteResult
for _, g := range stack {
names = append(names, coopDisplayName(p, g.SenderID))
totalMod += g.Modifier
}
result := voteResult
if result == "" {
result = "unresolved"
}
typeLabel := titleCaseWord(k.giftType)
if len(stack) > 1 {
typeLabel = fmt.Sprintf("%s ×%d", typeLabel, len(stack))
}
sb.WriteString(fmt.Sprintf(" Day %d: %s → %s → %s → %+d%%\n",
k.day, strings.Join(names, " + "), typeLabel, result, totalMod))
}
return sb.String()
}

View File

@@ -0,0 +1,282 @@
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
func coopDisplayName(p *AdventurePlugin, userID id.UserID) string {
char, err := loadAdvCharacter(userID)
if err == nil && char.DisplayName != "" {
return char.DisplayName
}
if p != nil {
return p.DisplayName(userID)
}
return string(userID)
}
func renderCoopInvite(run *CoopRun, members []CoopMember, leaderName string) string {
def := coopTierTable[run.Tier]
closes := run.CreatedAt.Add(coopInviteWindow).Format("Mon 15:04 UTC")
return fmt.Sprintf(
"⚔️ %s is opening a Co-op Dungeon — **Tier %d (%s)**, run #%d.\n"+
"Days: %d. Reward pool base: €%d (split among the party).\n"+
"Party: %d/%d. Type `!coop join %d` to enter.\n"+
"Locks at %s. Newbies are a liability — you knew that going in.",
leaderName, run.Tier, def.difficulty, run.ID,
def.totalDays, def.rewardBase,
len(members), coopMaxPartySize, run.ID, closes)
}
func renderCoopLock(run *CoopRun, members []CoopMember, p *AdventurePlugin) string {
var sb strings.Builder
def := coopTierTable[run.Tier]
sb.WriteString(fmt.Sprintf("⚔️ Tier %d Co-op #%d is locked.\n", run.Tier, run.ID))
sb.WriteString(fmt.Sprintf("Difficulty: %s. Days: %d. Reward pool: €%d.\n\nParty (turn order):\n",
def.difficulty, run.TotalDays, def.rewardBase))
for _, m := range members {
warn := ""
if m.IsLiability {
warn = " ⚠️ liability"
}
sb.WriteString(fmt.Sprintf(" %d. %s%s\n", m.TurnOrder+1, coopDisplayName(p, m.UserID), warn))
}
sb.WriteString("\nDay 1 begins now. Each member has until the next daily tick to fund. Inactive = None (-10%).")
bets, _ := loadCoopBets(run.ID)
sb.WriteString("\n\n")
sb.WriteString(renderCoopOddsLine(run, members, bets))
return sb.String()
}
func renderCoopFundingPrompt(run *CoopRun, day int) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Day %d/%d. Pick your funding tier with `!coop fund <tier>`.\n\n",
run.ID, day, run.TotalDays))
for _, t := range coopFundingOrder {
def := coopFundingTable[t]
sb.WriteString(fmt.Sprintf(" • `%s` — €%d, %+d%%\n", t, def.cost, def.modifier))
}
sb.WriteString("\nNo decision before the next daily tick = auto-played as None (-10%). Funding is non-refundable.")
return sb.String()
}
func renderCoopDailyResult(p *AdventurePlugin, run *CoopRun, members []CoopMember, day int,
choices map[string]CoopFundingTier, successPct int, won bool, autoPlayed []*CoopMember,
event *CoopEvent, winning string, eventMod int) string {
var sb strings.Builder
def := coopTierTable[run.Tier]
header := "✅ Floor cleared"
if !won {
header = "💥 Wipe"
}
if won && day >= run.TotalDays {
header = "🏆 Final floor cleared"
}
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s), Day %d/%d\n",
run.ID, run.Tier, def.difficulty, day, run.TotalDays))
sb.WriteString(fmt.Sprintf("Success chance: %d%%. %s.\n", successPct, header))
if event != nil {
sb.WriteString(fmt.Sprintf("Floor event: %s. Party voted **%s** (%+d%%).\n",
titleCaseWord(string(event.Category)), winning, eventMod))
}
sb.WriteString("\nFunding:\n")
for _, m := range members {
t := choices[string(m.UserID)]
fdef := coopFundingTable[t]
auto := ""
for _, ap := range autoPlayed {
if ap.UserID == m.UserID {
auto = " ← auto-played"
break
}
}
sb.WriteString(fmt.Sprintf(" %s: %s (%+d%%)%s\n",
coopDisplayName(p, m.UserID), fdef.label, fdef.modifier, auto))
}
if event != nil {
sb.WriteString("\n_TwinBee:_ ")
sb.WriteString(coopTwinBeeReaction(event, winning, won))
}
if !won {
sb.WriteString("\n\nThe dungeon does not editorialize. Combat actions for today are refunded if any. Funding is gone.")
} else if day < run.TotalDays {
sb.WriteString(fmt.Sprintf("\n\nNext floor unlocks at the next daily tick. %d day(s) remain.", run.TotalDays-day))
bets, _ := loadCoopBets(run.ID)
sb.WriteString("\n")
sb.WriteString(renderCoopOddsLine(run, members, bets))
}
return sb.String()
}
// coopTwinBeeReaction picks an outcome line based on whether his recommendation
// matched the winning vote and whether the floor succeeded.
func coopTwinBeeReaction(event *CoopEvent, winning string, won bool) string {
pickFrom := func(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}
switch {
case event.Recommended == winning && won:
return pickFrom(TwinBeeOutcomeCorrect)
case event.Recommended == winning && !won:
return pickFrom(TwinBeeOutcomeWrong)
case event.Recommended != winning && won:
return pickFrom(TwinBeeOutcomeNotRecommendedGood)
default:
return pickFrom(TwinBeeOutcomeNotRecommendedBad)
}
}
// renderCoopEventPost is the game-room post for a floor event vote prompt.
// Edited in place as votes come in.
func renderCoopEventPost(run *CoopRun, members []CoopMember, event *CoopEvent) string {
var sb strings.Builder
flavorPool := coopEventCategoryFlavor(event.Category)
flavor := ""
if event.EventIndex < len(flavorPool) {
flavor = flavorPool[event.EventIndex]
}
sb.WriteString(fmt.Sprintf("🗺️ **Co-op #%d — Day %d/%d, %s event**\n\n",
run.ID, event.Day, run.TotalDays, titleCaseWord(string(event.Category))))
sb.WriteString(flavor)
sb.WriteString("\n\nVote with `!coop vote A` (or B/C). 24h or until next tick. Ties go to the leader.\n\n")
tally := map[string]int{}
for _, v := range event.Votes {
tally[v]++
}
parts := []string{}
for _, label := range coopEventOptionLabels(event.Category, event.EventIndex) {
parts = append(parts, fmt.Sprintf("%s (%d)", label, tally[label]))
}
sb.WriteString("Current votes: ")
sb.WriteString(strings.Join(parts, " · "))
abstained := []string{}
for _, m := range members {
if _, voted := event.Votes[m.UserID]; !voted {
abstained = append(abstained, string(m.UserID))
}
}
if len(abstained) > 0 && len(abstained) < len(members) {
sb.WriteString(fmt.Sprintf("\nAwaiting: %d of %d", len(abstained), len(members)))
}
return sb.String()
}
func titleCaseWord(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
func renderCoopStatus(p *AdventurePlugin, run *CoopRun, members []CoopMember) string {
var sb strings.Builder
def := coopTierTable[run.Tier]
sb.WriteString(fmt.Sprintf("⚔️ Co-op #%d — Tier %d (%s)\n", run.ID, run.Tier, def.difficulty))
sb.WriteString(fmt.Sprintf("Status: %s. Day %d/%d. Pool: €%d.\n\nParty:\n",
run.Status, run.CurrentDay, run.TotalDays, run.GoldPool))
for _, m := range members {
warn := ""
if m.IsLiability {
warn = " ⚠️"
}
todayLabel := "—"
if run.CurrentDay >= 1 {
if t, ok := m.DailyFunding[run.CurrentDay]; ok {
todayLabel = coopFundingTable[t].label
} else {
todayLabel = "(unset)"
}
}
sb.WriteString(fmt.Sprintf(" %d. %s%s — today: %s, contributed: €%d\n",
m.TurnOrder+1, coopDisplayName(p, m.UserID), warn, todayLabel, m.TotalContributed))
}
if run.Status == "open" {
closes := run.CreatedAt.Add(coopInviteWindow)
sb.WriteString(fmt.Sprintf("\nLocks in: %s.", time.Until(closes).Truncate(time.Minute)))
}
if run.Status == "open" || run.Status == "active" {
bets, _ := loadCoopBets(run.ID)
sb.WriteString("\n")
sb.WriteString(renderCoopOddsLine(run, members, bets))
}
// Pending stacks with per-stack countdowns. Each game-room post is one
// stack (lead + N followers); we show the lead's id and the stack size.
if run.Status == "active" {
all, _ := loadAllCoopGifts(run.ID)
// Group unresolved gifts by lead. Followers contribute to the size
// but use the lead's expiry/votes.
type stackInfo struct {
lead CoopGift
size int
}
stacks := map[int]*stackInfo{}
order := []int{}
for _, g := range all {
if g.VoteResult != "" {
continue
}
leadID := g.ID
if g.StackLeadID != nil {
leadID = *g.StackLeadID
}
s, ok := stacks[leadID]
if !ok {
s = &stackInfo{}
stacks[leadID] = s
order = append(order, leadID)
}
if g.StackLeadID == nil {
s.lead = g
}
s.size++
}
if len(stacks) > 0 {
sb.WriteString("\n\nPending gifts:\n")
for _, leadID := range order {
s := stacks[leadID]
opens, leaves := 0, 0
for _, v := range s.lead.Votes {
if v == "open" {
opens++
} else if v == "leave" {
leaves++
}
}
countdown := "—"
if s.lead.ExpiresAt != nil {
remaining := time.Until(*s.lead.ExpiresAt).Truncate(time.Minute)
if remaining > 0 {
countdown = remaining.String()
} else {
countdown = "closing"
}
}
stackLabel := fmt.Sprintf("#%d", s.lead.ID)
if s.size > 1 {
stackLabel = fmt.Sprintf("#%d ×%d", s.lead.ID, s.size)
}
sb.WriteString(fmt.Sprintf(" %s (day %d) — open %d / leave %d — closes in %s\n",
stackLabel, s.lead.Day, opens, leaves, countdown))
}
}
}
return sb.String()
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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!",
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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") {

View File

@@ -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))
}
}

View File

@@ -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)
}
}
}

View File

@@ -3,6 +3,7 @@ package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"sync"
@@ -58,7 +59,8 @@ func NewHoldemPlugin(client *mautrix.Client, euro *EuroPlugin) *HoldemPlugin {
}
}
func (p *HoldemPlugin) Name() string { return "holdem" }
func (p *HoldemPlugin) Name() string { return "holdem" }
func (p *HoldemPlugin) Version() string { return "2.1.0" }
func (p *HoldemPlugin) Commands() []CommandDef {
return []CommandDef{
@@ -97,12 +99,16 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
}
// If this is a DM, resolve the player's game room so actions route correctly.
// Save the original DM room into OriginRoomID so sender-private replies (errors,
// help, status) route back via DM rather than broadcasting to the games room.
if isDM {
p.mu.Lock()
gameRoom := p.findGameRoom(ctx.Sender)
p.mu.Unlock()
if gameRoom != "" {
ctx.OriginRoomID = ctx.RoomID
ctx.RoomID = gameRoom
ctx.EventID = ""
}
}
@@ -110,7 +116,7 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
if !isDM && !isGamesRoom(ctx.RoomID) {
gr := gamesRoom()
if gr != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
return p.reply(ctx, "Games are only available in the games channel!")
}
}
@@ -136,14 +142,23 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
case args == "status":
return p.handleStatus(ctx)
case args == "help":
return p.SendReply(ctx.RoomID, ctx.EventID, renderHelpMessage())
return p.reply(ctx, renderHelpMessage())
case args == "addbot":
return p.handleAddBot(ctx)
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown command. Try `!holdem help`.")
return p.reply(ctx, "Unknown command. Try `!holdem help`.")
}
}
// reply routes sender-private responses to the originating DM when the command
// came from a DM (ctx.OriginRoomID set), otherwise replies in-thread.
func (p *HoldemPlugin) reply(ctx MessageContext, msg string) error {
if ctx.OriginRoomID != "" {
return p.SendMessage(ctx.OriginRoomID, msg)
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
// DM rooms typically have exactly 2 members.
members := p.RoomMembers(roomID)
@@ -163,11 +178,11 @@ func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
if !isDM {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
return p.reply(ctx, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
}
if p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips are always on for admins. 🐝")
return p.reply(ctx, "Tips are always on for admins. 🐝")
}
parts := strings.Fields(args)
@@ -177,18 +192,18 @@ func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bo
if !pref {
status = "off"
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
return p.reply(ctx, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
}
switch parts[1] {
case "on":
saveTipsPref(ctx.Sender, true)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips enabled. You'll receive coaching on your turn.")
return p.reply(ctx, "Tips enabled. You'll receive coaching on your turn.")
case "off":
saveTipsPref(ctx.Sender, false)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips disabled.")
return p.reply(ctx, "Tips disabled.")
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Use `!holdem tips on` or `!holdem tips off`.")
return p.reply(ctx, "Use `!holdem tips on` or `!holdem tips off`.")
}
}
@@ -246,25 +261,25 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
// Check if already seated.
if game.playerByUserID(ctx.Sender) != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table.")
return p.reply(ctx, "You're already at the table.")
}
// Max 9 players.
if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (9 players max).")
return p.reply(ctx, "Table is full (9 players max).")
}
// Check balance.
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(p.cfg.MinBuyin) {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance))
}
// Resolve DM room.
dmRoom, err := p.GetDMRoom(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Check your privacy settings.")
return p.reply(ctx, "Couldn't open a DM with you. Check your privacy settings.")
}
// Get display name.
@@ -277,19 +292,19 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
amountStr = strings.TrimPrefix(amountStr, "€")
requested, err := strconv.ParseInt(amountStr, 10, 64)
if err != nil || requested <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
}
if requested < p.cfg.MinBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
}
if requested > p.cfg.MaxBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
}
if requested > int64(balance) {
return p.SendReply(ctx.RoomID, ctx.EventID,
return p.reply(ctx,
fmt.Sprintf("You only have €%.0f.", balance))
}
buyin = requested
@@ -302,7 +317,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
// Debit buy-in immediately to prevent double-spending across tables.
if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reserve buy-in.")
return p.reply(ctx, "Failed to reserve buy-in.")
}
state := PlayerActive
@@ -340,19 +355,20 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
return p.reply(ctx, "No active game here.")
}
player := game.playerByUserID(ctx.Sender)
if player == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.")
return p.reply(ctx, "You're not at the table.")
}
if !game.HandInProgress || player.SittingOut {
// Credit remaining stack back (buy-in was debited at join).
cashout := player.Stack
if !player.IsNPC && cashout > 0 {
p.euro.Credit(player.UserID, float64(cashout), "holdem_cashout")
net, _ := communityTax(player.UserID, float64(cashout), 0.05)
p.euro.Credit(player.UserID, net, "holdem_cashout")
}
// Remove immediately.
p.removePlayer(game, ctx.Sender)
@@ -373,7 +389,8 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
// Credit remaining stack back (buy-in was debited at join).
if player.Stack > 0 {
p.euro.Credit(player.UserID, float64(player.Stack), "holdem_cashout")
net, _ := communityTax(player.UserID, float64(player.Stack), 0.05)
p.euro.Credit(player.UserID, net, "holdem_cashout")
}
p.removePlayer(game, ctx.Sender)
@@ -396,11 +413,11 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No players seated. Use `!holdem join` first.")
return p.reply(ctx, "No players seated. Use `!holdem join` first.")
}
if game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "A hand is already in progress.")
return p.reply(ctx, "A hand is already in progress.")
}
// Count non-sitting-out players.
@@ -416,7 +433,7 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
if ready == 1 {
hint = " Use `!holdem addbot` to add an AI opponent."
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start."+hint)
return p.reply(ctx, "Need at least 2 players to start."+hint)
}
p.startHand(game)
@@ -426,13 +443,13 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!holdem raise <amount>`")
return p.reply(ctx, "Usage: `!holdem raise <amount>`")
}
amtStr := strings.TrimPrefix(parts[1], "€")
amount, err := strconv.ParseInt(amtStr, 10, 64)
if err != nil || amount <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid amount. Usage: `!holdem raise <amount>`")
return p.reply(ctx, "Invalid amount. Usage: `!holdem raise <amount>`")
}
return p.handleAction(ctx, "raise", amount)
@@ -444,21 +461,21 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
game := p.games[ctx.RoomID]
if game == nil || !game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "No hand in progress.")
return p.reply(ctx, "No hand in progress.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
return p.reply(ctx, "You're not at this table.")
}
if seatIdx != game.ActionIdx {
return p.SendReply(ctx.RoomID, ctx.EventID, "It's not your turn.")
return p.reply(ctx, "It's not your turn.")
}
player := game.Players[seatIdx]
if player.State != PlayerActive {
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't act right now.")
return p.reply(ctx, "You can't act right now.")
}
var result ActionResult
@@ -478,7 +495,7 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
}
if errMsg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, errMsg)
return p.reply(ctx, errMsg)
}
// Reset action timer.
@@ -515,12 +532,12 @@ func (p *HoldemPlugin) handleStatus(ctx MessageContext) error {
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
return p.reply(ctx, "No active game here.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
return p.reply(ctx, "You're not at this table.")
}
player := game.Players[seatIdx]
@@ -551,12 +568,12 @@ func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
// Check if NPC already exists.
for _, pl := range game.Players {
if pl.IsNPC {
return p.SendReply(ctx.RoomID, ctx.EventID, "An AI opponent is already at the table.")
return p.reply(ctx, "An AI opponent is already at the table.")
}
}
if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full.")
return p.reply(ctx, "Table is full.")
}
// Get NPC house balance.
@@ -822,9 +839,10 @@ func (p *HoldemPlugin) doShowdown(game *HoldemGame) {
}
}
// Post end announcement to room.
// Post end announcement to room and DM each player.
endAnn := renderEndAnnouncement(results, game)
p.SendMessage(game.RoomID, endAnn)
p.broadcastDM(game, endAnn)
// Settle balances.
settleNetDeltas(game, p.euro)
@@ -857,6 +875,7 @@ func (p *HoldemPlugin) finishHand(game *HoldemGame) {
ann, winnerID := awardPotToLastPlayer(game)
if ann != "" {
p.SendMessage(game.RoomID, ann)
p.broadcastDM(game, ann)
}
// Track bot defeats (if NPC won by everyone folding).
@@ -900,7 +919,8 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
if pl.WantsLeave || pl.Stack <= 0 {
if !pl.IsNPC {
if pl.Stack > 0 {
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
p.euro.Credit(pl.UserID, net, "holdem_cashout")
}
p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
}
@@ -917,7 +937,8 @@ func (p *HoldemPlugin) endHand(game *HoldemGame) {
// Cash out remaining players.
for _, pl := range game.Players {
if !pl.IsNPC && pl.Stack > 0 {
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_cashout")
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
p.euro.Credit(pl.UserID, net, "holdem_cashout")
}
}
p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")
@@ -949,19 +970,33 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
if actionPlayer.TipsEnabled {
snap := snapshotForTip(game, game.ActionIdx)
dmRoom := actionPlayer.DMRoomID
go func() {
playerID := string(actionPlayer.UserID)
safeGo("holdem-tip", func() {
tipCtx := buildTipContext(snap)
tip := generateTip(tipCtx)
p.SendMessage(dmRoom, tip)
}()
logTipAudit(playerID, tipCtx, tip)
})
}
}
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
first := true
for _, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut {
continue
}
// Skip players whose DM room IS the game room — they already saw
// the message via the public-room post (solo-vs-bot case, where
// game.RoomID == player's DM).
if pl.DMRoomID == game.RoomID {
continue
}
if !first {
// Jitter 150400ms 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 {

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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"},
},
}

View File

@@ -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)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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}
}()
})
}
}

View File

@@ -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 130.
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())

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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()
}()
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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") {

View File

@@ -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
}
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)

Some files were not shown because too many files have changed in this diff Show More