Compare commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:54:58 -07:00
prosolisandClaude Opus 4.6 26d2846481 Add !adv alias, hospital insurance billing, wordle/UNO earnings tracking, streak death protection
- Add !adv shorthand for !adventure commands and DM replies
- Hospital bill now shows insurance deduction and amount due
- Refactor hospital nudge from per-goroutine sleep to shared ticker
- Dead players' streaks are frozen; grace period on revival day
- Track wordle earnings in wordle_stats, show in !stats and superstats
- Show UNO net earnings and trivia accuracy % in superstats
- Disable sentiment/profanity emoji reactions (classification still logs)
- Add URL_PREVIEW_IGNORE_USERS env var to skip bot users
- Add DISABLE_WOTD_POST env var, remove unused wotd.Prefetch()
- README updates for all of the above

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:51:28 -07:00
128 changed files with 28207 additions and 2487 deletions
+6
View File
@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
+2
View File
@@ -3,3 +3,5 @@ data/
.env.*
!.env.example
gogobee
gensolver
holdem-train
+185 -44
View File
@@ -1,6 +1,6 @@
# GogoBee
Matrix community bot with E2EE, 49 plugins, passive tracking, scheduled posts, and optional LLM features.
Matrix community bot with E2EE, 50 plugins, passive tracking, scheduled posts, and optional LLM features.
Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption and [modernc.org/sqlite](https://modernc.org/sqlite) for storage.
@@ -29,11 +29,11 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
- **49 plugins** with dependency injection and ordered registration
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-7 letter words, video game themed bonus words with category hints, dupe prevention across last 500 puzzles), Adventure (daily idle RPG via DMs — dungeon, mine, forage, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), all with channel restriction
- **50 plugins** with dependency injection and ordered registration
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 24 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure (daily idle RPG via DMs — dungeon, mine, forage, fish, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions, hospital revival system, Robbie the Friendly Bandit automated inventory cleaner), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), 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), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
- **LLM integration** (optional) - Ollama-powered sentiment analysis, roast profiles, room vibes, tarot readings, conversation summaries
- **Markdown rendering** - auto-detects `**bold**`, `_italic_`, and `` `code` `` in messages and sends proper HTML to Matrix clients
- **Encrypted quote wall** - AES-256-GCM encrypted quotes at rest, reply-to-save, search, leaderboard
@@ -107,7 +107,7 @@ Everything is configured through environment variables or a `.env` file.
| Variable | Service | Used By |
|----------|---------|---------|
| `RAWG_API_KEY` | [RAWG](https://rawg.io/apidocs) | `!game`, `!retro`, `!releases` |
| `DREAMDICT_URL` | DreamDict (self-hosted) | `!translate`, `!wotd`, Hangman multilingual clues, Wordle (guess validation + definitions) |
| `DREAMDICT_URL` | DreamDict (self-hosted) | `!translate`, `!wotd`, `!antonym`, `!pronounce`, `!etymology`, `!difficulty`, `!rhyme`, `!define` (antonyms), Hangman (multilingual clues + difficulty tier), Wordle (word selection + guess validation + definitions) |
| `CALENDARIFIC_API_KEY` | [Calendarific](https://calendarific.com) | Holiday posts |
| `OPENWEATHER_API_KEY` | [OpenWeather](https://openweathermap.org/api) | `!weather` |
| `FINNHUB_API_KEY` | [Finnhub](https://finnhub.io) | `!stock` |
@@ -121,7 +121,7 @@ Everything is configured through environment variables or a `.env` file.
|----------|-------------|
| `OLLAMA_HOST` | Ollama server URL, e.g. `http://localhost:11434` |
| `OLLAMA_MODEL` | Model name, e.g. `llama3.2` |
| `DREAMDICT_URL` | DreamDict instance for `!translate`, `!wotd`, Hangman clues (e.g. `http://127.0.0.1:7777`) |
| `DREAMDICT_URL` | DreamDict instance for `!translate`, `!wotd`, dictionary commands, Hangman clues, Wordle words (e.g. `http://127.0.0.1:7777`) |
| `LLM_SAMPLE_RATE` | Fraction of messages to classify (0.01.0, default `0.15`) |
### Encryption
@@ -152,6 +152,7 @@ Everything is configured through environment variables or a `.env` file.
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
| `FEATURE_MARKET` | Set to `true` to enable the market overview plugin |
| `MARKET_BROADCAST_SUMMARY` | Set to `true` to auto-post daily market summary to broadcast rooms |
| `DISABLE_WOTD_POST` | Set to `true` to suppress daily WOTD auto-post (the `!wotd` command and passive WOTD usage tracking still work) |
### Games & Economy
@@ -184,7 +185,7 @@ Everything is configured through environment variables or a `.env` file.
| `HOLDEM_NPC_NAME` | `TwinBee` | NPC bot display name |
| `HOLDEM_NPC_HOUSE_BALANCE` | `10000` | NPC starting bankroll |
| `HOLDEM_CFR_POLICY` | `data/policy.gob` | Path to CFR policy file |
| `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5, 6, or 7) |
| `WORDLE_DEFAULT_LENGTH` | `5` | Default word length (5-20) |
| `ADVENTURE_MORNING_HOUR` | `8` | Hour (UTC) to send morning DMs with daily choices |
| `ADVENTURE_SUMMARY_HOUR` | `20` | Hour (UTC) to post daily summary to games room |
@@ -284,7 +285,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
### Stats & Personality
| Command | Description |
|---------|-------------|
| `!stats [@user]` | Message statistics |
| `!stats [@user]` | Message statistics (includes Wordle and UNO earnings) |
| `!superstatsexplusalpha [@user]` | Comprehensive profile: economy, games W/L, adventure levels, achievements, and more |
| `!rankings [category]` | Rankings by words, links, questions, or emojis |
| `!personality` | Your community archetype |
@@ -336,7 +337,7 @@ Rep is earned when someone thanks you. The bot detects this automatically.
### UNO (games channel only)
UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All gameplay happens in DMs. The games channel is used for lobby management and public announcements.
UNO can be played solo (vs GogoBee) or multiplayer (1-4 players + bots). All gameplay happens in DMs. The games channel is used for lobby management and public announcements.
| Command | Description |
|---------|-------------|
@@ -347,7 +348,9 @@ UNO can be played solo (vs GogoBee) or multiplayer (2-4 players + bot). All game
| `!uno start nomercy €amount` | Multiplayer No Mercy lobby |
| `!uno start nomercy 7-0 €amount` | Multiplayer No Mercy with 7-0 rule |
| `!uno join` | Join an open lobby |
| `!uno go` | Start the game (host only, 2+ players required) |
| `!uno addbot` | Add a bot opponent to the lobby (max 2, funded from community pot) |
| `!uno removebot` | Remove the last added bot from the lobby |
| `!uno go` | Start the game (host only, 2+ participants required) |
| `!uno leave` | Leave the lobby (refunds ante) |
| `!uno cancel` | Cancel the lobby (host/admin, refunds all) |
@@ -385,6 +388,14 @@ Enabled by adding `7-0` to the command. When active:
- **Play a 7** — swap hands with another player of your choice (in multiplayer, you pick the target)
- **Play a 0** — all players pass their hand to the next player in play direction
#### Sudden Death
When a 2-player game (including bot matchups) exceeds 80 turns, a 20-turn countdown begins. At turn 100, both hands are scored by UNO card point values — lowest total wins. Tiebreaker: fewer cards, then advantage to the non-active player.
#### Multiplayer Bots
The default bot (GogoBee) is always present in multiplayer games. Up to 2 additional bots (WinBee, GwinBee) can be added via `!uno addbot` while the lobby is open. Addbot bots ante from the community pot, increasing the total pot. A single human can start a game if bots fill the remaining slots.
### Texas Hold'em (games channel only)
No-limit Texas Hold'em poker for 2-9 players. Buy-in is debited from your euro balance when you sit down; your remaining stack is cashed out when you leave. Private cards and coaching tips are delivered via DM — the room only sees start/end announcements.
@@ -448,7 +459,7 @@ Progress is logged with overall completion percentage and ETA. Checkpoints are s
### Wordle (games channel only)
Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection and validation powered by the Wordnik API (`WORDNIK_API_KEY`). A new puzzle auto-posts at midnight UTC. Word length is configurable (5-7 letters). Falls back to a bundled word list if the API is unavailable. The puzzle pool includes video game themed words (loaded from `data/wordle_games.txt`) — when one is selected, a category hint is shown. Duplicate prevention ensures the same word won't appear within the last 500 puzzles.
Daily cooperative Wordle — one puzzle per day, the community works together with a shared 6-guess limit. Word selection powered by DreamDict with a bundled word list as fallback. A new puzzle auto-posts at midnight UTC. Word length is configurable (5-20 letters). Guesses persist across bot restarts. When a puzzle expires at midnight, the bot reveals the answer and announces it to the room. The puzzle pool includes video game themed words (loaded from `data/wordle_games.txt`) — when one is selected, a category hint is shown. Duplicate prevention ensures the same word won't appear within the last 500 puzzles.
| Command | Description |
|---------|-------------|
@@ -456,57 +467,122 @@ Daily cooperative Wordle — one puzzle per day, the community works together wi
| `!wordle grid` | Re-post the current puzzle grid |
| `!wordle stats` | All-time leaderboard with community streak |
| `!wordle new` | Start a new puzzle (admin) |
| `!wordle new <5\|6\|7>` | New puzzle with specific word length (admin) |
| `!wordle new <5-20>` | New puzzle with specific word length (admin) |
| `!wordle skip` | Reveal answer and end puzzle (admin) |
| `!wordle help` | Show commands |
No economy integration — stats and leaderboard position are the reward.
Economy rewards are tracked per player — `!wordle stats` shows total earnings.
### Adventure (DM-based idle RPG)
A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, forage, visit the shop, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players.
A daily DM-driven idle RPG where each player takes one action per day — dungeon, mine, fish, forage, visit the shop, blacksmith, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players.
Characters auto-create on first `!adventure` command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict.
Characters auto-create on first `!adventure` (or `!adv`) command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict.
| Command | Description |
|---------|-------------|
| `!adventure` | Open today's action menu (sent via DM) |
| `!adventure` / `!adv` | Open today's action menu (sent via DM) |
| `!adventure status` | Character sheet (sent via DM) |
| `!adventure shop` | Browse equipment for sale (sent via DM) |
| `!adventure buy <item>` | Buy equipment by name or tier shorthand (`3 sword`, `t4 boots`) |
| `!adventure sell <item>` | Sell an inventory item (credits euro balance) |
| `!adventure sell all` | Sell entire inventory |
| `!adventure equip` | Equip masterwork gear from inventory |
| `!adventure inventory` | List current inventory |
| `!adventure leaderboard` | Top adventurers |
| `!adventure revive @user` | Revive a dead player (admin) |
| `!adventure respond <choice>` | Reply to today's action prompt (alternative to DM reply) |
| `!adventure summary` | Force daily summary post (admin) |
| `!adventure boost` | Double XP/money for all adventurers for a day (admin) |
| `!adventure babysit [week\|month\|status\|cancel]` | Hire TwinBee to run your daily action (trains your weakest gathering skill) |
| `!adventure blacksmith` | Show repair quotes for damaged gear |
| `!adventure repair [all\|<slot>]` | Repair one slot or all damaged gear at the blacksmith |
| `!adventure rivals` | Show the rival pool and any open challenges |
| `!thom [shop\|buy\|pay\|payoff\|autopay\|petbuy]` | Thom Krooke — housing, mortgage, and pet adoption broker |
| `!hospital` | Check in to St. Guildmore's Memorial Hospital for same-day revival (costs €25k × combat level) |
**DM replies:** Reply to the morning prompt with a number (`1``5`) or activity name (`dungeon`, `mine`, `forage`, `shop`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
**DM replies:** Reply to the morning prompt with a number (`1``7`) or activity name (`dungeon`, `mine`, `fish`, `forage`, `shop`, `blacksmith`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
#### Activities
Three activity types across 5 tiers of locations (15 total). Higher tiers require higher character level and equipment.
Four activity types across 5 tiers of locations. Higher tiers require higher character level and equipment.
- **Dungeon** — combat XP, chance of death, best loot potential
- **Mining** — mining XP, cave-in risk, ore and gem drops
- **Fishing** — fishing XP, hazard risk, catch drops
- **Foraging** — foraging XP, wildlife hazards, herb and reagent drops
#### Mechanics
- **Equipment** — 5 slots (weapon, armor, helmet, boots, tool) with tiered upgrades from the shop. Equipment degrades on bad outcomes and breaks at 0 condition.
- **Masterwork gear** — rare skill-specific equipment drops from gathering activities (mining, fishing, foraging). 15 items across 5 tiers with decreasing drop rates (5% T1 down to 0.5% T5). Location-gated — drops only at matching tier. Auto-equips if better than current gear; otherwise goes to inventory for manual equipping via `!adventure equip`. Masterwork items provide a +5% skill bonus when the item's source skill matches the current activity. Character sheet marks masterwork items with a star.
- **Treasures** — rare collectibles (up to 3) that provide passive bonuses (XP multipliers, death chance reduction, loot quality). Prompted to discard when at cap.
- **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting or dying resets your streak.
- **Streaks** — consecutive days of activity grant escalating bonuses (XP, loot quality, death chance reduction). Resting resets your streak. Dead players' streaks are frozen — you won't lose your streak to involuntary downtime.
- **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success.
- **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value.
- **Death** — locked out until midnight UTC. You're automatically revived when the next day starts.
- **Death** — locked out for 6 hours. Natural respawn happens automatically when the timer expires. Use `!hospital` for immediate revival at a cost. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. Dead players' streaks are preserved with a grace period — if you die and can't act on revival day, your streak won't reset.
- **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays.
- **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs.
- **Hospital** — St. Guildmore's Memorial Hospital offers same-day revival for dead players. The bill is comically inflated (€125k × combat level) but guild insurance covers 80%, leaving €25k × combat level. Players who can't afford it are discharged back to the natural respawn queue. Nurse Joy provides the bedside manner.
- **Robbie the Friendly Bandit** — an automated NPC who visits at a random hour each day (8: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.
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure for 6 hours. Each fight produces a turn-based combat log (Dragon Quest style) with fabricated HP pools and action narration — the outcome is determined by the roll, the log is assembled backward from the result. Arena losses award +60 participation XP.
| Command | Description |
|---------|-------------|
@@ -527,6 +603,61 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
- **Auto-cashout** — 10 minutes to decide after clearing a tier. GogoBee cashes out on your behalf if you're slow.
- **Tier entry confirmation** — `!arena tier N` previews the first opponent; `!arena fight` commits.
#### Co-op Dungeons (`!coop`)
Multi-day party runs separate from solo. 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 |
|---------|-------------|
@@ -594,13 +725,18 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
| `!movie watch\|watching\|unwatch` | Movie watchlist |
| `!upcoming movies` | Upcoming movies |
### Lookup
### Lookup & Reference
| Command | Description |
|---------|-------------|
| `!wiki <topic>` | Wikipedia summary |
| `!define <word>` | Dictionary definition |
| `!define <word> [lang]` | Dictionary definition — searches all languages via DreamDict by default, or specify one (en/fr/pt-PT/zh). Includes antonyms when available. Falls back to free dictionary API for English if DreamDict is unavailable. |
| `!urban <term>` | Urban Dictionary |
| `!translate <word> [lang]` | Cross-language word lookup (en/fr/pt-PT, auto-detects source) |
| `!antonym <word> [lang]` | Antonyms for a word (en/fr/pt-PT/zh) |
| `!pronounce <word> [lang]` | Pronunciation data — IPA, CMU phonemes (en), Pinyin (zh) |
| `!etymology <word> [lang]` | Word origin and history from Wiktionary |
| `!difficulty <word> [lang]` | Difficulty score (Easy/Medium/Hard/Brutal) with frequency info |
| `!rhyme <word>` | Rhyming words via CMU phoneme matching (English only, `--all` for full list) |
### Personal
| Command | Description |
@@ -711,7 +847,7 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
| Command | Description |
|---------|-------------|
| `!achievements [@user]` | Unlocked achievements |
| `!wotd` | Today's Palavra do Dia — Portuguese word with en/fr translations (use it in chat for 25 XP) |
| `!wotd` | Today's Palavra do Dia — Portuguese word with en/fr translations, etymology when available (use it in chat for 25 XP) |
| `!wotd force` | Pick a new word for today (moderator only) |
| `!botinfo` | Bot diagnostics (admin only) |
| `!help` | DMs the full command list |
@@ -735,7 +871,7 @@ All of these run in the background without any commands:
- **URL previews** - OG tag extraction (feature-flagged, off by default)
- **Reactions** - logs all reactions for `!emojiboard`
- **Space groups** - rooms with sufficient member overlap are automatically grouped. Leaderboards, trivia scores, and other per-room features aggregate across the group. Recomputed hourly; persisted to SQLite. Uses strict clique-based grouping (every room must meet the threshold with every other room in the group).
- **LLM classification** - sentiment (10 categories), profanity, insults, WOTD usage (needs Ollama)
- **LLM classification** - sentiment (10 categories), profanity, insults, WOTD usage (needs Ollama). Emoji reactions limited to fancy words (🎓), gratitude (💜), WOTD usage (📖), and insult responses
- **Message buffer** - last 50 messages per room held in memory for `!vibe` and `!tldr`. Not persisted to disk; resets on restart. Uptime reported when insufficient messages are buffered.
---
@@ -746,10 +882,9 @@ Uses [robfig/cron](https://github.com/robfig/cron). All times UTC.
| Time | Job | What it does |
|------|-----|--------------|
| 00:05 | Prefetch | Grabs WOTD data ahead of time |
| 06:00 | Birthdays | Birthday shoutouts + 100 XP + €1,000 |
| 07:00 | Holidays | Multi-calendar holidays (US, Asian, Jewish, Islamic) |
| 08:00 | WOTD | Posts the Word of the Day |
| 08:00 | WOTD | Posts the Word of the Day (disable with `DISABLE_WOTD_POST=true`) |
| 09:00 Mon | Releases | Weekly game releases |
| 10:00 | Anime | Anime airing today |
| 11:00 | Movies | Movie releases today |
@@ -822,22 +957,22 @@ Assigned based on your message patterns:
## Sentiment Classification
Every message (subject to `LLM_SAMPLE_RATE`) is classified by Ollama into one of 10 sentiment categories. The bot reacts with an emoji when the confidence score is strong enough (|score| > 0.5). Per-user counts are tracked in the database and viewable via `!sentiment`.
Every message (subject to `LLM_SAMPLE_RATE`) is classified by Ollama into one of 10 sentiment categories. Per-user counts are tracked in the database and viewable via `!sentiment`. The LLM also returns a float score (-1.0 to 1.0) for each message, averaged per user to derive an overall mood shown in `!sentiment` output and fed into `!howami` roast profiles.
| Sentiment | Emoji | Score range | Example |
|-----------|-------|-------------|---------|
| Positive | 👍 | > 0.5 | "This is awesome, great work!" |
| Excited | 🔥 | > 0.5 | "OH MY GOD I can't wait for this!!" |
| Supportive | 🤗 | > 0.5 | "You've got this, don't give up" |
| Grateful | 💜 | > 0.5 | "Thank you so much for helping me" |
| Humorous | 😂 | > 0.5 | "lmao that's the funniest thing I've seen all day" |
| Curious | 🧐 | > 0.5 | "How does that work exactly?" |
| Neutral | — | — | "I'll be back in 10 minutes" |
| Sarcastic | 🤨 | < -0.5 | "Oh sure, that'll definitely work" |
| Frustrated | 😮‍💨 | < -0.5 | "I've been trying to fix this for three hours" |
| Negative | 👎 | < -0.5 | "This is broken and nobody cares" |
Selective emoji reactions are still active: 🎓 for fancy/uncommon words, 💜 for gratitude, and 📖 for WOTD usage. The bot also reacts when insulted. Sentiment and profanity emoji reactions are disabled — classification still runs and logs to the database, but the bot won't react with sentiment emojis.
The LLM also returns a float score (-1.0 to 1.0) for each message. These scores are averaged per user to derive an overall mood shown in `!sentiment` output and fed into `!howami` roast profiles.
| Sentiment | Score range | Example |
|-----------|-------------|---------|
| Positive | > 0.5 | "This is awesome, great work!" |
| Excited | > 0.5 | "OH MY GOD I can't wait for this!!" |
| Supportive | > 0.5 | "You've got this, don't give up" |
| Grateful | > 0.5 | "Thank you so much for helping me" |
| Humorous | > 0.5 | "lmao that's the funniest thing I've seen all day" |
| Curious | > 0.5 | "How does that work exactly?" |
| Neutral | — | "I'll be back in 10 minutes" |
| Sarcastic | < -0.5 | "Oh sure, that'll definitely work" |
| Frustrated | < -0.5 | "I've been trying to fix this for three hours" |
| Negative | < -0.5 | "This is broken and nobody cares" |
---
@@ -848,7 +983,7 @@ All optional. The bot works fine without any of them, you just won't have those
| Service | Free? | What for |
|---------|-------|----------|
| [RAWG](https://rawg.io/apidocs) | Yes | Game lookups, releases |
| DreamDict | Self-host | Translations, WOTD, Hangman clues, Wordle validation |
| DreamDict | Self-host | Translations, WOTD (etymology), Hangman (clues + difficulty), Wordle (word selection + validation), dictionary commands (antonyms, pronunciation, etymology, difficulty, rhymes) |
| [Calendarific](https://calendarific.com) | Yes (1k/mo) | Holiday calendar |
| [HebCal](https://www.hebcal.com) | Yes, no key | Jewish holidays |
| [Aladhan](https://aladhan.com/prayer-times-api) | Yes, no key | Islamic dates |
@@ -917,6 +1052,7 @@ gogobee/
│ │ ├── birthday.go # Birthdays
│ │ ├── retro.go # Game lookups (RAWG)
│ │ ├── lookup.go # Wiki, dictionary, urban, translate
│ │ ├── dictionary.go # DreamDict commands (antonym, pronounce, etymology, difficulty, rhyme)
│ │ ├── countdown.go # Countdowns
│ │ ├── stocks.go # Stocks
│ │ ├── market.go # Market indices (Yahoo Finance + Finnhub fallback)
@@ -938,8 +1074,9 @@ gogobee/
│ │ ├── hangman.go # Collaborative Hangman
│ │ ├── blackjack.go # Multiplayer Blackjack
│ │ ├── uno.go # Solo UNO vs bot (DM-based)
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns)
│ │ ├── uno_multi.go # Multiplayer UNO (lobby + DM turns + addbot)
│ │ ├── uno_nomercy.go # No Mercy mode (deck, stacking, mercy rule, 7-0, bot AI)
│ │ ├── uno_test.go # UNO tests (sudden death, scoring)
│ │ ├── holdem.go # Texas Hold'em plugin (commands, game lifecycle, NPC)
│ │ ├── holdem_game.go # Game state, player types, deck, position labels
│ │ ├── holdem_betting.go # Blinds, action validation, side pots
@@ -961,12 +1098,16 @@ gogobee/
│ │ ├── adventure_twinbee.go # TwinBee NPC, reward distribution, buff gifts
│ │ ├── adventure_render.go # Character sheet, DMs, daily summary, leaderboard
│ │ ├── adventure_events.go # Mid-day random events
│ │ ├── adventure_hospital.go # Hospital revival system (Nurse Joy, itemized bill, nudges)
│ │ ├── adventure_robbie.go # Robbie the Friendly Bandit (automated inventory cleaner)
│ │ ├── adventure_scheduler.go # Morning DM, evening summary, midnight reset tickers
│ │ ├── adventure_holidays.go # Holiday detection (Hebrew/Islamic calendar, fixed dates)
│ │ ├── adventure_masterwork.go # Masterwork gear (15 tiered items, drop logic, equip command)
│ │ ├── adventure_arena.go # Arena commands, combat resolution, DB CRUD, auto-cashout
│ │ ├── adventure_arena_combat.go # Arena combat log generator (action pools, HP narration)
│ │ ├── adventure_arena_monsters.go # 5 tiers, 20 monsters, death messages
│ │ ├── adventure_arena_render.go # Arena UI rendering (menus, stats, leaderboard)
│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, treasure, twinbee, events, closing)
│ │ ├── adventure_flavor_*.go # Flavor text pools (dungeon, mining, fishing, treasure, twinbee, events, hospital, robbie, closing)
│ │ ├── esteemed.go # Satirical esteemed member posts
│ │ ├── moderation.go # Moderation system (strikes, word list, flood detection)
│ │ └── ratelimits.go # Rate limiting
+6
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
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
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.
+3 -3
View File
@@ -4,12 +4,15 @@ go 1.25.0
require (
github.com/PuerkitoBio/goquery v1.10.3
github.com/chehsunliu/poker v0.1.0
github.com/expr-lang/expr v1.17.5
github.com/fogleman/gg v1.3.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/olebedev/when v1.1.0
github.com/robfig/cron/v3 v3.0.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.36.0
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.37.1
)
@@ -18,9 +21,7 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/chehsunliu/poker v0.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -37,7 +38,6 @@ require (
go.mau.fi/util v0.9.6 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
+2
View File
@@ -30,6 +30,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914 h1:yAIlIiOkdoJvqd5xtWzM9tNDpLZrFfJdpnNSKha78G8=
github.com/loganjspears/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:76SAnflG7ZFhgtnaVCpP6A5Z1S/VMFzRBN7KGm5j4oc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -42,6 +43,7 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914 h1:xXPuFr3PVM4p6Vw3j0CP29oWYRVKO3cPZjR6D7BxggQ=
github.com/notnil/joker v0.0.0-20180219043703-3f2f69a75914/go.mod h1:L0Sdr2nYdktjerdXpIn9wOCn+GebPs/nCL2qH6RTGa0=
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
+165
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")
}
}
+374
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
@@ -73,6 +87,61 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE adventure_characters ADD COLUMN masterwork_drops_received INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_inventory ADD COLUMN slot TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_inventory ADD COLUMN skill_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE user_stats ADD COLUMN fancy_words INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN rival_pool INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN rival_unlocked_notified INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_active INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_expires_at DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN babysit_skill_focus TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN hospital_visits INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN robbie_visit_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE wordle_stats ADD COLUMN total_earned INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN combat_actions_used INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN harvest_actions_used INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN last_pardon_used DATETIME`,
`ALTER TABLE arena_runs ADD COLUMN tier_earnings INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE arena_runs ADD COLUMN xp_accumulated INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN misty_last_seen DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN arina_last_seen DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN misty_buff_expires DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN misty_debuff_expires DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN arina_buff_expires DATETIME`,
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN npc_msg_count_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN misty_roll_target INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN arina_roll_target INTEGER NOT NULL DEFAULT 0`,
// Housing
`ALTER TABLE adventure_characters ADD COLUMN house_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_loan_balance INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_loan_frozen INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_missed_payments INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_autopay INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN house_current_rate REAL NOT NULL DEFAULT 0`,
// Pets
`ALTER TABLE adventure_characters ADD COLUMN pet_type TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN pet_name TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN pet_xp INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_level INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_armor_tier INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_chased_away INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_reactivated INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_arrived INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN misty_encounter_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN misty_donated_count INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN thom_animal_line_fired INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN pet_level10_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE adventure_characters ADD COLUMN pet_morning_defense INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN auto_babysit INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE adventure_characters ADD COLUMN streak_decayed INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE coop_dungeon_runs ADD COLUMN last_resolved_day INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE coop_dungeon_members ADD COLUMN member_payout INTEGER`,
`ALTER TABLE coop_dungeon_runs ADD COLUMN invite_post_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE coop_dungeon_gifts ADD COLUMN expires_at DATETIME`,
`ALTER TABLE coop_dungeon_gifts ADD COLUMN applied_at DATETIME`,
`ALTER TABLE coop_dungeon_gifts ADD COLUMN stack_lead_id INTEGER`,
`ALTER TABLE adventure_characters ADD COLUMN crafts_succeeded INTEGER NOT NULL DEFAULT 0`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {
@@ -105,6 +174,50 @@ func MarkJobCompleted(jobName, dateKey string) {
)
}
// RecordCrash logs a panic/crash event with version and plugin context.
func RecordCrash(botVersion, plugin, message string) {
if globalDB == nil {
return
}
Exec("crash log",
`INSERT INTO crash_log (bot_version, plugin, message) VALUES (?, ?, ?)`,
botVersion, plugin, message,
)
}
// RecordTipAudit logs a poker tip with full context for bulk review.
func RecordTipAudit(userID, hole, board, street, position string, numActive int, equityPct float64, pot, toCall, stack int64, spr float64, handCat, drawDesc, preflopTier, action, tipText string) {
if globalDB == nil {
return
}
Exec("tip audit",
`INSERT INTO holdem_tip_audit (user_id, hole, board, street, position, num_active, equity_pct, pot, to_call, stack, spr, hand_cat, draw_desc, preflop_tier, action, tip_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
userID, hole, board, street, position, numActive, equityPct, pot, toCall, stack, spr, handCat, drawDesc, preflopTier, action, tipText,
)
}
// RecordStartup logs a version startup event.
func RecordStartup(version, commit string) {
Exec("version history",
`INSERT INTO version_history (version, commit_sha) VALUES (?, ?)`,
version, commit,
)
}
// CrashCount returns the number of crashes for a given bot version.
func CrashCount(botVersion string) int {
var count int
_ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log WHERE bot_version = ?`, botVersion).Scan(&count)
return count
}
// CrashCountAll returns the total number of recorded crashes.
func CrashCountAll() int {
var count int
_ = Get().QueryRow(`SELECT COUNT(*) FROM crash_log`).Scan(&count)
return count
}
// CacheGet returns cached data for the given key if it exists and is within ttlSeconds.
// Returns empty string if not cached or expired.
func CacheGet(key string, ttlSeconds int) string {
@@ -129,6 +242,47 @@ func CacheSet(key, data string) {
)
}
// Backup creates a consistent snapshot of the database using VACUUM INTO.
// Keeps the last 7 daily backups, deleting older ones.
func Backup() error {
backupDir := filepath.Join(dataPath, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return fmt.Errorf("create backup dir: %w", err)
}
filename := fmt.Sprintf("gogobee_%s.db", time.Now().UTC().Format("2006-01-02"))
backupPath := filepath.Join(backupDir, filename)
_, err := Get().Exec(fmt.Sprintf(`VACUUM INTO '%s'`, backupPath))
if err != nil {
return fmt.Errorf("vacuum into backup: %w", err)
}
slog.Info("database backup created", "path", backupPath)
// Prune backups older than 7 days
entries, err := os.ReadDir(backupDir)
if err != nil {
return nil // backup succeeded, prune failure is non-fatal
}
cutoff := time.Now().UTC().AddDate(0, 0, -7)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().Before(cutoff) {
os.Remove(filepath.Join(backupDir, e.Name()))
slog.Info("pruned old backup", "file", e.Name())
}
}
return nil
}
// RunMaintenance purges stale data from cache tables, old rate limits,
// expired logs, and runs SQLite optimization. Intended to run daily.
func RunMaintenance() {
@@ -243,6 +397,7 @@ CREATE TABLE IF NOT EXISTS user_stats (
total_emojis INTEGER DEFAULT 0,
night_messages INTEGER DEFAULT 0,
morning_messages INTEGER DEFAULT 0,
fancy_words INTEGER DEFAULT 0,
updated_at INTEGER DEFAULT (unixepoch())
);
@@ -684,6 +839,8 @@ CREATE TABLE IF NOT EXISTS euro_balances (
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_euro_bal_user ON euro_balances(user_id);
CREATE TABLE IF NOT EXISTS euro_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -820,6 +977,17 @@ CREATE TABLE IF NOT EXISTS wordle_puzzles (
PRIMARY KEY (puzzle_id, room_id)
);
CREATE TABLE IF NOT EXISTS wordle_guesses (
puzzle_id TEXT NOT NULL,
room_id TEXT NOT NULL,
guess_num INTEGER NOT NULL,
word TEXT NOT NULL,
player_id TEXT NOT NULL,
player_name TEXT NOT NULL,
guessed_at DATETIME NOT NULL,
PRIMARY KEY (puzzle_id, room_id, guess_num)
);
-- Space groups (rooms with overlapping membership)
CREATE TABLE IF NOT EXISTS space_groups (
room_id TEXT PRIMARY KEY,
@@ -1005,6 +1173,8 @@ CREATE TABLE IF NOT EXISTS arena_runs (
ended_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_arena_runs_user ON arena_runs(user_id, status);
CREATE TABLE IF NOT EXISTS arena_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
@@ -1027,6 +1197,50 @@ CREATE TABLE IF NOT EXISTS arena_stats (
updated_at INTEGER NOT NULL
);
-- Rival System
CREATE TABLE IF NOT EXISTS adventure_rival_records (
user_id TEXT NOT NULL,
rival_id TEXT NOT NULL,
wins INTEGER NOT NULL DEFAULT 0,
losses INTEGER NOT NULL DEFAULT 0,
last_duel_at DATETIME,
PRIMARY KEY (user_id, rival_id)
);
CREATE TABLE IF NOT EXISTS adventure_rival_challenges (
challenge_id TEXT PRIMARY KEY,
challenger_id TEXT NOT NULL,
challenged_id TEXT NOT NULL,
stake INTEGER NOT NULL,
round INTEGER NOT NULL DEFAULT 1,
player_score INTEGER NOT NULL DEFAULT 0,
rival_score INTEGER NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rival_challenges_user ON adventure_rival_challenges(challenged_id, expires_at);
CREATE TABLE IF NOT EXISTS community_pot (
id INTEGER PRIMARY KEY DEFAULT 1,
balance INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Babysitting Service
CREATE TABLE IF NOT EXISTS adventure_babysit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
log_date DATE NOT NULL,
activity TEXT NOT NULL,
outcome TEXT NOT NULL,
gold_earned INTEGER NOT NULL DEFAULT 0,
xp_gained INTEGER NOT NULL DEFAULT 0,
items_dropped TEXT DEFAULT NULL,
rival_refused TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_babysit_log_user ON adventure_babysit_log(user_id);
-- Forex
CREATE TABLE IF NOT EXISTS forex_rates (
currency TEXT NOT NULL,
@@ -1081,6 +1295,166 @@ CREATE TABLE IF NOT EXISTS market_daily_summary (
generated_at INTEGER
);
-- Archetype cache (recalculated nightly)
CREATE TABLE IF NOT EXISTS user_archetypes (
user_id TEXT NOT NULL,
archetype TEXT NOT NULL,
category TEXT NOT NULL DEFAULT '',
signal_score REAL NOT NULL DEFAULT 0,
flavor TEXT NOT NULL DEFAULT '',
assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, archetype)
);
CREATE INDEX IF NOT EXISTS idx_user_archetypes_user ON user_archetypes(user_id, signal_score DESC);
-- Lottery tickets
CREATE TABLE IF NOT EXISTS lottery_tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
week_start DATE NOT NULL,
numbers TEXT NOT NULL,
match_count INTEGER DEFAULT NULL,
prize INTEGER DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_lottery_tickets_week ON lottery_tickets(week_start, user_id);
-- Lottery draw history
CREATE TABLE IF NOT EXISTS lottery_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draw_date DATE NOT NULL,
winning_numbers TEXT NOT NULL,
jackpot_winners INTEGER DEFAULT 0,
jackpot_amount INTEGER DEFAULT 0,
match4_winners INTEGER DEFAULT 0,
match3_winners INTEGER DEFAULT 0,
match2_winners INTEGER DEFAULT 0,
match1_winners INTEGER DEFAULT 0,
pot_total INTEGER NOT NULL,
rolled_over INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS crash_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bot_version TEXT NOT NULL,
plugin TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS version_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL,
commit_sha TEXT NOT NULL DEFAULT '',
started_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tax_ledger (
user_id TEXT PRIMARY KEY,
total_paid INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS holdem_tip_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
hole TEXT NOT NULL,
board TEXT NOT NULL DEFAULT '',
street TEXT NOT NULL,
position TEXT NOT NULL,
num_active INTEGER NOT NULL,
equity_pct REAL NOT NULL,
pot INTEGER NOT NULL,
to_call INTEGER NOT NULL,
stack INTEGER NOT NULL,
spr REAL NOT NULL,
hand_cat TEXT NOT NULL DEFAULT '',
draw_desc TEXT NOT NULL DEFAULT '',
preflop_tier TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
tip_text TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_tip_audit_user ON holdem_tip_audit(user_id);
CREATE INDEX IF NOT EXISTS idx_tip_audit_action ON holdem_tip_audit(action, street);
-- Co-op Dungeon (party multi-day runs)
CREATE TABLE IF NOT EXISTS coop_dungeon_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tier INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open', -- open, active, complete, wiped, cancelled
leader_id TEXT NOT NULL,
current_day INTEGER NOT NULL DEFAULT 0,
total_days INTEGER NOT NULL,
base_difficulty INTEGER NOT NULL, -- per-floor failure % at zero modifier
gold_pool INTEGER NOT NULL DEFAULT 0, -- accumulated funding
reward_total INTEGER NOT NULL DEFAULT 0, -- final reward (set on completion)
last_resolved_day INTEGER NOT NULL DEFAULT 0, -- crash-resume guard: highest day whose floor outcome is final
invite_post_id TEXT NOT NULL DEFAULT '', -- Matrix event ID of the games-room invite (for pin/unpin)
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
locked_at DATETIME,
completed_at DATETIME
);
CREATE INDEX IF NOT EXISTS idx_coop_runs_status ON coop_dungeon_runs(status);
CREATE TABLE IF NOT EXISTS coop_dungeon_members (
run_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
turn_order INTEGER NOT NULL,
total_contributed INTEGER NOT NULL DEFAULT 0,
is_liability INTEGER NOT NULL DEFAULT 0,
daily_funding TEXT NOT NULL DEFAULT '{}', -- JSON: {"1":"standard","2":"all_in",...}
member_payout INTEGER, -- NULL until reward credited; idempotency claim
PRIMARY KEY (run_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_coop_members_user ON coop_dungeon_members(user_id);
CREATE TABLE IF NOT EXISTS coop_dungeon_events (
run_id INTEGER NOT NULL,
day INTEGER NOT NULL,
category TEXT NOT NULL, -- obstacle, opportunity, crisis, encounter
event_index INTEGER NOT NULL, -- index into the category flavor pool
recommended TEXT NOT NULL, -- A, B, or C — TwinBee's pick
votes TEXT NOT NULL DEFAULT '{}', -- JSON {"@user:host": "A"}
winning_vote TEXT DEFAULT NULL,
outcome TEXT DEFAULT NULL, -- 'success' or 'failure' (after resolution)
modifier_applied INTEGER DEFAULT 0,
post_event_id TEXT DEFAULT NULL, -- Matrix event ID for live edit
PRIMARY KEY (run_id, day)
);
CREATE INDEX IF NOT EXISTS idx_coop_events_outcome ON coop_dungeon_events(outcome);
CREATE TABLE IF NOT EXISTS coop_dungeon_bets (
run_id INTEGER NOT NULL,
player_id TEXT NOT NULL,
position TEXT NOT NULL, -- 'success' or 'failure'
amount INTEGER NOT NULL,
placed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
payout INTEGER, -- NULL until run resolves
PRIMARY KEY (run_id, player_id)
);
CREATE INDEX IF NOT EXISTS idx_coop_bets_run ON coop_dungeon_bets(run_id);
CREATE TABLE IF NOT EXISTS coop_dungeon_gifts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
sender_id TEXT NOT NULL,
day INTEGER NOT NULL,
gift_type TEXT NOT NULL, -- 'basket' or 'mimic'
votes TEXT NOT NULL DEFAULT '{}', -- JSON {"@user:host": "open"|"leave"}
vote_result TEXT, -- 'opened' or 'left'
outcome TEXT, -- 'boost' or 'reduction'
modifier INTEGER NOT NULL DEFAULT 0,
post_event_id TEXT,
sent_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME, -- voting closes; tally fires when reached
resolved_at DATETIME, -- vote tallied (vote_result/modifier set)
applied_at DATETIME, -- modifier merged into a floor success roll
stack_lead_id INTEGER -- NULL for lead/standalone; follower points to lead's id
);
CREATE INDEX IF NOT EXISTS idx_coop_gifts_run_day ON coop_dungeon_gifts(run_id, day, vote_result);
`
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
+112
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
}
+186 -3
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
@@ -64,8 +65,10 @@ func (c *Client) IsValidWord(word, lang string) (bool, error) {
}
// RandomWord returns a random word for a language with optional filters.
// pos can be empty. min/max of 0 means no filter.
func (c *Client) RandomWord(lang, pos string, min, max int) (string, error) {
// pos can be empty. min/max of 0 means no filter. minFreq of 0 means no
// frequency filter; values > 0 pass min_freq to the server and also veto
// results that come back below the threshold (belt-and-suspenders).
func (c *Client) RandomWord(lang, pos string, min, max, minFreq int) (string, error) {
params := url.Values{"lang": {lang}}
if pos != "" {
params.Set("pos", pos)
@@ -76,6 +79,9 @@ func (c *Client) RandomWord(lang, pos string, min, max int) (string, error) {
if max > 0 {
params.Set("max", strconv.Itoa(max))
}
if minFreq > 0 {
params.Set("min_freq", strconv.Itoa(minFreq))
}
u := c.baseURL + "/random?" + params.Encode()
resp, err := c.httpClient.Get(u)
@@ -92,11 +98,18 @@ func (c *Client) RandomWord(lang, pos string, min, max int) (string, error) {
}
var result struct {
Word string `json:"word"`
Word string `json:"word"`
Frequency int `json:"frequency"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("dreamdict: random: decode: %w", err)
}
// Client-side veto in case the server doesn't support min_freq yet.
if minFreq > 0 && result.Frequency > 0 && result.Frequency < minFreq {
return "", fmt.Errorf("dreamdict: random: word below frequency threshold")
}
return result.Word, nil
}
@@ -166,6 +179,176 @@ func (c *Client) Translate(word, from, to string) ([]string, error) {
return result.Translations, nil
}
// Frequency returns the frequency score for a word in a language.
// Higher values indicate more common words. Returns 0 if unknown.
func (c *Client) Frequency(word, lang string) (int, error) {
u := c.baseURL + "/frequency?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return 0, fmt.Errorf("dreamdict: frequency: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("dreamdict: frequency: status %d", resp.StatusCode)
}
var result struct {
Frequency int `json:"frequency"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("dreamdict: frequency: decode: %w", err)
}
return result.Frequency, nil
}
// FrequencyBatch returns frequency scores for multiple words in a single request.
func (c *Client) FrequencyBatch(words []string, lang string) (map[string]int, error) {
u := c.baseURL + "/frequency/batch?" + url.Values{
"words": {strings.Join(words, ",")},
"lang": {lang},
}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: frequency batch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: frequency batch: status %d", resp.StatusCode)
}
var result struct {
Frequencies map[string]int `json:"frequencies"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: frequency batch: decode: %w", err)
}
return result.Frequencies, nil
}
// Antonyms returns antonyms for a word in a language.
func (c *Client) Antonyms(word, lang string) ([]string, error) {
u := c.baseURL + "/antonyms?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: antonyms: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: antonyms: status %d", resp.StatusCode)
}
var result struct {
Antonyms []string `json:"antonyms"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: antonyms: decode: %w", err)
}
return result.Antonyms, nil
}
// Pronunciation holds pronunciation data for a word.
type Pronunciation struct {
Format string `json:"format"`
Value string `json:"value"`
Source string `json:"source"`
}
// Pronunciations returns pronunciation data for a word in a language.
func (c *Client) Pronunciations(word, lang string) ([]Pronunciation, error) {
u := c.baseURL + "/pronunciation?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: pronunciation: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: pronunciation: status %d", resp.StatusCode)
}
var result struct {
Pronunciations []Pronunciation `json:"pronunciations"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: pronunciation: decode: %w", err)
}
return result.Pronunciations, nil
}
// Etymology returns the etymology text for a word in a language.
func (c *Client) Etymology(word, lang string) (string, error) {
u := c.baseURL + "/etymology?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return "", fmt.Errorf("dreamdict: etymology: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("dreamdict: etymology: status %d", resp.StatusCode)
}
var result struct {
Etymology string `json:"etymology"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("dreamdict: etymology: decode: %w", err)
}
return result.Etymology, nil
}
// Difficulty returns the difficulty score (0.0-1.0) for a word in a language.
// Returns -1 if the word has no difficulty score.
func (c *Client) Difficulty(word, lang string) (float64, error) {
u := c.baseURL + "/difficulty?" + url.Values{"word": {word}, "lang": {lang}}.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return 0, fmt.Errorf("dreamdict: difficulty: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("dreamdict: difficulty: status %d", resp.StatusCode)
}
var result struct {
Difficulty float64 `json:"difficulty"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("dreamdict: difficulty: decode: %w", err)
}
return result.Difficulty, nil
}
// Rhymes returns words that rhyme with the given word (English only).
func (c *Client) Rhymes(word string, limit int) ([]string, error) {
params := url.Values{"word": {word}}
if limit > 0 {
params.Set("limit", fmt.Sprintf("%d", limit))
}
u := c.baseURL + "/rhyme?" + params.Encode()
resp, err := c.httpClient.Get(u)
if err != nil {
return nil, fmt.Errorf("dreamdict: rhyme: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("dreamdict: rhyme: status %d", resp.StatusCode)
}
var result struct {
Rhymes []string `json:"rhymes"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("dreamdict: rhyme: decode: %w", err)
}
return result.Rhymes, nil
}
// Health checks if the DreamDict service is reachable and returns stats.
func (c *Client) Health() (*HealthResponse, error) {
resp, err := c.httpClient.Get(c.baseURL + "/health")
+123 -1
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
},
},
}
}
+432 -133
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,6 +32,10 @@ type AdventurePlugin struct {
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
arenaBailCh sync.Map // userID string -> chan struct{} (bail signal for countdown goroutine)
shopSessions sync.Map // userID string -> *advShopSession
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
morningHour int
summaryHour int
}
@@ -37,7 +46,7 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
return val.(*sync.Mutex)
}
const advDMResponseWindow = 15 * time.Minute
const advDMResponseWindow = 3 * time.Hour
// advMarkMenuSent records that an actionable adventure menu was DM'd to the user.
// Only bare-number DM replies within this window will be treated as adventure choices.
@@ -65,17 +74,27 @@ type advPendingTreasureDiscard struct {
Existing []AdvTreasureDef
}
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin) *AdventurePlugin {
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin, xp *XPPlugin) *AdventurePlugin {
return &AdventurePlugin{
Base: NewBase(client),
euro: euro,
xp: xp,
dmToPlayer: make(map[id.RoomID]id.UserID),
morningHour: envInt("ADVENTURE_MORNING_HOUR", 8),
summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20),
}
}
func (p *AdventurePlugin) Name() string { return "adventure" }
// chatLevel returns the user's chat level for perk calculations.
func (p *AdventurePlugin) chatLevel(userID id.UserID) int {
if p.xp == nil {
return 0
}
return p.xp.GetLevel(userID)
}
func (p *AdventurePlugin) Name() string { return "adventure" }
func (p *AdventurePlugin) Version() string { return "2.5.0" }
// SetAchievements wires the achievements plugin after both are initialized.
func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
@@ -86,6 +105,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
}
}
@@ -108,6 +128,9 @@ func (p *AdventurePlugin) Init() error {
if err := resetAllAdvDailyActions(); err != nil {
slog.Error("adventure: startup daily reset failed", "err", err)
}
if err := lockCoopCombatActions(); err != nil {
slog.Error("adventure: startup coop combat lock failed", "err", err)
}
// Revive any characters whose DeadUntil has expired
p.catchUpRespawns(chars)
@@ -116,7 +139,12 @@ func (p *AdventurePlugin) Init() error {
go p.summaryTicker()
go p.midnightTicker()
go p.eventTicker()
go p.arenaAutoCashoutTicker()
// Arena auto-cashout ticker removed — replaced by per-session countdown goroutines
go p.rivalChallengeTicker()
go p.robbieTicker()
go p.hospitalNudgeTicker()
go p.mortgageTicker()
go p.coopTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
@@ -149,10 +177,23 @@ func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
// 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") {
return p.handleArenaBail(ctx)
}
if p.IsCommand(ctx.Body, "arena") {
return p.dispatchArenaCommand(ctx)
}
// 1b. Hospital commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "hospital") {
return p.handleHospitalCmd(ctx)
}
// 1c. Co-op dungeon commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "coop") {
return p.handleCoopCmd(ctx)
}
// 2. Check if this is a DM reply from a registered player
p.mu.Lock()
playerID, isDM := p.dmToPlayer[ctx.RoomID]
@@ -162,8 +203,20 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
return p.handleDMReply(ctx)
}
// 3. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") {
// 3. NPC encounter tracking — count room messages from adventure players
if !isDM && !strings.HasPrefix(ctx.Body, "!") {
safeGo("npc-track", func() {
p.npcTrackMessage(ctx.Sender)
})
}
// 4. Thom Krooke commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "thom") {
return p.handleThomCmd(ctx)
}
// 5. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
return nil
}
@@ -172,6 +225,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
if args == "" && p.IsCommand(ctx.Body, "adv") {
args = strings.TrimSpace(p.GetArgs(ctx.Body, "adv"))
}
lower := strings.ToLower(args)
switch {
@@ -199,6 +255,20 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleEventRespond(ctx)
case lower == "help":
return p.SendDM(ctx.Sender, advHelpText)
case lower == "rivals":
return p.handleRivalsCmd(ctx)
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
case lower == "blacksmith" || lower == "repair":
return p.handleBlacksmithCmd(ctx)
case lower == "repair all":
return p.handleRepairAllCmd(ctx)
case strings.HasPrefix(lower, "repair "):
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
case lower == "boost":
return p.handleBoostCmd(ctx)
case lower == "recipes":
return p.handleRecipesCmd(ctx)
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
@@ -216,6 +286,16 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `.
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
` + "`!adventure help`" + ` — This message
**Arena:**
@@ -227,6 +307,10 @@ const advHelpText = `**Adventure Commands**
` + "`!arena status`" + ` — Current run state
` + "`!arena leaderboard`" + ` — Top arena players
**Systems:**
• Foraging level 10+ unlocks auto-crafting consumables from loot ingredients before combat.
• A 5% community tax funds the lottery pot. Arena and hospital apply 10%.
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
// ── Command Handlers ─────────────────────────────────────────────────────────
@@ -238,29 +322,33 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
}
if !char.Alive {
text := renderAdvDeathStatusDM(char)
return p.SendDM(ctx.Sender, text)
}
if char.ActionTakenToday {
// On holidays, allow second action if not yet taken
isHol, _ := isHolidayToday()
if isHol && !char.HolidayActionTaken {
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
text := renderAdvHolidaySecondPrompt(char, equip, bonuses)
p.advMarkMenuSent(ctx.Sender)
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
text := renderAdvRespawnDM(char)
p.SendDM(ctx.Sender, text)
// Fall through to show menu
}
}
if !char.Alive {
text := renderAdvDeathStatusDM(char)
return p.SendDM(ctx.Sender, text)
}
}
isHol, holName := isHolidayToday()
if char.AllActionsUsed(isHol) {
now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now)
hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your action today. Tomorrow awaits. Try to survive it.\n\n"+
"You've used all your actions today. Tomorrow awaits. Try to survive it.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n"+
"Morning DM: %02d:00 UTC\n\n"+
"The Arena is always open: `!arena`",
@@ -272,7 +360,6 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID)
_, holName := isHolidayToday()
text := renderAdvMorningDM(char, equip, balance, bonuses, holName)
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
@@ -284,25 +371,57 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
}
equip, _ := loadAdvEquipment(ctx.Sender)
items, _ := loadAdvInventory(ctx.Sender)
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
// On-demand revive if death timer has expired
if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
}
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load equipment for status", "user", ctx.Sender, "err", err)
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load inventory for status", "user", ctx.Sender, "err", err)
}
treasures, err := loadAdvTreasureBonuses(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load treasures for status", "user", ctx.Sender, "err", err)
}
balance := p.euro.GetBalance(ctx.Sender)
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) error {
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error {
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
showAll := strings.Contains(strings.ToLower(args), "show all")
category := strings.TrimSpace(strings.Replace(strings.ToLower(args), "show all", "", 1))
p.shopSessionStart(ctx.Sender)
if category == "" {
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
text := luigiShopGreeting(ctx.Sender, equip, balance, showAll, p.chatLevel(ctx.Sender))
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
slot := advParseShopCategory(category)
@@ -310,18 +429,30 @@ func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, category string) err
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category))
}
return p.SendDM(ctx.Sender, advShopCategory(slot, equip, balance))
text := luigiCategoryView(ctx.Sender, slot, equip, balance, showAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_item",
Data: &advPendingShopItem{Slot: slot, ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
char, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. Shopping can wait until you've respawned.")
}
if itemName == "" {
return p.SendDM(ctx.Sender, "Usage: `!buy <item name>`. Type `!adventure shop` to browse.")
}
slot, def, found := advFindShopItem(itemName)
if !found {
return p.SendDM(ctx.Sender, fmt.Sprintf("No item matching '%s' found in the shop. Type `!adventure shop` to see what's available.", itemName))
@@ -334,7 +465,7 @@ func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) erro
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.")
@@ -413,12 +544,18 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
body := strings.TrimSpace(ctx.Body)
// Skip if it looks like a command for another plugin
if strings.HasPrefix(body, "!") && !strings.HasPrefix(strings.ToLower(body), "!adventure") {
lower := strings.ToLower(body)
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") && !strings.HasPrefix(lower, "!thom") {
return nil
}
// Strip !adventure prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(strings.ToLower(body), "!adventure") {
// Handle !thom in DMs
if strings.HasPrefix(lower, "!thom") {
return p.handleThomCmd(ctx)
}
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
return p.dispatchCommand(ctx)
}
@@ -429,6 +566,7 @@ func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
return p.resolvePendingInteraction(ctx, interaction)
}
p.pending.Delete(string(ctx.Sender))
p.shopSessionEnd(ctx.Sender)
p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
}
@@ -453,6 +591,30 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.handleMasterworkEquipReply(ctx, interaction)
case "masterwork_equip_confirm":
return p.handleMasterworkEquipConfirm(ctx, interaction)
case "rival_rps":
return p.resolveRivalRPSRound(ctx, interaction)
case "blacksmith_slot":
return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction)
case "shop_category":
return p.resolveShopCategoryChoice(ctx, interaction)
case "shop_item":
return p.resolveShopItemChoice(ctx, interaction)
case "shop_supply":
return p.resolveShopSupplyChoice(ctx, interaction)
case "shop_confirm":
return p.resolveShopConfirm(ctx, interaction)
case "hospital_pay":
return p.resolveHospitalPay(ctx, interaction)
case "npc_encounter":
return p.resolveNPCEncounter(ctx, interaction)
case "pet_arrival":
return p.resolvePetArrival(ctx)
case "pet_type":
return p.resolvePetType(ctx)
case "pet_name":
return p.resolvePetName(ctx)
}
return nil
}
@@ -499,47 +661,58 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
}
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
}
if char.ActionTakenToday {
// On holidays, allow second action if not yet taken
isHol, _ := isHolidayToday()
if !isHol || char.HolidayActionTaken {
// Only send the reminder once per day — subsequent DM messages
// are silently ignored so they can be handled by other plugins (e.g. UNO).
today := time.Now().UTC().Format("2006-01-02")
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
return nil
}
p.dmRemindedDate.Store(string(ctx.Sender), today)
now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now)
hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've already taken your action today. Rest now. Try again tomorrow.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
"The Arena is always open: `!arena`",
hours, minutes))
isHol, _ := isHolidayToday()
if char.AllActionsUsed(isHol) {
// Only send the reminder once per day — subsequent DM messages
// are silently ignored so they can be handled by other plugins (e.g. UNO).
today := time.Now().UTC().Format("2006-01-02")
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
return nil
}
// Fall through for holiday second action
p.dmRemindedDate.Store(string(ctx.Sender), today)
now := time.Now().UTC()
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
remaining := midnight.Sub(now)
hours := int(remaining.Hours())
minutes := int(remaining.Minutes()) % 60
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've used all your actions today. Rest now. Try again tomorrow.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
"The Arena is always open: `!arena`",
hours, minutes))
}
lower := strings.ToLower(body)
// Parse "6" or "rest"
if lower == "6" || lower == "rest" {
// Parse "7" or "rest"
if lower == "7" || lower == "rest" {
return p.resolveRest(ctx, char)
}
// Parse "6" or "blacksmith"
if lower == "6" || lower == "blacksmith" {
return p.handleBlacksmithCmd(ctx)
}
// Parse "5" or "shop"
if lower == "5" || lower == "shop" {
equip, _ := loadAdvEquipment(ctx.Sender)
balance := p.euro.GetBalance(ctx.Sender)
return p.SendDM(ctx.Sender, advShopOverview(equip, balance))
return p.handleShopCmd(ctx, "")
}
// Parse activity + location
@@ -614,6 +787,14 @@ func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCha
// ── Activity Resolution ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
isHol, _ := isHolidayToday()
if isCombatActivity(activity) && !char.CanDoCombat(isHol) {
return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.")
}
if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) {
return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.")
}
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
@@ -632,12 +813,38 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
return p.SendDM(ctx.Sender, fmt.Sprintf("You are not eligible for %s. Your level or equipment tier is too low.", loc.Name))
}
// Resolve the action
result := resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
// Per-location cooldown: 3 hours after a successful run
if remaining := advLocationCooldown(char.UserID, loc.Name); remaining > 0 {
mins := int(remaining.Minutes())
if mins < 1 {
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in less than a minute, or pick a different location.", loc.Name))
}
h, m := mins/60, mins%60
if h > 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %dh %dm, or pick a different location.", loc.Name, h, m))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m))
}
// Resolve the action — dungeon uses combat engine, others use probability bands
var result *AdvActionResult
if loc.Activity == AdvActivityDungeon {
result = p.resolveDungeonAction(char, equip, loc, bonuses, inPenaltyZone)
} else {
result = resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
}
// Select flavor text
result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result)
// Chat level XP bonus
if bonus := chatLevelXPBonus(p.chatLevel(char.UserID)); bonus > 0 {
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":
@@ -652,34 +859,38 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
// Check level up
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
if result.LeveledUp && result.XPSkill == "combat" {
p.checkRivalPoolUnlock(char)
}
engineDeathSaved := result.CombatLog != nil && checkDeathSaveEvent(result.CombatLog.Events)
// Handle death
deathReprieved := false
pardonFired := false
petRecovered := false
if result.Outcome == AdvOutcomeDeath {
// Sovereign set: Death's Reprieve — survive lethal outcome
if advEquippedArenaSets(equip)["sovereign"] && char.DeathReprieveAvailable() {
deathReprieved = true
now := time.Now().UTC()
char.DeathReprieveLast = &now
char.GrudgeLocation = loc.Name
// Gear absorbs the blow — all equipment set to 1 condition
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
eq.Condition = 1
}
}
// Post room announcement
nextWindow := now.Add(168 * time.Hour)
dt := transitionDeath(DeathTransitionParams{
Char: char,
Equip: equip,
ChatLevel: p.chatLevel(char.UserID),
Location: loc.Name,
AllowPardon: true,
AllowSovereign: true,
EngineSaved: engineDeathSaved,
})
pardonFired = dt.Pardoned
deathReprieved = dt.Pardoned || dt.Reprieved
petRecovered = dt.PetRecovered
if dt.Pardoned {
result.Outcome = AdvOutcomeEmpty
}
if dt.Reprieved {
nextWindow := time.Now().UTC().Add(168 * time.Hour)
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
}
} else {
char.Alive = false
now := time.Now().UTC()
deadUntil := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
char.DeadUntil = &deadUntil
char.GrudgeLocation = loc.Name
}
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
// Clear grudge on successful return
@@ -691,22 +902,22 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
_ = addAdvInventoryItem(char.UserID, item)
}
// Determine if this is the holiday second action
isAction2 := char.ActionTakenToday // already taken = this is the second
isHol, _ := isHolidayToday()
// Mark action taken and record the date for streak tracking
if !isAction2 {
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
// Roll for consumable drop on success/exceptional at T2+
if (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) && loc.Tier >= 2 {
if drop := RollConsumableDrop(loc.Activity, loc.Tier); drop != nil {
_ = addAdvInventoryItem(char.UserID, *drop)
result.LootItems = append(result.LootItems, *drop)
}
}
// Holiday flags: mark second action done, or mark it done on death during action 1
if isAction2 {
char.HolidayActionTaken = true
} else if isHol && result.Outcome == AdvOutcomeDeath && !deathReprieved {
char.HolidayActionTaken = true // died on action 1 — no second action
// Mark action consumed in the correct bucket
if isCombatActivity(activity) {
char.CombatActionsUsed++
} else if isHarvestActivity(activity) {
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
// Update streak info
result.StreakBonus = char.CurrentStreak
@@ -717,6 +928,16 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
}
// Pet XP
if char.HasPet() && result.Outcome != AdvOutcomeDeath {
if petGrantXP(char) {
_ = saveAdvCharacter(char)
_ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel))
} else {
_ = saveAdvCharacter(char)
}
}
// Save equipment changes
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
@@ -737,8 +958,8 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
if partyBonus > 0 {
result.TotalLootValue += partyBonus
// Credit the bonus directly
p.euro.Credit(char.UserID, float64(partyBonus), "adventure_party_bonus")
net, _ := communityTax(char.UserID, float64(partyBonus), 0.05)
p.euro.Credit(char.UserID, net, "adventure_party_bonus")
}
}
}
@@ -755,8 +976,38 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
if closing != "" {
text += "\n" + closing
}
if err := p.SendDM(ctx.Sender, text); err != nil {
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
// Dungeon combat: send phased combat messages with delays, then resolution DM
if result.CombatLog != nil {
phaseMessages := RenderCombatLog(*result.CombatLog, char.DisplayName, loc.Denizens)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
done := p.sendCombatMessages(ctx.Sender, phaseMessages, text)
go func() {
<-done
if pardonFired {
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
}
if petRecovered && char.PetName != "" {
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
}
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
p.sendHospitalAd(ctx.Sender, char)
}
}()
} else {
if err := p.SendDM(ctx.Sender, text); err != nil {
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
}
if pardonFired {
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
}
if petRecovered && char.PetName != "" {
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
}
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
p.sendHospitalAd(ctx.Sender, char)
}
}
// Check for treasure drop
@@ -765,34 +1016,40 @@ func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCha
p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome)
}
// TODO: holiday achievement hooks
// Holiday: offer second action if this was action 1 and player survived
if !isAction2 && isHol && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
equip2, _ := loadAdvEquipment(char.UserID)
treasures2, _ := loadAdvTreasureBonuses(char.UserID)
buffs2, _ := loadAdvActiveBuffs(char.UserID)
bonuses2 := computeAdvBonuses(treasures2, buffs2, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip2, bonuses2)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
// If the player still has actions remaining, nudge them
if !char.AllActionsUsed(isHol) && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
remaining := []string{}
if char.CanDoCombat(isHol) {
remaining = append(remaining, "combat")
}
if char.CanDoHarvest(isHol) {
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
harvestLeft := harvestMax - char.HarvestActionsUsed
remaining = append(remaining, fmt.Sprintf("%d harvest", harvestLeft))
}
p.SendDM(ctx.Sender, fmt.Sprintf("Actions remaining today: %s. Type `!adv` for the menu.", strings.Join(remaining, ", ")))
}
return nil
}
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
isAction2 := char.ActionTakenToday
isHol, _ := isHolidayToday()
if !isAction2 {
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
}
if isAction2 {
char.HolidayActionTaken = true
// Rest consumes all remaining actions
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
char.CombatActionsUsed = combatMax
char.HarvestActionsUsed = harvestMax
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
@@ -822,25 +1079,13 @@ func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharact
slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
}
// Holiday: offer second action if this was action 1
if !isAction2 && isHol {
equip, _ := loadAdvEquipment(char.UserID)
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
prompt := renderAdvHolidaySecondPrompt(char, equip, bonuses)
if err := p.SendDM(ctx.Sender, prompt); err != nil {
slog.Error("adventure: failed to send holiday second prompt", "user", ctx.Sender, "err", err)
}
}
return nil
}
// ── Treasure Drop Check ─────────────────────────────────────────────────────
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
drop := rollAdvTreasureDrop(loc.Tier, userID)
drop := rollAdvTreasureDrop(loc.Tier, userID, p.chatLevel(userID))
if drop == nil {
return
}
@@ -876,7 +1121,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha
NewTreasure: drop.Def,
Existing: existing,
},
ExpiresAt: time.Now().Add(5 * time.Minute),
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
text := renderAdvTreasureDiscardPrompt(drop.Def, existing)
@@ -1042,7 +1287,12 @@ func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *Adv
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
char, err := loadAdvCharacter(userID)
if err != nil {
// Auto-create
if !errors.Is(err, sql.ErrNoRows) {
// Query error (e.g. missing column) — do NOT auto-create over existing data
slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
return nil, nil, fmt.Errorf("failed to load character: %w", err)
}
// Genuinely new player — auto-create
displayName := p.DisplayName(userID)
if err := createAdvCharacter(userID, displayName); err != nil {
return nil, nil, err
@@ -1068,3 +1318,52 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter
return char, equip, nil
}
// ── Double XP/Money Boost ───────────────────────────────────────────────────
const advBoostCacheKey = "adv_boost_active"
func advBoostActive() bool {
return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
}
func advSetBoost(active bool) {
v := "0"
if active {
v = "1"
}
db.CacheSet(advBoostCacheKey, v)
}
func advApplyBoost(result *AdvActionResult) {
if !advBoostActive() {
return
}
result.XPGained *= 2
result.TotalLootValue = 0
for i := range result.LootItems {
result.LootItems[i].Value *= 2
result.TotalLootValue += result.LootItems[i].Value
}
}
func (p *AdventurePlugin) handleRecipesCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill))
}
func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
if advBoostActive() {
advSetBoost(false)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
}
advSetBoost(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
}
+104 -52
View File
@@ -3,10 +3,15 @@ package plugin
import (
"math"
"math/rand/v2"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
const advLocationCooldownDuration = 3 * time.Hour
// ── Activity & Outcome Types ─────────────────────────────────────────────────
type AdvActivityType string
@@ -125,7 +130,7 @@ func findAdvLocationByTier(activity AdvActivityType, tier int) *AdvLocation {
// ── Loot Tables ──────────────────────────────────────────────────────────────
var advDungeonLoot = map[int][]AdvLootDef{
1: {{"Copper Coins", "treasure", 1, 5}, {"Rat Pelt", "treasure", 3, 8}, {"Mouldy Bread", "treasure", 1, 3}, {"Bent Nail", "treasure", 1, 2}},
1: {{"Copper Coins", "treasure", 5, 12}, {"Rat Pelt", "treasure", 8, 18}, {"Mouldy Bread", "treasure", 3, 8}, {"Bent Nail", "treasure", 2, 6}},
2: {{"Iron Scraps", "ore", 20, 40}, {"Goblin Trinket", "treasure", 25, 50}, {"Small Gem", "gem", 40, 80}},
3: {{"Silver Bar", "ore", 100, 200}, {"Ancient Artifact", "treasure", 150, 300}, {"Quality Gem", "gem", 200, 400}},
4: {{"Gold Ingot", "ore", 500, 1000}, {"Enchanted Fragment", "treasure", 800, 1500}, {"Rare Gem", "gem", 1000, 2000}},
@@ -133,7 +138,7 @@ var advDungeonLoot = map[int][]AdvLootDef{
}
var advMiningLoot = map[int][]AdvLootDef{
1: {{"Copper Ore", "ore", 2, 5}, {"Tin Ore", "ore", 3, 6}, {"Coal", "ore", 2, 4}},
1: {{"Copper Ore", "ore", 5, 12}, {"Tin Ore", "ore", 6, 14}, {"Coal", "ore", 4, 10}},
2: {{"Iron Ore", "ore", 15, 25}, {"Lead Ore", "ore", 18, 30}, {"Saltpetre", "ore", 20, 40}},
3: {{"Silver Ore", "ore", 60, 100}, {"Quartz", "ore", 80, 120}, {"Nickel Ore", "ore", 70, 110}},
4: {{"Gold Ore", "ore", 200, 400}, {"Sapphire", "gem", 300, 500}, {"Titanium Ore", "ore", 250, 450}},
@@ -141,7 +146,7 @@ var advMiningLoot = map[int][]AdvLootDef{
}
var advForagingLoot = map[int][]AdvLootDef{
1: {{"Berries", "fruit", 1, 4}, {"Twigs", "wood", 2, 5}, {"Common Herbs", "fruit", 3, 8}},
1: {{"Berries", "fruit", 3, 10}, {"Twigs", "wood", 5, 12}, {"Common Herbs", "fruit", 6, 15}},
2: {{"Hardwood", "wood", 10, 20}, {"Wild Fruit", "fruit", 12, 22}, {"Mushrooms", "fruit", 15, 30}},
3: {{"Ancient Timber", "wood", 40, 80}, {"Rare Herbs", "fruit", 50, 100}, {"Honey", "fruit", 60, 120}},
4: {{"Exotic Wood", "wood", 150, 300}, {"Tropical Fruits", "fruit", 180, 400}, {"Spores", "fruit", 200, 500}},
@@ -149,7 +154,7 @@ var advForagingLoot = map[int][]AdvLootDef{
}
var advFishingLoot = map[int][]AdvLootDef{
1: {{"Sad Fish", "fish", 1, 4}, {"Old Boot", "junk", 2, 5}, {"Tin Can", "junk", 1, 3}},
1: {{"Sad Fish", "fish", 4, 10}, {"Old Boot", "junk", 5, 12}, {"Tin Can", "junk", 3, 8}},
2: {{"Creek Trout", "fish", 12, 22}, {"Iron Scale", "fish", 15, 28}, {"River Pearl", "gem", 20, 40}},
3: {{"Silver Bass", "fish", 50, 90}, {"Lake Sturgeon", "fish", 60, 110}, {"Blooper Ink", "treasure", 80, 150}},
4: {{"Deep Eel", "fish", 180, 350}, {"River Serpent Scale", "treasure", 250, 500}, {"Abyssal Pearl", "gem", 300, 600}},
@@ -339,15 +344,41 @@ func computeAdvBonuses(treasures []AdvTreasureBonus, buffs []AdvBuff, streak int
return b
}
// ── Location Cooldown ───────────────────────────────────────────────────────
// advLocationCooldown returns how long until a player can run the same location
// again. Returns 0 if no cooldown is active. Only successful runs trigger cooldown.
func advLocationCooldown(userID id.UserID, location string) time.Duration {
d := db.Get()
var loggedAt string
err := d.QueryRow(
`SELECT logged_at FROM adventure_activity_log
WHERE user_id = ? AND location = ? AND outcome IN ('success', 'exceptional')
ORDER BY logged_at DESC LIMIT 1`,
string(userID), location,
).Scan(&loggedAt)
if err != nil {
return 0
}
t, err := time.Parse("2006-01-02 15:04:05", loggedAt)
if err != nil {
return 0
}
remaining := advLocationCooldownDuration - time.Since(t)
if remaining <= 0 {
return 0
}
return remaining
}
// ── Eligibility ──────────────────────────────────────────────────────────────
// advIsEligible checks if a character can enter a location.
// Returns (eligible, inPenaltyZone).
func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary) (bool, bool) {
// Get effective skill level
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
if skillLevel < loc.MinLevel {
// Tier gating uses base skill only — buffs improve success chances, not access.
baseLevel := advBaseSkill(char, loc.Activity)
if baseLevel < loc.MinLevel {
return false, false
}
@@ -365,11 +396,25 @@ func advIsEligible(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipme
return false, false
}
// Penalty zone: within 3 levels of minimum
penalty := skillLevel-loc.MinLevel < 3
// Penalty zone: within 3 levels of minimum (base skill only)
penalty := baseLevel-loc.MinLevel < 3
return true, penalty
}
func advBaseSkill(char *AdventureCharacter, activity AdvActivityType) int {
switch activity {
case AdvActivityDungeon:
return char.CombatLevel
case AdvActivityMining:
return char.MiningSkill
case AdvActivityForaging:
return char.ForagingSkill
case AdvActivityFishing:
return char.FishingSkill
}
return 1
}
func advEffectiveSkill(char *AdventureCharacter, activity AdvActivityType, bonuses *AdvBonusSummary) int {
switch activity {
case AdvActivityDungeon:
@@ -394,7 +439,7 @@ type advProbabilities struct {
}
func calculateAdvProbabilities(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) advProbabilities {
eqScore := float64(advEquipmentScore(equip))
eqScore := advEquipmentScore(equip)
skillLevel := float64(advEffectiveSkill(char, loc.Activity, bonuses))
deathPct := loc.BaseDeathPct - (eqScore * 0.8) - (skillLevel * 0.5) + bonuses.DeathModifier
@@ -503,7 +548,6 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
switch outcome {
case AdvOutcomeDeath:
// All slots -20, weapon and armor -30 (additional)
for _, slot := range allSlots {
damage[slot] = 20
}
@@ -515,7 +559,6 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
damage[SlotArmor] = 10
case AdvOutcomeEmpty:
// Failed dungeon run
damage[SlotWeapon] = 15
damage[SlotArmor] = 10
@@ -527,32 +570,9 @@ func applyAdvEquipDegradation(equip map[EquipmentSlot]*AdvEquipment, outcome Adv
damage[SlotBoots] = 20
case AdvOutcomeHornets:
// No equipment damage — they don't care about your sword
}
// Tempered set: Seasoned — condition degrades 25% slower (applied once per set)
tempered := advEquippedArenaSets(equip)["tempered"]
// Apply damage and check for breaks
for slot, dmg := range damage {
eq, ok := equip[slot]
if !ok {
continue
}
if tempered {
dmg = int(float64(dmg) * 0.75)
}
// Equipment mastery: well-used gear degrades slower
if eq.ActionsUsed >= 20 {
dmg = int(float64(dmg) * 0.8)
}
eq.Condition -= dmg
if eq.Condition < 0 {
eq.Condition = 0
}
}
return damage
return applyDegradationModifiers(damage, equip)
}
// advCheckBrokenEquipment checks which slots hit 0 condition and reverts them to tier 0.
@@ -578,6 +598,31 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS
return broken
}
// ── Overlevel Penalty ───────────────────────────────────────────────────────
// advOverlevelMultiplier returns a multiplier (0.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%.
// No penalty if no higher-tier location of the same activity is accessible.
func advOverlevelMultiplier(effectiveLevel int, loc *AdvLocation) float64 {
gap := effectiveLevel - loc.MinLevel
if gap <= 3 {
return 1.0
}
hasHigherAccessible := false
for _, other := range allAdvLocations(loc.Activity) {
if other.MinLevel > loc.MinLevel && other.MinLevel <= effectiveLevel {
hasHigherAccessible = true
break
}
}
if !hasHigherAccessible {
return 1.0
}
mult := 1.0 - 0.15*float64(gap-3)
return math.Max(0.05, mult)
}
// ── Outcome Resolution ───────────────────────────────────────────────────────
type AdvActionResult struct {
@@ -586,6 +631,7 @@ type AdvActionResult struct {
LootItems []AdvItem
TotalLootValue int64
XPGained int
XPBreakdown string // human-readable bonus breakdown
XPSkill string
EquipDamage map[EquipmentSlot]int
LeveledUp bool
@@ -596,6 +642,7 @@ type AdvActionResult struct {
EquipBroken []EquipmentSlot
NearDeath bool
StreakBonus int
CombatLog *CombatResult
}
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
@@ -606,6 +653,10 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
// Overlevel penalty — reduces loot and XP for farming low-tier content
skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
overlevelMult := advOverlevelMultiplier(skillLevel, loc)
// Roll outcome
roll := rand.Float64() * 100
@@ -629,6 +680,12 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
// Generate loot for success/exceptional
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
// Apply overlevel penalty to loot values
if overlevelMult < 1.0 {
for i := range result.LootItems {
result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
}
}
for _, item := range result.LootItems {
result.TotalLootValue += item.Value
}
@@ -643,20 +700,15 @@ func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEqui
}
}
// Near-death XP bonus
if result.NearDeath {
xp = int(float64(xp) * 1.15)
}
// XP multiplier from bonuses
if bonuses.XPMultiplier != 0 {
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
}
// Ironclad set: Battle-Hardened — +5% XP gain
if advEquippedArenaSets(equip)["ironclad"] {
xp = int(float64(xp) * 1.05)
}
result.XPGained = xp
xpResult := applyXPBonuses(XPBonusParams{
BaseXP: xp,
NearDeath: result.NearDeath,
BonusMult: bonuses.XPMultiplier,
Ironclad: advEquippedArenaSets(equip)["ironclad"],
OverlevelMult: overlevelMult,
})
result.XPGained = xpResult.Total
result.XPBreakdown = xpResult.Breakdown
// Equipment degradation on bad outcomes
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
@@ -737,7 +789,7 @@ func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*Adv
// advCheckPartyBonus checks if other players visited the same location today.
func advCheckPartyBonus(userID id.UserID, location string) bool {
logs, err := loadAdvTodayLogs()
logs, err := loadAdvLogsForDate(time.Now().UTC().Format("2006-01-02"))
if err != nil {
return false
}
File diff suppressed because it is too large Load Diff
-442
View File
@@ -3,376 +3,8 @@ package plugin
import (
"fmt"
"math/rand/v2"
"strings"
)
// ── Combat Log Types ───────────────────────────────────────────────────────
type ArenaCombatLog struct {
Rounds []ArenaCombatRound
PlayerHP int
EnemyHP int
PlayerWon bool
}
type ArenaCombatRound struct {
Number int
Text string // action description with damage filled in
Type string // "player_hit", "enemy_hit", "block", "environmental"
DamageToPlayer int
DamageToEnemy int
PlayerHP int // HP after this round
EnemyHP int // HP after this round
}
// ── Combat Log Generation ──────────────────────────────────────────────────
// generateArenaCombatLog assembles a turn-by-turn narrative for a fight whose
// outcome is already determined. The log is cosmetic — the roll already happened.
// closeness is 0.0 (decisive) to 1.0 (razor-thin margin).
func generateArenaCombatLog(playerWon bool, closeness float64) *ArenaCombatLog {
// Pick HP pools
playerHP := 60 + rand.IntN(41) // 60-100
enemyHP := 60 + rand.IntN(41) // 60-100
// Determine round count: decisive=3-4, close=5-6
numRounds := 3
if closeness > 0.7 {
numRounds = 5 + rand.IntN(2) // 5-6
} else if closeness > 0.4 {
numRounds = 4 + rand.IntN(2) // 4-5
} else {
numRounds = 3 + rand.IntN(2) // 3-4
}
// Assign round types
types := assignRoundTypes(numRounds, playerWon)
// Calculate damage distribution
picker := newActionPicker()
rounds := distributeDamage(types, playerHP, enemyHP, playerWon, picker)
return &ArenaCombatLog{
Rounds: rounds,
PlayerHP: playerHP,
EnemyHP: enemyHP,
PlayerWon: playerWon,
}
}
// assignRoundTypes determines what happens each round.
// Final round is always winner hitting — this is enforced.
// Guarantees at least 1 hit round per side.
func assignRoundTypes(numRounds int, playerWon bool) []string {
types := make([]string, numRounds)
// Final round: winner lands the killing blow
if playerWon {
types[numRounds-1] = "player_hit"
} else {
types[numRounds-1] = "enemy_hit"
}
// Fill remaining rounds
for i := 0; i < numRounds-1; i++ {
roll := rand.Float64()
switch {
case roll < 0.15:
types[i] = "environmental"
case roll < 0.35:
types[i] = "block"
case roll < 0.65:
if i%2 == 0 {
types[i] = "enemy_hit"
} else {
types[i] = "player_hit"
}
default:
if i%2 == 0 {
types[i] = "player_hit"
} else {
types[i] = "enemy_hit"
}
}
}
// Guarantee at least 1 hit round per side (besides the final round).
hasPlayerHit := false
hasEnemyHit := false
for _, t := range types {
if t == "player_hit" {
hasPlayerHit = true
}
if t == "enemy_hit" || t == "environmental" {
hasEnemyHit = true
}
}
// If missing a side, convert the first block round (or first non-final round).
if !hasPlayerHit {
for i := 0; i < numRounds-1; i++ {
if types[i] == "block" || types[i] == "enemy_hit" || types[i] == "environmental" {
types[i] = "player_hit"
break
}
}
}
if !hasEnemyHit {
for i := 0; i < numRounds-1; i++ {
if types[i] == "block" || types[i] == "player_hit" {
types[i] = "enemy_hit"
break
}
}
}
return types
}
// distributeDamage creates rounds with damage values that sum correctly.
func distributeDamage(types []string, playerHP, enemyHP int, playerWon bool, picker *actionPicker) []ArenaCombatRound {
numRounds := len(types)
// Total damage dealt: winner kills the loser (deals their full HP).
// Loser deals some but not all of winner's HP.
var totalDmgToEnemy, totalDmgToPlayer int
if playerWon {
totalDmgToEnemy = enemyHP
totalDmgToPlayer = int(float64(playerHP) * (0.3 + rand.Float64()*0.5)) // 30-80% of player HP
} else {
totalDmgToPlayer = playerHP
totalDmgToEnemy = int(float64(enemyHP) * (0.3 + rand.Float64()*0.5))
}
// Count damage rounds for each side
var playerHitRounds, enemyHitRounds []int
for i, t := range types {
switch t {
case "player_hit":
playerHitRounds = append(playerHitRounds, i)
case "enemy_hit":
enemyHitRounds = append(enemyHitRounds, i)
case "environmental":
enemyHitRounds = append(enemyHitRounds, i) // environmental damages player
}
}
// Distribute damage to enemy across player_hit rounds
enemyDmgPerRound := splitDamage(totalDmgToEnemy, len(playerHitRounds))
// Distribute damage to player across enemy_hit + environmental rounds
playerDmgPerRound := splitDamage(totalDmgToPlayer, len(enemyHitRounds))
// Build rounds
rounds := make([]ArenaCombatRound, numRounds)
currentPlayerHP := playerHP
currentEnemyHP := enemyHP
playerDmgIdx := 0
enemyDmgIdx := 0
for i, t := range types {
r := ArenaCombatRound{
Number: i + 1,
Type: t,
}
switch t {
case "player_hit":
dmg := 0
if enemyDmgIdx < len(enemyDmgPerRound) {
dmg = enemyDmgPerRound[enemyDmgIdx]
enemyDmgIdx++
}
r.DamageToEnemy = dmg
currentEnemyHP -= dmg
if currentEnemyHP < 0 {
currentEnemyHP = 0
}
r.Text = pickFrom(arenaPlayerHitActions, picker.player, dmg)
case "enemy_hit":
dmg := 0
if playerDmgIdx < len(playerDmgPerRound) {
dmg = playerDmgPerRound[playerDmgIdx]
playerDmgIdx++
}
r.DamageToPlayer = dmg
currentPlayerHP -= dmg
if currentPlayerHP < 0 {
currentPlayerHP = 0
}
r.Text = pickFrom(arenaEnemyActions, picker.enemy, dmg)
case "block":
r.Text = pickFromNoFmt(arenaBlockActions, picker.block)
case "environmental":
dmg := 0
if playerDmgIdx < len(playerDmgPerRound) {
dmg = playerDmgPerRound[playerDmgIdx]
playerDmgIdx++
}
r.DamageToPlayer = dmg
currentPlayerHP -= dmg
if currentPlayerHP < 0 {
currentPlayerHP = 0
}
r.Text = pickFrom(arenaEnvironmentalActions, picker.environment, dmg)
}
r.PlayerHP = currentPlayerHP
r.EnemyHP = currentEnemyHP
rounds[i] = r
}
// Ensure final round ends at exactly 0 for the loser
last := &rounds[numRounds-1]
if playerWon {
last.EnemyHP = 0
} else {
last.PlayerHP = 0
}
return rounds
}
// splitDamage distributes total damage across n rounds with some variance.
// Each round gets at least 1 damage. If total < n, excess rounds get 0.
func splitDamage(total, n int) []int {
if n <= 0 {
return nil
}
if n == 1 {
return []int{total}
}
result := make([]int, n)
// If total < n, give 1 to the first `total` rounds, 0 to the rest.
if total <= n {
for i := 0; i < total && i < n; i++ {
result[i] = 1
}
return result
}
remaining := total
for i := 0; i < n-1; i++ {
avg := remaining / (n - i)
if avg <= 0 {
avg = 1
}
// Variance: 50%-150% of average
lo := avg / 2
if lo < 1 {
lo = 1
}
hi := avg + avg/2
if hi < lo {
hi = lo
}
dmg := lo + rand.IntN(hi-lo+1)
// Reserve at least 1 per remaining round
maxThisRound := remaining - (n - 1 - i)
if maxThisRound < 1 {
maxThisRound = 1
}
if dmg > maxThisRound {
dmg = maxThisRound
}
if dmg < 1 {
dmg = 1
}
result[i] = dmg
remaining -= dmg
}
result[n-1] = remaining
if result[n-1] < 1 {
result[n-1] = 1
}
return result
}
// actionPicker tracks used indices per pool to avoid repeats within a fight.
type actionPicker struct {
enemy map[int]bool
player map[int]bool
block map[int]bool
environment map[int]bool
}
func newActionPicker() *actionPicker {
return &actionPicker{
enemy: make(map[int]bool),
player: make(map[int]bool),
block: make(map[int]bool),
environment: make(map[int]bool),
}
}
// pickFrom selects a random unused entry from pool, formats it with damage, and marks it used.
// Resets if pool is exhausted.
func pickFrom(pool []string, used map[int]bool, damage int) string {
if len(used) >= len(pool) {
for k := range used {
delete(used, k)
}
}
idx := rand.IntN(len(pool))
for used[idx] {
idx = (idx + 1) % len(pool)
}
used[idx] = true
return fmt.Sprintf(pool[idx], damage)
}
func pickFromNoFmt(pool []string, used map[int]bool) string {
if len(used) >= len(pool) {
for k := range used {
delete(used, k)
}
}
idx := rand.IntN(len(pool))
for used[idx] {
idx = (idx + 1) % len(pool)
}
used[idx] = true
return pool[idx]
}
// ── Render ─────────────────────────────────────────────────────────────────
func renderArenaCombatLog(log *ArenaCombatLog, monster *ArenaMonster, won bool, reward int64, xp int, closerLine string) string {
var sb strings.Builder
for _, r := range log.Rounds {
sb.WriteString(r.Text + "\n")
// Compact HP status line — damage is already in the action text via %d
switch r.Type {
case "player_hit":
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
case "enemy_hit", "environmental":
sb.WriteString(fmt.Sprintf(" [You: %d/%d | Enemy: %d/%d]\n", r.PlayerHP, log.PlayerHP, r.EnemyHP, log.EnemyHP))
}
// Blocks: no HP line (no damage happened)
}
sb.WriteString("\n")
if won {
sb.WriteString(fmt.Sprintf("💀 %s has been defeated.\n", monster.Name))
sb.WriteString(closerLine + "\n")
sb.WriteString(fmt.Sprintf("🏆 +%d XP | €%d earned\n", xp, reward))
} else {
sb.WriteString("The healers are already moving.\n")
sb.WriteString("💀 Defeated.\n")
sb.WriteString(closerLine + "\n")
sb.WriteString(fmt.Sprintf("+%d XP (participation) | Back tomorrow.\n", arenaParticipationXP))
}
return sb.String()
}
const arenaParticipationXP = 60
// ── Closer Lines ───────────────────────────────────────────────────────────
@@ -402,77 +34,3 @@ func arenaLoseCloser(winnerName string, lastRound int) string {
}
return closers[rand.IntN(len(closers))]
}
// ── Action Pools ───────────────────────────────────────────────────────────
// Enemy actions — hit the player. %d is damage.
var arenaEnemyActions = []string{
"The enemy insults your clothing choices. Spot-on. Hits you for %d emotional damage. They weren't wrong about the boots.",
"The enemy puts their weapon away, walks up to you, and Will Smiths you across the face. The audacity hurts more than the hit. %d damage.",
"The enemy questions your life choices. You pause to genuinely reflect. They hit you during the pause. %d damage.",
"The enemy delivers a full monologue. You listen to the whole thing. It was actually pretty good. %d damage from the time lost.",
"The enemy compliments you unexpectedly. You thank them. They snicker. %d damage.",
"The enemy points at something behind you. You don't fall for it. They throw a projectile that bounces off the wall and hits you in the back of the head. %d damage.",
"The enemy pulls out their phone and starts filming. You perform for the camera. This was a mistake. %d damage.",
"The enemy sneezes directly in your face. You lose your turn being disgusted. %d damage while you process this.",
"The enemy whispers something. You lean in to hear it. %d damage. There was nothing worth hearing.",
"The enemy trips. Recovers. Hits you anyway. %d damage. You were rooting for them for a second there.",
"The enemy takes a phone call, hits you one-handed, and continues the call. %d damage. You were barely a distraction.",
"The enemy critiques your fighting stance in detail. You correct it instinctively. Your corrected stance is worse. %d damage.",
"The enemy yawns mid-fight. Not performatively. Genuinely. %d damage while you process the disrespect.",
"The enemy pauses to stretch before attacking. You wait. You don't know why you waited. %d damage when they finish.",
"The enemy hits you with the flat of their blade. A choice. A message. %d damage. The message is received.",
"The enemy stares at you for an uncomfortably long time before attacking. You break eye contact first. %d damage.",
"The enemy sighs before hitting you. Like they had somewhere better to be. %d damage.",
"The enemy recounts a mildly interesting story mid-fight. You get drawn in. %d damage before the ending, which was not worth it.",
"The enemy raises one eyebrow at you and then attacks. The eyebrow did more damage than the hit. %d damage total.",
"The enemy adjusts their grip, rolls their shoulders, and hits you with the bare minimum of effort. %d damage.",
}
// Player actions — hit the enemy. %d is damage.
var arenaPlayerHitActions = []string{
"You make a joke using a painfully dated reference. While the enemy ponders what on earth you could mean, you strike. %d damage.",
"You attempt a battle cry. It comes out as a question. The enemy is briefly confused. You hit them for %d damage before they recover.",
"You wind up for a big hit and connect for %d damage. You pulled something. The enemy doesn't know this yet.",
"You hit the enemy for %d damage. They seem fine. You are less fine about this than they are.",
"You connect cleanly for %d damage and immediately look at your hand like you're surprised it worked. You were.",
"You score a clean hit for %d damage and immediately start explaining to no one how you did that. Nobody asked.",
"You land a hit for %d damage and follow up with a second strike that connects with nothing. You style it out. Nobody is convinced.",
}
// Block/dodge actions — no damage.
var arenaBlockActions = []string{
"You swing with conviction. The enemy sidesteps it with the energy of someone who has somewhere else to be. Nothing happens. You both reset.",
"The enemy lunges. You step aside. They continue past you for several feet and have to walk back. The pause is awkward for everyone.",
"You block the incoming strike so cleanly that the enemy looks at their weapon like it betrayed them personally.",
"The enemy's attack grazes you but doesn't connect. They seem more annoyed by this than you are relieved.",
"You duck. The enemy's strike passes exactly where your head was. You both take a moment to appreciate how close that was.",
"The enemy deflects your attack with a move that was frankly unnecessary for the situation. It worked.",
"You parry. The enemy's weapon skids off yours and they stumble slightly. They recover before you can do anything about it.",
"The enemy blocks your poor-timed strike with their forearm. This speaks more about your striking abilities than their forearm.",
"You dodge sideways into a pillar. It hurts but it doesn't count as a hit. The pillar gets no credit.",
"The enemy telegraphs the attack so clearly that you block it before they've finished committing. They look briefly embarrassed.",
"You attempt a dodge and accidentally do something that looks extremely skilled. It was not intentional.",
"The enemy's strike is deflected off your shoulder guard and disappears somewhere into the arena.",
"You and the enemy swing at exactly the same moment. Both weapons meet in the middle. You stare at each other.",
"The enemy's attack comes in low. You jump. Not gracefully. But adequately. Nothing connects.",
"You sidestep a strike that wasn't aimed at you. The enemy had already redirected. Both end up slightly confused.",
}
// Environmental actions — damage to player. %d is damage.
var arenaEnvironmentalActions = []string{
"Your mother calls in the middle of battle asking about grandchildren. The enemy hits you for %d damage while you answer on speakerphone.",
"A bird lands between you and the enemy. Both stop. The bird leaves. The enemy recovers first. %d damage.",
"A spectator is eating something that smells incredible. Both fighters lose focus. The enemy had less going on mentally. %d damage.",
"The arena announcer mispronounces your name. You correct them mid-fight. The enemy hits you for %d damage.",
"An old acquaintance you've been avoiding is in the crowd. You make brief eye contact. The enemy hits you for %d damage during this.",
"Someone in the crowd drops their drink. The sound is startling. You both flinch. The enemy flinches smaller. %d damage.",
"A cloud passes in front of the sun at the wrong moment. %d damage. The cloud did not mean anything by it.",
"The arena's background music cuts out unexpectedly. The silence is louder than the fight. %d damage in the disorientation.",
"The arena PA crackles and announces something completely unrelated. You both look up. The enemy looks back down first. %d damage.",
"Something falls from the spectator area. Nobody claims it. You both look at it. The enemy decides faster. %d damage.",
"A dog wanders into the arena perimeter briefly. Both fighters stop. The dog is removed. The enemy uses the reset better. %d damage.",
"The arena's scoreboard updates mid-fight and briefly shows wrong numbers. You spend a round working out if that changes anything. %d damage.",
"The arena sells a limited merch item at exactly this moment. The announcement is enthusiastic. You are briefly curious. %d damage.",
"The crowd goes quiet at an inopportune moment. You can hear everything. Including things you did not want to hear. %d damage.",
}
@@ -21,6 +21,7 @@ type ArenaMonster struct {
Flavor string
BaseLethality float64
ThreatLevel int
Ability *MonsterAbility // nil = no special ability
}
var arenaTiers = [5]ArenaTier{
@@ -75,6 +76,7 @@ var arenaTiers = [5]ArenaTier{
Name: "Armored Disagreement",
Flavor: "A dark knight who has made peace with violence as a communication strategy. Extensively equipped.",
BaseLethality: 0.65, ThreatLevel: 30,
Ability: &MonsterAbility{Name: "Shield Bash", Phase: "clash", ProcChance: 0.20, Effect: "stun"},
},
},
},
@@ -92,6 +94,7 @@ var arenaTiers = [5]ArenaTier{
Name: "Wyrm of Moderate Ambition",
Flavor: "Aspires to be a world-ending dragon. Currently a regional threat at best. Very sensitive about this.",
BaseLethality: 0.65, ThreatLevel: 42,
Ability: &MonsterAbility{Name: "Venom Breath", Phase: "clash", ProcChance: 0.30, Effect: "poison"},
},
{
Name: "Behemoth Adjacent",
@@ -102,6 +105,7 @@ var arenaTiers = [5]ArenaTier{
Name: "The Inevitable",
Flavor: "A reaper-class entity. No grievances. No agenda. Simply the direction all things are heading.",
BaseLethality: 0.80, ThreatLevel: 55,
Ability: &MonsterAbility{Name: "Soul Drain", Phase: "clash", ProcChance: 0.25, Effect: "lifesteal"},
},
},
},
@@ -114,6 +118,7 @@ var arenaTiers = [5]ArenaTier{
Name: "The Watcher in the Peripheral",
Flavor: "Seventeen eyes. None of them blink at the same time. Has been observing you specifically for longer than you've been alive.",
BaseLethality: 0.72, ThreatLevel: 62,
Ability: &MonsterAbility{Name: "Gaze of Unmaking", Phase: "opening", ProcChance: 0.35, Effect: "armor_break"},
},
{
Name: "Herald of the Outer Dark",
@@ -124,11 +129,13 @@ var arenaTiers = [5]ArenaTier{
Name: "Lich Adjacent",
Flavor: "Not the Lich King. Definitely not. Unrelated individual. Happens to be a skeletal sorcerer of immense power. Coincidence.",
BaseLethality: 0.87, ThreatLevel: 78,
Ability: &MonsterAbility{Name: "Necrotic Siphon", Phase: "any", ProcChance: 0.30, Effect: "lifesteal"},
},
{
Name: "The Collector of Faces",
Flavor: "It has yours already. Has had it for some time. The fight is a formality at this point.",
BaseLethality: 0.92, ThreatLevel: 88,
Ability: &MonsterAbility{Name: "Harvest", Phase: "clash", ProcChance: 0.35, Effect: "cleave"},
},
},
},
@@ -141,21 +148,25 @@ var arenaTiers = [5]ArenaTier{
Name: "Omega Mk. Zero",
Flavor: "A machine built to be the final test. Has never lost. Is aware of this.",
BaseLethality: 0.85, ThreatLevel: 95,
Ability: &MonsterAbility{Name: "System Override", Phase: "opening", ProcChance: 0.40, Effect: "armor_break"},
},
{
Name: "The Calamity That Dreamed It Was Sleeping",
Flavor: "An ancient parasitic entity that fell from the sky an indeterminate number of years ago and has been ending timelines since. Definitely not Lavos.",
BaseLethality: 0.90, ThreatLevel: 105,
Ability: &MonsterAbility{Name: "Temporal Drain", Phase: "any", ProcChance: 0.35, Effect: "lifesteal"},
},
{
Name: "The Architect of Endings",
Flavor: "A god who decided the world was a failed experiment and appointed itself project manager of its destruction. Silver hair. Long coat. Personal.",
BaseLethality: 0.95, ThreatLevel: 115,
Ability: &MonsterAbility{Name: "Deconstruct", Phase: "clash", ProcChance: 0.40, Effect: "cleave"},
},
{
Name: "That Which Has Always Been",
Flavor: "Pre-dates language. Pre-dates light. The Arena was built around it, not the other way around. Winning this fight is not something the game's designers fully accounted for.",
BaseLethality: 0.98, ThreatLevel: 130,
Ability: &MonsterAbility{Name: "Primordial Wrath", Phase: "decisive", ProcChance: 0.50, Effect: "enrage"},
},
},
},
+32 -92
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)
}
+659 -70
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)
}
}
+549
View File
@@ -0,0 +1,549 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Pricing ─────────────────────────────────────────────────────────────────
func babysitDailyCost(combatLevel int) int {
return 100 + (combatLevel * 20)
}
// ── Weakest Skill ───────────────────────────────────────────────────────────
func babysitWeakestSkill(char *AdventureCharacter) string {
skills := []struct {
name string
level int
}{
{"mining", char.MiningSkill},
{"fishing", char.FishingSkill},
{"foraging", char.ForagingSkill},
}
minLevel := skills[0].level
for _, s := range skills[1:] {
if s.level < minLevel {
minLevel = s.level
}
}
// Collect ties
var tied []string
for _, s := range skills {
if s.level == minLevel {
tied = append(tied, s.name)
}
}
return tied[rand.IntN(len(tied))]
}
// skillToActivity maps a skill name to its activity type.
func skillToActivity(skill string) AdvActivityType {
switch skill {
case "mining":
return AdvActivityMining
case "fishing":
return AdvActivityFishing
case "foraging":
return AdvActivityForaging
}
return AdvActivityMining
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) error {
lower := strings.ToLower(strings.TrimSpace(args))
switch {
case lower == "status":
return p.handleBabysitStatus(ctx)
case lower == "cancel":
return p.handleBabysitCancel(ctx)
case lower == "week":
return p.handleBabysitPurchase(ctx, 7)
case lower == "month":
return p.handleBabysitPurchase(ctx, 30)
case lower == "auto":
return p.handleBabysitAutoToggle(ctx)
default:
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
"`!adventure babysit week` — 7 days of service\n"+
"`!adventure babysit month` — 30 days of service\n"+
"`!adventure babysit auto` — toggle auto-babysit on missed days\n"+
"`!adventure babysit status` — check service status\n"+
"`!adventure babysit cancel` — cancel early (no refund)")
}
}
func (p *AdventurePlugin) handleBabysitAutoToggle(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
char.AutoBabysit = !char.AutoBabysit
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save auto-babysit toggle", "user", ctx.Sender, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
}
daily := babysitDailyCost(char.CombatLevel)
if char.AutoBabysit {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 **Auto-babysit: ON**\n\nIf you miss a day, the babysitter steps in automatically (€%d/day). Your streak stays alive. Disable anytime with `!adventure babysit auto`.", daily))
}
return p.SendDM(ctx.Sender, "🍼 **Auto-babysit: OFF**\n\nYou're on your own. Miss a day and your streak takes the hit.")
}
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
}
if char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
}
daily := babysitDailyCost(char.CombatLevel)
totalCost := daily * days
balance := p.euro.GetBalance(char.UserID)
if balance < float64(totalCost) {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 The babysitting service costs %s for %d days. You have %s. The service has standards. Not many, but some.", fmtEuro(totalCost), days, fmtEuro(balance)))
}
// Debit gold
if !p.euro.Debit(char.UserID, float64(totalCost), "babysit_purchase") {
return p.SendDM(ctx.Sender, "Payment failed. The babysitter looked at your wallet and walked away.")
}
// Clear any leftover logs from prior service so this period's summary is clean
clearBabysitLogs(char.UserID)
// Set babysit fields
skill := babysitWeakestSkill(char)
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
char.BabysitActive = true
char.BabysitExpiresAt = &expires
char.BabysitSkillFocus = skill
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
// Refund
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
}
confirm := pickBabysitFlavor(babysitConfirmLines)
durLabel := "1 week"
if days == 30 {
durLabel = "1 month"
}
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
"Duration: %s (%d days)\n"+
"Cost: €%d\n"+
"Focus: %s (currently level %d)\n"+
"Rival duels: declined on your behalf\n\n"+
"Daily DMs are suspended until the service ends.\n\n"+
"_%s_", durLabel, days, totalCost, titleCase(skill), babysitSkillLevel(char, skill), confirm)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
autoLabel := "OFF"
if char.AutoBabysit {
autoLabel = "ON"
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, fmt.Sprintf("🍼 No active babysitting service.\nAuto-babysit: **%s** (`!adventure babysit auto` to toggle)\n\nUse `!adventure babysit week` or `!adventure babysit month` to start.", autoLabel))
}
remaining := "unknown"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day"
} else {
remaining = fmt.Sprintf("%d days", days)
}
}
// Load log stats
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
"Time remaining: %s\n"+
"Skill focus: %s\n"+
"Days completed: %d\n"+
"Gold earned: €%d\n"+
"XP gained: %d\n"+
"Items claimed by babysitter: %d\n"+
"Rivals declined: %d\n"+
"Auto-babysit: %s",
remaining, titleCase(char.BabysitSkillFocus), len(logs), totalGold, totalXP, itemsClaimed, rivalsRefused, autoLabel)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found.")
}
if !char.BabysitActive {
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
}
// Compile partial summary
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(char, logs)
// Clear babysit state
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
}
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
}
// ── Daily Auto-Resolution ───────────────────────────────────────────────────
func (p *AdventurePlugin) runBabysitDaily(char *AdventureCharacter) {
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
isHol, _ := isHolidayToday()
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
bonuses := &AdvBonusSummary{}
focusActivity := skillToActivity(char.BabysitSkillFocus)
var totalGold int
var totalXP int
var allItems []string
// Use all harvest actions on the focused skill — no combat, too dangerous
for char.HarvestActionsUsed < harvestMax {
gold, xp, items := p.runBabysitAction(char, equip, focusActivity, bonuses)
totalGold += gold
totalXP += xp
allItems = append(allItems, items...)
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
slog.Error("babysit: failed to save character after daily", "user", char.UserID, "err", err)
}
// Log combined daily totals
itemsJSON := ""
if len(allItems) > 0 {
itemsJSON = strings.Join(allItems, ", ")
}
logBabysitActivity(char.UserID, string(focusActivity), "babysit_daily",
totalGold, totalXP, itemsJSON)
}
// runBabysitAction resolves a single action for the babysitter. Returns gold, xp, item names.
func (p *AdventurePlugin) runBabysitAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) (int, int, []string) {
eligible := advEligibleLocations(char, equip, activity, bonuses)
if len(eligible) == 0 {
return 0, 0, nil
}
loc := eligible[len(eligible)-1].Location
inPenalty := eligible[len(eligible)-1].InPenaltyZone
result := resolveAdvAction(char, equip, loc, bonuses, inPenalty)
// Babysitter never lets the adventurer die
if result.Outcome == AdvOutcomeDeath {
result.Outcome = AdvOutcomeEmpty
result.LootItems = nil
result.TotalLootValue = 0
result.EquipDamage = nil
result.EquipBroken = nil
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":
char.CombatXP += result.XPGained
case "mining":
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
checkAdvLevelUp(char, result.XPSkill)
// Credit gold
if result.TotalLootValue > 0 {
net, _ := communityTax(char.UserID, float64(result.TotalLootValue), 0.05)
p.euro.Credit(char.UserID, net, "babysit_haul")
}
// No treasure drops during babysitting
result.TreasureFound = nil
var items []string
for _, item := range result.LootItems {
items = append(items, item.Name)
}
return int(result.TotalLootValue), result.XPGained, items
}
// runAutoBabysitDay runs a single day of babysit actions for auto-babysit.
// Called by the scheduler when a player with auto-babysit enabled misses a day.
func (p *AdventurePlugin) runAutoBabysitDay(char *AdventureCharacter) {
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
slog.Error("auto-babysit: failed to load equipment", "user", char.UserID, "err", err)
return
}
isHol, _ := isHolidayToday()
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
bonuses := &AdvBonusSummary{}
skill := babysitWeakestSkill(char)
activity := skillToActivity(skill)
var totalGold int
var totalXP int
var allItems []string
for char.HarvestActionsUsed < harvestMax {
gold, xp, items := p.runBabysitAction(char, equip, activity, bonuses)
totalGold += gold
totalXP += xp
allItems = append(allItems, items...)
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
itemsJSON := ""
if len(allItems) > 0 {
itemsJSON = strings.Join(allItems, ", ")
}
logBabysitActivity(char.UserID, string(activity), "auto_babysit_daily",
totalGold, totalXP, itemsJSON)
}
// ── Expiry Check ────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
now := time.Now().UTC()
for _, char := range chars {
if !char.BabysitActive {
continue
}
if char.BabysitExpiresAt == nil || now.Before(*char.BabysitExpiresAt) {
continue
}
// Service expired — compile summary and send DM
logs, err := loadBabysitLogs(char.UserID)
if err != nil {
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
}
summary := renderBabysitSummary(&char, logs)
char.BabysitActive = false
char.BabysitExpiresAt = nil
char.BabysitSkillFocus = ""
if err := saveAdvCharacter(&char); err != nil {
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
continue
}
if err := p.SendDM(char.UserID, summary); err != nil {
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
}
}
}
// ── Summary Rendering ───────────────────────────────────────────────────────
func renderBabysitSummary(char *AdventureCharacter, logs []babysitLogEntry) string {
totalGold, totalXP, itemsClaimed, rivalsRefused := babysitLogStats(logs)
var sb strings.Builder
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
sb.WriteString(fmt.Sprintf("Duration: %d days\n", len(logs)))
sb.WriteString(fmt.Sprintf("Tasks completed: %d\n", len(logs)))
sb.WriteString(fmt.Sprintf("Skill focused: %s\n", titleCase(char.BabysitSkillFocus)))
sb.WriteString(fmt.Sprintf("Gold earned from hauls: €%d\n", totalGold))
sb.WriteString(fmt.Sprintf("XP gained: %d\n", totalXP))
sb.WriteString(fmt.Sprintf("Items dropped: %d items. Claimed by the babysitter as per the terms.\n", itemsClaimed))
if rivalsRefused > 0 {
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
// Pick a rival refusal flavor (generic — no specific rival name available)
for _, log := range logs {
if log.RivalRefused != "" {
line := pickBabysitFlavor(babysitRivalRefusalLines)
sb.WriteString(fmt.Sprintf(" %s\n", fmt.Sprintf(line, log.RivalRefused, log.LogDate)))
}
}
}
// Diaper line
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
// Closing
sb.WriteString(fmt.Sprintf("\n\nYour adventurer is fed, rested, and slightly better at %s.", char.BabysitSkillFocus))
return sb.String()
}
// ── Babysit Log CRUD ────────────────────────────────────────────────────────
type babysitLogEntry struct {
ID int64
UserID id.UserID
LogDate string
Activity string
Outcome string
GoldEarned int
XPGained int
ItemsDropped string
RivalRefused string
}
func logBabysitActivity(userID id.UserID, activity, outcome string, gold, xp int, items string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, gold_earned, xp_gained, items_dropped)
VALUES (?, DATE('now'), ?, ?, ?, ?, ?)`,
string(userID), activity, outcome, gold, xp, items)
if err != nil {
slog.Error("babysit: failed to log activity", "user", userID, "err", err)
}
}
func logBabysitRivalRefusal(userID id.UserID, rivalName string) {
d := db.Get()
_, err := d.Exec(`INSERT INTO adventure_babysit_log (user_id, log_date, activity, outcome, rival_refused)
VALUES (?, DATE('now'), 'rival_refused', 'declined', ?)`,
string(userID), rivalName)
if err != nil {
slog.Error("babysit: failed to log rival refusal", "user", userID, "err", err)
}
}
func loadBabysitLogs(userID id.UserID) ([]babysitLogEntry, error) {
d := db.Get()
rows, err := d.Query(`SELECT id, user_id, log_date, activity, outcome, gold_earned, xp_gained,
COALESCE(items_dropped,''), COALESCE(rival_refused,'')
FROM adventure_babysit_log WHERE user_id = ? ORDER BY log_date`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var logs []babysitLogEntry
for rows.Next() {
var l babysitLogEntry
if err := rows.Scan(&l.ID, &l.UserID, &l.LogDate, &l.Activity, &l.Outcome,
&l.GoldEarned, &l.XPGained, &l.ItemsDropped, &l.RivalRefused); err != nil {
return nil, err
}
logs = append(logs, l)
}
return logs, nil
}
func clearBabysitLogs(userID id.UserID) {
d := db.Get()
if _, err := d.Exec(`DELETE FROM adventure_babysit_log WHERE user_id = ?`, string(userID)); err != nil {
slog.Error("babysit: failed to clear logs", "user", userID, "err", err)
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
func babysitSkillLevel(char *AdventureCharacter, skill string) int {
switch skill {
case "mining":
return char.MiningSkill
case "fishing":
return char.FishingSkill
case "foraging":
return char.ForagingSkill
}
return 0
}
func babysitLogStats(logs []babysitLogEntry) (totalGold, totalXP, itemsClaimed, rivalsRefused int) {
for _, l := range logs {
totalGold += l.GoldEarned
totalXP += l.XPGained
if l.ItemsDropped != "" {
// Count comma-separated items
itemsClaimed += len(strings.Split(l.ItemsDropped, ", "))
}
if l.RivalRefused != "" {
rivalsRefused++
}
}
return
}
+405
View File
@@ -0,0 +1,405 @@
package plugin
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Pricing ─────────────────────────────────────────────────────────────────
var blacksmithBaseRates = [6]int{1, 2, 5, 12, 30, 80}
func blacksmithRepairCost(eq *AdvEquipment) int {
if eq == nil || eq.Condition >= 100 {
return 0
}
// Masterwork and Arena items use the next tier's base rate.
tier := eq.Tier
if eq.Masterwork || eq.ArenaTier > 0 {
tier++
if tier > 5 {
tier = 5
}
}
if tier < 0 {
tier = 0
}
if tier > 5 {
tier = 5
}
baseRate := float64(blacksmithBaseRates[tier])
damage := float64(100 - eq.Condition)
condMult := 1.0 + damage/200.0
costPerPoint := baseRate * condMult
return int(math.Ceil(costPerPoint * damage))
}
// ── Pending Interaction Types ───────────────────────────────────────────────
type advPendingBlacksmithSlot struct{}
type advPendingBlacksmithConfirm struct {
Slots []EquipmentSlot
Costs map[EquipmentSlot]int
Total int
}
// ── Command Handlers ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleBlacksmithCmd(ctx MessageContext) error {
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
text := renderBlacksmithShop(ctx.Sender, equip)
// Store pending interaction for slot selection.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "blacksmith_slot",
Data: &advPendingBlacksmithSlot{},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleRepairAllCmd(ctx MessageContext) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
return p.buildRepairAllConfirm(ctx.Sender, equip)
}
func (p *AdventurePlugin) handleRepairSlotCmd(ctx MessageContext, slotName string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
return p.buildRepairSlotConfirm(ctx.Sender, equip, slotName)
}
// ── Slot Selection Resolution ───────────────────────────────────────────────
func (p *AdventurePlugin) resolveBlacksmithSlotChoice(ctx MessageContext, interaction *advPendingInteraction) error {
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply != "all" && parseSlotName(reply) == "" {
// Re-store pending and reprompt.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Reply with a slot name (weapon, armor, helmet, boots, tool) or \"all\".")
}
// Lock and reload fresh equipment (the cached data may be stale).
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if reply == "all" {
return p.buildRepairAllConfirm(ctx.Sender, equip)
}
return p.buildRepairSlotConfirm(ctx.Sender, equip, reply)
}
// ── Confirm Resolution ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveBlacksmithConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingBlacksmithConfirm)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "yes" || reply == "y" || reply == "confirm" {
return p.executeRepair(ctx.Sender, data)
}
return p.SendDM(ctx.Sender, "Repair cancelled. The blacksmith watches you leave. They'll be here when you're ready.")
}
// ── Builders ────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) buildRepairAllConfirm(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) error {
var damaged []EquipmentSlot
costs := make(map[EquipmentSlot]int)
total := 0
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
continue
}
cost := blacksmithRepairCost(eq)
if cost > 0 {
damaged = append(damaged, slot)
costs[slot] = cost
total += cost
}
}
if len(damaged) == 0 {
flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
return p.SendDM(userID, "⚒️ All your equipment is at full condition. "+flavor)
}
balance := p.euro.GetBalance(userID)
if balance < float64(total) {
// Can't afford everything — suggest cheapest slot.
cheapestSlot := damaged[0]
cheapestCost := costs[damaged[0]]
for _, s := range damaged[1:] {
if costs[s] < cheapestCost {
cheapestSlot = s
cheapestCost = costs[s]
}
}
return p.SendDM(userID, fmt.Sprintf("⚒️ Repairing everything costs €%d. You have €%.0f.\n\n"+
"The blacksmith suggests starting with your %s (€%d). They say this with some reluctance — they wanted to do everything.\n\n"+
"Use `!adventure repair %s` to repair just that slot.",
total, balance, slotTitle(cheapestSlot), cheapestCost, string(cheapestSlot)))
}
// Build breakdown.
var sb strings.Builder
sb.WriteString("⚒️ **Repair All — Confirmation**\n\n")
for _, slot := range damaged {
eq := equip[slot]
sb.WriteString(fmt.Sprintf(" %s %s: [%d/100] → [100/100] €%d\n",
slotEmoji(slot), slotTitle(slot), eq.Condition, costs[slot]))
}
sb.WriteString(fmt.Sprintf("\n**Total: €%d**\n\nReply **yes** to confirm or **no** to cancel.", total))
p.pending.Store(string(userID), &advPendingInteraction{
Type: "blacksmith_confirm",
Data: &advPendingBlacksmithConfirm{Slots: damaged, Costs: costs, Total: total},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(userID, sb.String())
}
func (p *AdventurePlugin) buildRepairSlotConfirm(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, slotName string) error {
slot := parseSlotName(slotName)
if slot == "" {
return p.SendDM(userID, "Unknown slot. Valid slots: weapon, armor, helmet, boots, tool.")
}
eq := equip[slot]
if eq == nil {
return p.SendDM(userID, fmt.Sprintf("You don't have any %s equipped.", slotTitle(slot)))
}
if eq.Condition >= 100 {
flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
return p.SendDM(userID, fmt.Sprintf("⚒️ %s %s — %s", slotEmoji(slot), eq.Name, flavor))
}
cost := blacksmithRepairCost(eq)
balance := p.euro.GetBalance(userID)
if balance < float64(cost) {
return p.SendDM(userID, fmt.Sprintf("⚒️ Repairing your %s costs €%d. You have €%.0f. The blacksmith waits.",
slotTitle(slot), cost, balance))
}
// Build inspection message.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚒️ %s %s — **%s** [%d/100]\n\n",
slotEmoji(slot), slotTitle(slot), eq.Name, eq.Condition))
// Special flavor for masterwork, arena, or broken.
if eq.Condition == 0 {
f, _ := advPickFlavor(blacksmithBrokenCondition, userID, "bs_broken")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
} else if eq.Masterwork {
f, _ := advPickFlavor(blacksmithMasterwork, userID, "bs_master")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
} else if eq.ArenaTier > 0 {
f, _ := advPickFlavor(blacksmithArena, userID, "bs_arena")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
} else {
f, _ := advPickFlavor(blacksmithInspection, userID, "bs_inspect")
sb.WriteString(fmt.Sprintf("*%s*\n\n", f))
}
sb.WriteString(fmt.Sprintf("Repair to 100: **€%d**\n\nReply **yes** to confirm or **no** to cancel.", cost))
costs := map[EquipmentSlot]int{slot: cost}
p.pending.Store(string(userID), &advPendingInteraction{
Type: "blacksmith_confirm",
Data: &advPendingBlacksmithConfirm{Slots: []EquipmentSlot{slot}, Costs: costs, Total: cost},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(userID, sb.String())
}
// ── Repair Execution ────────────────────────────────────────────────────────
func (p *AdventurePlugin) executeRepair(userID id.UserID, data *advPendingBlacksmithConfirm) error {
// Reload equipment fresh and recompute costs to avoid stale-price TOCTOU.
equip, err := loadAdvEquipment(userID)
if err != nil {
return p.SendDM(userID, "Something went wrong loading your equipment.")
}
// Recompute actual costs from current condition.
actualTotal := 0
actualCosts := make(map[EquipmentSlot]int)
for _, slot := range data.Slots {
eq := equip[slot]
if eq == nil {
continue
}
cost := blacksmithRepairCost(eq)
if cost > 0 {
actualCosts[slot] = cost
actualTotal += cost
}
}
if actualTotal == 0 {
return p.SendDM(userID, "⚒️ Your equipment is already at full condition. No repair needed.")
}
// Cap at the quoted price so the user never pays more than they agreed to.
if actualTotal > data.Total {
actualTotal = data.Total
}
if !p.euro.Debit(userID, float64(actualTotal), "blacksmith_repair") {
return p.SendDM(userID, "Payment failed. You don't have enough gold.")
}
var repaired []string
repairedCost := 0
for _, slot := range data.Slots {
eq := equip[slot]
if eq == nil || eq.Condition >= 100 {
continue
}
eq.Condition = 100
if err := saveAdvEquipment(userID, eq); err != nil {
slog.Error("blacksmith: failed to save repaired equipment", "user", userID, "slot", slot, "err", err)
continue
}
repaired = append(repaired, fmt.Sprintf("%s %s", slotEmoji(slot), eq.Name))
repairedCost += actualCosts[slot]
}
if len(repaired) == 0 {
p.euro.Credit(userID, float64(actualTotal), "blacksmith_refund")
return p.SendDM(userID, "Nothing was repaired. Gold refunded.")
}
// Partial refund if some slots failed to save.
if repairedCost < actualTotal {
refund := actualTotal - repairedCost
p.euro.Credit(userID, float64(refund), "blacksmith_partial_refund")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚒️ **Repair Complete** — €%d\n\n", repairedCost))
for _, item := range repaired {
sb.WriteString(fmt.Sprintf(" %s → [100/100]\n", item))
}
payFlavor, _ := advPickFlavor(blacksmithPayment, userID, "bs_pay")
doneFlavor, _ := advPickFlavor(blacksmithCompletion, userID, "bs_done")
sb.WriteString(fmt.Sprintf("\n*%s*\n\n", payFlavor))
sb.WriteString(fmt.Sprintf("*%s*", doneFlavor))
return p.SendDM(userID, sb.String())
}
// ── Shop Display ────────────────────────────────────────────────────────────
func renderBlacksmithShop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) string {
var sb strings.Builder
greet, _ := advPickFlavor(blacksmithGreetings, userID, "bs_greet")
sb.WriteString("⚒️ **The Blacksmith**\n\n")
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
sb.WriteString("Your equipment:\n")
hasDamaged := false
for _, slot := range allSlots {
eq := equip[slot]
if eq == nil {
continue
}
marker := ""
if eq.Masterwork {
marker = " \u2b50"
} else if eq.ArenaTier > 0 {
marker = " \u2694\ufe0f"
}
if eq.Condition >= 100 {
sb.WriteString(fmt.Sprintf(" %s %s:%s T%d [100/100] \u2713 No repair needed\n",
slotEmoji(slot), padRight(eq.Name+marker, 22), "", eq.Tier))
} else {
hasDamaged = true
cost := blacksmithRepairCost(eq)
sb.WriteString(fmt.Sprintf(" %s %s:%s T%d [%d/100] Repair to 100: \u20ac%d\n",
slotEmoji(slot), padRight(eq.Name+marker, 22), "", eq.Tier, eq.Condition, cost))
}
}
if !hasDamaged {
fullFlavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full")
sb.WriteString(fmt.Sprintf("\n*%s*", fullFlavor))
} else {
sb.WriteString("\nReply with the slot name to repair (weapon / armor / helmet / boots / tool) or \"all\" to repair everything.")
}
return sb.String()
}
// ── Helpers ─────────────────────────────────────────────────────────────────
func parseSlotName(s string) EquipmentSlot {
switch strings.ToLower(strings.TrimSpace(s)) {
case "weapon", "wep", "sword":
return SlotWeapon
case "armor", "armour", "chest":
return SlotArmor
case "helmet", "helm", "hat", "head":
return SlotHelmet
case "boots", "boot", "shoes", "feet":
return SlotBoots
case "tool", "pick", "pickaxe":
return SlotTool
}
return ""
}
func padRight(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}
+384 -27
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
@@ -49,8 +51,51 @@ type AdventureCharacter struct {
GrudgeLocation string
CreatedAt time.Time
LastActiveAt time.Time
DeathReprieveLast *time.Time
DeathReprieveLast *time.Time
MasterworkDropsReceived int
RivalPool int
RivalUnlockedNotified bool
BabysitActive bool
BabysitExpiresAt *time.Time
BabysitSkillFocus string
HospitalVisits int
RobbieVisitCount int
LastDeathDate string
LastPardonUsed *time.Time
MistyLastSeen *time.Time
ArinaLastSeen *time.Time
MistyBuffExpires *time.Time
MistyDebuffExpires *time.Time
ArinaBuffExpires *time.Time
NPCMsgCount int
NPCMsgCountDate string
MistyRollTarget int
ArinaRollTarget int
// Housing
HouseTier int
HouseLoanBalance int
HouseLoanFrozen bool
HouseMissedPayments int
HouseAutopay bool
HouseCurrentRate float64
// Pets
PetType string
PetName string
PetXP int
PetLevel int
PetArmorTier int
PetChasedAway bool
PetReactivated bool
PetArrived bool
MistyEncounterCount int
MistyDonatedCount int
ThomAnimalLineFired bool
PetSupplyShopUnlocked bool
PetLevel10Date string
PetMorningDefense bool
AutoBabysit bool
StreakDecayed bool
CraftsSucceeded int
}
type AdvEquipment struct {
@@ -195,40 +240,127 @@ func (c *AdventureCharacter) DeathReprieveAvailable() bool {
return time.Since(*c.DeathReprieveLast) >= 168*time.Hour
}
// PardonAvailable returns true if the chat level death pardon cooldown
// has expired (or was never triggered). 7-day rolling cooldown.
func (c *AdventureCharacter) PardonAvailable() bool {
if c.LastPardonUsed == nil {
return true
}
return time.Since(*c.LastPardonUsed) >= 168*time.Hour
}
// Kill marks the character as dead with a 6-hour respawn timer.
func (c *AdventureCharacter) Kill() {
c.Alive = false
deadUntil := time.Now().UTC().Add(6 * time.Hour)
c.DeadUntil = &deadUntil
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
}
// HasPet returns true if the player has an active pet (not chased away).
func (c *AdventureCharacter) HasPet() bool {
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
}
// HasHouse returns true if the player has purchased at least a base house.
func (c *AdventureCharacter) HasHouse() bool {
return c.HouseTier > 0 || c.HouseLoanBalance > 0
}
// HouseHPBonus returns the HP bonus percentage from housing tier.
// Tier 1 (Base) = +0%, Tier 2 (Livable) = +5%, Tier 3 (Comfortable) = +12%, Tier 4 (Established) = +20%
func (c *AdventureCharacter) HouseHPBonus() float64 {
switch c.HouseTier {
case 2:
return 0.05
case 3:
return 0.12
case 4:
return 0.20
default:
return 0
}
}
// ── Action Economy ──────────────────────────────────────────────────────────
const maxCombatActions = 1
const maxHarvestActions = 3
func (c *AdventureCharacter) CanDoCombat(isHoliday bool) bool {
max := maxCombatActions
if isHoliday {
max++
}
return c.CombatActionsUsed < max
}
func (c *AdventureCharacter) CanDoHarvest(isHoliday bool) bool {
max := maxHarvestActions
if isHoliday {
max++
}
return c.HarvestActionsUsed < max
}
func (c *AdventureCharacter) HasActedToday() bool {
return c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0
}
func (c *AdventureCharacter) AllActionsUsed(isHoliday bool) bool {
return !c.CanDoCombat(isHoliday) && !c.CanDoHarvest(isHoliday)
}
func isCombatActivity(activity AdvActivityType) bool {
return activity == AdvActivityDungeon
}
func isHarvestActivity(activity AdvActivityType) bool {
return activity == AdvActivityMining || activity == AdvActivityForaging || activity == AdvActivityFishing
}
// ── Equipment Score ──────────────────────────────────────────────────────────
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) int {
score := 0
func advEquipmentScore(equip map[EquipmentSlot]*AdvEquipment) float64 {
score := 0.0
arenaSets := advEquippedArenaSets(equip)
for _, slot := range allSlots {
eq, ok := equip[slot]
if !ok {
continue
}
tierContrib := eq.Tier
// Arena gear: 1.5x effectiveness
if eq.ArenaTier > 0 {
tierContrib = int(float64(tierContrib) * 1.5)
} else if eq.Masterwork {
// Masterwork: 1.25x effectiveness
tierContrib = int(float64(tierContrib) * 1.25)
}
contrib := advEffectiveTier(eq)
if slot == SlotWeapon {
tierContrib *= 2
contrib *= 2
}
// Condition modifier: below 50 halves contribution
if eq.Condition < 50 {
tierContrib /= 2
contrib /= 2
}
score += tierContrib
score += contrib
}
// Champion's set: Commanding Presence — +10% equipment score
if arenaSets["champions"] {
score = int(float64(score) * 1.10)
score *= 1.10
}
return score
}
// advEffectiveTier returns the effective tier of a piece of equipment,
// accounting for Masterwork (1.25x) and Arena (1.5x) bonuses.
func advEffectiveTier(eq *AdvEquipment) float64 {
if eq == nil {
return 0
}
if eq.ArenaTier > 0 {
return float64(eq.Tier) * 1.5
}
if eq.Masterwork {
return float64(eq.Tier) * 1.25
}
return float64(eq.Tier)
}
// ── XP & Level-Up ────────────────────────────────────────────────────────────
const maxAdvLevel = 50
@@ -282,8 +414,13 @@ func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
d := db.Get()
c := &AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil, reprieveLast sql.NullTime
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
var autoBabysit, streakDecayed int
err := d.QueryRow(`
SELECT user_id, display_name,
@@ -293,7 +430,23 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date,
combat_actions_used, harvest_actions_used,
last_pardon_used,
misty_last_seen, arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
npc_msg_count, npc_msg_count_date,
misty_roll_target, arina_roll_target,
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
house_autopay, house_current_rate,
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -303,6 +456,22 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
&pardonUsed,
&mistyLastSeen, &arinaLastSeen,
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
&houseAutopay, &c.HouseCurrentRate,
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
&petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
)
if err != nil {
return nil, err
@@ -310,12 +479,45 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) {
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1
c.AutoBabysit = autoBabysit == 1
c.StreakDecayed = streakDecayed == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
if pardonUsed.Valid {
c.LastPardonUsed = &pardonUsed.Time
}
if mistyLastSeen.Valid {
c.MistyLastSeen = &mistyLastSeen.Time
}
if arinaLastSeen.Valid {
c.ArinaLastSeen = &arinaLastSeen.Time
}
if mistyBuffExp.Valid {
c.MistyBuffExpires = &mistyBuffExp.Time
}
if mistyDebuffExp.Valid {
c.MistyDebuffExpires = &mistyDebuffExp.Time
}
if arinaBuffExp.Valid {
c.ArinaBuffExpires = &arinaBuffExp.Time
}
c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1
c.PetReactivated = petReactivated == 1
c.PetArrived = petArrived == 1
c.ThomAnimalLineFired = thomAnimalLine == 1
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
return c, nil
}
@@ -362,6 +564,54 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if char.HolidayActionTaken {
holidayTaken = 1
}
rivalUnlocked := 0
if char.RivalUnlockedNotified {
rivalUnlocked = 1
}
babysitAct := 0
if char.BabysitActive {
babysitAct = 1
}
houseFrozen := 0
if char.HouseLoanFrozen {
houseFrozen = 1
}
houseAutopay := 0
if char.HouseAutopay {
houseAutopay = 1
}
petChasedAway := 0
if char.PetChasedAway {
petChasedAway = 1
}
petReactivated := 0
if char.PetReactivated {
petReactivated = 1
}
petArrived := 0
if char.PetArrived {
petArrived = 1
}
thomAnimalLine := 0
if char.ThomAnimalLineFired {
thomAnimalLine = 1
}
petSupplyUnlocked := 0
if char.PetSupplyShopUnlocked {
petSupplyUnlocked = 1
}
petMorningDef := 0
if char.PetMorningDefense {
petMorningDef = 1
}
autoBabysit := 0
if char.AutoBabysit {
autoBabysit = 1
}
streakDecayed := 0
if char.StreakDecayed {
streakDecayed = 1
}
_, err := d.Exec(`
UPDATE adventure_characters SET
@@ -371,14 +621,53 @@ func saveAdvCharacter(char *AdventureCharacter) error {
arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?,
current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?,
last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?,
masterwork_drops_received = ?
masterwork_drops_received = ?,
rival_pool = ?, rival_unlocked_notified = ?,
babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?,
combat_actions_used = ?, harvest_actions_used = ?,
last_pardon_used = ?,
misty_last_seen = ?, arina_last_seen = ?,
misty_buff_expires = ?, misty_debuff_expires = ?, arina_buff_expires = ?,
npc_msg_count = ?, npc_msg_count_date = ?,
misty_roll_target = ?, arina_roll_target = ?,
house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, house_missed_payments = ?,
house_autopay = ?, house_current_rate = ?,
pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, pet_armor_tier = ?,
pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?,
misty_encounter_count = ?, misty_donated_count = ?,
thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?,
pet_morning_defense = ?,
auto_babysit = ?,
streak_decayed = ?,
crafts_succeeded = ?
WHERE user_id = ?`,
char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill,
char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP,
alive, char.DeadUntil, actionTaken, holidayTaken,
char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title,
char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation,
char.DeathReprieveLast, char.MasterworkDropsReceived, string(char.UserID),
char.DeathReprieveLast, char.MasterworkDropsReceived,
char.RivalPool, rivalUnlocked,
babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus,
char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate,
char.CombatActionsUsed, char.HarvestActionsUsed,
char.LastPardonUsed,
char.MistyLastSeen, char.ArinaLastSeen,
char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires,
char.NPCMsgCount, char.NPCMsgCountDate,
char.MistyRollTarget, char.ArinaRollTarget,
char.HouseTier, char.HouseLoanBalance, houseFrozen, char.HouseMissedPayments,
houseAutopay, char.HouseCurrentRate,
char.PetType, char.PetName, char.PetXP, char.PetLevel, char.PetArmorTier,
petChasedAway, petReactivated, petArrived,
char.MistyEncounterCount, char.MistyDonatedCount,
thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date,
petMorningDef,
autoBabysit,
streakDecayed,
char.CraftsSucceeded,
string(char.UserID),
)
return err
}
@@ -492,7 +781,23 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
arena_wins, arena_losses, invasion_score, title,
current_streak, best_streak, last_action_date, grudge_location,
created_at, last_active_at, death_reprieve_last,
masterwork_drops_received
masterwork_drops_received,
rival_pool, rival_unlocked_notified,
babysit_active, babysit_expires_at, babysit_skill_focus,
hospital_visits, robbie_visit_count, last_death_date,
combat_actions_used, harvest_actions_used,
last_pardon_used,
misty_last_seen, arina_last_seen,
misty_buff_expires, misty_debuff_expires, arina_buff_expires,
npc_msg_count, npc_msg_count_date,
misty_roll_target, arina_roll_target,
house_tier, house_loan_balance, house_loan_frozen, house_missed_payments,
house_autopay, house_current_rate,
pet_type, pet_name, pet_xp, pet_level, pet_armor_tier,
pet_chased_away, pet_reactivated, pet_arrived,
misty_encounter_count, misty_donated_count,
thom_animal_line_fired, pet_supply_shop_unlocked, pet_level10_date,
pet_morning_defense, auto_babysit, streak_decayed, crafts_succeeded
FROM adventure_characters`)
if err != nil {
return nil, err
@@ -502,8 +807,12 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
var chars []AdventureCharacter
for rows.Next() {
c := AdventureCharacter{}
var alive, actionTaken, holidayTaken int
var deadUntil, reprieveLast sql.NullTime
var alive, actionTaken, holidayTaken, rivalUnlocked, babysitAct int
var deadUntil, reprieveLast, babysitExp, pardonUsed sql.NullTime
var mistyLastSeen, arinaLastSeen, mistyBuffExp, mistyDebuffExp, arinaBuffExp sql.NullTime
var houseFrozen, houseAutopay int
var petChasedAway, petReactivated, petArrived, thomAnimalLine, petSupplyUnlocked, petMorningDef int
var autoBabysit, streakDecayed int
if err := rows.Scan(
&c.UserID, &c.DisplayName,
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
@@ -513,18 +822,67 @@ func loadAllAdvCharacters() ([]AdventureCharacter, error) {
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
&c.MasterworkDropsReceived,
&c.RivalPool, &rivalUnlocked,
&babysitAct, &babysitExp, &c.BabysitSkillFocus,
&c.HospitalVisits, &c.RobbieVisitCount, &c.LastDeathDate,
&c.CombatActionsUsed, &c.HarvestActionsUsed,
&pardonUsed,
&mistyLastSeen, &arinaLastSeen,
&mistyBuffExp, &mistyDebuffExp, &arinaBuffExp,
&c.NPCMsgCount, &c.NPCMsgCountDate,
&c.MistyRollTarget, &c.ArinaRollTarget,
&c.HouseTier, &c.HouseLoanBalance, &houseFrozen, &c.HouseMissedPayments,
&houseAutopay, &c.HouseCurrentRate,
&c.PetType, &c.PetName, &c.PetXP, &c.PetLevel, &c.PetArmorTier,
&petChasedAway, &petReactivated, &petArrived,
&c.MistyEncounterCount, &c.MistyDonatedCount,
&thomAnimalLine, &petSupplyUnlocked, &c.PetLevel10Date,
&petMorningDef, &autoBabysit, &streakDecayed, &c.CraftsSucceeded,
); err != nil {
return nil, err
}
c.Alive = alive == 1
c.ActionTakenToday = actionTaken == 1
c.HolidayActionTaken = holidayTaken == 1
c.RivalUnlockedNotified = rivalUnlocked == 1
c.BabysitActive = babysitAct == 1
c.PetMorningDefense = petMorningDef == 1
c.AutoBabysit = autoBabysit == 1
c.StreakDecayed = streakDecayed == 1
c.HouseLoanFrozen = houseFrozen == 1
c.HouseAutopay = houseAutopay == 1
c.PetChasedAway = petChasedAway == 1
c.PetReactivated = petReactivated == 1
c.PetArrived = petArrived == 1
c.ThomAnimalLineFired = thomAnimalLine == 1
c.PetSupplyShopUnlocked = petSupplyUnlocked == 1
if deadUntil.Valid {
c.DeadUntil = &deadUntil.Time
}
if reprieveLast.Valid {
c.DeathReprieveLast = &reprieveLast.Time
}
if babysitExp.Valid {
c.BabysitExpiresAt = &babysitExp.Time
}
if pardonUsed.Valid {
c.LastPardonUsed = &pardonUsed.Time
}
if mistyLastSeen.Valid {
c.MistyLastSeen = &mistyLastSeen.Time
}
if arinaLastSeen.Valid {
c.ArinaLastSeen = &arinaLastSeen.Time
}
if mistyBuffExp.Valid {
c.MistyBuffExpires = &mistyBuffExp.Time
}
if mistyDebuffExp.Valid {
c.MistyDebuffExpires = &mistyDebuffExp.Time
}
if arinaBuffExp.Valid {
c.ArinaBuffExpires = &arinaBuffExp.Time
}
chars = append(chars, c)
}
return chars, rows.Err()
@@ -535,7 +893,7 @@ func resetAllAdvDailyActions() error {
// Only reset actions taken before today — protects against race if a player
// resolves their action at exactly midnight.
today := time.Now().UTC().Format("2006-01-02")
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
_, err := d.Exec(`UPDATE adventure_characters SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0, pet_morning_defense = 0 WHERE last_action_date < ? OR last_action_date IS NULL`, today)
return err
}
@@ -596,14 +954,13 @@ type AdvDayLog struct {
XPGained int
}
func loadAdvTodayLogs() ([]AdvDayLog, error) {
func loadAdvLogsForDate(date string) ([]AdvDayLog, error) {
d := db.Get()
today := time.Now().UTC().Format("2006-01-02")
rows, err := d.Query(`
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, xp_gained
FROM adventure_activity_log
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
ORDER BY logged_at`, today, today)
ORDER BY logged_at`, date, date)
if err != nil {
return nil, err
}
+38
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?"
}
}
+561
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
}
@@ -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)
}
}
}
+45 -37
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
@@ -0,0 +1,37 @@
package plugin
import "math/rand/v2"
// ── Babysitting Service Flavor Pools ────────────────────────────────────────
// babysitConfirmLines are shown on purchase confirmation.
var babysitConfirmLines = []string{
"Your adventurer is now in capable hands. Relatively speaking. We've done this before.",
"Service begins immediately. Go do whatever it is you do when you're not doing this.",
"Payment received. Your adventurer will be fine. Probably.",
"We've looked at your skill levels. We know what needs work. We'll handle it. You handle whatever you're handling.",
}
// babysitRivalRefusalLines are used when a rival showed up during babysitting.
// All lines use exactly two %s: (rival display name, date string).
var babysitRivalRefusalLines = []string{
"%s came by on %s with what appeared to be prepared remarks. They were turned away at the door. Their remarks went undelivered. This is probably for the best.",
"%s attempted to initiate a duel on %s. The babysitter informed them this was not possible. They stood there for a moment. Then left.",
"%s showed up on %s. The babysitter made them regret it in ways you could only dream of doing.",
}
// babysitDiaperLines appear once per summary, rotated.
var babysitDiaperLines = []string{
"The diapers have been handled. We don't discuss the diapers.",
"Diaper situation: resolved. No further details will be provided.",
"Standard diaper protocols were followed. The adventurer was cooperative. Mostly.",
"The diapers. They happened. They were handled. Moving on.",
}
// pickBabysitFlavor returns a random entry from the given pool.
func pickBabysitFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}
@@ -0,0 +1,66 @@
package plugin
// ── Blacksmith Flavor Text Pools ────────────────────────────────────────────
var blacksmithGreetings = []string{
"_wipes hands on apron and looks you over slowly_ You've seen some action. I can tell. Come inside. Let me experience what you've been working with.",
"_sets down hammer_ Don't be shy. I've seen worse. Bring it here. I'll get it back to its full size in no time.",
"I don't judge the condition things arrive in but I _always_ know how to make them feel... oh so much better. Weapons and armor too.",
"_gestures broadly at the forge_ Everything in here gets my full and undivided attention. You have my word on that. Unless a ball gag is involved. \u2764\ufe0f",
"You look like someone who's been putting their equipment through a lot. That's what I'm here for. Show me everything.",
}
var blacksmithInspection = []string{
"Oh. Oh that's seen some use. _runs thumb along the edge_ Don't worry. I know exactly what this needs.",
"This one's been neglected. I can tell. People don't appreciate what they have until it's been beaten down until it can't take any more. Then it's my job to _slowly_ build you... it... back up completely from bottom to the top.",
"_whistles low_ That's a lot of damage. I'm not complaining. I'm just saying I'm going to need some time with this one.",
"Good bones. Good bones. A little beaten up but my skilled fingers have brought worse back from the brink to highs rarely seen nor experienced on Earth! ...Oh I can fix your item too.",
"_holds it up to the light_ I've had my hands on a lot of these. This one needs work. The good kind of work. _looks you deeply in the eyes_ The only kind of work I know how to do.",
}
var blacksmithPayment = []string{
"_pockets the gold without looking at it_ Money is fine. The work is the reward.",
"Generous. I'd have done it for less. Don't tell anyone.",
"_nods slowly_ Fair. Now let me get to it. I work better when I'm not being watched. Come back in a bit.",
"You're paying for the expertise. Anyone can hit metal. Not everyone knows where to hit it. Or how to hit it to get it quivering after each strike until it's begging for more! _there's an awkward silence_ Anyway. Time to get to work.",
"The gold is appreciated. Now. Unless you have need for my services from the _ahem_ hidden menu, come back later.",
}
var blacksmithCompletion = []string{
"There. _slides it across the counter_ Good as new. Better, actually. I know what I'm doing.",
"_breathes heavily_ Done. That one took something out of me. Worth it though. Always worth it.",
"You feel that? That's what it's supposed to feel like. Full. Firm. Ready for anything at any time. Just like me.",
"I worked it until it couldn't take any more. Then I worked it a little more. I'm more than happy to give you a demonstration sometime.",
"_wipes forehead_ The difficult ones are always the most satisfying. Take it. Come back when it needs attention again. And it will need attention again.",
"It's hot right now. Give it a moment before you put it on. Trust me on that.",
"_stares at the finished piece for a moment longer than necessary_ Beautiful. Okay. Take it. Go.",
}
var blacksmithMasterwork = []string{
"_pauses_ This is Masterwork. _sets everything else down_ This gets my complete focus. Nothing else exists right now.",
"I don't rush Masterwork. I don't care how long it takes. Some things deserve to be done properly.",
"_closes eyes briefly_ I've been waiting for one of these to come through here. You have no idea how long I've been waiting.",
"Extra charge for Masterwork. Not because it's harder. Because it deserves more of me. It demands more of me. And oh god it will get it, don't you worry at all.",
}
var blacksmithArena = []string{
"_goes very still_ Is that... Arena gear? _looks up at you_ Where did you get this. Never mind. Don't tell me. Just leave it.",
"I've heard about this. I've never had one on my table before. _voice drops_ Close the door.",
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
}
var blacksmithFullCondition = []string{
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
"Nothing to fix. Come back when you've broken something. Or you need me to break you. Special offer. Just for you.",
}
var blacksmithBrokenCondition = []string{
"_long pause_ ...How. _another pause_ You know what. It doesn't matter. I've seen things. Sit down.",
"This is what happens when people don't come see me regularly. _not accusatory. somehow worse than accusatory._",
"I can fix this. It'll take longer. And cost more. And I'm going to need you to just... not talk while I work. Can you do that.",
"_picks it up gingerly_ It's not dead. It just needs someone who knows what they're doing. Lucky for you I always know what to do in _every_ situation. \u2764\ufe0f",
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
}
+4 -7
View File
@@ -191,14 +191,11 @@ var ClosingFailure = []string{
var ClosingDeath = []string{
"─────────────────────────────\n" +
"That's your day. Healthcare has the rest.\n\n" +
"A separate DM is inbound with the details.\n" +
"The details are not good. You already know the details.\n\n" +
"Next action: after recovery\n" +
"Morning DM: {morning_time} UTC — subject to medical clearance.",
"🏥 A DM from St. Guildmore's is on its way.\n" +
"Type `!hospital` for same-day revival.",
"─────────────────────────────\n" +
"Day over. Healthcare is involved.\n" +
"Expect a DM shortly with the full situation.\n\n" +
"The full situation: {location} won today.\n" +
"Tomorrow is pending insurance confirmation.",
"🏥 St. Guildmore's has been notified.\n\n" +
"Check your DMs, or type `!hospital`.",
}
+52 -52
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",
},
@@ -1,5 +1,13 @@
package plugin
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
//
// This file contains hand-written flavor text. Every entry is the length it is
// on purpose. The voice, pacing, and specific wording are intentional.
// If an entry seems too long: it isn't. Leave it alone.
// If you are tempted to "clean up" or "condense" entries: don't.
// If your context window is too small to fit this file: that is your problem, not ours.
//
// ── FISHING FLAVOR TEXT ───────────────────────────────────────────────────────
//
// Tier 1: Muddy Pond — garbage, sad fish, puns, Stardew energy
@@ -0,0 +1,283 @@
package plugin
import (
"fmt"
"math/rand/v2"
"strings"
)
// ── St. Guildmore's Memorial Hospital — Nurse Joy Flavor Text ──────────────
// nurseJoyFirstVisit is shown once on the player's first hospital visit.
var nurseJoyFirstVisit = "🏥 **Welcome to St. Guildmore's Memorial Hospital!**\n\n" +
"We're the official healthcare provider for members of the Adventurer's Guild. " +
"Even better, we've modeled our pricing after the United States of America, " +
"which means we offer only the quickest and best care. _whispers_ ...for those who can afford it.\n\n" +
"Your Guild insurance is on file. Nurse Joy will be right with you.\n\n"
var nurseJoyAdmission = []string{
"_looks up from clipboard with a bright smile_ Hi! Name? Guild card? Oh you're covered under the Collective, that's wonderful! Let me get Nurse Joy to come take a look at you. _is Nurse Joy_",
"Oh you're with the Guild? That's great, we love Guild members! Very comprehensive plan. Someone will be right with you! _gestures at chairs_ Can I get you anything while you wait? We have water. It's complimentary. The water is complimentary.",
"_already typing, still cheerful_ Cause of death? Temporary obviously, just for the record! And your Guild card number? Perfect! We'll get you all sorted out. _sorted out does not mean quickly but she means it warmly_",
"Welcome to St. Guildmore's! You're in such good hands here. _has said this ten thousand times and means it every single time_ Insurance on file? Wonderful. Take a number and we'll be right with you!",
"_looks up, immediately sympathetic in a clinical way_ Oh you don't look great! That's okay, that's what we're here for. Guild member? Amazing. Nurse Joy will be right out. _is Nurse Joy. will be right out._",
}
var nurseJoyBillDelivery = []string{
"_slides it across with a smile_ So that's the total before insurance! Your plan is really good though -- the Collective is wonderful. We're going to call them right now. _already reaching for the phone, still smiling_",
"Here's the full rate! _sets it down cheerfully_ Don't worry about that number, insurance is going to take care of so much of it. So much. Give us just a sec! _hums while dialing_",
"That's what we call the pre-insurance figure! It's a big number but honestly that's just how we price things here. The Collective will bring it way down. Way down. _wanders off still visibly pleased with how this is going_",
"_hands it over like she's giving you a gift_ Your Guild plan is really comprehensive -- one of the better ones we see honestly. Let me just get them on the line! _does not register your expression at all_",
}
// nurseJoyAfterInsurance — format with afterInsurance cost.
// The last entry has two %d placeholders (intentional — she says the number twice).
var nurseJoyAfterInsurance = []string{
"_practically skips back in_ Great news! The Collective was amazing -- they covered so much of it. Your portion is only **€%d**! _says \"only\" like she's reporting a minor inconvenience_",
"_beaming_ Okay so the insurance came through and honestly? Really good result today. You're looking at **€%d** out of pocket! That's after everything they covered. _genuinely proud of this outcome_",
"_sits across from you, clasps hands_ I have such good news. The Collective took care of the bulk of it. Your share is just **€%d**! I know it sounds like a lot but you should see what it was before. _you have seen what it was before_ Actually you did see it. But still -- this is so much better!",
"_slides over a new, smaller, still alarming piece of paper_ So they covered everything above your threshold! You just owe **€%d**. _taps it encouragingly_ And then you're all set! Good as new! _has never once considered that **€%d** might be a problem_",
}
var nurseJoyCantAfford = []string{
"Oh! It looks like your balance isn't quite there today! _signals to the orderlies_ That's okay! We'll get you back to where we found you and you can rest up overnight! _waves as the stretcher is wheeled back out_",
"Hmm! So the balance isn't quite covering it! No worries at all -- _already nodding to someone behind you_ -- the natural recovery process works just as well! We'll get you back to your ditch! Have a great night!",
"Oh that's a little short! That's okay! _cheerfully_ We're going to pop you back where you were and you'll be right as rain tomorrow morning! _you are deposited back in the ditch_",
}
var nurseJoyNudge = []string{
"Hi! This is Nurse Joy at St. Guildmore's Memorial! We noticed you haven't come in yet.\n\n" +
"We have a bed ready and your insurance is all on file! We even have Derek standing by but that's only relevant if there's a billing issue and I'm sure there won't be!\n\n" +
"Come in whenever you're ready! We're open! _we are always open_\n\n" +
"Type `!hospital` to check in.",
}
var nurseJoyAlive = []string{
"_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_",
"_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_",
}
var nurseJoyAlreadyRevived = "You seem to have recovered on your own! " +
"That's the body's natural healing process at work. Beautiful thing. " +
"No charge! _beams_"
// ── Room Announcements ───────────────────────────────────────────────────
var hospitalDischargeAnnounce = "🏥 %s has been discharged from St. Guildmore's Memorial Hospital. " +
"Recovered. Back in action. The bill has been described as \"a lot.\""
var hospitalDitchAnnounce = "🏥 %s was brought into St. Guildmore's on a stretcher. " +
"They have been returned to the ditch. They'll be fine tomorrow."
// ── Itemized Bill Generation ───────────────────────────────────────────────
type hospitalBillItem struct {
Name string
Note string // optional italicized sub-line
Min float64 // min fraction of total
Max float64 // max fraction of total
}
// hospitalBillPool is the pool of possible line items. Each bill picks 8-12.
var hospitalBillPool = []hospitalBillItem{
{"Resuscitation services (standard)", "", 0.04, 0.10},
{"Resuscitation services (premium)", "(performed simultaneously with standard, non-optional)", 0.03, 0.08},
{"Facility fee", "", 0.02, 0.05},
{"Facility fee (after hours)", "(you died at 2pm. this is contested. we will not contest it.)", 0.02, 0.04},
{"Physician consultation", "", 0.03, 0.06},
{"Physician consultation (second opinion)", "(same physician. different hat.)", 0.03, 0.06},
{"Gauze", "", 0.01, 0.02},
{"Gauze (application fee)", "", 0.01, 0.02},
{"Gauze (removal, future, pre-billed)", "", 0.01, 0.03},
{"Convenience fee", "(thank you for allowing us to exist)", 0.02, 0.05},
{"Inconvenience fee", "(deterrent to prevent emergency room abuse or usage in general)", 0.02, 0.05},
{"Guild membership verification fee", "(your guild card was checked. this takes resources.)", 0.01, 0.03},
{"Administrative processing", "", 0.01, 0.03},
{"Spiritual realignment surcharge", "(your soul was briefly elsewhere. retrieval is billable.)", 0.02, 0.04},
{"Bed occupancy charge", "(per minute, retroactive to estimated time of death)", 0.02, 0.05},
{"Oxygen (ambient)", "(you breathed hospital air. the air is not free.)", 0.01, 0.03},
{"Emotional support services", "(Nurse Joy smiled at you. that's a service.)", 0.01, 0.02},
{"Post-mortem orientation fee", "(the pamphlet you did not read.)", 0.01, 0.02},
{"Equipment sterilization", "(your gear was near our equipment. proximity counts.)", 0.01, 0.03},
{"Discharge planning", "(we are already planning your discharge. this costs money.)", 0.01, 0.03},
}
// hospitalHolidaySurcharge is included only on holidays.
var hospitalHolidaySurcharge = hospitalBillItem{
"Holiday surcharge", "(applied if death occurs on or adjacent to a recognized holiday)", 0.02, 0.02,
}
// hospitalFrequentCustomer is included when hospitalVisits > 0.
// It's a "discount" that is actually a positive charge. Because healthcare.
var hospitalFrequentCustomer = hospitalBillItem{
"Frequent customer discount", "(thank you for your continued patronage.)", 0.01, 0.02,
}
// generateItemizedBill builds a procedural hospital bill that sums to the given total.
func generateItemizedBill(total int64, afterInsurance int64, hospitalVisits int, isHoliday bool) string {
// Shuffle and pick 8-12 items from the pool
pool := make([]hospitalBillItem, len(hospitalBillPool))
copy(pool, hospitalBillPool)
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
count := 8 + rand.IntN(5) // 8-12
if count > len(pool) {
count = len(pool)
}
items := pool[:count]
// Add conditional items
if isHoliday {
items = append(items, hospitalHolidaySurcharge)
}
if hospitalVisits > 0 {
items = append(items, hospitalFrequentCustomer)
}
// Assign amounts
type billLine struct {
Name string
Note string
Amount int64
}
var lines []billLine
var assigned int64
for _, item := range items {
frac := item.Min + rand.Float64()*(item.Max-item.Min)
amount := int64(float64(total) * frac)
if amount < 1 {
amount = 1
}
lines = append(lines, billLine{Name: item.Name, Note: item.Note, Amount: amount})
assigned += amount
}
// Miscellaneous absorbs the remainder
misc := total - assigned
if misc < 0 {
// Over-assigned — scale everything down proportionally
scale := float64(total) / float64(assigned)
assigned = 0
for i := range lines {
lines[i].Amount = int64(float64(lines[i].Amount) * scale)
if lines[i].Amount < 1 {
lines[i].Amount = 1
}
assigned += lines[i].Amount
}
misc = total - assigned
}
if misc > 0 {
lines = append(lines, billLine{
Name: "Miscellaneous",
Note: "",
Amount: misc,
})
}
// Find max name width for alignment
maxName := 0
for _, l := range lines {
if len(l.Name) > maxName {
maxName = len(l.Name)
}
}
if maxName < 35 {
maxName = 35
}
// Format
var sb strings.Builder
sb.WriteString("```\n")
sb.WriteString("ST. GUILDMORE'S MEMORIAL HOSPITAL\n")
sb.WriteString("─────────────────────────────────\n")
for _, l := range lines {
padding := maxName - len(l.Name) + 2
if padding < 2 {
padding = 2
}
sb.WriteString(l.Name)
sb.WriteString(strings.Repeat(" ", padding))
sb.WriteString(formatBillAmount(l.Amount))
sb.WriteByte('\n')
if l.Note != "" {
sb.WriteString(" " + l.Note + "\n")
}
}
sb.WriteString("─────────────────────────────────\n")
totalLabel := "TOTAL (before insurance)"
padding := maxName - len(totalLabel) + 2
if padding < 2 {
padding = 2
}
sb.WriteString(totalLabel)
sb.WriteString(strings.Repeat(" ", padding))
sb.WriteString(formatBillAmount(total))
sb.WriteByte('\n')
insLabel := "Guild Adventurer's Insurance"
insPadding := maxName - len(insLabel) + 2
if insPadding < 2 {
insPadding = 2
}
sb.WriteString(insLabel)
sb.WriteString(strings.Repeat(" ", insPadding))
sb.WriteString("-" + formatBillAmount(total-afterInsurance))
sb.WriteByte('\n')
sb.WriteString("─────────────────────────────────\n")
oweLabel := "AMOUNT DUE"
owePadding := maxName - len(oweLabel) + 2
if owePadding < 2 {
owePadding = 2
}
sb.WriteString(oweLabel)
sb.WriteString(strings.Repeat(" ", owePadding))
sb.WriteString(formatBillAmount(afterInsurance))
sb.WriteByte('\n')
sb.WriteString("```")
return sb.String()
}
// formatBillAmount formats an int64 as €X,XXX with comma separators.
func formatBillAmount(n int64) string {
if n == 0 {
return "€0"
}
neg := n < 0
if neg {
n = -n
}
// Format with commas
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
if neg {
return "-€" + s
}
return "€" + s
}
// Insert commas from right
var result strings.Builder
remainder := len(s) % 3
if remainder > 0 {
result.WriteString(s[:remainder])
}
for i := remainder; i < len(s); i += 3 {
if result.Len() > 0 {
result.WriteByte(',')
}
result.WriteString(s[i : i+3])
}
if neg {
return "-€" + result.String()
}
return "€" + result.String()
}
+27 -26
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.",
},
}
+85
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!",
}
+363
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.",
}
+105
View File
@@ -0,0 +1,105 @@
package plugin
import "math/rand/v2"
// ── Rival Trash Talk Pools ──────────────────────────────────────────────────
// rivalOpeningTaunts are delivered with the challenge DM (Round 1).
var rivalOpeningTaunts = []string{
"Your strategy is filled with more holes than your boots. This will be like taking candy from a baby. Actually I shouldn't joke about that -- it looks like that's how you sustain yourself.",
"I've seen better odds on a three-legged horse. The horse at least had a plan.",
"I want you to know I considered not crossing the street. I crossed anyway. That tells you everything about how this is going to go.",
"You look nervous. You should look nervous. That's the first sensible thing I've seen from you.",
"I've done this before. You can tell by how little I'm sweating. Look at you. Look at me. See the difference?",
"I'm not saying this will be easy. I'm saying it will be quick. There's a distinction.",
"Take your time. Think it through. It won't help but I want you to feel like you had a fair shot.",
}
// rivalRoundWon are said by the rival after they win a round.
var rivalRoundWon = []string{
"Looks like you won't be buying any tacos today. You don't look like you're used to eating real food anyway.",
"One down. The math is unkind to you right now. As is most things, I suspect.",
"Did that hurt? The losing, I mean. Or is this familiar enough to be comfortable?",
"I knew what you were going to throw before you did. I know what you're going to throw next. I won't tell you.",
"You should have thrown something else. You know that now. I knew it before you threw.",
"_takes a moment to write something down_ Sorry. Just keeping notes. Continue.",
"The expression on your face right now is doing a lot of work. Most of it sad.",
}
// rivalRoundLost are said by the rival after they lose a round.
var rivalRoundLost = []string{
"Even a battered, dirty, _sniffs_ smelly, and broken beyond any hope of repair clock is right twice a day.",
"Fine. You got one. Don't read into it. Actually -- you won't know what to read into. Never mind.",
"I want you to enjoy this moment. Really sit in it. It may be all you have to take home today.",
"I respect the throw. I don't respect the thrower. These are two separate things.",
"Lucky. I've seen luck before and that's what that was. Make peace with it.",
"_pauses_ Okay. _pauses again_ I wasn't expecting that. That changes nothing. Next round.",
"You threw the right thing at the right time. The stopped clock principle applies. Ask someone to explain it to you later.",
}
// rivalTied are said by the rival after a tied round (before re-throw).
var rivalTied = []string{
"Same throw. Neither of us embarrassed ourselves. One of us is about to rectify that.",
"We're going again. That's fine. I have nowhere to be. Do you have somewhere to be? It doesn't matter.",
"You matched me. I want to be clear that this is the ceiling of your performance and we both know it.",
"_narrows eyes_ Interesting. Going again. Don't get comfortable.",
}
// rivalClosingWin are said by the rival when they win the match.
var rivalClosingWin = []string{
"I told you. I always tell people. People don't listen. Then it's over and they know I was right. Every time.",
"I hope the walk home gives you some time to think. Not about what you could have done differently. Just in general. Thinking is good for people.",
"Take care of yourself out there. Eat something. You look like you haven't in a while. _folds hands_ Actually that's your business. Good day.",
"The gold is mine. The record is updated. You may go.",
"I've done my good deed. The world is slightly more balanced now. _nods slowly and walks away_",
"You gave it everything. _looks you over_ Everything wasn't enough. But still. Points for showing up.",
}
// rivalClosingLoss are said by the rival when they lose the match.
var rivalClosingLoss = []string{
"You look like you've won the damn lottery. The sheer excitement on your face from winning a handful of gold is genuinely depressing. _sighs_ I suppose I've done my good deed for today. _checks off an item on a list titled 'Feed a waif today'_",
"Fine. Take it. You've clearly never had this much at one time and I'm not going to be the one to take that from you. Today.",
"I'll be back. Enjoy the gold. Buy yourself something warm to eat. You look cold.",
"_stares at you for a long moment_ No. _turns and walks away_",
"You won. I lost. The sun is back out. These things are unrelated. _looks up at the sky anyway_",
"I'm not angry. I'm not even surprised. I'm something else entirely and I don't have the energy to name it right now. Congratulations.",
"Keep it. Consider it a loan. _has not indicated when or if repayment is expected_ You chuckle to yourself, convinced this was said in jest. You notice them scribbling a collection date.",
}
// rivalRoundOutcomeWin are the player-wins-round outcome lines.
var rivalRoundOutcomeWin = []string{
"%s loses to %s. This is known.",
"You read them perfectly. Or you guessed. Either way.",
"%s takes it. Round to you.",
}
// rivalRoundOutcomeLoss are the player-loses-round outcome lines.
var rivalRoundOutcomeLoss = []string{
"%s beats %s. The math is what it is.",
"Wrong call. It happens.",
"%s takes it. Round to them.",
}
// rivalUnlockDM is sent once when a player reaches Combat Level 5.
const rivalUnlockDM = `You have reached Combat Level 5.
This means something now. Not in the way you think. Somewhere out there, someone else hit level 5 too. They're going about their day. So are you. At some point, one of you will receive a DM.
That DM will not be friendly.
Good luck.`
// rivalForfeitLines are used when a challenge expires without response.
var rivalForfeitLines = []string{
"You didn't respond. The gold has been collected. The rival walked away satisfied. You weren't even there to see it.",
"Time's up. The rival waited. You didn't show. The gold is gone.",
"The 24 hours have passed. The rival has claimed their winnings by default. They seemed disappointed. Not about the gold.",
}
// pickRivalFlavor returns a random entry from the given pool.
func pickRivalFlavor(pool []string) string {
if len(pool) == 0 {
return ""
}
return pool[rand.IntN(len(pool))]
}
@@ -0,0 +1,54 @@
package plugin
// ── Robbie the Friendly Bandit — Flavor Text ─────────────────────────────────
var robbieOpenings = []string{
"Ello there! Robbie here. I had a little look at your inventory and -- well. " +
"I'm just going to say it. You've been holdin' onto some gear that's well below your station " +
"and I can't in good conscience let that stand. So I've gone ahead and sorted it for ya. You're welcome.",
"_tips hat_ Robbie, at your service. I noticed you had some items in there that were frankly " +
"bringing down the average and I've taken the liberty of relieving you of them. " +
"Left ya somethin' for the trouble. Fair's fair.",
"Right so. I was havin' a look -- as I do -- and I couldn't help but notice the state of your inventory. " +
"No judgment! Well. Some judgment. I've handled it. Check your gold.",
"Hiya! It's Robbie. You had some things you didn't need. I took 'em. " +
"I left ya €%d for the lot. I also donated everything to a good cause " +
"so technically you're a philanthropist now. You're welcome for that too.",
}
var robbieClosings = []string{
"Right then. I'll be off. Don't go hoarding gear again now -- I'll know. _taps nose_",
"Pleasure doing business. As always, Robbie leaves things better than he found them. " +
"Debatable, but that's his position and he's sticking to it.",
"You're all sorted. Don't thank me. Actually -- do thank me. I worked hard today.",
"_already gone by the time you read this_",
}
var robbieMasterworkGotCard = "Now THIS -- _holds it up_ -- this is a lovely piece. " +
"I'm not going to pretend otherwise. I've left you something special for this one. " +
"Consider it a bonus from Robbie. Don't spend it all at once. _winks and disappears_"
var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excellent. " +
"I'll keep this baby for myself then -- in case one of yous wakes up again and gives me a wallopping. o_o"
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
// ── Room Announcements ───────────────────────────────────────────────────────
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
"€%d left behind. Everything donated to the community pot. " +
"Robbie considers this a net positive for all parties."
var robbieRoomMasterworkCard = "🎩 Robbie paid %s a visit and helped himself to a Masterwork piece. " +
"He left a Get Out of Medical Debt Free card and €%d. Robbie's words: \"Fair's fair.\""
var robbieRoomMasterworkAlreadyHas = "🎩 Robbie paid %s a visit, found they already had a " +
"Get Out of Medical Debt Free card, and pocketed the Masterwork's card for himself. " +
"He still left €%d. Robbie is if nothing else consistent."
+200
View File
@@ -0,0 +1,200 @@
package plugin
// ── Luigi's Shop Flavor Text ────────────────────────────────────────────────
var luigiGreetings = []string{
"_leans on the counter_ Whadda y'all have? Menu is right above me. Everything's in stock, made fresh daily. Mostly.",
"Welcome to Luigi's! Everything here is fresh out the oven. What can I get started for you today?",
"_slides menu across the counter_ We've got some great stuff today. The Tier 3 armor just came in. Very fresh. Very fresh.",
"ORDER'S UP for the last customer! _turns to you_ Hi! Welcome! What are we doing today, quick service or are you browsing?",
"You look like someone who needs an upgrade. Good news -- Luigi's has got you covered. Fresh stock, hot and ready.",
"_straightens apron_ We're fully staffed today and the inventory is looking real good. What are you in the market for?",
"Luigi's! Home of the freshest gear in the region. _genuinely means this_ What can I get you?",
}
var luigiWeaponIntros = []string{
"Okay so the weapons -- everything here is made with quality ingredients. The higher the tier, the fresher the materials. You can taste the difference. _pause_ Metaphorically. Don't taste the sword.",
"Weapons are our most popular item. People come in for the sword, stay for the full combo. Can I interest you in the armor to go with that?",
"Fresh blades today. The Tier 4 came in this morning. I'd move on that before the rush.",
}
var luigiArmorIntros = []string{
"Armor is our specialty. Every piece is crafted to order. Well. It was crafted to order at some point. It's here now. Still fresh.",
"We've got full chest protection from Tier 1 to Tier 5. The Tier 5 is premium. Made with only the finest materials. You can tell by the weight of it.",
"The armor today is looking really good. Really fresh. I had the Tier 3 out front for the morning crowd and it moved fast.",
}
var luigiHelmetIntros = []string{
"Helmets. Very important. People forget about the helmet and then they're in here asking why things went wrong. Get the helmet.",
"We've got a full helmet lineup. Everything from the Dented Iron Helm -- which is a great value, honestly, a great value -- up to the Crown of the Fallen. Very premium item. Very fresh.",
}
var luigiBootsIntros = []string{
"Boots are underrated. I always say that. People come in for the sword and walk out in the same boots they came in with and then wonder why their feet hurt. The boots matter.",
"We've got boots across all tiers. The Tier 3 Swift Boots just came in. Fresh. Very fresh.",
}
var luigiToolIntros = []string{
"The tools. Yes. Very important for the miners. We've got everything from the Copper Pickaxe -- which, listen, it does the job, I'm not going to oversell it -- all the way up to the Diamond Pickaxe. Premium tool. Made with the best materials we have.",
}
var luigiCategoryIntros = map[EquipmentSlot][]string{
SlotWeapon: luigiWeaponIntros,
SlotArmor: luigiArmorIntros,
SlotHelmet: luigiHelmetIntros,
SlotBoots: luigiBootsIntros,
SlotTool: luigiToolIntros,
}
// ── Purchase Confirmations ──────────────────────────────────────────────────
var luigiPurchaseConfirm = []string{
"_punches it in_\nORDER UP! %s, one count! _slides it across the counter_\n\n€%d deducted. Good choice. I mean that -- genuinely good choice. Come back and see us.",
"_punches it in_\nORDER UP! %s! _slides it across the counter with both hands_\n\n€%d deducted. You made the right call today. Luigi's guarantee.",
"_punches it in_\nORDER UP! One %s, hot and ready! _nods approvingly_\n\n€%d deducted. That's going to serve you well. I stand behind every item on this floor.",
}
var luigiTier5Confirm = []string{
"_stops what he's doing_ Okay. This is -- I want to put this together myself. Not because the staff can't handle it, they absolutely can, I just -- a Tier 5 order deserves the personal touch. Give me one second.\nORDER UP! _voice cracks slightly_ You made a great call today. A genuinely great call.\n\n€%d deducted for **%s**.",
"_takes off his apron, puts it back on, adjusts it_ This is a Tier 5 order. I need -- I need a moment. _assembles the order with visible care_\nORDER UP! _barely containing himself_ This is what I'm here for. This right here.\n\n€%d deducted for **%s**.",
}
var luigiComboConfirm = []string{
"Okay so we've got the full spread -- that's a combo right there and I love seeing a combo. Let me get all of that together for you.\nORDER UP! Full combo! _beams like he always does on a full combo_\n\n€%d deducted for **%s**.",
"_looks at the order_ That's a combo. A real combo. People don't do combos enough and I've never understood why. This is how you shop.\nORDER UP! Full combo! _genuinely emotional_\n\n€%d deducted for **%s**.",
}
// ── Insufficient Funds ──────────────────────────────────────────────────────
var luigiInsufficientFunds = []string{
"I hear you and I wish I could help but I can't move product at a loss -- that's not how this works and honestly it wouldn't be fair to you either. Come back when you've got the funds and I'll make sure it's here for you. Fresh.",
}
// ── Browsing Without Buying ─────────────────────────────────────────────────
var luigiBrowseTimeout = []string{
"No rush. _glances at the door_ We've got people coming in so whenever you're ready. No pressure at all. Just -- whenever you're ready.",
"_reorganizes the display case_ Take your time. I'm not going anywhere. The stock isn't going anywhere. We're all just... here. Waiting. Ready when you are.",
"I'm going to need you to make a decision, we've got people behind you. _there are no people behind you_",
}
// ── Maxed Out / Fully Kitted ────────────────────────────────────────────────
var luigiMaxedOut = []string{
"_looks you over_ You're fully kitted out. I respect that. I really do. _pause_ Is there anything I can -- no. You've got everything. _nods once_ Come back if anything needs replacing. Or just come by. The door's always open.",
}
// ── Masterwork / Arena Acknowledgement ──────────────────────────────────────
var luigiMasterworkAck = []string{
"I see you've got a Masterwork piece in that slot. I can't beat that. But if you ever need a backup...",
"That's a Masterwork item you've got there. _respectful nod_ I'm not going to pretend I carry anything that competes with that. But the rest of the menu is still worth a look.",
"_notices the Arena gear_ You earned that. I know what that takes. I'm not here to replace it -- just here if you need anything else.",
}
// ── Show All Comment ────────────────────────────────────────────────────────
var luigiShowAllComment = []string{
"Sure, I'll show you everything. No judgment.",
"Full menu? You got it. I respect the thoroughness.",
}
// ── Unprompted Commentary ───────────────────────────────────────────────────
var luigiCommentary = []string{
"_gestures at the Vorpal Sword_ That one's been sitting there for a while but I want to be clear -- it's still fresh. We rotate stock.",
"The Dragonscale armor -- I'll be honest with you, I had a hard time sourcing that one. Supply chain issues. But it's here now and it is fresh.",
"_taps the Dented Iron Helm_ This is a value item. I'm not going to pretend it's premium. But for the price? You're not going to beat it. I stand behind the value items.",
"The Diamond Pickaxe just came in. Very fresh. Miners know. They always know when the fresh stock arrives.",
"_quietly, about the Knobby Boots_ We still carry the Tier 0 boots. For... continuity. I don't recommend them. But they're there.",
}
// ── Cancellation ────────────────────────────────────────────────────────────
var luigiCancellation = []string{
"Understood. No hard feelings. The door's always open and the stock is always fresh.",
"That's fine. Totally fine. I'm not going to pressure you. _moves the item back behind the counter_ Come back anytime.",
}
// ── Item Descriptions ───────────────────────────────────────────────────────
type luigiItemKey struct {
Slot EquipmentSlot
Tier int
}
// luigiItemDescriptions — full multi-sentence descriptions for the confirm view.
var luigiItemDescriptions = map[luigiItemKey]string{
// Weapons
{SlotWeapon, 1}: "I'm not going to dress it up -- it's an entry level blade. Iron. Holds an edge if you treat it right. Good for someone just getting started and I mean that genuinely. Everyone starts somewhere and this is a solid somewhere.",
{SlotWeapon, 2}: "Step up from the iron. You can feel it in the grip -- the steel is fresher, better balance. Not going to win any awards but I've seen people clear serious dungeons with this and come back smiling. Honest work. I stand behind it.",
{SlotWeapon, 3}: "Now we're talking. Silver edge, proper weight, made with quality materials. This is the one people come back for and I'll tell you why -- it performs. Every time. If you're going to make one investment in your kit today, this is the one I'd point you to.",
{SlotWeapon, 4}: "Premium item. Enchanted, fresh, made with the finest materials we source. I'm not going to tell you it's a limited time offer because it isn't -- we keep it stocked. What I will tell you is it moves fast because people who try it don't go back. That's not a sales pitch. That's just what happens.",
{SlotWeapon, 5}: "_lowers voice_ The Vorpal Sword. I want to be straight with you -- I don't know everything that goes into this. The supplier doesn't share the full recipe and I've learned not to push on it. What I know is the quality. I've held one. You feel it immediately. This is the item. If you're here for this, you already know.",
// Armor
{SlotArmor, 1}: "The name. I know. It's the supplier's name, not mine, and I'll be upfront -- there's some wear on it. It's not damage, it's use history, and it's been inspected. For the price it's the best value protection I carry and I wouldn't sell it if I didn't mean that.",
{SlotArmor, 2}: "Okay so -- the name. I hear it every day and I'll tell you what I tell everyone: I questioned it too when it first came in. Had my guy look at every link. Solid. The name came from the original batch which had issues. This isn't that batch. This batch passed everything. I wouldn't have it on the floor otherwise.",
{SlotArmor, 3}: "Full plate. Heavy -- and that's a good thing, that weight is quality steel, you don't get that from cheap materials. This is the real thing and if you're at the point in your adventure where you need real protection, this is what real looks like.",
{SlotArmor, 4}: "My personal recommendation for anyone who's serious about staying alive out there. Enchanted, properly made, and I've had customers come back three times for this. Not because it broke -- because they wanted a second one. That tells you everything.",
{SlotArmor, 5}: "_takes a breath_ I'm going to be straight with you about the Dragonscale. I know what it's made of. I know where it comes from. I just -- I don't ask the details because some things you don't need to know and the quality makes the conversation unnecessary. What I can tell you is it's the finest piece of armor in this shop and I've never had a complaint. Not one. In this business that's the only thing that matters.",
// Helmets
{SlotHelmet, 1}: "It's an iron pot. I know what it looks like. I know the history. But listen -- it stops things from hitting your head and that's what a helmet does. For the price you are not going to find better head protection. I guarantee that personally.",
{SlotHelmet, 2}: "The provenance is questionable. I'll give you that. The scratches were there when we got it and the previous owner didn't leave a forwarding address. But the steel is sound, the fit is decent, and it'll keep your skull in one piece. Nobody will compliment this helmet. Nobody needs to.",
{SlotHelmet, 3}: "Reinforced, fitted properly -- or close enough. This is the helmet you buy when you're serious about not dying from the top down. Doesn't make you look competent but competence is what you do with the gear, not what the gear does to your face.",
{SlotHelmet, 4}: "Guardian-grade. This helm has seen real battles, kept real heads intact, and carries itself with the quiet dignity you are only just beginning to deserve. I don't say that to be harsh. I say it because the helm is that good.",
{SlotHelmet, 5}: "Crown of the Fallen. Every previous owner died in it. None of them died because of it. It will outlast you too. _pauses_ I want to be clear -- that's a selling point. This is the most premium headwear I carry and it's not even close.",
// Boots
{SlotBoots, 1}: "Taken off a corpse. I'm not going to sugarcoat it. The corpse didn't need them anymore and honestly these boots have more life in them than some of the Tier 2 stock. For the price? It's a no-brainer. Don't think about the previous owner.",
{SlotBoots, 2}: "They've been places. Bad places. Places that did things to the leather you'd rather not examine. But they've held together through all of it and that tells you something about the construction. Mild discomfort is a feature. It means they're working.",
{SlotBoots, 3}: "Light enough to move in, grip decent enough to trust. These boots are built for someone who moves with purpose, which -- and I'm being honest with you -- you are in the process of becoming. The boots believe in you. Let them.",
{SlotBoots, 4}: "Ranger's boots. You move quieter. Faster. Longer. The ground cooperates with these in a way I can't fully explain. The forest notices. Something shifts. I've had rangers come in and refuse to take them off to try a different pair. That's how good they are.",
{SlotBoots, 5}: "The wind doesn't slow you. Terrain offers suggestions you are free to decline. These boots are an affront to the concept of obstacles. I had a pair behind the counter once. I don't anymore because someone bought them within an hour. That's the demand we're talking about here.",
// Tools
{SlotTool, 1}: "Copper. Soft. It gets the job done if you hit very hard and the ore is feeling cooperative. I'm not going to oversell it. But for someone who's just getting into mining, it's the right starting point and I mean that.",
{SlotTool, 2}: "Iron. Chipped to hell but bites the rock with something approaching intention. This is a pickaxe that exists and functions. The chip is cosmetic. Mostly. It won't affect performance in any way you'd notice if you weren't looking for it.",
{SlotTool, 3}: "Steel, properly weighted, properly edged. The mountain will acknowledge this pickaxe. Not respect it -- that takes time -- but acknowledge it. This is the tool for someone who's ready to be taken seriously by the ore.",
{SlotTool, 4}: "Mithril. Weighs nothing. Hits like consequence. The ores don't resist this so much as rearrange themselves out of respect. I've had miners come in after using one of these and I can see it in their eyes. Everything changed.",
{SlotTool, 5}: "Diamond. Breaks anything short of fate and occasionally that too. I'm going to be honest -- this is the finest mining tool I have ever carried and I've been doing this long enough to know the difference. The only limits left are your arm, your nerve, and the number of hours in a day.",
}
// luigiItemOneLiners — short quotes for the category listing view.
var luigiItemOneLiners = map[luigiItemKey]string{
// Weapons
{SlotWeapon, 1}: "Entry level. Honest work.",
{SlotWeapon, 2}: "Fresher steel. Better balance. Honest work.",
{SlotWeapon, 3}: "The one people come back for. Every time.",
{SlotWeapon, 4}: "Moves fast. People who try it don't go back.",
{SlotWeapon, 5}: "This is the item. You already know.",
// Armor
{SlotArmor, 1}: "Best value protection I carry. I mean that.",
{SlotArmor, 2}: "The name came from the old batch. This batch is solid.",
{SlotArmor, 3}: "This is what real protection looks like.",
{SlotArmor, 4}: "My personal recommendation. Customers come back three times.",
{SlotArmor, 5}: "The finest piece of armor in this shop. Not one complaint.",
// Helmets
{SlotHelmet, 1}: "Stops things from hitting your head. Great value.",
{SlotHelmet, 2}: "The steel is sound. Nobody will compliment it. Nobody needs to.",
{SlotHelmet, 3}: "For when you're serious about not dying from the top down.",
{SlotHelmet, 4}: "Guardian-grade. Quiet dignity. The real deal.",
{SlotHelmet, 5}: "Every previous owner died in it. None because of it.",
// Boots
{SlotBoots, 1}: "Don't think about the previous owner.",
{SlotBoots, 2}: "Mild discomfort is a feature. Means they're working.",
{SlotBoots, 3}: "Built for someone who moves with purpose.",
{SlotBoots, 4}: "Rangers refuse to take them off. That's how good they are.",
{SlotBoots, 5}: "An affront to the concept of obstacles.",
// Tools
{SlotTool, 1}: "Gets the job done if the ore cooperates.",
{SlotTool, 2}: "Exists and functions. The chip is cosmetic. Mostly.",
{SlotTool, 3}: "The mountain will acknowledge this pickaxe.",
{SlotTool, 4}: "Weighs nothing. Hits like consequence.",
{SlotTool, 5}: "Breaks anything short of fate.",
}
@@ -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},
+283
View File
@@ -0,0 +1,283 @@
package plugin
import (
"fmt"
"log/slog"
"math"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Pending Interaction Types ──────────────────────────────────────────────
type advPendingHospitalPay struct {
Cost int64 // after-insurance amount (recomputed at confirm time for TOCTOU safety)
}
// ── Hospital Command ───────────────────────────────────────────────────────
func (p *AdventurePlugin) handleHospitalCmd(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "You're not registered for Adventure yet. Type `!adventure` to begin.")
}
// If alive, reject
if char.Alive {
text, _ := advPickFlavor(nurseJoyAlive, ctx.Sender, "hospital_alive")
return p.SendDM(ctx.Sender, text)
}
// On-demand revive if death timer already expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("hospital: on-demand revive failed", "user", char.UserID, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong at the hospital. Try again in a moment.")
}
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
// Compute costs
beforeInsurance := int64(char.CombatLevel) * 125_000
afterInsurance := int64(char.CombatLevel) * 25_000
// Check balance
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(afterInsurance) {
// Can't afford — back to the ditch
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
p.SendDM(ctx.Sender, text)
// Room announcement
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
}
return nil
}
// Check if holiday for bill surcharge
isHol, _ := isHolidayToday()
// Build the full multi-act DM
var sb strings.Builder
// Act 1 — Admission
if char.HospitalVisits == 0 {
sb.WriteString(nurseJoyFirstVisit)
}
admission, _ := advPickFlavor(nurseJoyAdmission, ctx.Sender, "hospital_admit")
sb.WriteString(admission)
sb.WriteString("\n\n")
// Act 2 — The Bill
sb.WriteString(generateItemizedBill(beforeInsurance, afterInsurance, char.HospitalVisits, isHol))
sb.WriteString("\n\n")
delivery, _ := advPickFlavor(nurseJoyBillDelivery, ctx.Sender, "hospital_delivery")
sb.WriteString(delivery)
sb.WriteString("\n\n───\n\n")
// Act 3 — After Insurance
afterText, _ := advPickFlavor(nurseJoyAfterInsurance, ctx.Sender, "hospital_after")
// Some entries have two %d placeholders (she says the number twice)
count := strings.Count(afterText, "%d")
switch count {
case 1:
afterText = fmt.Sprintf(afterText, afterInsurance)
case 2:
afterText = fmt.Sprintf(afterText, afterInsurance, afterInsurance)
}
sb.WriteString(afterText)
sb.WriteString("\n\n")
// Payment prompt
sb.WriteString(fmt.Sprintf("**St. Guildmore's Memorial Hospital**\nAmount due: **%s**\n\n", fmtEuro(afterInsurance)))
sb.WriteString("Pay now? (yes / no)\n\n")
sb.WriteString("*Note: Declining payment will result in discharge to the natural respawn queue. " +
"You'll be back tomorrow. The chair in the waiting room is available in the meantime.*")
// Store pending interaction
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "hospital_pay",
Data: &advPendingHospitalPay{Cost: afterInsurance},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, sb.String())
}
// ── Payment Resolution ─────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveHospitalPay(ctx MessageContext, interaction *advPendingInteraction) error {
reply := strings.TrimSpace(strings.ToLower(ctx.Body))
if reply == "yes" || reply == "y" {
// Acquire lock
mu := p.advUserLock(ctx.Sender)
mu.Lock()
defer mu.Unlock()
// Reload fresh (TOCTOU protection)
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Something went wrong loading your character.")
}
// Already alive (or timer expired while they were deciding)?
if char.Alive {
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
_ = saveAdvCharacter(char)
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
}
// Recompute cost from current combat level (don't trust cached value)
cost := int64(char.CombatLevel) * 25_000
// Check balance
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(cost) {
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
p.SendDM(ctx.Sender, text)
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
}
return nil
}
// Debit
if !p.euro.Debit(ctx.Sender, float64(cost), "hospital_revival") {
text, _ := advPickFlavor(nurseJoyCantAfford, ctx.Sender, "hospital_broke")
return p.SendDM(ctx.Sender, text)
}
// Community health fund: 10% of revival cost.
if potCut := int(math.Round(float64(cost) * 0.1)); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
// Revive
char.Alive = true
char.DeadUntil = nil
char.HospitalVisits++
if err := saveAdvCharacter(char); err != nil {
slog.Error("hospital: failed to save character after revival", "user", char.UserID, "err", err)
// Refund on save failure
p.euro.Credit(ctx.Sender, float64(cost), "hospital_revival_refund")
return p.SendDM(ctx.Sender, "Something went wrong during recovery. Your payment has been refunded.")
}
// Discharge DM
p.SendDM(ctx.Sender, fmt.Sprintf(
"✅ **Discharged — you're alive!**\n\n"+
"%s deducted. Nurse Joy wishes you the best. "+
"She means it. She always means it.\n\n"+
"Go get 'em. Type `!adventure` when you're ready.",
fmtEuro(cost)))
// Room announcement
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDischargeAnnounce, char.DisplayName))
}
return nil
}
// Declined or anything else — back to the ditch
p.SendDM(ctx.Sender, "Understood. Nurse Joy nods cheerfully and signals the orderlies. "+
"You're wheeled back to where they found you.\n\n"+
"Natural respawn will occur in due time. Rest up.")
char, err := loadAdvCharacter(ctx.Sender)
if err == nil {
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, char.DisplayName))
}
}
return nil
}
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
beforeInsurance := int64(char.CombatLevel) * 125_000
afterInsurance := int64(char.CombatLevel) * 25_000
respawnTime := "unknown"
if char.DeadUntil != nil {
respawnTime = char.DeadUntil.Format("15:04")
}
text := fmt.Sprintf(
"🏥 **St. Guildmore's Memorial Hospital**\n\n"+
"Nurse Joy has been notified. A bed is being prepared.\n\n"+
"Type `!hospital` to check in for same-day revival.\n"+
"Estimated bill: %s (Guild insurance covers %s → you pay %s)\n\n"+
"Or rest up — natural respawn at %s UTC.",
fmtEuro(beforeInsurance), fmtEuro(beforeInsurance-afterInsurance), fmtEuro(afterInsurance), respawnTime)
time.AfterFunc(10*time.Second, func() {
if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send hospital ad", "user", userID, "err", err)
return
}
// Schedule nudge for 2 hours from now — checked by hospitalNudgeTicker
p.hospitalNudges.Store(string(userID), time.Now().UTC().Add(2*time.Hour))
})
}
// ── Hospital Nudge Ticker ──────────────────────────────────────────────────
// Runs every minute, checks for nudges that are due. No long-lived goroutines.
func (p *AdventurePlugin) hospitalNudgeTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
p.hospitalNudges.Range(func(key, val any) bool {
uid := key.(string)
nudgeAt := val.(time.Time)
if now.Before(nudgeAt) {
return true
}
// Due — remove regardless of outcome
p.hospitalNudges.Delete(uid)
userID := id.UserID(uid)
char, err := loadAdvCharacter(userID)
if err != nil || char.Alive {
return true
}
// Don't nudge if already in hospital flow
if v, ok := p.pending.Load(uid); ok {
if pi, ok := v.(*advPendingInteraction); ok && pi.Type == "hospital_pay" {
return true
}
}
text, _ := advPickFlavor(nurseJoyNudge, userID, "hospital_nudge")
if err := p.SendDM(userID, text); err != nil {
slog.Error("hospital: failed to send nudge", "user", userID, "err", err)
}
return true
})
}
}
+759
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))
}
+375
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)
}
}
}
+59 -33
View File
@@ -19,7 +19,7 @@ type MasterworkDef struct {
Tier int // 1-5, matches location tier
Name string
Description string // character sheet / trade listing
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.015
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005
}
var masterworkDefs = []MasterworkDef{
@@ -46,7 +46,7 @@ var masterworkDefs = []MasterworkDef{
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 5,
Name: "The Motherload", DropRate: 0.015,
Name: "The Motherload", DropRate: 0.005,
Description: "There is no good explanation for why this was in the rock. The rock isn't talking. You're not asking. You've decided to stop thinking about it and just use it.",
},
@@ -73,7 +73,7 @@ var masterworkDefs = []MasterworkDef{
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 5,
Name: "The Deepforged Carapace", DropRate: 0.015,
Name: "The Deepforged Carapace", DropRate: 0.005,
Description: "This came up on the line from the Abyssal Trench and the line almost didn't hold. It is not from a fish. It is not from anything you have seen or want to see. It fits perfectly. You find this more unsettling than the alternative.",
},
@@ -100,7 +100,7 @@ var masterworkDefs = []MasterworkDef{
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 5,
Name: "The Last Step", DropRate: 0.015,
Name: "The Last Step", DropRate: 0.005,
Description: "Found at the edge of the Fungal Dark where the light stops. You almost didn't go that far. Something about wearing them suggests you will always go that far, from now on. This is either good news or the other kind.",
},
}
@@ -169,26 +169,28 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC
return // no masterwork available for this activity+tier (e.g. dungeon)
}
// Roll for drop
if rand.Float64() >= def.DropRate {
// Roll for drop (chat level rare bonus applied additively)
dropRate := def.DropRate + chatLevelRareBonus(p.chatLevel(userID))
if rand.Float64() >= dropRate {
return
}
// Check current equipment in target slot
existing := equip[def.Slot]
// Determine: auto-equip, inventory, or silent discard
// Determine: auto-equip, inventory, or silent discard.
// Compare effective tiers: Masterwork = tier*1.25, Arena = tier*1.5, Shop = tier.
// Only auto-equip if the new Masterwork drop is strictly better.
newEffective := float64(def.Tier) * 1.25 // incoming masterwork
existingEffective := advEffectiveTier(existing)
autoEquip := false
if existing == nil {
autoEquip = true
} else if existing.Masterwork {
if def.Tier > existing.Tier {
autoEquip = true // upgrade over lower-tier masterwork
} else {
return // silent discard: same or higher tier masterwork already equipped
}
} else {
autoEquip = true // any masterwork > shop gear
} else if existing.Masterwork && def.Tier <= existing.Tier {
return // silent discard: same or lower tier masterwork already equipped
} else if newEffective > existingEffective {
autoEquip = true // genuinely better than what they have
}
// First-drop detection
@@ -331,16 +333,16 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
return p.SendDM(ctx.Sender, "Failed to load inventory.")
}
// Filter to masterwork items only
// Filter to special gear (Masterwork + Arena) items only
var mwItems []AdvItem
for _, it := range items {
if it.Type == "MasterworkGear" {
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
mwItems = append(mwItems, it)
}
}
if len(mwItems) == 0 {
return p.SendDM(ctx.Sender, "You have no Masterwork gear waiting to be equipped. Go find some.")
return p.SendDM(ctx.Sender, "You have no special gear waiting to be equipped. Go find some.")
}
equip, err := loadAdvEquipment(ctx.Sender)
@@ -350,7 +352,7 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
// Build listing
var sb strings.Builder
sb.WriteString("⭐ **Your unequipped Masterwork gear:**\n\n")
sb.WriteString("⭐ **Your unequipped special gear:**\n\n")
for i, it := range mwItems {
current := equip[it.Slot]
@@ -364,8 +366,12 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
}
currentDesc = fmt.Sprintf("%s (%s)", current.Name, tag)
}
sb.WriteString(fmt.Sprintf("%d. %s (T%d %s) — currently: %s\n",
i+1, it.Name, it.Tier, slotTitle(it.Slot), currentDesc))
marker := "⭐"
if it.Type == "ArenaGear" {
marker = "⚔️"
}
sb.WriteString(fmt.Sprintf("%d. %s %s (T%d %s) — currently: %s\n",
i+1, marker, it.Name, it.Tier, slotTitle(it.Slot), currentDesc))
}
sb.WriteString("\nReply with a number to equip, or \"cancel\".")
@@ -373,7 +379,7 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "masterwork_equip",
Data: &advPendingMasterworkEquip{Items: mwItems},
ExpiresAt: time.Now().Add(5 * time.Minute),
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, sb.String())
@@ -428,17 +434,22 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "masterwork_equip_confirm",
Data: &advPendingMasterworkConfirm{Item: selected},
ExpiresAt: time.Now().Add(5 * time.Minute),
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier)
bonusDesc := ""
if def != nil {
bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource)
marker := "⭐"
if selected.Type == "ArenaGear" {
marker = "⚔️"
} else {
def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier)
if def != nil {
bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource)
}
}
return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.",
selected.Name, selected.Tier, slotTitle(selected.Slot), warning, currentName, bonusDesc))
return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d %s %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.",
selected.Name, selected.Tier, marker, slotTitle(selected.Slot), warning, currentName, bonusDesc))
}
func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
@@ -486,10 +497,21 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
eq.Condition = 100
eq.Name = selected.Name
eq.ActionsUsed = 0
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = true
eq.SkillSource = selected.SkillSource
if selected.Type == "ArenaGear" {
eq.Masterwork = false
eq.SkillSource = ""
eq.ArenaTier = selected.Tier
eq.ArenaSet = ""
if gs := arenaGearByName(selected.Name); gs != nil {
eq.ArenaSet = gs.SetKey
}
} else {
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = true
eq.SkillSource = selected.SkillSource
}
if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
return p.SendDM(ctx.Sender, "Failed to save equipment. Try again.")
@@ -500,7 +522,11 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter
slog.Error("adventure: failed to remove equipped item from inventory", "user", ctx.Sender, "err", err)
}
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. ⭐ Masterwork %s, Tier %d.", selected.Name, slotTitle(selected.Slot), selected.Tier))
kind := "⭐ Masterwork"
if selected.Type == "ArenaGear" {
kind = "⚔️ Arena"
}
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. %s %s, Tier %d.", selected.Name, kind, slotTitle(selected.Slot), selected.Tier))
}
// slotToActivity converts an EquipmentSlot to the activity that drops masterwork for it.
+158
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))
}
+391
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)
}
}
+448
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
}
+442
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)
}
}
}
+152 -106
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)
@@ -140,7 +146,7 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
}
}
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
sb.WriteString(fmt.Sprintf(" Equipment Score: %.1f\n", eqScore))
// Treasures
if len(treasures) > 0 {
@@ -172,11 +178,55 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
sb.WriteString(fmt.Sprintf("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
// Today's action
if char.ActionTakenToday {
sb.WriteString("\n📅 Today: Action taken")
// Babysit status
if char.BabysitActive {
remaining := "active"
if char.BabysitExpiresAt != nil {
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
if days < 1 {
remaining = "less than a day left"
} else {
remaining = fmt.Sprintf("%d days left", days)
}
}
sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus))
}
// Rival status
if char.RivalPool == 1 {
records, _ := loadAllRivalRecords(char.UserID)
sb.WriteString("\n⚔️ Rivals: Unlocked")
if len(records) > 0 {
totalW, totalL := 0, 0
for _, r := range records {
totalW += r.Wins
totalL += r.Losses
}
sb.WriteString(fmt.Sprintf(" (%dW / %dL)", totalW, totalL))
}
sb.WriteString(" — `!adventure rivals` for details\n")
}
// Today's actions
isHolSheet, _ := isHolidayToday()
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHolSheet {
combatMax++
harvestMax++
}
combatRemaining := combatMax - char.CombatActionsUsed
if combatRemaining < 0 {
combatRemaining = 0
}
harvestRemaining := harvestMax - char.HarvestActionsUsed
if harvestRemaining < 0 {
harvestRemaining = 0
}
if char.HasActedToday() {
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions remaining", combatRemaining, harvestRemaining))
} else {
sb.WriteString("\n📅 Today: No action yet — reply to morning DM or type `!adventure`")
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions available", combatRemaining, harvestRemaining))
}
return sb.String()
@@ -189,7 +239,7 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
// Holiday notice (before greeting)
if holidayName != "" {
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you are able to take **two actions** today.\n\n", holidayName, holidayName))
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName))
}
// Pick a morning greeting
@@ -230,45 +280,84 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
sb.WriteString("\n\n")
}
// Action budget
isHol := holidayName != ""
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
combatLeft := combatMax - char.CombatActionsUsed
if combatLeft < 0 {
combatLeft = 0
}
harvestLeft := harvestMax - char.HarvestActionsUsed
// Co-op participants have combat locked for the duration of the run.
coopRun, _ := loadCoopRunForUser(char.UserID)
inCoop := coopRun != nil && coopRun.Status == "active"
if inCoop {
sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n",
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax))
} else {
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
}
// Location choices
sb.WriteString("**1️⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
if inCoop {
sb.WriteString(fmt.Sprintf("**1️⃣ Dungeon:** _(off in the Co-op, no solo combat — `!coop status` for run state)_\n"))
} else if char.CanDoCombat(isHol) {
sb.WriteString("**1️⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} else {
sb.WriteString("**1️⃣ Dungeon:** _(no combat actions remaining)_\n")
}
sb.WriteString("**2️⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
if char.CanDoHarvest(isHol) {
sb.WriteString("**2️⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**3️⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
sb.WriteString("**3️⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4️⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
sb.WriteString("**4️⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
} else {
sb.WriteString("**2️⃣ Mine:** _(no harvest actions remaining)_\n")
sb.WriteString("**3️⃣ Forage:** _(no harvest actions remaining)_\n")
sb.WriteString("**4️⃣ Fish:** _(no harvest actions remaining)_\n")
}
sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6️⃣ Rest** — skip today, bank your luck\n\n")
sb.WriteString("**6️⃣ Blacksmith** — repair damaged equipment\n")
sb.WriteString("**7️⃣ Rest** — skip today, bank your luck\n")
sb.WriteString("**8️⃣ Thom** — `!thom` visit Krooke Realty 🏠\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
sb.WriteString("You have until midnight UTC to choose.")
@@ -276,56 +365,6 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
return sb.String()
}
// ── Holiday Second Action Prompt ──────────────────────────────────────────────
func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, bonuses *AdvBonusSummary) string {
var sb strings.Builder
sb.WriteString("✅ Action 1 complete.\n\nNow choose your **second action**:\n\n")
sb.WriteString("**1️⃣ Dungeon:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityDungeon, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**2️⃣ Mine:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityMining, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**3️⃣ Forage:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityForaging, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**4️⃣ Fish:**\n")
for _, el := range advEligibleLocations(char, equip, AdvActivityFishing, bonuses) {
warn := ""
if el.InPenaltyZone {
warn = " ⚠️"
}
sb.WriteString(fmt.Sprintf(" • %s (Tier %d, ~%.0f%% death%s)\n", el.Location.Name, el.Location.Tier, el.DeathPct, warn))
}
sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6️⃣ Rest** — skip the second action\n\n")
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
return sb.String()
}
// ── Resolution DM ────────────────────────────────────────────────────────────
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
@@ -349,13 +388,16 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
}
if len(result.LootItems) > 0 {
sb.WriteString(fmt.Sprintf("💰 Loot: €%d total\n", result.TotalLootValue))
sb.WriteString(fmt.Sprintf("💰 Loot: %s total\n", fmtEuro(result.TotalLootValue)))
for _, item := range result.LootItems {
sb.WriteString(fmt.Sprintf(" • %s — €%d\n", item.Name, item.Value))
sb.WriteString(fmt.Sprintf(" • %s — %s\n", item.Name, fmtEuro(item.Value)))
}
}
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
if result.XPBreakdown != "" {
sb.WriteString(fmt.Sprintf(" (%s)", result.XPBreakdown))
}
if result.LeveledUp {
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel))
}
@@ -443,20 +485,15 @@ func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string,
// ── Death Status DM ──────────────────────────────────────────────────────────
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
text, _ := advPickFlavor(DeathDM, char.UserID, "death_dm")
remaining := ""
cost := int64(char.CombatLevel) * 25_000
remaining := "unknown"
if char.DeadUntil != nil {
remaining = char.DeadUntil.Format("15:04")
}
location := char.GrudgeLocation
if location == "" {
location = "an unknown location"
}
return advSubstituteFlavor(text, map[string]string{
"{name}": char.DisplayName,
"{time}": remaining,
"{location}": location,
})
return fmt.Sprintf("💀 You're still dead.\n\n"+
"🏥 Type `!hospital` for same-day revival (€%d after insurance)\n"+
"⏳ Or wait — natural respawn at %s UTC",
cost, remaining)
}
// ── Respawn DM ───────────────────────────────────────────────────────────────
@@ -547,12 +584,17 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString("\n")
if tbRewards.Eligible > 0 {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
if tbRewards.GoldShare > 0 {
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
}
if tbRewards.GiftCount > 0 {
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
hasRewards := tbRewards.GoldShare > 0 || tbRewards.GiftCount > 0
if hasRewards {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
if tbRewards.GoldShare > 0 {
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
}
if tbRewards.GiftCount > 0 {
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
}
} else {
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
}
}
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
@@ -588,8 +630,12 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n",
p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill))
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
if p.Location != "" {
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
} else {
sb.WriteString(" Acted today — no log recorded.\n\n")
}
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
bestPlayer = p
@@ -706,11 +752,11 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
}
var entries []entry
for _, c := range chars {
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill) * 10
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
entries = append(entries, entry{
Name: c.DisplayName,
Score: score,
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill),
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
Streak: c.CurrentStreak,
})
}
+839
View File
@@ -0,0 +1,839 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"math"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"github.com/google/uuid"
"maunium.net/go/mautrix/id"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
rivalMinCombatLevel = 5
rivalChallengeWindow = 24 * time.Hour
rivalSamePairCooldown = 7 * 24 * time.Hour
rivalMinIntervalHours = 3 * 24 // 3 days in hours
rivalMaxIntervalHours = 4 * 24 // 4 days in hours
)
// ── Types ────────────────────────────────────────────────────────────────────
type advRivalChallenge struct {
ChallengeID string
ChallengerID id.UserID
ChallengedID id.UserID
Stake int
Round int
PlayerScore int
RivalScore int
ExpiresAt time.Time
CreatedAt time.Time
}
type advRivalRecord struct {
RivalID id.UserID
Wins int
Losses int
LastDuelAt *time.Time
}
type advPendingRivalRPS struct {
ChallengeID string
}
// ── Stake Calculation ────────────────────────────────────────────────────────
func rivalStake(combatLevel int) int {
return (combatLevel / 5) * 1000
}
// ── Rival Pool Unlock ────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
if char.CombatLevel >= rivalMinCombatLevel && char.RivalPool == 0 {
char.RivalPool = 1
if !char.RivalUnlockedNotified {
char.RivalUnlockedNotified = true
p.SendDM(char.UserID, rivalUnlockDM)
}
}
}
// ── DB CRUD ──────────────────────────────────────────────────────────────────
func loadRivalChallengeByID(challengeID string) (*advRivalChallenge, error) {
d := db.Get()
c := &advRivalChallenge{}
err := d.QueryRow(`
SELECT challenge_id, challenger_id, challenged_id, stake,
round, player_score, rival_score, expires_at, created_at
FROM adventure_rival_challenges WHERE challenge_id = ?`, challengeID).Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return c, nil
}
func insertRivalChallenge(c *advRivalChallenge) error {
db.Exec("rival: insert challenge",
`INSERT INTO adventure_rival_challenges
(challenge_id, challenger_id, challenged_id, stake, round, player_score, rival_score, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID),
c.Stake, c.Round, c.PlayerScore, c.RivalScore, c.ExpiresAt,
)
return nil
}
func saveRivalChallengeRound(c *advRivalChallenge) {
db.Exec("rival: update round",
`UPDATE adventure_rival_challenges
SET round = ?, player_score = ?, rival_score = ?
WHERE challenge_id = ?`,
c.Round, c.PlayerScore, c.RivalScore, c.ChallengeID,
)
}
func deleteRivalChallenge(challengeID string) {
db.Exec("rival: delete challenge",
`DELETE FROM adventure_rival_challenges WHERE challenge_id = ?`,
challengeID,
)
}
func upsertRivalRecord(userID, rivalID id.UserID, won bool) {
if won {
db.Exec("rival: upsert record win",
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
VALUES (?, ?, 1, 0, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, rival_id) DO UPDATE SET wins = wins + 1, last_duel_at = CURRENT_TIMESTAMP`,
string(userID), string(rivalID),
)
} else {
db.Exec("rival: upsert record loss",
`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
VALUES (?, ?, 0, 1, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, rival_id) DO UPDATE SET losses = losses + 1, last_duel_at = CURRENT_TIMESTAMP`,
string(userID), string(rivalID),
)
}
}
func loadAllRivalRecords(userID id.UserID) ([]advRivalRecord, error) {
d := db.Get()
rows, err := d.Query(`
SELECT rival_id, wins, losses, last_duel_at
FROM adventure_rival_records
WHERE user_id = ?
ORDER BY last_duel_at DESC`, string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var records []advRivalRecord
for rows.Next() {
r := advRivalRecord{}
var lastDuel sql.NullTime
if err := rows.Scan(&r.RivalID, &r.Wins, &r.Losses, &lastDuel); err != nil {
return nil, err
}
if lastDuel.Valid {
r.LastDuelAt = &lastDuel.Time
}
records = append(records, r)
}
return records, rows.Err()
}
func communityPotAdd(amount int) {
db.Exec("rival: community pot add",
`INSERT INTO community_pot (id, balance, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
amount, amount,
)
}
func communityTax(userID id.UserID, gross float64, rate float64) (net float64, tax int) {
t := math.Round(gross * rate)
tax = int(t)
net = gross - t
if tax > 0 {
communityPotAdd(tax)
trackTaxPaid(userID, tax)
}
return net, tax
}
func trackTaxPaid(userID id.UserID, amount int) {
if amount <= 0 {
return
}
db.Exec("tax: track paid",
`INSERT INTO tax_ledger (user_id, total_paid, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id) DO UPDATE SET total_paid = total_paid + ?, updated_at = CURRENT_TIMESTAMP`,
string(userID), amount, amount,
)
}
func communityPotBalance() int {
d := db.Get()
var balance int
_ = d.QueryRow(`SELECT COALESCE(balance, 0) FROM community_pot WHERE id = 1`).Scan(&balance)
return balance
}
func communityPotDebit(amount int) bool {
d := db.Get()
res, err := d.Exec(`UPDATE community_pot SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP
WHERE id = 1 AND balance >= ?`, amount, amount)
if err != nil {
return false
}
n, _ := res.RowsAffected()
return n == 1
}
func loadExpiredRivalChallenges() ([]advRivalChallenge, error) {
d := db.Get()
rows, err := d.Query(`
SELECT challenge_id, challenger_id, challenged_id, stake,
round, player_score, rival_score, expires_at, created_at
FROM adventure_rival_challenges
WHERE expires_at <= CURRENT_TIMESTAMP`)
if err != nil {
return nil, err
}
defer rows.Close()
var challenges []advRivalChallenge
for rows.Next() {
c := advRivalChallenge{}
if err := rows.Scan(
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
); err != nil {
return nil, err
}
challenges = append(challenges, c)
}
return challenges, rows.Err()
}
func lastRivalChallengeTime() time.Time {
d := db.Get()
var t sql.NullTime
_ = d.QueryRow(`SELECT MAX(created_at) FROM adventure_rival_challenges`).Scan(&t)
if t.Valid {
return t.Time
}
return time.Time{}
}
func hasActiveChallenge(userID id.UserID) bool {
d := db.Get()
var count int
_ = d.QueryRow(`
SELECT COUNT(*) FROM adventure_rival_challenges
WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
string(userID), string(userID)).Scan(&count)
return count > 0
}
func lastDuelBetween(a, b id.UserID) time.Time {
d := db.Get()
var t sql.NullTime
_ = d.QueryRow(`
SELECT last_duel_at FROM adventure_rival_records
WHERE user_id = ? AND rival_id = ?`, string(a), string(b)).Scan(&t)
if t.Valid {
return t.Time
}
return time.Time{}
}
// ── Pair Selection ───────────────────────────────────────────────────────────
func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureCharacter) {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("rival: load characters", "err", err)
return nil, nil
}
// Filter to eligible pool members.
var pool []AdventureCharacter
for _, c := range chars {
if c.RivalPool == 0 || !c.Alive || c.BabysitActive {
continue
}
if hasActiveChallenge(c.UserID) {
continue
}
stake := rivalStake(c.CombatLevel)
if stake <= 0 {
continue
}
bal := p.euro.GetBalance(c.UserID)
if bal < float64(stake) {
continue
}
pool = append(pool, c)
}
if len(pool) < 2 {
return nil, nil
}
// Shuffle and find first valid pair.
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
now := time.Now().UTC()
for i := 0; i < len(pool); i++ {
for j := i + 1; j < len(pool); j++ {
a, b := &pool[i], &pool[j]
// Check same-pair cooldown (7 days).
last := lastDuelBetween(a.UserID, b.UserID)
if !last.IsZero() && now.Sub(last) < rivalSamePairCooldown {
continue
}
// Randomly assign challenger/challenged.
if rand.IntN(2) == 0 {
return a, b
}
return b, a
}
}
return nil, nil
}
// ── Challenge Issuance ───────────────────────────────────────────────────────
func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) {
stake := rivalStake(challenged.CombatLevel)
challenge := &advRivalChallenge{
ChallengeID: uuid.New().String()[:12],
ChallengerID: challenger.UserID,
ChallengedID: challenged.UserID,
Stake: stake,
Round: 1,
PlayerScore: 0,
RivalScore: 0,
ExpiresAt: time.Now().UTC().Add(rivalChallengeWindow),
}
insertRivalChallenge(challenge)
// Notify the rival (challenger) that a challenge was issued in their name.
p.SendDM(challenger.UserID, fmt.Sprintf(
"⚔️ You've been matched as a rival against **%s**. A challenge has been issued in your name. "+
"Your throws will be generated automatically. Sit back and wait for the result.",
challenged.DisplayName))
// Send the dramatic challenge DM to the challenged player.
openingTaunt := pickRivalFlavor(rivalOpeningTaunts)
dm := fmt.Sprintf(` RIVAL CHALLENGE
You're walking along enjoying a nice sunny day when you look across the road and notice someone staring at you intensely -- the look of someone who caught a faint but unmistakable smell at a restaurant and has traced it back to its source.
The person crosses the street. The sun disappears behind dark, angry clouds as if on cue.
*"You're looking at me like you got a problem."*
They look you over slowly. The scowl becomes a smug smile.
*"Your face looks like someone who's fought countless battles and lost just as many. Ah well. I already crossed the street so I guess I'll make this quick."*
They reach to their side. You ready yourself.
The stakes are %d. Best of 3. Rock Paper Scissors.
*%s*
Reply with **rock**, **paper**, or **scissors** for Round 1.
You have 24 hours.`, stake, openingTaunt)
p.SendDM(challenged.UserID, dm)
// Store pending interaction for the challenged player.
p.pending.Store(string(challenged.UserID), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
slog.Info("rival: challenge issued",
"challenger", challenger.DisplayName,
"challenged", challenged.DisplayName,
"stake", stake)
}
// ── RPS Resolution ───────────────────────────────────────────────────────────
type rpsThrow int
const (
rpsRock rpsThrow = iota
rpsPaper
rpsScissors
)
var rpsNames = map[rpsThrow]string{
rpsRock: "rock",
rpsPaper: "paper",
rpsScissors: "scissors",
}
func parseRPS(s string) (rpsThrow, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "rock", "r":
return rpsRock, true
case "paper", "p":
return rpsPaper, true
case "scissors", "s", "scissor":
return rpsScissors, true
}
return 0, false
}
func randomRPS() rpsThrow {
return rpsThrow(rand.IntN(3))
}
// rpsResult: 1 = player wins, -1 = rival wins, 0 = tie.
func rpsResult(player, rival rpsThrow) int {
if player == rival {
return 0
}
if (player == rpsRock && rival == rpsScissors) ||
(player == rpsPaper && rival == rpsRock) ||
(player == rpsScissors && rival == rpsPaper) {
return 1
}
return -1
}
func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *advPendingInteraction) error {
// Acquire per-user lock to prevent concurrent resolution from rapid messages.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingRivalRPS)
playerThrow, ok := parseRPS(ctx.Body)
if !ok {
// Re-store the pending interaction so the player can try again.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Rock, paper, or scissors. That's it. Those are the options.")
}
challenge, err := loadRivalChallengeByID(data.ChallengeID)
if err != nil || challenge == nil {
// Challenge was expired/deleted by the ticker — don't re-store pending.
return p.SendDM(ctx.Sender, "That challenge is no longer active.")
}
// Resolve throw.
rivalThrow := randomRPS()
result := rpsResult(playerThrow, rivalThrow)
// Tie — both re-throw. Re-prompt the player.
if result == 0 {
tieLine := pickRivalFlavor(rivalTied)
text := fmt.Sprintf("You threw %s. They threw %s. Tie.\n\n*%s*\n\n"+
"Reply with **rock**, **paper**, or **scissors** to re-throw.",
rpsNames[playerThrow], rpsNames[rivalThrow], tieLine)
// Re-store the pending interaction for the same round.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
return p.SendDM(ctx.Sender, text)
}
// Build the round DM.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("⚔️ ROUND %d RESULT\n\n", challenge.Round))
sb.WriteString(fmt.Sprintf("You threw %s. They threw %s.\n\n", rpsNames[playerThrow], rpsNames[rivalThrow]))
if result == 1 {
// Player won the round.
challenge.PlayerScore++
// Outcome line.
outcomePool := rivalRoundOutcomeWin
outcome := pickRivalFlavor(outcomePool)
switch strings.Count(outcome, "%s") {
case 2:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
case 1:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
// Rival reaction (they lost the round).
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundLost)))
} else {
// Rival won the round.
challenge.RivalScore++
outcomePool := rivalRoundOutcomeLoss
outcome := pickRivalFlavor(outcomePool)
switch strings.Count(outcome, "%s") {
case 2:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
case 1:
outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
}
sb.WriteString(outcome + "\n\n")
sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
// Rival reaction (they won the round).
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundWon)))
}
// Check for match end (best of 3 = first to 2).
if challenge.PlayerScore >= 2 || challenge.RivalScore >= 2 {
playerWon := challenge.PlayerScore >= 2
saveRivalChallengeRound(challenge)
p.pending.Delete(string(ctx.Sender))
p.finalizeRivalMatch(challenge, playerWon, &sb)
return p.SendDM(ctx.Sender, sb.String())
}
// Match continues — next round.
challenge.Round++
saveRivalChallengeRound(challenge)
sb.WriteString(fmt.Sprintf("\nReply with **rock**, **paper**, or **scissors** for Round %d.", challenge.Round))
// Update pending interaction for next round.
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "rival_rps",
Data: &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
ExpiresAt: challenge.ExpiresAt,
})
return p.SendDM(ctx.Sender, sb.String())
}
// ── Match Finalization ───────────────────────────────────────────────────────
func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playerWon bool, sb *strings.Builder) {
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
var winnerID, loserID id.UserID
var winnerName, loserName string
if playerWon {
winnerID = challenge.ChallengedID
loserID = challenge.ChallengerID
winnerName = ""
loserName = ""
if challengedChar != nil {
winnerName = challengedChar.DisplayName
}
if challengerChar != nil {
loserName = challengerChar.DisplayName
}
} else {
winnerID = challenge.ChallengerID
loserID = challenge.ChallengedID
if challengerChar != nil {
winnerName = challengerChar.DisplayName
}
if challengedChar != nil {
loserName = challengedChar.DisplayName
}
}
// Gold transfer — try full stake, fall back to available balance.
stake := challenge.Stake
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss") {
// Loser can't cover full stake — debit whatever they have.
bal := p.euro.GetBalance(loserID)
stake = int(bal)
if stake > 0 {
// Best-effort debit of remaining balance. If it fails (concurrent drain), stake becomes 0.
if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss_partial") {
stake = 0
}
}
}
winnerShare := int(math.Round(float64(stake) * 0.3))
potShare := stake - winnerShare
if winnerShare > 0 {
p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
}
if potShare > 0 {
communityPotAdd(potShare)
trackTaxPaid(loserID, potShare)
}
// Update rival records (both directions).
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, playerWon)
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, !playerWon)
// Append closing line to the challenged player's DM.
sb.WriteString("\n⚔️ DUEL RESOLVED\n\n")
sb.WriteString(fmt.Sprintf("Final score: You %d -- Rival %d\n", challenge.PlayerScore, challenge.RivalScore))
if playerWon {
sb.WriteString(fmt.Sprintf("€%d added to your balance. €%d added to the community pot.\n\n", winnerShare, potShare))
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingLoss)))
} else {
sb.WriteString(fmt.Sprintf("€%d removed from your balance. €%d added to the community pot.\n\n", stake, potShare))
sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingWin)))
}
// Load W/L record for display.
records, _ := loadAllRivalRecords(challenge.ChallengedID)
for _, r := range records {
if r.RivalID == challenge.ChallengerID {
rivalName := string(challenge.ChallengerID)
if challengerChar != nil {
rivalName = challengerChar.DisplayName
}
sb.WriteString(fmt.Sprintf("Record vs %s: %dW-%dL\n", rivalName, r.Wins, r.Losses))
break
}
}
// Send closing DM to the rival (challenger).
var rivalDM strings.Builder
rivalDM.WriteString("⚔️ DUEL RESOLVED\n\n")
rivalDM.WriteString(fmt.Sprintf("Your challenge against **%s** has concluded.\n",
func() string {
if challengedChar != nil {
return challengedChar.DisplayName
}
return string(challenge.ChallengedID)
}()))
rivalDM.WriteString(fmt.Sprintf("Final score: %s %d -- %s %d\n",
func() string {
if challengedChar != nil {
return challengedChar.DisplayName
}
return "Them"
}(), challenge.PlayerScore,
func() string {
if challengerChar != nil {
return challengerChar.DisplayName
}
return "You"
}(), challenge.RivalScore))
if !playerWon {
rivalDM.WriteString(fmt.Sprintf("You won! +€%d to your balance. €%d to the community pot.\n", winnerShare, potShare))
} else {
rivalDM.WriteString(fmt.Sprintf("You lost. €%d removed from your balance.\n", stake))
}
p.SendDM(challenge.ChallengerID, rivalDM.String())
// Room announcement.
gr := gamesRoom()
if gr != "" {
var announce string
if challenge.Round >= 3 {
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s** in a nail-biting best of 3!",
winnerName, stake, loserName)
} else {
announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**!",
winnerName, stake, loserName)
}
p.SendMessage(gr, announce)
}
// Delete the challenge row.
deleteRivalChallenge(challenge.ChallengeID)
slog.Info("rival: match resolved",
"winner", winnerName, "loser", loserName,
"stake", stake, "rounds", challenge.Round)
}
// ── Expiry Handling ──────────────────────────────────────────────────────────
func (p *AdventurePlugin) expireRivalChallenges() {
expired, err := loadExpiredRivalChallenges()
if err != nil {
slog.Error("rival: load expired challenges", "err", err)
return
}
for _, challenge := range expired {
// Challenged player forfeits — rival wins.
challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)
stake := challenge.Stake
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit") {
bal := p.euro.GetBalance(challenge.ChallengedID)
stake = int(bal)
if stake > 0 {
if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit_partial") {
stake = 0
}
}
}
winnerShare := int(math.Round(float64(stake) * 0.3))
potShare := stake - winnerShare
if winnerShare > 0 {
p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
}
if potShare > 0 {
communityPotAdd(potShare)
trackTaxPaid(challenge.ChallengedID, potShare)
}
upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false)
upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, true)
// DM the challenged player (forfeit notice).
forfeitLine := pickRivalFlavor(rivalForfeitLines)
p.SendDM(challenge.ChallengedID, fmt.Sprintf("⚔️ DUEL FORFEITED\n\n%s\n\n€%d removed from your balance.",
forfeitLine, stake))
// DM the challenger.
challengedName := string(challenge.ChallengedID)
if challengedChar != nil {
challengedName = challengedChar.DisplayName
}
p.SendDM(challenge.ChallengerID, fmt.Sprintf(
"⚔️ Your rival **%s** didn't respond in time. You win by default. +€%d to your balance.",
challengedName, winnerShare))
// Room announcement.
gr := gamesRoom()
if gr != "" {
winnerName := string(challenge.ChallengerID)
if challengerChar != nil {
winnerName = challengerChar.DisplayName
}
loserName := challengedName
p.SendMessage(gr, fmt.Sprintf(
"⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**, who couldn't even be bothered to show up!",
winnerName, stake, loserName))
}
deleteRivalChallenge(challenge.ChallengeID)
p.pending.Delete(string(challenge.ChallengedID))
slog.Info("rival: challenge expired",
"challenger", challenge.ChallengerID,
"challenged", challenge.ChallengedID,
"stake", stake)
}
}
// ── Challenge Scheduler ──────────────────────────────────────────────────────
func (p *AdventurePlugin) rivalChallengeTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
// Roll the next challenge interval once. Re-roll after each issued challenge.
nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
for range ticker.C {
now := time.Now().UTC()
// Only fire on the hour.
if now.Minute() != 0 {
continue
}
// Only issue challenges between 08:00 and 22:00 UTC.
if now.Hour() < 8 || now.Hour() >= 22 {
// Still check for expiry outside challenge hours.
p.expireRivalChallenges()
continue
}
// Expire old challenges first.
p.expireRivalChallenges()
// Check if enough time has passed since last challenge.
last := lastRivalChallengeTime()
if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
continue
}
// Try to issue a challenge.
challenger, challenged := p.selectRivalPair()
if challenger == nil || challenged == nil {
continue
}
p.issueRivalChallenge(challenger, challenged)
// Roll a fresh interval for the next challenge.
nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
}
}
// ── Rivals Command ───────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return err
}
if char.RivalPool == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You need Combat Level %d to enter the rival pool. Currently: %d.",
rivalMinCombatLevel, char.CombatLevel))
}
records, err := loadAllRivalRecords(char.UserID)
if err != nil || len(records) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"⚔️ You're in the rival pool but haven't been challenged yet. Your time will come.")
}
var sb strings.Builder
sb.WriteString("⚔️ **Rival Record**\n\n")
for _, r := range records {
rivalChar, _ := loadAdvCharacter(r.RivalID)
name := string(r.RivalID)
if rivalChar != nil {
name = rivalChar.DisplayName
}
daysAgo := ""
if r.LastDuelAt != nil {
days := int(time.Since(*r.LastDuelAt).Hours() / 24)
if days == 0 {
daysAgo = "today"
} else if days == 1 {
daysAgo = "1 day ago"
} else {
daysAgo = fmt.Sprintf("%d days ago", days)
}
}
sb.WriteString(fmt.Sprintf(" %-16s %dW - %dL last duel: %s\n", name, r.Wins, r.Losses, daysAgo))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
+303
View File
@@ -0,0 +1,303 @@
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// ── Robbie the Friendly Bandit ───────────────────────────────────────────────
//
// Robbie is an automated NPC who visits players at a random hour each day,
// takes sub-tier inventory items, leaves €50 per item, donates everything
// to the community pot, and occasionally drops a Get Out of Medical Debt
// Free card when collecting Masterwork gear.
// In-memory target hour — picked fresh each day, regenerated on restart.
var (
robbieTargetHour int = -1
robbieTargetDay string // "2006-01-02"
)
// ── Ticker ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) robbieTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now().UTC()
dateKey := now.Format("2006-01-02")
// At midnight (or first tick of the day), pick today's target hour.
if robbieTargetDay != dateKey {
robbieTargetHour = 8 + rand.IntN(14) // 821 inclusive
robbieTargetDay = dateKey
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
}
if now.Hour() < robbieTargetHour {
continue
}
jobName := "adventure_robbie"
if db.JobCompleted(jobName, dateKey) {
continue
}
slog.Info("adventure: robbie sweep starting")
p.robbieVisitAll()
db.MarkJobCompleted(jobName, dateKey)
}
}
// ── Visit All Players ────────────────────────────────────────────────────────
func (p *AdventurePlugin) robbieVisitAll() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("adventure: robbie: failed to load characters", "err", err)
return
}
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
for i, char := range chars {
if !char.Alive {
continue
}
// Jitter between players to avoid Matrix rate limits.
if i > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
p.robbieVisitPlayer(char.UserID, char.DisplayName)
}
}
// ── Single Player Visit ──────────────────────────────────────────────────────
func (p *AdventurePlugin) robbieVisitPlayer(userID id.UserID, displayName string) {
mu := p.advUserLock(userID)
mu.Lock()
defer mu.Unlock()
// Load inventory + equipped gear
inv, err := loadAdvInventory(userID)
if err != nil {
return
}
equip, err := loadAdvEquipment(userID)
if err != nil {
return
}
// Find qualifying items
qualifying := robbieQualifyingItems(inv, equip)
if len(qualifying) == 0 {
return
}
// 40% chance of visiting
if rand.Float64() >= 0.40 {
return
}
// Execute the visit — collect items
var totalPayout int64
var communityTotal int64
var masterworkTaken bool
var takenItems []AdvItem
for _, item := range qualifying {
if err := removeAdvInventoryItem(item.ID); err != nil {
slog.Error("adventure: robbie: failed to remove item", "item_id", item.ID, "err", err)
continue
}
takenItems = append(takenItems, item)
payout := item.Value / 4
if payout < 1 {
payout = 1
}
totalPayout += payout
communityTotal += item.Value
if item.Type == "MasterworkGear" {
masterworkTaken = true
}
}
if len(takenItems) == 0 {
return
}
// Credit player
p.euro.Credit(userID, float64(totalPayout), "robbie_handling_fee")
// Donate to community pot
if communityTotal > 0 {
communityPotAdd(int(communityTotal))
}
// Handle Get Out of Medical Debt Free card
hasCard := robbiePlayerHasCard(userID)
gaveCard := false
if masterworkTaken && !hasCard {
_ = addAdvInventoryItem(userID, AdvItem{
Name: "Get Out of Medical Debt Free",
Type: "card",
Tier: 0,
Value: 0,
})
gaveCard = true
}
// Update visit count
char, err := loadAdvCharacter(userID)
if err == nil {
char.RobbieVisitCount++
_ = saveAdvCharacter(char)
}
// Send DM
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
if err := p.SendDM(userID, dm); err != nil {
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
}
// Room announcement
gr := gamesRoom()
if gr != "" {
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
p.SendMessage(gr, announcement)
}
}
// ── Qualifying Items ─────────────────────────────────────────────────────────
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
var result []AdvItem
for _, item := range inv {
// Never touch Arena gear, cards, or consumables. Consumables are a
// player-curated stockpile (crafted or dropped); selling them is an
// explicit decision the player must make themselves.
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
continue
}
// Non-gear items (ores, fish, junk, treasure, etc.) — always take
if item.Slot == "" {
result = append(result, item)
continue
}
// Slotted gear — check against equipped piece
eq, hasSlot := equip[item.Slot]
if !hasSlot {
continue
}
if item.Type == "MasterworkGear" {
// Take MW items only if equipped piece in same slot is also MW
// and has effective tier >= this item's effective tier.
if eq.Masterwork && advEffectiveTier(eq) >= float64(item.Tier)*1.25 {
result = append(result, item)
}
} else {
// Regular shop gear: take if item tier < equipped tier
if item.Tier < eq.Tier {
result = append(result, item)
}
}
}
return result
}
// ── Card Check ───────────────────────────────────────────────────────────────
func robbiePlayerHasCard(userID id.UserID) bool {
inv, err := loadAdvInventory(userID)
if err != nil {
return false
}
for _, item := range inv {
if item.Type == "card" && item.Name == "Get Out of Medical Debt Free" {
return true
}
}
return false
}
// ── DM Rendering ─────────────────────────────────────────────────────────────
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
var sb strings.Builder
// Opening
opening, _ := advPickFlavor(robbieOpenings, userID, "robbie_opening")
if strings.Contains(opening, "%d") {
opening = fmt.Sprintf(opening, total)
}
sb.WriteString(opening)
sb.WriteString("\n\n")
// Itemized list
sb.WriteString("Items collected:\n\n")
cardShownOnLine := false
for _, item := range items {
emoji := slotEmoji(item.Slot)
payout := item.Value / 4
if payout < 1 {
payout = 1
}
if item.Type == "MasterworkGear" {
sb.WriteString(fmt.Sprintf(" %s %s (Masterwork T%d) → €%d", emoji, item.Name, item.Tier, payout))
if gaveCard && !cardShownOnLine {
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
cardShownOnLine = true
}
} else {
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €%d", emoji, item.Name, item.Tier, payout))
}
sb.WriteByte('\n')
}
sb.WriteString(fmt.Sprintf("\nTotal left for you: €%d\n", total))
sb.WriteString("Everything else donated to the community pot. Good on ya.\n\n")
// Context line
if mwTaken {
if gaveCard {
sb.WriteString(robbieMasterworkGotCard)
} else {
sb.WriteString(robbieMasterworkAlreadyHas)
}
} else {
sb.WriteString(fmt.Sprintf(robbieAllShopGear, total))
}
sb.WriteString("\n\n")
// Closing
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
sb.WriteString(closing)
return sb.String()
}
// ── Room Announcement ────────────────────────────────────────────────────────
func renderRobbieRoomAnnouncement(name string, count int, total int64, mwTaken, gaveCard bool) string {
if mwTaken && gaveCard {
return fmt.Sprintf(robbieRoomMasterworkCard, name, total)
}
if mwTaken && !gaveCard {
return fmt.Sprintf(robbieRoomMasterworkAlreadyHas, name, total)
}
return fmt.Sprintf(robbieRoomStandard, name, count, total)
}
+141 -19
View File
@@ -4,6 +4,8 @@ import (
"fmt"
"log/slog"
"math/rand/v2"
"os"
"strings"
"time"
"gogobee/internal/db"
@@ -60,9 +62,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
// Check if dead and ready to respawn
// Check if dead and ready to respawn (before babysit check so
// dead+babysitting characters don't stay stuck dead forever).
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
// Revive
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(&char); err != nil {
@@ -77,6 +79,16 @@ func (p *AdventurePlugin) sendMorningDMs() {
}
}
// Babysitting: auto-resolve daily action, skip DM
if char.BabysitActive {
if !char.Alive {
// Dead and not yet ready to respawn — skip babysit action
continue
}
p.runBabysitDaily(&char)
continue
}
// If still dead, send death status
if !char.Alive {
text := renderAdvDeathStatusDM(&char)
@@ -86,11 +98,25 @@ func (p *AdventurePlugin) sendMorningDMs() {
continue
}
// If already acted today, skip
if char.ActionTakenToday {
// If all actions used today, skip
isHol, _ := isHolidayToday()
if char.AllActionsUsed(isHol) {
continue
}
// Pet arrival check (fires before normal morning DM)
if petShouldArrive(&char) {
p.petArrivalDM(char.UserID)
continue
}
// Morning pet event
petEvent := petMorningEvent(&char)
if petEvent != "" {
char.PetMorningDefense = true
_ = saveAdvCharacter(&char)
}
// Send morning DM with choices
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
@@ -108,6 +134,9 @@ func (p *AdventurePlugin) sendMorningDMs() {
holidayLabel = holName
}
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
if petEvent != "" {
text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text)
}
p.advMarkMenuSent(char.UserID)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send morning DM", "user", char.UserID, "err", err)
@@ -160,7 +189,11 @@ func (p *AdventurePlugin) postDailySummary() {
return
}
todayLogs, _ := loadAdvTodayLogs()
// Load logs for today and yesterday — players may act across the UTC boundary
now := time.Now().UTC()
todayLogs, _ := loadAdvLogsForDate(now.Format("2006-01-02"))
yesterdayLogs, _ := loadAdvLogsForDate(now.AddDate(0, 0, -1).Format("2006-01-02"))
todayLogs = append(todayLogs, yesterdayLogs...)
// Group logs per user — holiday days may produce 2 entries per user
logsPerUser := make(map[id.UserID][]*AdvDayLog)
for i := range todayLogs {
@@ -215,7 +248,7 @@ func (p *AdventurePlugin) postDailySummary() {
continue
}
if !c.ActionTakenToday {
if !c.HasActedToday() {
ps.IsResting = true
if len(SummaryResting) > 0 {
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
@@ -266,15 +299,18 @@ func (p *AdventurePlugin) midnightTicker() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
lastRanDate := ""
for range ticker.C {
now := time.Now().UTC()
if now.Hour() != 0 || now.Minute() != 0 {
dateKey := time.Now().UTC().Format("2006-01-02")
if dateKey == lastRanDate {
continue
}
dateKey := now.Format("2006-01-02")
// New UTC day — check DB in case we already ran (e.g. bot restart).
jobName := "adventure_midnight"
if db.JobCompleted(jobName, dateKey) {
lastRanDate = dateKey
continue
}
@@ -284,6 +320,7 @@ func (p *AdventurePlugin) midnightTicker() {
continue
}
db.MarkJobCompleted(jobName, dateKey)
lastRanDate = dateKey
}
}
@@ -298,11 +335,46 @@ func (p *AdventurePlugin) midnightReset() error {
dmsSent := 0
for _, char := range chars {
if !char.Alive {
continue
}
if !char.HasActedToday() {
// If the player died today or yesterday, they couldn't act — no shame,
// no streak reset. This covers both currently-dead players and players
// who were just revived at midnight (Alive already flipped to true by
// the reminder loop before midnightReset runs).
if char.LastDeathDate == today ||
char.LastDeathDate == time.Now().UTC().Add(-24*time.Hour).Format("2006-01-02") {
continue
}
// Auto-babysit: if enabled, alive, and affordable, run a single babysit day instead of losing streak
if char.AutoBabysit && char.Alive && !char.BabysitActive {
daily := babysitDailyCost(char.CombatLevel)
if p.euro.GetBalance(char.UserID) >= float64(daily) {
if p.euro.Debit(char.UserID, float64(daily), "auto_babysit") {
p.runAutoBabysitDay(&char)
if char.CurrentStreak > 0 {
char.CurrentStreak++
if char.CurrentStreak > char.BestStreak {
char.BestStreak = char.CurrentStreak
}
} else {
char.CurrentStreak = 1
}
_ = saveAdvCharacter(&char)
if p.achievements != nil {
p.achievements.GrantAchievement(char.UserID, "adv_auto_babysit")
}
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
}
dmsSent++
p.SendDM(char.UserID, fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.", daily, char.CurrentStreak))
continue
}
}
}
if !char.ActionTakenToday {
// Jitter between DMs to avoid Matrix rate limits
if dmsSent > 0 {
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
@@ -311,15 +383,19 @@ func (p *AdventurePlugin) midnightReset() error {
// Idle shame DM
text := renderAdvIdleShameDM(&char)
if char.CurrentStreak > 0 {
oldStreak := char.CurrentStreak
char.CurrentStreak /= 2
char.StreakDecayed = true
text += fmt.Sprintf("\n\n🔥 Streak: %d → %d days", oldStreak, char.CurrentStreak)
if char.CurrentStreak > 0 {
text += " — not all is lost."
}
_ = saveAdvCharacter(&char)
}
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: failed to send idle shame DM", "user", char.UserID, "err", err)
}
// Reset streak
if char.CurrentStreak > 0 {
char.CurrentStreak = 0
_ = saveAdvCharacter(&char)
}
} else {
// Update streak — LastActionDate was set at action time
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
@@ -346,6 +422,13 @@ func (p *AdventurePlugin) midnightReset() error {
slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr)
time.Sleep(time.Duration(attempt+1) * 2 * time.Second)
}
if resetErr == nil {
// Re-lock combat for active co-op participants after the universal
// reset would otherwise have given them a fresh combat action.
if err := lockCoopCombatActions(); err != nil {
slog.Error("adventure: post-reset coop combat lock failed", "err", err)
}
}
if resetErr != nil {
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
}
@@ -355,6 +438,9 @@ func (p *AdventurePlugin) midnightReset() error {
slog.Error("adventure: failed to prune expired buffs", "err", err)
}
// Reset NPC message counts and regenerate roll targets
npcMidnightReset()
// Clear flavor history to prevent unbounded memory growth.
// Entries are only used for dedup within a day, so clearing at midnight is fine.
advClearFlavorHistory()
@@ -365,9 +451,45 @@ func (p *AdventurePlugin) midnightReset() error {
return true
})
// Expire any rival challenges that went unanswered
p.expireRivalChallenges()
// Check babysitting service expirations
p.checkBabysitExpiry(chars)
// Pet supply shop unlock check
p.petMidnightCheck()
// Reset holdem NPC house balance
resetNPCHouseBalance()
// Daily database backup (async to avoid blocking reset if DM sends are slow)
go func() {
if err := db.Backup(); err != nil {
slog.Error("adventure: daily backup failed", "err", err)
p.notifyAdmins(fmt.Sprintf("⚠️ **Daily backup failed:** %v", err))
}
}()
return nil
}
func (p *AdventurePlugin) notifyAdmins(msg string) {
admins := os.Getenv("ADMIN_USERS")
if admins == "" {
return
}
for _, a := range strings.Split(admins, ",") {
uid := id.UserID(strings.TrimSpace(a))
if uid == "" {
continue
}
if err := p.SendDM(uid, msg); err != nil {
slog.Error("adventure: failed to notify admin", "user", uid, "err", err)
}
}
}
// ── Helper ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) registerDMRoom(userID id.UserID) {
+745 -106
View File
@@ -2,7 +2,11 @@ package plugin
import (
"fmt"
"log/slog"
"math"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
@@ -36,66 +40,242 @@ func slotTitle(slot EquipmentSlot) string {
return strings.ToUpper(s[:1]) + s[1:]
}
// advShopOverview shows a compact category menu with current equipment and next upgrade.
func advShopOverview(equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
// ── Pending Interaction Types ───────────────────────────────────────────────
type advPendingShopCategory struct {
ShowAll bool
}
type advPendingShopItem struct {
Slot EquipmentSlot
ShowAll bool
}
type advPendingShopConfirm struct {
Slot EquipmentSlot
Tier int
}
// ── Session Tracking ────────────────────────────────────────────────────────
type advShopSession struct {
StartedAt time.Time
ItemsBought int
}
func (p *AdventurePlugin) shopSessionGet(userID id.UserID) *advShopSession {
if val, ok := p.shopSessions.Load(string(userID)); ok {
return val.(*advShopSession)
}
return nil
}
func (p *AdventurePlugin) shopSessionStart(userID id.UserID) {
if p.shopSessionGet(userID) == nil {
p.shopSessions.Store(string(userID), &advShopSession{
StartedAt: time.Now(),
})
}
}
func (p *AdventurePlugin) shopSessionBump(userID id.UserID) {
sess := p.shopSessionGet(userID)
if sess != nil {
sess.ItemsBought++
}
}
func (p *AdventurePlugin) shopSessionEnd(userID id.UserID) {
p.shopSessions.Delete(string(userID))
}
// ── Browse Timeout ──────────────────────────────────────────────────────────
// shopNudgeGen tracks a generation counter per user so only the latest nudge fires.
// Key: userID string, Value: *int64 (atomic counter)
func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) {
// Bump generation so any prior goroutine becomes stale.
var gen int64
if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); ok {
gen = val.(int64) + 1
}
p.shopSessions.Store(string(userID)+":nudge_gen", gen)
capturedGen := gen
safeGo("shop-nudge", func() {
time.Sleep(2 * time.Minute)
// Check if this goroutine is still the latest.
if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); !ok || val.(int64) != capturedGen {
return
}
val, ok := p.pending.Load(string(userID))
if !ok {
return
}
pi, ok := val.(*advPendingInteraction)
if !ok || !strings.HasPrefix(pi.Type, "shop_") {
return
}
flavor, _ := advPickFlavor(luigiBrowseTimeout, userID, "luigi_browse")
p.SendDM(userID, fmt.Sprintf("*%s*", flavor))
})
}
// ── Display: Luigi Greeting + Category Menu ─────────────────────────────────
func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool, chatLevel int) string {
var sb strings.Builder
sb.WriteString("🛒 **Equipment Shop**\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
// Check if fully maxed out.
allMaxed := true
for _, slot := range allSlots {
current := equip[slot]
currentTier := 0
currentName := "None"
if current != nil {
currentTier = current.Tier
currentName = current.Name
eq := equip[slot]
if eq == nil || eq.Tier < 5 {
allMaxed = false
break
}
emoji := slotEmoji(slot)
title := slotTitle(slot)
// Find next upgrade.
defs := equipmentTiers[slot]
nextUpgrade := ""
for _, def := range defs {
if def.Tier > currentTier && def.Price > 0 {
nextUpgrade = fmt.Sprintf("Next: %s — €%.0f", def.Name, def.Price)
break
}
}
sb.WriteString(fmt.Sprintf("%s **%s** — %s (T%d)\n", emoji, title, currentName, currentTier))
if nextUpgrade != "" {
sb.WriteString(fmt.Sprintf(" %s\n", nextUpgrade))
} else {
sb.WriteString(" ✨ Maxed out!\n")
}
sb.WriteString("\n")
}
sb.WriteString("Browse a category: `!adventure shop <category>`\n")
sb.WriteString("Categories: `weapon` · `armor` · `helmet` · `boots` · `tool`")
if allMaxed {
flavor, _ := advPickFlavor(luigiMaxedOut, userID, "luigi_maxed")
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n\n*%s*", flavor))
return sb.String()
}
var greet string
if chatLevel >= 10 {
greet = shopGreeting(chatLevel)
} else {
greet, _ = advPickFlavor(luigiGreetings, userID, "luigi_greet")
}
sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n💰 Balance: €%.0f\n\n", balance))
sb.WriteString(fmt.Sprintf("*%s*\n\n", greet))
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
sb.WriteString("🪖 Helmets 👢 Boots\n")
sb.WriteString("⛏️ Tools 🧪 Supplies\n\n")
if showAll {
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
sb.WriteString(fmt.Sprintf("*%s*\n\n", flavor))
}
sb.WriteString("Reply with a category name to browse.")
return sb.String()
}
var shopCategoryAliases = map[string]EquipmentSlot{
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
"armor": SlotArmor, "armour": SlotArmor,
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
"boots": SlotBoots, "boot": SlotBoots,
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
}
// ── Display: Category Item List ─────────────────────────────────────────────
// advParseShopCategory maps user input to an EquipmentSlot.
func advParseShopCategory(input string) EquipmentSlot {
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
}
// advShopCategory shows detailed listings for a single equipment slot.
func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64) string {
func luigiCategoryView(userID id.UserID, slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string {
var sb strings.Builder
current := equip[slot]
currentTier := 0
currentName := "None"
isMWOrArena := false
if current != nil {
currentTier = current.Tier
currentName = current.Name
isMWOrArena = current.Masterwork || current.ArenaTier > 0
}
emoji := slotEmoji(slot)
title := strings.ToUpper(string(slot))
// Category intro
if intros, ok := luigiCategoryIntros[slot]; ok && len(intros) > 0 {
intro, _ := advPickFlavor(intros, userID, "luigi_cat_"+string(slot))
sb.WriteString(fmt.Sprintf("*%s*\n\n", intro))
}
sb.WriteString(fmt.Sprintf("%s **%s** — Your current: %s (T%d)", emoji, title, currentName, currentTier))
if isMWOrArena {
sb.WriteString(" ⭐")
}
sb.WriteString("\n\n")
if isMWOrArena {
ack, _ := advPickFlavor(luigiMasterworkAck, userID, "luigi_mw_ack")
sb.WriteString(fmt.Sprintf("*%s*\n\n", ack))
}
defs := equipmentTiers[slot]
hasItems := false
// Show current equipped item first.
if current != nil {
for _, def := range defs {
if def.Tier == currentTier {
sb.WriteString(fmt.Sprintf("🟢 %-28s T%d €%.0f Currently equipped\n",
def.Name, def.Tier, def.Price))
break
}
}
}
for _, def := range defs {
if def.Price == 0 {
continue // skip tier 0
}
if def.Tier == currentTier {
continue // already shown as 🟢
}
if !showAll && def.Tier < currentTier {
continue // skip downgrades unless show all
}
hasItems = true
// Upgrade indicator
var indicator string
switch {
case def.Tier > currentTier:
indicator = "⬆️"
case def.Tier == currentTier:
indicator = "➡️"
default:
indicator = "⬇️"
}
// Affordability
afford := "✅"
if balance < def.Price {
afford = "💸"
}
// One-liner
oneLiner := ""
if line, ok := luigiItemOneLiners[luigiItemKey{slot, def.Tier}]; ok {
oneLiner = fmt.Sprintf(" \"%s\"", line)
}
sb.WriteString(fmt.Sprintf("%s %-28s T%d €%-8.0f %s%s\n",
indicator, def.Name, def.Tier, def.Price, afford, oneLiner))
}
// Occasional unprompted Luigi commentary (~30% chance).
if hasItems && rand.IntN(100) < 30 {
comment, _ := advPickFlavor(luigiCommentary, userID, "luigi_comment")
sb.WriteString(fmt.Sprintf("\n*%s*\n", comment))
}
if !hasItems {
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n")
}
if !showAll && currentTier > 1 {
sb.WriteString(fmt.Sprintf("\nTiers below T%d omitted. Use `!adventure shop show all` to see everything.\n", currentTier))
}
sb.WriteString("\nReply with an item name to buy, or \"back\" to return to categories.")
return sb.String()
}
// ── Display: Item Confirm ───────────────────────────────────────────────────
func luigiItemConfirm(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, current *AdvEquipment, balance float64) string {
var sb strings.Builder
currentTier := 0
currentName := "None"
if current != nil {
@@ -103,42 +283,294 @@ func advShopCategory(slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment,
currentName = current.Name
}
emoji := slotEmoji(slot)
title := slotTitle(slot)
sb.WriteString(fmt.Sprintf("%s **%s Shop**\n", emoji, title))
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
sb.WriteString(fmt.Sprintf("Equipped: **%s** (Tier %d)\n\n", currentName, currentTier))
defs := equipmentTiers[slot]
hasUpgrades := false
for _, def := range defs {
if def.Tier <= currentTier || def.Price == 0 {
continue
}
hasUpgrades = true
priceTag := fmt.Sprintf("€%.0f", def.Price)
if balance < def.Price {
priceTag += " 💸 can't afford"
} else {
priceTag += " ✅ affordable"
}
sb.WriteString(fmt.Sprintf("**Tier %d: %s** — %s\n", def.Tier, def.Name, priceTag))
sb.WriteString(fmt.Sprintf("%s\n\n", def.Description))
// Upgrade indicator
indicator := "⬆️"
if def.Tier == currentTier {
indicator = "➡️"
} else if def.Tier < currentTier {
indicator = "⬇️"
}
if !hasUpgrades {
sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n\n")
sb.WriteString(fmt.Sprintf("%s **%s** — T%d %s — €%.0f\n\n", indicator, def.Name, def.Tier, slotTitle(slot), def.Price))
if def.Tier > currentTier {
sb.WriteString(fmt.Sprintf("An upgrade from your %s (T%d).\n", currentName, currentTier))
} else if def.Tier == currentTier {
sb.WriteString(fmt.Sprintf("Same tier as your current %s.\n", currentName))
} else {
sb.WriteString(fmt.Sprintf("A downgrade from your %s (T%d).\n", currentName, currentTier))
}
sb.WriteString("To buy: `!adventure buy <item name>` or `!adventure buy <tier> <category>`\n")
sb.WriteString("Example: `!adventure buy Enchanted Blade` or `!adventure buy 4 sword`\n")
sb.WriteString("Back to overview: `!adventure shop`")
// Full Luigi description
if desc, ok := luigiItemDescriptions[luigiItemKey{slot, def.Tier}]; ok {
sb.WriteString(fmt.Sprintf("\n\"%s\"\n", desc))
}
afterBalance := balance - def.Price
sb.WriteString(fmt.Sprintf("\nYour balance: €%.0f → €%.0f after purchase\n\n", balance, afterBalance))
sb.WriteString("Confirm? (yes / no)")
return sb.String()
}
// ── Resolver: Category Choice ───────────────────────────────────────────────
func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingShopCategory)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "back" || reply == "exit" || reply == "cancel" {
p.shopSessionEnd(ctx.Sender)
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
}
// Check for supplies category first
if reply == "supplies" || reply == "supply" || reply == "consumables" || reply == "potions" {
balance := p.euro.GetBalance(ctx.Sender)
text := luigiSuppliesView(ctx.Sender, balance)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_supply",
Data: data,
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
slot := advParseShopCategory(reply)
if slot == "" {
// Re-store pending and reprompt.
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, or supplies.")
}
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
text := luigiCategoryView(ctx.Sender, slot, equip, balance, data.ShowAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_item",
Data: &advPendingShopItem{Slot: slot, ShowAll: data.ShowAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
// ── Resolver: Item Choice ───────────────────────────────────────────────────
func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingShopItem)
reply := strings.TrimSpace(ctx.Body)
lower := strings.ToLower(reply)
if lower == "back" {
// Return to category menu.
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
if lower == "exit" || lower == "cancel" {
p.shopSessionEnd(ctx.Sender)
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
}
// Try to find the item scoped to the current slot first, then globally.
slot, def, found := advFindShopItemInSlot(reply, data.Slot)
if !found {
slot, def, found = advFindShopItem(reply)
}
if !found {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("I don't have anything matching \"%s\". Reply with an item name from the list, or \"back\" to return.", reply))
}
// Load fresh data.
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
current := equip[slot]
// Check Arena/Masterwork block — only block if shop item isn't clearly better.
// Arena gear always blocks (1.5x multiplier, earned through combat).
// Masterwork blocks only if the shop item's tier doesn't exceed the MW effective tier.
if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear, T%d) in that slot. That T%d shop item isn't an upgrade.\n\nPick something else, or \"back\" to return.",
current.Name, current.ArenaTier, def.Tier))
}
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Masterwork ⭐) in that slot. That T%d shop item isn't an upgrade over your T%d Masterwork.\n\nPick something else, or \"back\" to return.",
current.Name, def.Tier, current.Tier))
}
text := luigiItemConfirm(ctx.Sender, slot, def, current, balance)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_confirm",
Data: &advPendingShopConfirm{Slot: slot, Tier: def.Tier},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
// ── Resolver: Purchase Confirm ──────────────────────────────────────────────
func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *advPendingInteraction) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
data := interaction.Data.(*advPendingShopConfirm)
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply != "yes" && reply != "y" && reply != "confirm" {
p.shopSessionEnd(ctx.Sender)
flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor))
}
// Reload fresh equipment and balance (TOCTOU protection).
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
// Find the def fresh.
defs := equipmentTiers[data.Slot]
var def *EquipmentDef
for i := range defs {
if defs[i].Tier == data.Tier {
def = &defs[i]
break
}
}
if def == nil {
return p.SendDM(ctx.Sender, "Something went wrong. Item not found.")
}
current := equip[data.Slot]
// Re-check Arena/Masterwork block.
if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier {
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Arena gear T%d) is not worse than that T%d shop item.", current.Name, current.ArenaTier, def.Tier))
}
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Masterwork ⭐) is better than that T%d shop item.", current.Name, def.Tier))
}
// Affordability.
if balance < def.Price {
flavor, _ := advPickFlavor(luigiInsufficientFunds, ctx.Sender, "luigi_broke")
return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, def.Price))
}
// Debit.
if !p.euro.Debit(ctx.Sender, def.Price, "adventure_shop_"+string(data.Slot)) {
return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.")
}
// Community contribution: 5% of purchase price.
if potCut := int(def.Price * 0.05); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
// Move old gear to inventory before replacing.
if current != nil && current.Tier > 0 {
itemType := "ShopGear"
var resaleValue int64
if current.Masterwork {
itemType = "MasterworkGear"
// Masterwork has no resale value (it's special, not shop-priced)
} else if current.ArenaTier > 0 {
itemType = "ArenaGear"
} else if current.Tier < len(equipmentTiers[data.Slot]) {
resaleValue = int64(equipmentTiers[data.Slot][current.Tier].Price * 0.3)
}
err := addAdvInventoryItem(ctx.Sender, AdvItem{
Name: current.Name,
Type: itemType,
Tier: current.Tier,
Value: resaleValue,
Slot: data.Slot,
SkillSource: current.SkillSource,
})
if err != nil {
slog.Error("shop: failed to move old gear to inventory", "user", ctx.Sender, "slot", data.Slot, "err", err)
}
}
// Save new equipment.
eq := &AdvEquipment{
Slot: data.Slot,
Tier: def.Tier,
Condition: 100,
Name: def.Name,
ActionsUsed: 0,
}
if err := saveAdvEquipment(ctx.Sender, eq); err != nil {
// Refund on DB error.
p.euro.Credit(ctx.Sender, def.Price, "adventure_shop_refund")
return p.SendDM(ctx.Sender, "Something went wrong saving your equipment. You've been refunded.")
}
// Build confirmation message.
p.shopSessionBump(ctx.Sender)
var confirmText string
sess := p.shopSessionGet(ctx.Sender)
isCombo := sess != nil && sess.ItemsBought >= 3
if isCombo {
flavor, _ := advPickFlavor(luigiComboConfirm, ctx.Sender, "luigi_combo")
confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
} else if def.Tier == 5 {
flavor, _ := advPickFlavor(luigiTier5Confirm, ctx.Sender, "luigi_t5")
confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name)
} else {
flavor, _ := advPickFlavor(luigiPurchaseConfirm, ctx.Sender, "luigi_buy")
confirmText = fmt.Sprintf(flavor, def.Name, int(def.Price))
}
var sb strings.Builder
sb.WriteString(confirmText)
sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
if current != nil && current.Tier > 0 {
sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
}
sb.WriteString(fmt.Sprintf("\nBalance: €%.0f", balance-def.Price))
return p.SendDM(ctx.Sender, sb.String())
}
// ── Find Shop Item ───────────────────────────────────────────────────────────
// normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents.
@@ -174,6 +606,22 @@ func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) {
return "", nil, false
}
// advFindShopItemInSlot finds an item scoped to a specific slot.
func advFindShopItemInSlot(name string, slot EquipmentSlot) (EquipmentSlot, *EquipmentDef, bool) {
name = normalizeQuotes(strings.TrimSpace(name))
for i := range equipmentTiers[slot] {
def := &equipmentTiers[slot][i]
if def.Price == 0 {
continue
}
if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) {
return slot, def, true
}
}
return "", nil, false
}
// advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots".
func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
lower := strings.ToLower(input)
@@ -213,7 +661,20 @@ func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) {
return "", nil, false
}
// ── Buy Equipment ────────────────────────────────────────────────────────────
var shopCategoryAliases = map[string]EquipmentSlot{
"weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon,
"armor": SlotArmor, "armour": SlotArmor,
"helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet,
"boots": SlotBoots, "boot": SlotBoots,
"tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool,
}
// advParseShopCategory maps user input to an EquipmentSlot.
func advParseShopCategory(input string) EquipmentSlot {
return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))]
}
// ── Buy Equipment (legacy !adventure buy path) ──────────────────────────────
func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string {
current := equip[slot]
@@ -221,35 +682,63 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.",
current.Name, current.Tier, def.Name, def.Tier)
}
// Block shop purchases that would overwrite arena or masterwork gear
if current != nil && (current.ArenaTier > 0 || current.Masterwork) {
label := "Masterwork"
if current.ArenaTier > 0 {
label = "Arena"
}
return fmt.Sprintf("You already have **%s** (%s gear). A shop item cannot replace this. "+
"You earned that. Don't throw it away.",
current.Name, label)
// Block shop purchases that would overwrite arena gear (always) or
// masterwork gear (only if shop item isn't clearly better).
if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier {
return fmt.Sprintf("You already have **%s** (Arena gear T%d). That T%d shop item isn't an upgrade.",
current.Name, current.ArenaTier, def.Tier)
}
if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) {
return fmt.Sprintf("You already have **%s** (Masterwork ⭐ T%d). That T%d shop item isn't an upgrade.",
current.Name, current.Tier, def.Tier)
}
balance := p.euro.GetBalance(userID)
if balance < def.Price {
return fmt.Sprintf("You cannot afford %s. You have €%.0f. %s costs €%.0f. "+
"The gap between these numbers is both mathematical and deeply personal. "+
"Go do something about it. Not gambling. You know what I mean.",
def.Name, balance, def.Name, def.Price)
flavor, _ := advPickFlavor(luigiInsufficientFunds, userID, "luigi_broke")
return fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, def.Price)
}
if !p.euro.Debit(userID, def.Price, "adventure_shop_"+string(slot)) {
return "Transaction failed. The economy is having a moment."
}
// Community contribution: 5% of purchase price.
if potCut := int(def.Price * 0.05); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(userID, potCut)
}
// Move old gear to inventory.
if current != nil && current.Tier > 0 {
itemType := "ShopGear"
var resaleValue int64
if current.Masterwork {
itemType = "MasterworkGear"
} else if current.ArenaTier > 0 {
itemType = "ArenaGear"
} else if current.Tier < len(equipmentTiers[slot]) {
resaleValue = int64(equipmentTiers[slot][current.Tier].Price * 0.3)
}
err := addAdvInventoryItem(userID, AdvItem{
Name: current.Name,
Type: itemType,
Tier: current.Tier,
Value: resaleValue,
Slot: slot,
SkillSource: current.SkillSource,
})
if err != nil {
slog.Error("shop: failed to move old gear to inventory", "user", userID, "slot", slot, "err", err)
}
}
// Update equipment
eq := &AdvEquipment{
Slot: slot,
Tier: def.Tier,
Condition: 100,
Name: def.Name,
Slot: slot,
Tier: def.Tier,
Condition: 100,
Name: def.Name,
ActionsUsed: 0,
}
if err := saveAdvEquipment(userID, eq); err != nil {
@@ -259,10 +748,20 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot,
}
equip[slot] = eq
return fmt.Sprintf("You have purchased **%s** for €%.0f.\n\n_%s_\n\n"+
"This is an upgrade over what you had. That bar was underground, but you cleared it. "+
"Equip it now before you do something stupid with the money instead.",
def.Name, def.Price, def.Description)
var sb strings.Builder
if def.Tier == 5 {
flavor, _ := advPickFlavor(luigiTier5Confirm, userID, "luigi_t5")
sb.WriteString(fmt.Sprintf(flavor, int(def.Price), def.Name))
} else {
flavor, _ := advPickFlavor(luigiPurchaseConfirm, userID, "luigi_buy")
sb.WriteString(fmt.Sprintf(flavor, def.Name, int(def.Price)))
}
sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name))
if current != nil && current.Tier > 0 {
sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name))
}
return sb.String()
}
// ── Sell Items ────────────────────────────────────────────────────────────────
@@ -279,11 +778,16 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
var total float64
var sold int
var keptSpecial int
var keptConsumable int
for _, item := range items {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
keptSpecial++
continue
}
if item.Type == "consumable" {
keptConsumable++
continue
}
if err := removeAdvInventoryItem(item.ID); err != nil {
continue
}
@@ -292,20 +796,34 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string {
}
if sold == 0 {
if keptSpecial > 0 {
switch {
case keptSpecial > 0 && keptConsumable > 0:
return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell <name>` for individual consumables."
case keptSpecial > 0:
return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead."
case keptConsumable > 0:
return "Your inventory only contains consumables. `sell all` won't touch them — use `!adventure sell <name>` to sell a specific consumable."
}
return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time."
}
p.euro.Credit(userID, total, "adventure_sell_all")
potCut := math.Round(total * 0.05)
payout := total - potCut
p.euro.Credit(userID, payout, "adventure_sell_all")
if int(potCut) > 0 {
communityPotAdd(int(potCut))
trackTaxPaid(userID, int(potCut))
}
msg := fmt.Sprintf("Sold %d items for **€%.0f**.\n\nThe merchant took everything without comment. "+
msg := fmt.Sprintf("Sold %d items for **%s** (%s after 5%% broker fee → community pot).\n\nThe merchant took everything without comment. "+
"This is the most respect anyone has shown your loot collection. Take the money.",
sold, total)
sold, fmtEuro(total), fmtEuro(payout))
if keptSpecial > 0 {
msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial)
}
if keptConsumable > 0 {
msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell <name>`.)", keptConsumable)
}
return msg
}
@@ -318,14 +836,20 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string
// Fuzzy match
for _, item := range items {
if containsFold(item.Name, itemName) {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" {
if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" {
return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name)
}
if err := removeAdvInventoryItem(item.ID); err != nil {
return "Failed to sell that item."
}
p.euro.Credit(userID, float64(item.Value), "adventure_sell_"+item.Name)
return fmt.Sprintf("Sold **%s** for **€%d**. The merchant nodded. That's it. That's the transaction.", item.Name, item.Value)
potCut := int(math.Round(float64(item.Value) * 0.05))
payout := int(item.Value) - potCut
p.euro.Credit(userID, float64(payout), "adventure_sell_"+item.Name)
if potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(userID, potCut)
}
return fmt.Sprintf("Sold **%s** for **%s** (%s after 5%% broker fee → community pot). The merchant nodded. That's it. That's the transaction.", item.Name, fmtEuro(item.Value), fmtEuro(payout))
}
}
@@ -352,6 +876,8 @@ func advInventoryDisplay(userID id.UserID) string {
sb.WriteString(fmt.Sprintf(" ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
} else if item.Type == "ArenaGear" {
sb.WriteString(fmt.Sprintf(" ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot)))
} else if item.Type == "card" {
sb.WriteString(fmt.Sprintf(" 🃏 %s\n", item.Name))
} else {
sb.WriteString(fmt.Sprintf(" %s (T%d %s) — €%d\n", item.Name, item.Tier, item.Type, item.Value))
total += item.Value
@@ -362,3 +888,116 @@ func advInventoryDisplay(userID id.UserID) string {
sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`")
return sb.String()
}
// ── Supplies (Consumables) ──────────────────────────────────────────────────
func luigiSuppliesView(_ id.UserID, balance float64) string {
var sb strings.Builder
sb.WriteString("🧪 **Supplies** — combat consumables (auto-used)\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
sb.WriteString("Items are used automatically during combat. The engine picks up to 2 per fight based on threat level — it won't waste them on easy fights.\n\n")
defs := BuyableConsumables()
if len(defs) == 0 {
sb.WriteString("_Nothing in stock right now._\n\n")
} else {
for _, def := range defs {
effectDesc := consumableEffectDescription(def.Effect, def.Value)
sb.WriteString(fmt.Sprintf("**%s** (T%d) — €%d\n %s\n\n", def.Name, def.Tier, def.Price, effectDesc))
}
}
sb.WriteString("Reply with an item name to buy, or `back` to return.\n")
sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.")
return sb.String()
}
func consumableEffectDescription(effect ConsumableEffect, value float64) string {
switch effect {
case EffectHeal:
return fmt.Sprintf("Restores %d HP mid-combat (triggers at <50%% HP)", int(value))
case EffectDefBoost:
return fmt.Sprintf("+%.0f%% Defense for the fight", value*100)
case EffectAtkBoost:
return fmt.Sprintf("+%.0f%% Attack for the fight", value*100)
case EffectWard:
return "Blocks one hit completely"
case EffectSpeedBoost:
return fmt.Sprintf("+%.0f%% Speed for the fight (evasion boost)", value*100)
case EffectCritBoost:
return fmt.Sprintf("+%.0f%% Crit Rate for the fight", value*100)
case EffectFlatDmg:
return fmt.Sprintf("Deals %d damage at fight start", int(value))
case EffectSpore:
return fmt.Sprintf("15%% enemy miss chance for %d rounds", int(value))
case EffectReflect:
return fmt.Sprintf("Reflects %.0f%% of next hit back at enemy", value*100)
case EffectAutoCrit:
return fmt.Sprintf("First hit is auto-crit + %.0f%% Attack", value*100)
}
return ""
}
func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interaction *advPendingInteraction) error {
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
if reply == "back" {
data := interaction.Data.(*advPendingShopCategory)
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender))
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: data.ShowAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, text)
}
if reply == "exit" || reply == "cancel" {
p.shopSessionEnd(ctx.Sender)
return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*")
}
// Find matching consumable
var match *ConsumableDef
for i := range consumableDefs {
if consumableDefs[i].Buyable && containsFold(consumableDefs[i].Name, reply) {
match = &consumableDefs[i]
break
}
}
if match == nil {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "I don't have that. Reply with an item name from the list, or `back` to return.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(match.Price) {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d for %s but only have €%.0f.", match.Price, match.Name, balance))
}
// Purchase the consumable
p.euro.Debit(ctx.Sender, float64(match.Price), "shop_consumable")
if potCut := int(math.Round(float64(match.Price) * 0.05)); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
item := AdvItem{
Name: match.Name,
Type: "consumable",
Tier: match.Tier,
Value: match.Price / 2,
}
_ = addAdvInventoryItem(ctx.Sender, item)
// Stay in supplies view for more purchases
p.pending.Store(string(ctx.Sender), interaction)
newBalance := p.euro.GetBalance(ctx.Sender)
return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%d. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
match.Name, match.Price, newBalance))
}
+353
View File
@@ -0,0 +1,353 @@
package plugin
import (
"strings"
"testing"
)
// ── Display Tests ───────────────────────────────────────────────────────────
func TestLuigiShopGreeting_Basic(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 2, Condition: 100},
}
text := luigiShopGreeting("@user:test", equip, 5000, false, 0)
if !strings.Contains(text, "Luigi's") {
t.Error("greeting should contain Luigi's")
}
if !strings.Contains(text, "€5000") {
t.Error("greeting should show balance")
}
if !strings.Contains(text, "Weapons") {
t.Error("greeting should show category grid")
}
if !strings.Contains(text, "Reply with a category") {
t.Error("greeting should prompt for category")
}
}
func TestLuigiShopGreeting_MaxedOut(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5, Condition: 100},
SlotArmor: {Tier: 5, Condition: 100},
SlotHelmet: {Tier: 5, Condition: 100},
SlotBoots: {Tier: 5, Condition: 100},
SlotTool: {Tier: 5, Condition: 100},
}
text := luigiShopGreeting("@user:test", equip, 99999, false, 0)
// Should NOT show category grid.
if strings.Contains(text, "Reply with a category") {
t.Error("maxed-out greeting should not show category prompt")
}
// Should use maxed-out flavor.
if !strings.Contains(text, "fully kitted") {
t.Error("maxed-out greeting should mention being fully kitted")
}
}
func TestLuigiShopGreeting_ShowAll(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{}
text := luigiShopGreeting("@user:test", equip, 1000, true, 0)
// Should contain a show-all comment.
if !strings.Contains(text, "judgment") && !strings.Contains(text, "thoroughness") {
t.Error("show all greeting should contain a show-all comment")
}
}
func TestLuigiCategoryView_UpgradeIndicators(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 2, Condition: 100, Name: "Dull Steel Sword of Mediocrity"},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
// Should show current equipped.
if !strings.Contains(text, "🟢") {
t.Error("should show 🟢 for currently equipped item")
}
// Should show upgrade indicators.
if !strings.Contains(text, "⬆️") {
t.Error("should show ⬆️ for items above current tier")
}
// Should NOT show T1 (below current tier) by default.
if strings.Contains(text, "Sad Iron Sword") {
t.Error("should not show items below current tier when showAll=false")
}
}
func TestLuigiCategoryView_ShowAll(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3, Condition: 100, Name: "Sword (It's Fine)"},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, true)
// Should show items below current tier.
if !strings.Contains(text, "Sad Iron Sword") {
t.Error("show all should include items below current tier")
}
// Below-tier items should show ⬇️.
if !strings.Contains(text, "⬇️") {
t.Error("show all should show ⬇️ for items below current tier")
}
}
func TestLuigiCategoryView_Affordability(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 0, Condition: 100, Name: "Basic Ass Sword"},
}
// Can afford T1 (€100) but not T2 (€450).
text := luigiCategoryView("@user:test", SlotWeapon, equip, 200, false)
if !strings.Contains(text, "✅") {
t.Error("should show ✅ for affordable items")
}
if !strings.Contains(text, "💸") {
t.Error("should show 💸 for unaffordable items")
}
}
func TestLuigiCategoryView_MasterworkAck(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 3, Condition: 100, Name: "MW Blade", Masterwork: true},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
if !strings.Contains(text, "⭐") {
t.Error("should show ⭐ for masterwork gear")
}
// The masterwork acknowledgement pool has varied text — just check it's referenced.
hasMWAck := strings.Contains(text, "Masterwork") || strings.Contains(text, "Arena gear") || strings.Contains(text, "can't beat that")
if !hasMWAck {
t.Error("should acknowledge masterwork/arena gear")
}
}
func TestLuigiCategoryView_MaxedSlot(t *testing.T) {
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Tier: 5, Condition: 100, Name: "Vorpal Sword"},
}
text := luigiCategoryView("@user:test", SlotWeapon, equip, 50000, false)
if !strings.Contains(text, "max tier") {
t.Error("should show max-tier message when nothing to buy")
}
}
// ── Item Confirm Tests ─────────────────────────────────────────────────────
func TestLuigiItemConfirm_Upgrade(t *testing.T) {
def := &EquipmentDef{Name: "Silver Blade", Tier: 3, Price: 1500}
current := &AdvEquipment{Tier: 2, Name: "Dull Steel Sword"}
text := luigiItemConfirm("@user:test", SlotWeapon, def, current, 5000)
if !strings.Contains(text, "⬆️") {
t.Error("should show upgrade indicator")
}
if !strings.Contains(text, "upgrade from") {
t.Error("should mention it's an upgrade")
}
if !strings.Contains(text, "€5000") {
t.Error("should show current balance")
}
if !strings.Contains(text, "€3500") {
t.Error("should show balance after purchase")
}
if !strings.Contains(text, "Confirm?") {
t.Error("should prompt for confirmation")
}
}
func TestLuigiItemConfirm_NoCurrent(t *testing.T) {
def := &EquipmentDef{Name: "Sad Iron Sword", Tier: 1, Price: 100}
text := luigiItemConfirm("@user:test", SlotWeapon, def, nil, 1000)
if !strings.Contains(text, "upgrade from") {
t.Error("should say upgrade from None")
}
}
// ── Item Lookup Tests ──────────────────────────────────────────────────────
func TestAdvFindShopItemInSlot_ExactMatch(t *testing.T) {
slot, def, ok := advFindShopItemInSlot("Enchanted Plate", SlotArmor)
if !ok {
t.Fatal("should find Enchanted Plate in armor slot")
}
if slot != SlotArmor {
t.Errorf("slot should be armor, got %s", slot)
}
if def.Tier != 4 {
t.Errorf("Enchanted Plate should be tier 4, got %d", def.Tier)
}
}
func TestAdvFindShopItemInSlot_WrongSlot(t *testing.T) {
_, _, ok := advFindShopItemInSlot("Dragonscale", SlotWeapon)
if ok {
t.Error("should not find armor item in weapon slot")
}
}
func TestAdvFindShopItemInSlot_PartialMatch(t *testing.T) {
slot, def, ok := advFindShopItemInSlot("Enchanted Blade", SlotWeapon)
if !ok {
t.Fatal("should find Enchanted Blade by partial match")
}
if slot != SlotWeapon {
t.Errorf("slot should be weapon, got %s", slot)
}
if def.Tier != 4 {
t.Errorf("Enchanted Blade should be tier 4, got %d", def.Tier)
}
}
func TestAdvFindShopItemInSlot_CaseInsensitive(t *testing.T) {
_, _, ok := advFindShopItemInSlot("enchanted blade", SlotWeapon)
if !ok {
t.Error("should find item case-insensitively")
}
}
func TestAdvFindShopItemInSlot_NoTier0(t *testing.T) {
_, _, ok := advFindShopItemInSlot("Basic Ass Sword", SlotWeapon)
if ok {
t.Error("should not find tier 0 items (price 0)")
}
}
func TestAdvFindShopItem_TierShorthand(t *testing.T) {
slot, def, ok := advFindShopItem("3 sword")
if !ok {
t.Fatal("should find via tier shorthand")
}
if slot != SlotWeapon {
t.Errorf("slot should be weapon, got %s", slot)
}
if def.Tier != 3 {
t.Errorf("tier should be 3, got %d", def.Tier)
}
}
func TestAdvFindShopItem_TierPrefix(t *testing.T) {
_, def, ok := advFindShopItem("tier 5 boots")
if !ok {
t.Fatal("should find via 'tier 5 boots'")
}
if def.Tier != 5 {
t.Errorf("tier should be 5, got %d", def.Tier)
}
}
func TestAdvFindShopItem_TShorthand(t *testing.T) {
_, def, ok := advFindShopItem("t4 armor")
if !ok {
t.Fatal("should find via 't4 armor'")
}
if def.Tier != 4 {
t.Errorf("tier should be 4, got %d", def.Tier)
}
}
// ── Flavor Coverage Tests ───────────────────────────────────────────────────
func TestLuigiItemDescriptions_Coverage(t *testing.T) {
for _, slot := range allSlots {
defs := equipmentTiers[slot]
for _, def := range defs {
if def.Price == 0 {
continue // skip tier 0
}
key := luigiItemKey{slot, def.Tier}
if _, ok := luigiItemDescriptions[key]; !ok {
t.Errorf("missing Luigi item description for %s tier %d (%s)", slot, def.Tier, def.Name)
}
if _, ok := luigiItemOneLiners[key]; !ok {
t.Errorf("missing Luigi one-liner for %s tier %d (%s)", slot, def.Tier, def.Name)
}
}
}
}
func TestLuigiCategoryIntros_Coverage(t *testing.T) {
for _, slot := range allSlots {
intros, ok := luigiCategoryIntros[slot]
if !ok || len(intros) == 0 {
t.Errorf("missing Luigi category intros for slot %s", slot)
}
}
}
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"luigiGreetings": luigiGreetings,
"luigiPurchaseConfirm": luigiPurchaseConfirm,
"luigiTier5Confirm": luigiTier5Confirm,
"luigiComboConfirm": luigiComboConfirm,
"luigiInsufficientFunds": luigiInsufficientFunds,
"luigiBrowseTimeout": luigiBrowseTimeout,
"luigiMaxedOut": luigiMaxedOut,
"luigiMasterworkAck": luigiMasterworkAck,
"luigiShowAllComment": luigiShowAllComment,
"luigiCommentary": luigiCommentary,
"luigiCancellation": luigiCancellation,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty", name)
}
}
}
// ── Format String Safety ────────────────────────────────────────────────────
func TestLuigiPurchaseConfirm_FormatStrings(t *testing.T) {
for i, tmpl := range luigiPurchaseConfirm {
// Should have %s then %d
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
if !result {
t.Errorf("luigiPurchaseConfirm[%d] missing %%s or %%d placeholder", i)
}
}
}
func TestLuigiTier5Confirm_FormatStrings(t *testing.T) {
for i, tmpl := range luigiTier5Confirm {
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
if !result {
t.Errorf("luigiTier5Confirm[%d] missing %%s or %%d placeholder", i)
}
}
}
func TestLuigiComboConfirm_FormatStrings(t *testing.T) {
for i, tmpl := range luigiComboConfirm {
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
if !result {
t.Errorf("luigiComboConfirm[%d] missing %%s or %%d placeholder", i)
}
}
}
// ── Tier Bounds Safety ──────────────────────────────────────────────────────
func TestEquipmentTiers_ConsistentIndexing(t *testing.T) {
for _, slot := range allSlots {
defs := equipmentTiers[slot]
for i, def := range defs {
if def.Tier != i {
t.Errorf("slot %s: tier %d at index %d (should match for safe direct indexing)", slot, def.Tier, i)
}
}
}
}
func TestEquipmentTiers_SixTiersPerSlot(t *testing.T) {
for _, slot := range allSlots {
defs := equipmentTiers[slot]
if len(defs) != 6 {
t.Errorf("slot %s: expected 6 tiers (0-5), got %d", slot, len(defs))
}
}
}
+36 -7
View File
@@ -5,7 +5,7 @@ import "testing"
func TestAdvEquipmentScore_Empty(t *testing.T) {
score := advEquipmentScore(map[EquipmentSlot]*AdvEquipment{})
if score != 0 {
t.Errorf("empty equipment should score 0, got %d", score)
t.Errorf("empty equipment should score 0, got %.2f", score)
}
}
@@ -16,8 +16,8 @@ func TestAdvEquipmentScore_WeaponDoubled(t *testing.T) {
}
score := advEquipmentScore(equip)
// Weapon: 3*2=6, Armor: 3
if score != 9 {
t.Errorf("got %d, want 9 (weapon 6 + armor 3)", score)
if score != 9.0 {
t.Errorf("got %.2f, want 9 (weapon 6 + armor 3)", score)
}
}
@@ -27,8 +27,8 @@ func TestAdvEquipmentScore_LowConditionHalved(t *testing.T) {
}
score := advEquipmentScore(equip)
// Tier 4, halved = 2
if score != 2 {
t.Errorf("got %d, want 2 (tier 4 halved for low condition)", score)
if score != 2.0 {
t.Errorf("got %.2f, want 2 (tier 4 halved for low condition)", score)
}
}
@@ -42,8 +42,8 @@ func TestAdvEquipmentScore_FullLoadout(t *testing.T) {
}
score := advEquipmentScore(equip)
// Weapon: 5*2=10, others: 5*4=20, total=30
if score != 30 {
t.Errorf("got %d, want 30", score)
if score != 30.0 {
t.Errorf("got %.2f, want 30", score)
}
}
@@ -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{
+2 -1
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
+8 -7
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++
+2 -2
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
}
+886
View File
@@ -0,0 +1,886 @@
package plugin
import (
"database/sql"
"fmt"
"log/slog"
"math"
"sort"
"strings"
"gogobee/internal/db"
)
// ─── Threshold Constants ──────────────────────────────────────────
const (
// Communication
thNovelistAvgWords = 7
thNovelistMinMsgs = 100
thMinimalistMaxAvgWords = 4
thMinimalistMinMsgs = 50
thInquisitorPct = 8
thInquisitorMinMsgs = 50
thEnthusiastPct = 8
thEnthusiastMinMsgs = 50
thChatterboxMinMsgs = 500
thChatterboxMinAvgWords = 5
thLinkmasterPct = 5
thLinkmasterMinMsgs = 50
// Temporal
thNightOwlPct = 40
thNightOwlMinMsgs = 100
thEarlyBirdPct = 40
thEarlyBirdMinMsgs = 100
// Emotional (LLM-gated)
thEmotionalMinClassified = 100
thCheerleaderPosPct = 50
thPhilosopherNeutPct = 40
thPhilosopherQPct = 10
thPhilosopherAvgWords = 8
thAgitatorNegPct = 30
thAgitatorMinMsgs = 200
thWildcardStdDev = 0.5
thWildcardMinClassified = 150
thHypeMachineExclPct = 20
thHypeMachinePosPct = 60
// Economy
thBrokeSpiritedMaxBalance = 100.0
thDegenerateMinLosses = 10
// Games
thSharkWinRate = 55
thSharkMinGames = 15
thWordleMinPuzzles = 10
thArenaChampMinTier = 4
thArenaChampWinRate = 50
thArenaCowardMinRuns = 3
thArenaCowardMaxAvgTier = 2
thTriviaNerdMinCorrect = 10
// Adventure
thAdvMinDays = 10
thAdvDiverseMinDays = 15
thAdvDiverseMinTypes = 3
thAdvDiverseMaxShare = 40
thGearheadMinMasterwork = 3
// Communication (vocabulary)
thWordsmithMinFancyWords = 10
// Social
thPatronMinRepGiven = 5
thPatronRatioMultiplier = 2
// Display
maxDisplayArchetypes = 6
)
// ─── Flavor Text ──────────────────────────────────────────────────
var archetypeFlavors = map[string]string{
// Communication
"Novelist": "Writes in paragraphs. Has opinions. Probably re-reads their own messages.",
"Minimalist": "Says a lot with very little. You're never sure if they're fine or not.",
"Inquisitor": "Always asking. Never satisfied with the first answer. Probably has follow-ups.",
"Enthusiast": "Genuinely excited about things. All the things. Possibly all at once.",
"Chatterbox": "Has thoughts. Many thoughts. Shares them all. You wouldn't have it any other way.",
"Linkmaster": "The community's unofficial curator. Their tab count is not your business.",
"Wordsmith": "Uses words most people have to look up. The thesaurus fears them.",
// Temporal
"Night Owl": "Awake when they probably shouldn't be. Thriving despite all evidence.",
"Early Bird": "Up before anyone else. Has already formed opinions about the day.",
// Emotional
"Cheerleader": "Lifts the room. Probably the reason someone didn't log off that one time.",
"Philosopher": "Thinks out loud. Asks questions that don't have comfortable answers.",
"Agitator": "Keeps things interesting. You don't always agree but you're never bored.",
"Wildcard": "You never quite know what you're getting. That's the charm. Mostly.",
"Hype Machine": "Arrived and immediately made everything louder. The room is better for it.",
// Economy
"Whale": "Has money. Spends money. Has more money somehow. The math is unclear.",
"Degenerate": "Knows exactly what they're doing. Does it anyway. Respects it.",
"Broke But Spirited": "Down but not out. The pot fears them anyway.",
// Games
"Shark": "Wins more than they should. GogoBee has opinions about this.",
"Wordle Devotee": "Shows up every day. Rain or shine. Five letters at a time.",
"Arena Champion": "Walks into the arena and the monsters check their insurance.",
"Arena Coward": "Shows up. Wins a little. Leaves immediately. Smart, actually.",
"Trivia Nerd": "Has retained an unreasonable amount of information.",
// Adventure
"Dungeon Crawler": "Always in the dungeon. The dungeon knows their name by now.",
"The Miner": "Steady. Patient. Has more ore than anyone needs. Still mining.",
"The Forager": "Out in the field while everyone else is underground. Knows where the good stuff is.",
"The Angler": "Patient beyond reason. Has caught things nobody else has caught.",
"The Merchant": "Buys. Sells. Optimizes. The economy is a puzzle and they're solving it.",
"Resting Face": "Technically participating. The numbers are still going up. Good enough.",
"The Adventurer": "Does a bit of everything. Refuses to specialize. Somehow works.",
"Gearhead": "Has the good stuff. Spent the time getting it. You can tell.",
// Social
"Patron": "Gives credit generously. Asks for nothing in return. Suspicious, honestly.",
"Reactor": "Expresses everything through emoji. An entire emotional life, no words required.",
// Fallback
"Regular": "Shows up. Participates. The backbone of any community, honestly.",
}
// ── Mutual Exclusions ────────────────────────────────────────────
var mutualExclusions = [][2]string{
{"Novelist", "Minimalist"},
{"Early Bird", "Night Owl"},
{"Arena Champion", "Arena Coward"},
{"Whale", "Broke But Spirited"},
}
// ─── Core Types ───────────────────────────────────────────────────
type archetypeResult struct {
Name string
Category string
SignalScore float64
Flavor string
}
// ─── Community Percentiles ────────────────────────────────────────
type communityPercentiles struct {
whaleBalanceThreshold float64 // top 10%
repGivenP75 int // top 25% rep given
reactionsGivenP75 int // top 25% reactions given
medianMsgCount int // median total_messages
}
func computePercentiles(d *sql.DB) communityPercentiles {
var p communityPercentiles
// Whale: top 10% balance
var userCount int
d.QueryRow(`SELECT COUNT(*) FROM euro_balances WHERE balance > 0`).Scan(&userCount)
offset := userCount / 10
if offset < 1 {
offset = 1
}
d.QueryRow(`SELECT balance FROM euro_balances ORDER BY balance DESC LIMIT 1 OFFSET ?`, offset).Scan(&p.whaleBalanceThreshold)
// Rep given: top 25%
repGiven := queryIntList(d, `SELECT COUNT(*) FROM rep_cooldowns GROUP BY giver ORDER BY COUNT(*) DESC`)
p.repGivenP75 = percentile75(repGiven)
// Reactions given: top 25%
reactionsGiven := queryIntList(d, `SELECT COUNT(*) FROM reaction_log GROUP BY sender ORDER BY COUNT(*) DESC`)
p.reactionsGivenP75 = percentile75(reactionsGiven)
// Median message count
msgCounts := queryIntList(d, `SELECT total_messages FROM user_stats ORDER BY total_messages`)
if len(msgCounts) > 0 {
p.medianMsgCount = msgCounts[len(msgCounts)/2]
}
return p
}
func queryIntList(d *sql.DB, query string) []int {
rows, err := d.Query(query)
if err != nil {
return nil
}
defer rows.Close()
var result []int
for rows.Next() {
var v int
if rows.Scan(&v) == nil {
result = append(result, v)
}
}
return result
}
func percentile75(sorted []int) int {
if len(sorted) == 0 {
return 0
}
idx := len(sorted) / 4 // top 25% = first quarter of desc-sorted list
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
// ─── Per-User Data Loaders ────────────────────────────────────────
type userData struct {
userID string
// user_stats
totalMsgs int
totalWords int
totalLinks int
totalImages int
totalQuestions int
totalExcl int
totalEmojis int
nightMsgs int
morningMsgs int
fancyWords int
// sentiment_stats
sentPositive int
sentNegative int
sentNeutral int
// llm_classifications
classifiedCount int
sentVariance float64
// economy
balance float64
gamblingLosses int
recentGaming bool
// games
bjPlayed, bjWon int
hmPlayed, hmWon int
holdemPlayed int
holdemNetPositive bool
unoSoloPlayed, unoSoloWon int
unoMultiPlayed, unoMultiWon int
wordlePlayed int
triviaCorrect int
// arena
arenaHighestTier int
arenaRuns int
arenaWins int
arenaAvgCashTier float64
// adventure
advDays int
advActivities map[string]int // activity_type -> count
masterworkCount int
// social
repGiven int
repReceived int
reactionsGiven int
}
func loadUserData(d *sql.DB, userID string) userData {
u := userData{userID: userID, advActivities: make(map[string]int)}
// user_stats
d.QueryRow(`SELECT total_messages, total_words, total_links, total_images,
total_questions, total_exclamations, total_emojis, night_messages, morning_messages,
COALESCE(fancy_words, 0)
FROM user_stats WHERE user_id = ?`, userID).Scan(
&u.totalMsgs, &u.totalWords, &u.totalLinks, &u.totalImages,
&u.totalQuestions, &u.totalExcl, &u.totalEmojis, &u.nightMsgs, &u.morningMsgs,
&u.fancyWords)
// sentiment_stats
d.QueryRow(`SELECT COALESCE(positive,0), COALESCE(negative,0), COALESCE(neutral,0)
FROM sentiment_stats WHERE user_id = ?`, userID).Scan(
&u.sentPositive, &u.sentNegative, &u.sentNeutral)
// llm_classifications count + variance
d.QueryRow(`SELECT COUNT(*) FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.classifiedCount)
if u.classifiedCount > 1 {
d.QueryRow(`SELECT AVG(sentiment_score * sentiment_score) - AVG(sentiment_score) * AVG(sentiment_score)
FROM llm_classifications WHERE user_id = ?`, userID).Scan(&u.sentVariance)
if u.sentVariance < 0 {
u.sentVariance = 0
}
}
// economy
d.QueryRow(`SELECT COALESCE(balance, 0) FROM euro_balances WHERE user_id = ?`, userID).Scan(&u.balance)
// Count actual gambling losses (not bets/antes which are always negative)
d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ? AND amount < 0
AND reason IN ('holdem_loss')`,
userID).Scan(&u.gamblingLosses)
// Add blackjack losses (games played minus games won)
var bjLosses int
d.QueryRow(`SELECT MAX(COALESCE(games_played,0) - COALESCE(games_won,0), 0) FROM blackjack_scores WHERE user_id = ?`,
userID).Scan(&bjLosses)
u.gamblingLosses += bjLosses
var recentCount int
d.QueryRow(`SELECT COUNT(*) FROM euro_transactions WHERE user_id = ?
AND created_at > datetime('now', '-30 days')
AND reason IN ('blackjack_bet','blackjack_win','holdem_loss','holdem_win',
'uno_wager','uno_win','uno_multi_ante','uno_multi_win')`,
userID).Scan(&recentCount)
u.recentGaming = recentCount > 0
// blackjack
d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM blackjack_scores WHERE user_id = ?`,
userID).Scan(&u.bjPlayed, &u.bjWon)
// hangman
d.QueryRow(`SELECT COALESCE(games_played,0), COALESCE(games_won,0) FROM hangman_scores WHERE user_id = ?`,
userID).Scan(&u.hmPlayed, &u.hmWon)
// holdem
var totalWon, totalLost int
d.QueryRow(`SELECT COALESCE(hands_played,0), COALESCE(total_won,0), COALESCE(total_lost,0) FROM holdem_scores WHERE user_id = ?`,
userID).Scan(&u.holdemPlayed, &totalWon, &totalLost)
u.holdemNetPositive = totalWon > totalLost
// uno solo
d.QueryRow(`SELECT COUNT(*), COALESCE(SUM(CASE WHEN result='player_win' THEN 1 ELSE 0 END), 0) FROM uno_games WHERE player_id = ?`,
userID).Scan(&u.unoSoloPlayed, &u.unoSoloWon)
// uno multi
d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE player_ids LIKE ?`,
"%"+userID+"%").Scan(&u.unoMultiPlayed)
d.QueryRow(`SELECT COUNT(*) FROM uno_multi_games WHERE winner_id = ?`,
userID).Scan(&u.unoMultiWon)
// wordle
d.QueryRow(`SELECT COALESCE(puzzles_played,0) FROM wordle_stats WHERE user_id = ?`,
userID).Scan(&u.wordlePlayed)
// trivia
d.QueryRow(`SELECT COALESCE(SUM(correct),0) FROM trivia_scores WHERE user_id = ?`,
userID).Scan(&u.triviaCorrect)
// arena
d.QueryRow(`SELECT COALESCE(highest_tier,0), COALESCE(total_runs,0) FROM arena_stats WHERE user_id = ?`,
userID).Scan(&u.arenaHighestTier, &u.arenaRuns)
d.QueryRow(`SELECT COUNT(*) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`,
userID).Scan(&u.arenaWins)
d.QueryRow(`SELECT COALESCE(AVG(tier), 0) FROM arena_runs WHERE user_id = ? AND status = 'cashed_out'`,
userID).Scan(&u.arenaAvgCashTier)
// adventure
d.QueryRow(`SELECT COUNT(DISTINCT DATE(logged_at)) FROM adventure_activity_log WHERE user_id = ?`,
userID).Scan(&u.advDays)
rows, err := d.Query(`SELECT activity_type, COUNT(*) FROM adventure_activity_log WHERE user_id = ? GROUP BY activity_type`, userID)
if err == nil {
defer rows.Close()
for rows.Next() {
var atype string
var cnt int
if rows.Scan(&atype, &cnt) == nil {
u.advActivities[atype] = cnt
}
}
}
d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id = ? AND masterwork = 1`,
userID).Scan(&u.masterworkCount)
// social
d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE giver = ?`, userID).Scan(&u.repGiven)
d.QueryRow(`SELECT COUNT(*) FROM rep_cooldowns WHERE receiver = ?`, userID).Scan(&u.repReceived)
d.QueryRow(`SELECT COUNT(*) FROM reaction_log WHERE sender = ?`, userID).Scan(&u.reactionsGiven)
return u
}
// ── Archetype Evaluators ─────────────────────────────────────────
func evaluateArchetypes(u userData, pct communityPercentiles) []archetypeResult {
var results []archetypeResult
m := max1arch(u.totalMsgs)
// ── Communication Style ──
avgWords := u.totalWords / m
if u.totalMsgs >= thNovelistMinMsgs && avgWords >= thNovelistAvgWords {
results = append(results, archetypeResult{
Name: "Novelist", Category: "Communication",
SignalScore: clampSignal(float64(avgWords-thNovelistAvgWords) / float64(thNovelistAvgWords)),
})
}
if u.totalMsgs >= thMinimalistMinMsgs && avgWords <= thMinimalistMaxAvgWords {
results = append(results, archetypeResult{
Name: "Minimalist", Category: "Communication",
SignalScore: clampSignal(float64(thMinimalistMaxAvgWords-avgWords+1) / float64(thMinimalistMaxAvgWords)),
})
}
qPct := u.totalQuestions * 100 / m
if u.totalMsgs >= thInquisitorMinMsgs && qPct >= thInquisitorPct {
results = append(results, archetypeResult{
Name: "Inquisitor", Category: "Communication",
SignalScore: clampSignal(float64(qPct-thInquisitorPct) / float64(thInquisitorPct)),
})
}
ePct := u.totalExcl * 100 / m
if u.totalMsgs >= thEnthusiastMinMsgs && ePct >= thEnthusiastPct {
results = append(results, archetypeResult{
Name: "Enthusiast", Category: "Communication",
SignalScore: clampSignal(float64(ePct-thEnthusiastPct) / float64(thEnthusiastPct)),
})
}
if u.totalMsgs >= thChatterboxMinMsgs && avgWords >= thChatterboxMinAvgWords {
results = append(results, archetypeResult{
Name: "Chatterbox", Category: "Communication",
SignalScore: clampSignal(float64(u.totalMsgs-thChatterboxMinMsgs) / float64(thChatterboxMinMsgs)),
})
}
lPct := u.totalLinks * 100 / m
if u.totalMsgs >= thLinkmasterMinMsgs && lPct >= thLinkmasterPct {
results = append(results, archetypeResult{
Name: "Linkmaster", Category: "Communication",
SignalScore: clampSignal(float64(lPct-thLinkmasterPct) / float64(thLinkmasterPct)),
})
}
if u.fancyWords >= thWordsmithMinFancyWords {
results = append(results, archetypeResult{
Name: "Wordsmith", Category: "Communication",
SignalScore: clampSignal(float64(u.fancyWords) / float64(thWordsmithMinFancyWords*3)),
})
}
// ── Temporal ──
if u.totalMsgs >= thNightOwlMinMsgs {
nightPct := u.nightMsgs * 100 / m
if nightPct >= thNightOwlPct {
results = append(results, archetypeResult{
Name: "Night Owl", Category: "Temporal",
SignalScore: clampSignal(float64(nightPct-thNightOwlPct) / float64(100-thNightOwlPct)),
})
}
}
if u.totalMsgs >= thEarlyBirdMinMsgs {
morningPct := u.morningMsgs * 100 / m
if morningPct >= thEarlyBirdPct {
results = append(results, archetypeResult{
Name: "Early Bird", Category: "Temporal",
SignalScore: clampSignal(float64(morningPct-thEarlyBirdPct) / float64(100-thEarlyBirdPct)),
})
}
}
// ── Emotional Signature (LLM-gated) ──
sentTotal := u.sentPositive + u.sentNegative + u.sentNeutral
if u.classifiedCount >= thEmotionalMinClassified && sentTotal > 0 {
posPct := u.sentPositive * 100 / sentTotal
negPct := u.sentNegative * 100 / sentTotal
neutPct := u.sentNeutral * 100 / sentTotal
// Cheerleader
if posPct >= thCheerleaderPosPct && u.repGiven >= pct.repGivenP75 && u.reactionsGiven >= pct.reactionsGivenP75 {
results = append(results, archetypeResult{
Name: "Cheerleader", Category: "Emotional",
SignalScore: clampSignal(float64(posPct-thCheerleaderPosPct) / float64(100-thCheerleaderPosPct)),
})
}
// Philosopher
if neutPct >= thPhilosopherNeutPct && qPct >= thPhilosopherQPct && avgWords >= thPhilosopherAvgWords {
results = append(results, archetypeResult{
Name: "Philosopher", Category: "Emotional",
SignalScore: clampSignal(float64(neutPct-thPhilosopherNeutPct) / float64(100-thPhilosopherNeutPct)),
})
}
// Agitator
if negPct >= thAgitatorNegPct && u.totalMsgs >= thAgitatorMinMsgs {
results = append(results, archetypeResult{
Name: "Agitator", Category: "Emotional",
SignalScore: clampSignal(float64(negPct-thAgitatorNegPct) / float64(100-thAgitatorNegPct)),
})
}
// Hype Machine
if ePct >= thHypeMachineExclPct && posPct >= thHypeMachinePosPct && u.reactionsGiven >= pct.reactionsGivenP75 {
results = append(results, archetypeResult{
Name: "Hype Machine", Category: "Emotional",
SignalScore: clampSignal((float64(ePct-thHypeMachineExclPct)/float64(thHypeMachineExclPct) +
float64(posPct-thHypeMachinePosPct)/float64(100-thHypeMachinePosPct)) / 2),
})
}
}
// Wildcard (separate min classified threshold)
if u.classifiedCount >= thWildcardMinClassified {
stddev := math.Sqrt(u.sentVariance)
if stddev >= thWildcardStdDev {
results = append(results, archetypeResult{
Name: "Wildcard", Category: "Emotional",
SignalScore: clampSignal((stddev - thWildcardStdDev) / thWildcardStdDev),
})
}
}
// ── Economy ──
if pct.whaleBalanceThreshold > 0 && u.balance >= pct.whaleBalanceThreshold {
results = append(results, archetypeResult{
Name: "Whale", Category: "Economy",
SignalScore: clampSignal(u.balance / pct.whaleBalanceThreshold / 2),
})
}
if u.gamblingLosses >= thDegenerateMinLosses && u.recentGaming {
results = append(results, archetypeResult{
Name: "Degenerate", Category: "Economy",
SignalScore: clampSignal(float64(u.gamblingLosses) / float64(thDegenerateMinLosses*3)),
})
}
if u.balance < thBrokeSpiritedMaxBalance && u.recentGaming {
results = append(results, archetypeResult{
Name: "Broke But Spirited", Category: "Economy",
SignalScore: clampSignal((thBrokeSpiritedMaxBalance - u.balance) / thBrokeSpiritedMaxBalance),
})
}
// ── Games ──
totalGames := u.bjPlayed + u.hmPlayed + u.unoSoloPlayed + u.unoMultiPlayed
totalWins := u.bjWon + u.hmWon + u.unoSoloWon + u.unoMultiWon
if u.holdemPlayed > 0 {
totalGames += u.holdemPlayed
if u.holdemNetPositive {
totalWins += u.holdemPlayed / 2 // approximate: net positive counts as ~50% wins
}
}
if totalGames >= thSharkMinGames {
winRate := totalWins * 100 / totalGames
if winRate >= thSharkWinRate {
results = append(results, archetypeResult{
Name: "Shark", Category: "Games",
SignalScore: clampSignal(float64(winRate-thSharkWinRate) / float64(100-thSharkWinRate)),
})
}
}
if u.wordlePlayed >= thWordleMinPuzzles {
results = append(results, archetypeResult{
Name: "Wordle Devotee", Category: "Games",
SignalScore: clampSignal(float64(u.wordlePlayed-thWordleMinPuzzles) / float64(thWordleMinPuzzles)),
})
}
if u.arenaHighestTier >= thArenaChampMinTier && u.arenaRuns > 0 {
arenaWinRate := u.arenaWins * 100 / max1arch(u.arenaRuns)
if arenaWinRate >= thArenaChampWinRate {
results = append(results, archetypeResult{
Name: "Arena Champion", Category: "Games",
SignalScore: clampSignal(float64(u.arenaHighestTier-thArenaChampMinTier+1) / 2.0),
})
}
}
if u.arenaWins >= thArenaCowardMinRuns && u.arenaAvgCashTier > 0 && u.arenaAvgCashTier <= float64(thArenaCowardMaxAvgTier) {
results = append(results, archetypeResult{
Name: "Arena Coward", Category: "Games",
SignalScore: clampSignal((float64(thArenaCowardMaxAvgTier) - u.arenaAvgCashTier + 1) / float64(thArenaCowardMaxAvgTier)),
})
}
if u.triviaCorrect >= thTriviaNerdMinCorrect {
results = append(results, archetypeResult{
Name: "Trivia Nerd", Category: "Games",
SignalScore: clampSignal(float64(u.triviaCorrect-thTriviaNerdMinCorrect) / float64(thTriviaNerdMinCorrect)),
})
}
// ── Adventure ──
if u.advDays >= thAdvMinDays {
totalAdv := 0
for _, c := range u.advActivities {
totalAdv += c
}
if totalAdv > 0 {
// Find plurality activity
var topActivity string
var topCount int
for act, cnt := range u.advActivities {
if cnt > topCount {
topActivity = act
topCount = cnt
}
}
topShare := topCount * 100 / totalAdv
activityArchetypes := map[string]string{
"dungeon": "Dungeon Crawler",
"mining": "The Miner",
"foraging": "The Forager",
"fishing": "The Angler",
"shop": "The Merchant",
"rest": "Resting Face",
}
// Check for diverse adventurer first
if len(u.advActivities) >= thAdvDiverseMinTypes && u.advDays >= thAdvDiverseMinDays && topShare <= thAdvDiverseMaxShare {
results = append(results, archetypeResult{
Name: "The Adventurer", Category: "Adventure",
SignalScore: clampSignal(float64(len(u.advActivities)) / float64(thAdvDiverseMinTypes+2)),
})
} else if name, ok := activityArchetypes[topActivity]; ok {
results = append(results, archetypeResult{
Name: name, Category: "Adventure",
SignalScore: clampSignal(float64(topShare) / 100.0),
})
}
}
}
if u.masterworkCount >= thGearheadMinMasterwork {
results = append(results, archetypeResult{
Name: "Gearhead", Category: "Adventure",
SignalScore: clampSignal(float64(u.masterworkCount) / float64(thGearheadMinMasterwork*2)),
})
}
// ── Social ──
if u.repGiven >= thPatronMinRepGiven {
repReceived := max1arch(u.repReceived)
if u.repGiven >= repReceived*thPatronRatioMultiplier {
results = append(results, archetypeResult{
Name: "Patron", Category: "Social",
SignalScore: clampSignal(float64(u.repGiven) / float64(thPatronMinRepGiven*3)),
})
}
}
if u.reactionsGiven >= pct.reactionsGivenP75 && pct.reactionsGivenP75 > 0 && u.totalMsgs <= pct.medianMsgCount {
results = append(results, archetypeResult{
Name: "Reactor", Category: "Social",
SignalScore: clampSignal(float64(u.reactionsGiven) / float64(pct.reactionsGivenP75*2)),
})
}
// Fill in flavor text
for i := range results {
results[i].Flavor = archetypeFlavors[results[i].Name]
}
// Apply mutual exclusions
results = applyExclusions(results)
// Sort by signal score desc
sort.Slice(results, func(i, j int) bool {
return results[i].SignalScore > results[j].SignalScore
})
return results
}
func applyExclusions(results []archetypeResult) []archetypeResult {
nameIdx := make(map[string]int)
for i, r := range results {
nameIdx[r.Name] = i
}
remove := make(map[int]bool)
for _, pair := range mutualExclusions {
idxA, hasA := nameIdx[pair[0]]
idxB, hasB := nameIdx[pair[1]]
if hasA && hasB {
if results[idxA].SignalScore >= results[idxB].SignalScore {
remove[idxB] = true
} else {
remove[idxA] = true
}
}
}
if len(remove) == 0 {
return results
}
var filtered []archetypeResult
for i, r := range results {
if !remove[i] {
filtered = append(filtered, r)
}
}
return filtered
}
// ─── Cache Read/Write ─────────────────────────────────────────────
// GetUserArchetypes returns cached archetypes for a user, sorted by signal_score desc.
func GetUserArchetypes(userID string) []archetypeResult {
d := db.Get()
rows, err := d.Query(`SELECT archetype, category, signal_score, flavor
FROM user_archetypes WHERE user_id = ? ORDER BY signal_score DESC`, userID)
if err != nil {
return nil
}
defer rows.Close()
var results []archetypeResult
for rows.Next() {
var r archetypeResult
if rows.Scan(&r.Name, &r.Category, &r.SignalScore, &r.Flavor) == nil {
results = append(results, r)
}
}
return results
}
// GetUserArchetypesLimited returns up to maxDisplayArchetypes for profile cards.
func GetUserArchetypesLimited(userID string) []archetypeResult {
results := GetUserArchetypes(userID)
if len(results) > maxDisplayArchetypes {
results = results[:maxDisplayArchetypes]
}
if len(results) == 0 {
return []archetypeResult{{
Name: "Regular", Category: "Fallback",
Flavor: archetypeFlavors["Regular"],
}}
}
return results
}
// ─── Refresh Engine ───────────────────────────────────────────────
// RefreshAllArchetypes recalculates archetypes for all users and writes to user_archetypes.
func RefreshAllArchetypes() {
d := db.Get()
// Get all user IDs
rows, err := d.Query(`SELECT user_id FROM user_stats WHERE total_messages > 0`)
if err != nil {
slog.Error("archetype: failed to list users", "err", err)
return
}
var userIDs []string
for rows.Next() {
var uid string
if rows.Scan(&uid) == nil {
userIDs = append(userIDs, uid)
}
}
rows.Close()
if len(userIDs) == 0 {
return
}
// Compute community percentiles once
pct := computePercentiles(d)
// Evaluate each user
type userResult struct {
userID string
archs []archetypeResult
}
var allResults []userResult
for _, uid := range userIDs {
u := loadUserData(d, uid)
archs := evaluateArchetypes(u, pct)
if len(archs) > 0 {
allResults = append(allResults, userResult{uid, archs})
}
}
// Write to cache in a transaction
tx, err := d.Begin()
if err != nil {
slog.Error("archetype: failed to begin tx", "err", err)
return
}
_, err = tx.Exec(`DELETE FROM user_archetypes`)
if err != nil {
tx.Rollback()
slog.Error("archetype: failed to clear cache", "err", err)
return
}
stmt, err := tx.Prepare(`INSERT INTO user_archetypes (user_id, archetype, category, signal_score, flavor)
VALUES (?, ?, ?, ?, ?)`)
if err != nil {
tx.Rollback()
slog.Error("archetype: failed to prepare insert", "err", err)
return
}
defer stmt.Close()
totalAssigned := 0
for _, ur := range allResults {
for _, a := range ur.archs {
if _, err := stmt.Exec(ur.userID, a.Name, a.Category, a.SignalScore, a.Flavor); err != nil {
slog.Error("archetype: insert failed", "user", ur.userID, "arch", a.Name, "err", err)
}
totalAssigned++
}
}
if err := tx.Commit(); err != nil {
slog.Error("archetype: commit failed", "err", err)
return
}
slog.Info("archetype: refresh complete", "users", len(allResults), "archetypes_assigned", totalAssigned)
}
// ─── Helpers ──────────────────────────────────────────────────────
func max1arch(n int) int {
if n < 1 {
return 1
}
return n
}
func clampSignal(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
// FormatArchetypeNames returns a " · " separated list of archetype names.
func FormatArchetypeNames(archs []archetypeResult) string {
if len(archs) == 0 {
return "Regular"
}
names := make([]string, len(archs))
for i, a := range archs {
names[i] = a.Name
}
return strings.Join(names, " · ")
}
// FormatArchetypesFull returns a full display with names and flavor text.
func FormatArchetypesFull(archs []archetypeResult) string {
if len(archs) == 0 {
return fmt.Sprintf("**Regular**\n_%s_", archetypeFlavors["Regular"])
}
var sb strings.Builder
for i, a := range archs {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(fmt.Sprintf("**%s**\n_%s_", a.Name, a.Flavor))
}
return sb.String()
}
+325
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")
}
}
+12 -5
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"
+48 -2
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())
}
+583
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)
}
+276
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)
}
}
+700
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
}
+579
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")
}
}
+581
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.",
}
+192
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")
}
}
+222
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
}
+333
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)
}
}
}
+2 -2
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
}
+535
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
}
@@ -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()
}
+345
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)
}
+462
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
}
+913
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()
}
+282
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()
}
+558
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
}
+245
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()
}
+375
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
}
+203
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
}
+523
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!",
}
+367
View File
@@ -0,0 +1,367 @@
package plugin
import (
"fmt"
"log/slog"
"strings"
"gogobee/internal/dreamclient"
"maunium.net/go/mautrix"
)
// DictionaryPlugin provides extended dictionary commands using DreamDict.
type DictionaryPlugin struct {
Base
dict *dreamclient.Client
}
func NewDictionaryPlugin(client *mautrix.Client, dict *dreamclient.Client) *DictionaryPlugin {
return &DictionaryPlugin{
Base: NewBase(client),
dict: dict,
}
}
func (p *DictionaryPlugin) Name() string { return "dictionary" }
func (p *DictionaryPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "antonym", Description: "Look up antonyms for a word", Usage: "!antonym <word> [lang]", Category: "Lookup & Reference"},
{Name: "pronounce", Description: "Look up pronunciation of a word", Usage: "!pronounce <word> [lang]", Category: "Lookup & Reference"},
{Name: "etymology", Description: "Look up the etymology of a word", Usage: "!etymology <word> [lang]", Category: "Lookup & Reference"},
{Name: "difficulty", Description: "Show difficulty score for a word", Usage: "!difficulty <word> [lang]", Category: "Lookup & Reference"},
{Name: "rhyme", Description: "Find rhyming words (English)", Usage: "!rhyme <word>", Category: "Lookup & Reference"},
}
}
func (p *DictionaryPlugin) Init() error { return nil }
func (p *DictionaryPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *DictionaryPlugin) OnMessage(ctx MessageContext) error {
var handler func(MessageContext) error
switch {
case p.IsCommand(ctx.Body, "antonym"):
handler = p.handleAntonym
case p.IsCommand(ctx.Body, "pronounce"):
handler = p.handlePronounce
case p.IsCommand(ctx.Body, "etymology"):
handler = p.handleEtymology
case p.IsCommand(ctx.Body, "difficulty"):
handler = p.handleDifficulty
case p.IsCommand(ctx.Body, "rhyme"):
handler = p.handleRhyme
default:
return nil
}
safeGo("dictionary-handler", func() {
if err := handler(ctx); err != nil {
slog.Error("dictionary: handler error", "err", err)
}
})
return nil
}
const dictUnavailable = "Dictionary service unavailable. Try again shortly."
const dictSupportedLangs = "en, fr, pt-PT, zh"
// parseWordLang extracts word and language from command args. Returns empty word if missing.
func parseWordLang(args string) (word, lang string) {
parts := strings.Fields(args)
if len(parts) == 0 {
return "", ""
}
word = strings.ToLower(parts[0])
lang = "en"
if len(parts) >= 2 {
if l := normaliseLangExt(parts[len(parts)-1]); l != "" {
lang = l
if len(parts) > 2 {
word = strings.ToLower(strings.Join(parts[:len(parts)-1], " "))
}
}
}
return word, lang
}
// normaliseLangExt normalises a language tag, including zh.
func normaliseLangExt(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
switch s {
case "en":
return "en"
case "fr":
return "fr"
case "pt-pt", "pt":
return "pt-PT"
case "zh":
return "zh"
default:
return ""
}
}
func (p *DictionaryPlugin) handleAntonym(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "antonym"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!antonym <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
// Check word exists.
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
langName := langDisplayName(lang)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langName))
}
ants, err := p.dict.Antonyms(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if len(ants) == 0 {
langName := langDisplayName(lang)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🔄 No antonyms found for \"**%s**\" in %s.", word, langName))
}
display := ants
more := ""
if len(display) > 6 {
more = fmt.Sprintf(" (+%d more)", len(display)-6)
display = display[:6]
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🔄 Antonyms of **%s** (%s): %s%s", word, lang, strings.Join(display, ", "), more))
}
func (p *DictionaryPlugin) handlePronounce(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "pronounce"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!pronounce <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
langName := langDisplayName(lang)
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langName))
}
prons, err := p.dict.Pronunciations(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if len(prons) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🔊 No pronunciation data found for \"**%s**\" in %s.", word, langDisplayName(lang)))
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🔊 **%s** (%s)\n", word, lang))
for _, pr := range prons {
label := strings.ToUpper(pr.Format)
if lang == "zh" && pr.Format == "ipa" {
label = "Pinyin"
}
sb.WriteString(fmt.Sprintf(" %s: %s\n", label, pr.Value))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *DictionaryPlugin) handleEtymology(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "etymology"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!etymology <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langDisplayName(lang)))
}
etym, err := p.dict.Etymology(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if etym == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📜 No etymology data found for \"**%s**\" in %s.", word, langDisplayName(lang)))
}
// Truncate at 400 chars for standalone command.
if len(etym) > 400 {
// Try to truncate at sentence boundary.
if idx := strings.LastIndex(etym[:400], "."); idx > 200 {
etym = etym[:idx+1] + "..."
} else {
etym = etym[:400] + "..."
}
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📜 Etymology of **%s** (%s)\n %s", word, lang, etym))
}
func (p *DictionaryPlugin) handleDifficulty(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "difficulty"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!difficulty <word> [lang]`\nSupported languages: "+dictSupportedLangs)
}
word, lang := parseWordLang(args)
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
valid, err := p.dict.IsValidWord(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if !valid {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("\"**%s**\" not found in %s.", word, langDisplayName(lang)))
}
diff, err := p.dict.Difficulty(word, lang)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if diff < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📊 No difficulty score available for \"**%s**\" in %s.", word, langDisplayName(lang)))
}
tier := difficultyTier(diff)
// Get frequency for display.
freq, _ := p.dict.Frequency(word, lang)
freqLabel := frequencyLabel(freq)
wordLen := len([]rune(word))
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("📊 **%s** (%s) -- %s (%.2f)\n Frequency: %s | Length: %d",
word, lang, tier, diff, freqLabel, wordLen))
}
func (p *DictionaryPlugin) handleRhyme(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "rhyme"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: `!rhyme <word>`\nNote: rhyme matching is English only.")
}
parts := strings.Fields(args)
word := strings.ToLower(parts[0])
showAll := false
for _, arg := range parts[1:] {
if arg == "--all" {
showAll = true
}
}
if p.dict == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
rhymes, err := p.dict.Rhymes(word, 50)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, dictUnavailable)
}
if len(rhymes) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🎵 No rhymes found for \"**%s**\". (This is not surprising.)", word))
}
display := rhymes
more := ""
if !showAll && len(display) > 7 {
more = fmt.Sprintf(" (+%d more)\n Use `!rhyme %s --all` to see all results", len(display)-7, word)
display = display[:7]
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("🎵 Rhymes with **%s**: %s%s", word, strings.Join(display, ", "), more))
}
// difficultyTier maps a 0.0-1.0 score to a human-readable label.
func difficultyTier(score float64) string {
switch {
case score <= 0.25:
return "Easy"
case score <= 0.50:
return "Medium"
case score <= 0.75:
return "Hard"
default:
return "Brutal"
}
}
// frequencyLabel maps a frequency score to a display label.
func frequencyLabel(freq int) string {
switch {
case freq > 500:
return "high"
case freq >= 100:
return "medium"
case freq > 0:
return "low"
default:
return "unknown"
}
}
// langDisplayName returns a human-readable name for a language code.
func langDisplayName(lang string) string {
switch lang {
case "en":
return "English"
case "fr":
return "French"
case "pt-PT":
return "Portuguese"
case "zh":
return "Chinese"
default:
return lang
}
}
+2 -1
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)
+188 -10
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 = 10.00
baseMsg = 20.00
case words >= 26:
amount = 5.00
baseMsg = 10.00
case words >= 11:
amount = 2.50
baseMsg = 5.00
case words >= 4:
amount = 1.25
baseMsg = 2.50
default:
amount = 0.50
baseMsg = 1.00
}
msgMult := p.advanceCombo(ctx.Sender, "message", comboMsgCap)
p.credit(ctx.Sender, baseMsg*msgMult, "message")
// Link combo — bonus per URL in the message.
if urls := urlPattern.FindAllString(ctx.Body, -1); len(urls) > 0 {
linkMult := p.advanceCombo(ctx.Sender, "link", comboLinkCap)
p.credit(ctx.Sender, comboLinkBase*linkMult*float64(len(urls)), "link_combo")
}
p.ensureBalance(ctx.Sender)
p.credit(ctx.Sender, amount, "message")
// Image combo — bonus for image/video/file attachments.
if mc := ctx.Event.Content.AsMessage(); mc != nil {
if mc.MsgType == event.MsgImage || mc.MsgType == event.MsgVideo || mc.MsgType == event.MsgFile {
imgMult := p.advanceCombo(ctx.Sender, "image", comboImageCap)
p.credit(ctx.Sender, comboImageBase*imgMult, "image_combo")
}
}
}
// advanceCombo increments a user's combo for a category, resets if timed out
// or daily cap reached, and returns the current multiplier.
func (p *EuroPlugin) advanceCombo(userID id.UserID, category string, dailyCap int) float64 {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
today := now.UTC().Format("2006-01-02")
userCombos, ok := p.combos[userID]
if !ok {
userCombos = make(map[string]*comboState)
p.combos[userID] = userCombos
}
cs, ok := userCombos[category]
if !ok {
cs = &comboState{}
userCombos[category] = cs
}
// Reset on new day.
if cs.Today != today {
cs.Step = 0
cs.DayHits = 0
cs.Today = today
}
// Reset if combo timed out.
if !cs.LastAt.IsZero() && now.Sub(cs.LastAt) > comboTimeout {
cs.Step = 0
}
// At daily cap, return base multiplier (no combo bonus).
if cs.DayHits >= dailyCap {
return 1.0
}
cs.DayHits++
cs.Step++
cs.LastAt = now
return 1.0 + float64(cs.Step)*comboMultPerStep
}
// ---------------------------------------------------------------------------
// !combo command
// ---------------------------------------------------------------------------
func (p *EuroPlugin) handleCombo(ctx MessageContext) error {
p.mu.Lock()
userCombos := p.combos[ctx.Sender]
now := time.Now()
today := now.UTC().Format("2006-01-02")
type info struct {
name string
step int
dayHits int
cap int
active bool
}
cats := []info{
{"Messages", 0, 0, comboMsgCap, false},
{"Links", 0, 0, comboLinkCap, false},
{"Images", 0, 0, comboImageCap, false},
}
keys := []string{"message", "link", "image"}
if userCombos != nil {
for i, k := range keys {
if cs, ok := userCombos[k]; ok && cs.Today == today {
cats[i].step = cs.Step
cats[i].dayHits = cs.DayHits
cats[i].active = !cs.LastAt.IsZero() && now.Sub(cs.LastAt) < comboTimeout
}
}
}
p.mu.Unlock()
var sb strings.Builder
sb.WriteString("🔥 **Activity Combo**\n\n")
for _, c := range cats {
mult := 1.0 + float64(c.step)*comboMultPerStep
status := "💤 inactive"
if c.active {
status = fmt.Sprintf("⚡ **%.0fx** multiplier", mult)
} else if c.step > 0 {
status = "⏳ expired (resets on next activity)"
}
sb.WriteString(fmt.Sprintf("**%s**: step %d/%d — %s\n", c.name, c.dayHits, c.cap, status))
}
sb.WriteString(fmt.Sprintf("\nCombo grows +%.0f%% per step. Resets after %d min idle.", comboMultPerStep*100, int(comboTimeout.Minutes())))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ---------------------------------------------------------------------------
@@ -189,6 +359,10 @@ func (p *EuroPlugin) credit(userID id.UserID, amount float64, reason string) {
// Debit subtracts euros atomically. Returns false if this would exceed debt limit.
// Uses a conditional UPDATE to prevent race conditions (check-and-act in one statement).
func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool {
if amount <= 0 {
slog.Warn("euro: Debit called with non-positive amount", "user", userID, "amount", amount)
return false
}
p.ensureBalance(userID)
d := db.Get()
@@ -216,6 +390,10 @@ func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool
// Credit adds euros (exported for other plugins).
func (p *EuroPlugin) Credit(userID id.UserID, amount float64, reason string) {
if amount <= 0 {
slog.Warn("euro: Credit called with non-positive amount", "user", userID, "amount", amount)
return
}
p.ensureBalance(userID)
p.credit(userID, amount, reason)
}
+8 -5
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
+2 -2
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") {
+18 -8
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{
@@ -574,7 +575,7 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
// Pick a word, optionally with a translatable clue
var word, clueWord string
for attempt := 0; attempt < 3; attempt++ {
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
candidate, err := p.dict.RandomWord(lang, "", 4, 12, 0)
if err != nil {
slog.Error("hangman: random word failed", "lang", lang, "err", err)
rollback()
@@ -597,7 +598,7 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
if word == "" {
// Fallback: start without a clue
candidate, err := p.dict.RandomWord(lang, "", 4, 12)
candidate, err := p.dict.RandomWord(lang, "", 4, 12, 0)
if err != nil {
rollback()
return p.SendReply(ctx.RoomID, ctx.EventID, "❌ Dictionary service unavailable. Try again shortly.")
@@ -615,6 +616,14 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
p.games[ctx.RoomID] = game
p.mu.Unlock()
// Fetch difficulty tier from DreamDict.
diffLabel := ""
if p.dict != nil {
if diff, err := p.dict.Difficulty(strings.ToLower(word), lang); err == nil && diff >= 0 {
diffLabel = " (" + difficultyTier(diff) + ")"
}
}
// Build thread root
var sb strings.Builder
if clueLang != "" {
@@ -622,9 +631,9 @@ func (p *HangmanPlugin) startMultilingualGame(ctx MessageContext, lang, clueLang
if clueLangName == "" {
clueLangName = clueLang
}
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s** (clue: %s)\n", langName, clueLangName))
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s** (clue: %s)\n", langName, diffLabel, clueLangName))
} else {
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s**\n", langName))
sb.WriteString(fmt.Sprintf("🐝 **Hangman — %s%s**\n", langName, diffLabel))
}
sb.WriteString(fmt.Sprintf("Word: %s (%d letters)\n", game.displayPhrase(), len([]rune(word))))
@@ -812,10 +821,11 @@ func (p *HangmanPlugin) endGame(roomID id.RoomID, game *hangmanGame) error {
payout = share * p.bonusMul
label = " (early solve bonus!)"
}
p.euro.Credit(uid, payout, "hangman_win")
p.recordHangmanScore(uid, payout)
net, _ := communityTax(uid, payout, 0.05)
p.euro.Credit(uid, net, "hangman_win")
p.recordHangmanScore(uid, net)
name := p.DisplayName(uid)
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(payout), label))
sb.WriteString(fmt.Sprintf(" **%s**: +€%d%s\n", name, int(net), label))
}
}
+313
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)
}
}
}
+85 -42
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 {
+211
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
}
+177
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
}
}
+526
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"},
},
}
@@ -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
+353
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")
}
}
+3 -3
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
}
+91 -36
View File
@@ -17,6 +17,7 @@ import (
"unicode"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -57,6 +58,7 @@ type queueItem struct {
type LLMPassivePlugin struct {
Base
xp *XPPlugin
dict *dreamclient.Client
ollamaHost string
ollamaModel string
sampleRate float64
@@ -71,7 +73,7 @@ type LLMPassivePlugin struct {
}
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin {
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
host := os.Getenv("OLLAMA_HOST")
model := os.Getenv("OLLAMA_MODEL")
enabled := host != "" && model != ""
@@ -86,6 +88,7 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin
p := &LLMPassivePlugin{
Base: NewBase(client),
xp: xp,
dict: dict,
ollamaHost: host,
ollamaModel: model,
sampleRate: sampleRate,
@@ -154,6 +157,18 @@ func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
return nil
}
// Check for fancy words on every message (independent of LLM sampling)
if p.dict != nil {
safeGo("llm-fancy-word", func() {
if p.hasFancyWord(ctx.Body) {
_ = p.SendReact(ctx.RoomID, ctx.EventID, "\U0001f393") // 🎓 graduation cap
db.Exec("llm: track fancy word",
`UPDATE user_stats SET fancy_words = fancy_words + 1 WHERE user_id = ?`,
string(ctx.Sender))
}
})
}
// Pre-filter: only classify messages that match certain criteria
var fmtBody string
if ctx.Event != nil {
@@ -309,7 +324,12 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
mentionHint = "\nMentioned users: " + strings.Join(parts, ", ")
}
result, err := p.callOllama(item.Body + mentionHint)
// Fetch today's WOTD so the LLM can check for correct usage
today := time.Now().UTC().Format("2006-01-02")
var todayWOTD string
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
result, err := p.callOllama(item.Body+mentionHint, todayWOTD)
if err != nil {
return fmt.Errorf("ollama call: %w", err)
}
@@ -424,40 +444,13 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
)
}
// React with emojis based on sentiment
sentimentEmojis := map[string]string{
"positive": "\U0001f44d", // 👍
"negative": "\U0001f44e", // 👎
"excited": "\U0001f525", // 🔥
"sarcastic": "\U0001f928", // 🤨
"frustrated": "\U0001f62e\u200d\U0001f4a8", // 😮‍💨
"curious": "\U0001f9d0", // 🧐
"grateful": "\U0001f49c", // 💜
"humorous": "\U0001f602", // 😂
"supportive": "\U0001f917", // 🤗
}
if emoji, ok := sentimentEmojis[result.Sentiment]; ok && result.Sentiment != "neutral" {
// Only react to strong sentiments (|score| > 0.5)
score := result.SentimentScore
if score > 0.5 || score < -0.5 {
_ = p.SendReact(item.RoomID, item.EventID, emoji)
}
}
if result.Profanity {
switch result.ProfanitySeverity {
case 3:
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f92c") // 🤬
case 2:
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f632") // 😲
default:
_ = p.SendReact(item.RoomID, item.EventID, "\U0001fae3") // 🫣
}
}
// Sentiment/profanity emoji reactions disabled — logging continues above.
// Insult-response reactions (bot reacts when insulted) remain active above.
if result.WOTDUsed {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // open book
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // 📖 open book
}
if result.GratitudeTarget != "" {
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // 💜 purple heart
}
// Grant XP for gratitude
@@ -485,7 +478,12 @@ type ollamaResponse struct {
}
// callOllama sends a classification prompt to Ollama and parses the JSON result.
func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult, error) {
func (p *LLMPassivePlugin) callOllama(messageText, wotd string) (*classificationResult, error) {
wotdInstruction := `"wotd_used": false`
if wotd != "" {
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
}
prompt := fmt.Sprintf(`Classify the following chat message. Respond ONLY with valid JSON (no markdown, no explanation).
JSON schema:
@@ -496,11 +494,11 @@ JSON schema:
"profanity": true | false,
"profanity_severity": 0 | 1 | 2 | 3 (0=none, 1=mild e.g. damn/hell/crap, 2=moderate e.g. shit/ass/bitch, 3=scorching e.g. fuck/cunt and slurs),
"insult_target": "" or "@user:server" if someone is being insulted,
"wotd_used": true | false (if the message uses an unusual/sophisticated word),
%s,
"gratitude_target": "" or "@user:server" if thanking someone
}
Message: %s`, messageText)
Message: %s`, wotdInstruction, messageText)
reqBody := ollamaRequest{
Model: p.ollamaModel,
@@ -540,6 +538,63 @@ Message: %s`, messageText)
}
// parseClassification parses a JSON classification response with repair logic.
// fancyWordMaxFreq is the threshold below which a word is considered rare/sophisticated.
// SCOWL tiers: 10=1000, 20=800, 35=600, 50=400, 60=200, 70=50.
// A frequency of 100 or below corresponds to SCOWL tier 65-70 (rare words).
const fancyWordMaxFreq = 100
// fancyWordMinLength filters out short words that may just be uncommon abbreviations.
const fancyWordMinLength = 6
// fancyWordCache caches frequency lookups to avoid repeated requests.
var fancyWordCache sync.Map // word (string) -> freq (int)
// hasFancyWord checks if any word in the message is a rare/sophisticated English word
// by looking up frequency scores in DreamDict via a single batch request.
func (p *LLMPassivePlugin) hasFancyWord(message string) bool {
// Extract candidate words
var candidates []string
var cached []string
for _, w := range strings.Fields(strings.ToLower(message)) {
w = strings.Trim(w, ".,!?;:\"'()[]{}…—–-")
if len(w) < fancyWordMinLength {
continue
}
if strings.Contains(w, "://") || strings.HasPrefix(w, "@") {
continue
}
// Check cache first
if freq, ok := fancyWordCache.Load(w); ok {
if f := freq.(int); f > 0 && f <= fancyWordMaxFreq {
return true
}
cached = append(cached, w)
continue
}
candidates = append(candidates, w)
}
if len(candidates) == 0 {
return false
}
// Single batch request for all uncached words
freqs, err := p.dict.FrequencyBatch(candidates, "en")
if err != nil {
slog.Debug("hasFancyWord: batch lookup failed", "err", err)
return false
}
for _, w := range candidates {
freq := freqs[w] // 0 if not in result (unknown word)
fancyWordCache.Store(w, freq)
if freq > 0 && freq <= fancyWordMaxFreq {
return true
}
}
return false
}
func parseClassification(raw string) (*classificationResult, error) {
cleaned := raw
+156 -52
View File
@@ -39,7 +39,7 @@ func (p *LookupPlugin) Name() string { return "lookup" }
func (p *LookupPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
{Name: "define", Description: "Look up a word definition", Usage: "!define <word>", Category: "Lookup & Reference"},
{Name: "define", Description: "Look up a word definition", Usage: "!define <word> [lang]", Category: "Lookup & Reference"},
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
{Name: "translate", Description: "Look up word translations (en/fr/pt-PT)", Usage: "!translate <word> [lang]", Category: "Lookup & Reference"},
}
@@ -63,11 +63,11 @@ func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
default:
return nil
}
go func() {
safeGo("lookup-handler", func() {
if err := handler(ctx); err != nil {
slog.Error("lookup: handler error", "err", err)
}
}()
})
return nil
}
@@ -129,68 +129,172 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
}
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if word == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
args := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word> [lang]")
}
// Check 24h cache
cacheKey := "define:" + strings.ToLower(word)
parts := strings.Fields(args)
word := parts[0]
var filterLang string
if len(parts) >= 2 {
filterLang = normaliseLangExt(parts[len(parts)-1])
if filterLang != "" && len(parts) > 2 {
word = strings.Join(parts[:len(parts)-1], " ")
} else if filterLang == "" {
// Last token isn't a lang — treat the whole thing as the word.
word = args
}
}
wordLower := strings.ToLower(word)
// Check 24h cache.
cacheKey := "define:" + wordLower
if filterLang != "" {
cacheKey += ":" + filterLang
}
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
resp, err := httpClient.Get(apiURL)
if err != nil {
slog.Error("lookup: define request", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
}
var entries []struct {
Word string `json:"word"`
Meanings []struct {
PartOfSpeech string `json:"partOfSpeech"`
Definitions []struct {
Definition string `json:"definition"`
Example string `json:"example"`
} `json:"definitions"`
} `json:"meanings"`
}
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
entry := entries[0]
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
foundDreamDict := false
for i, meaning := range entry.Meanings {
if i >= 3 { // Limit to 3 meanings
break
// Try DreamDict across languages.
if p.dict != nil {
langs := []string{"en", "fr", "pt-PT", "zh"}
if filterLang != "" {
langs = []string{filterLang}
}
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
for j, def := range meaning.Definitions {
if j >= 2 { // Limit to 2 definitions per meaning
// Fire antonym fetch concurrently (for whichever lang the word is found in).
type antResult struct {
ants []string
}
antCh := make(chan antResult, 1)
antStarted := false
for _, lang := range langs {
defs, err := p.dict.Define(wordLower, lang)
if err != nil {
slog.Error("lookup: dreamdict define", "lang", lang, "err", err)
continue
}
if len(defs) == 0 {
continue
}
if !foundDreamDict {
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n", word))
foundDreamDict = true
}
langName := langDisplayName(lang)
sb.WriteString(fmt.Sprintf("\n🏷️ %s\n", langName))
count := 0
for _, def := range defs {
if count >= 4 {
break
}
if def.POS != "" {
sb.WriteString(fmt.Sprintf(" (%s) %s\n", def.POS, def.Gloss))
} else {
sb.WriteString(fmt.Sprintf(" %s\n", def.Gloss))
}
count++
}
// Start antonym fetch for the first language we find a match in.
if !antStarted {
antStarted = true
antLang := lang
safeGo("lookup-antonyms", func() {
ants, err := p.dict.Antonyms(wordLower, antLang)
if err != nil {
antCh <- antResult{}
return
}
antCh <- antResult{ants: ants}
})
}
}
// Collect antonyms (500ms timeout).
if antStarted {
timer := time.NewTimer(500 * time.Millisecond)
select {
case r := <-antCh:
timer.Stop()
if len(r.ants) > 0 {
display := r.ants
if len(display) > 3 {
display = display[:3]
}
sb.WriteString(fmt.Sprintf("\nAntonyms: %s\n", strings.Join(display, ", ")))
}
case <-timer.C:
}
}
}
// Fall back to free dictionary API for English if DreamDict had no results.
if !foundDreamDict && (filterLang == "" || filterLang == "en") {
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
resp, err := httpClient.Get(apiURL)
if err != nil {
slog.Error("lookup: define request", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
}
var entries []struct {
Word string `json:"word"`
Meanings []struct {
PartOfSpeech string `json:"partOfSpeech"`
Definitions []struct {
Definition string `json:"definition"`
Example string `json:"example"`
} `json:"definitions"`
} `json:"meanings"`
}
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
}
entry := entries[0]
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
for i, meaning := range entry.Meanings {
if i >= 3 {
break
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
if def.Example != "" {
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
for j, def := range meaning.Definitions {
if j >= 2 {
break
}
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
if def.Example != "" {
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
}
}
}
} else if !foundDreamDict {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("No definition found for \"%s\" in %s.", word, langDisplayName(filterLang)))
}
msg := sb.String()
+300
View File
@@ -0,0 +1,300 @@
package plugin
import (
"fmt"
"math/rand/v2"
"sort"
"strconv"
"strings"
"time"
"maunium.net/go/mautrix"
)
// ── Plugin ──────────────────────────────────────────────────────────────────
type LotteryPlugin struct {
Base
euro *EuroPlugin
}
func NewLotteryPlugin(client *mautrix.Client, euro *EuroPlugin) *LotteryPlugin {
return &LotteryPlugin{
Base: NewBase(client),
euro: euro,
}
}
func (p *LotteryPlugin) Name() string { return "lottery" }
func (p *LotteryPlugin) Version() string { return "1.1.0" }
func (p *LotteryPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "lottery", Description: "Community lottery — buy tickets, win big", Usage: "!lottery buy [N]", Category: "Games"},
}
}
func (p *LotteryPlugin) Init() error {
go p.drawTicker()
go p.reminderTicker()
return nil
}
func (p *LotteryPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *LotteryPlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "lottery") {
return nil
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "lottery"))
lower := strings.ToLower(args)
switch {
case lower == "" || lower == "help":
return p.handleLotteryHelp(ctx)
case lower == "buy" || strings.HasPrefix(lower, "buy "):
return p.handleLotteryBuy(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
case lower == "tickets":
return p.handleLotteryTickets(ctx)
case lower == "pot":
return p.handleLotteryPot(ctx)
case lower == "odds":
return p.handleLotteryOdds(ctx)
case lower == "history":
return p.handleLotteryHistory(ctx)
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown lottery command. Try `!lottery help`.")
}
// ── Commands ────────────────────────────────────────────────────────────────
func (p *LotteryPlugin) handleLotteryHelp(ctx MessageContext) error {
text := `🎟 **Community Lottery**
` + "`!lottery buy [N]`" + ` Purchase N tickets (default 1, max 100/week). 1 each.
` + "`!lottery tickets`" + ` View your tickets for this week's draw
` + "`!lottery pot`" + ` Current pot balance and draw countdown
` + "`!lottery odds`" + ` Prize tier table and odds
` + "`!lottery history`" + ` Last 5 draw results
Draw: Every Friday at 23:59 UTC`
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *LotteryPlugin) handleLotteryBuy(ctx MessageContext, args string) error {
n := 1
if args != "" {
parsed, err := strconv.Atoi(args)
if err != nil || parsed < 1 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!lottery buy [N]` where N is 1-100.")
}
n = parsed
}
weekStart := lotteryCurrentWeekStart()
existing := lotteryTicketCount(ctx.Sender, weekStart)
remaining := 100 - existing
if remaining <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the 100 ticket limit for this week's draw. Come back Friday.")
}
adjusted := false
if n > remaining {
n = remaining
adjusted = true
}
cost := float64(n)
if !p.euro.Debit(ctx.Sender, cost, "lottery_tickets") {
balance := p.euro.GetBalance(ctx.Sender)
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tickets cost €1 each. You need €%d but have €%.0f.", n, balance))
}
// Generate tickets.
tickets := make([][]int, n)
for i := range tickets {
tickets[i] = generateLotteryNumbers()
}
if err := lotteryInsertTickets(ctx.Sender, weekStart, tickets); err != nil {
// Refund — tickets didn't persist.
p.euro.Credit(ctx.Sender, cost, "lottery_refund")
return p.SendReply(ctx.RoomID, ctx.EventID, "Something went wrong buying tickets. You've been refunded.")
}
// Build confirmation.
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🎟️ **%d ticket(s) purchased** — €%d\n\n", n, n))
displayLimit := n
if displayLimit > 10 {
displayLimit = 10
}
for i := 0; i < displayLimit; i++ {
sb.WriteString(fmt.Sprintf(" #%d — %s\n", existing+i+1, formatLotteryNumbers(tickets[i])))
}
if n > 10 {
sb.WriteString(fmt.Sprintf(" ... and %d more\n", n-10))
}
if adjusted {
sb.WriteString(fmt.Sprintf("\nAdjusted to %d (weekly cap reached).", n))
}
sb.WriteString(fmt.Sprintf("\nTotal tickets this week: %d/100", existing+n))
sb.WriteString(fmt.Sprintf("\nDraw: Friday 23:59 UTC (%s)", lotteryDrawCountdown()))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *LotteryPlugin) handleLotteryTickets(ctx MessageContext) error {
weekStart := lotteryCurrentWeekStart()
tickets, err := lotteryLoadUserTickets(ctx.Sender, weekStart)
if err != nil || len(tickets) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "🎟️ You have no tickets for this week's draw. `!lottery buy` to get started.")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🎟️ **Your tickets this week** (%d):\n\n", len(tickets)))
displayLimit := len(tickets)
if displayLimit > 20 {
displayLimit = 20
}
for i := 0; i < displayLimit; i++ {
sb.WriteString(fmt.Sprintf(" #%d — %s\n", i+1, formatLotteryNumbers(tickets[i].Numbers)))
}
if len(tickets) > 20 {
sb.WriteString(fmt.Sprintf(" ... and %d more\n", len(tickets)-20))
}
pot := communityPotBalance()
sb.WriteString(fmt.Sprintf("\nDraw: Friday 23:59 UTC (%s) | Pot: €%d", lotteryDrawCountdown(), pot))
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *LotteryPlugin) handleLotteryPot(ctx MessageContext) error {
weekStart := lotteryCurrentWeekStart()
pot := communityPotBalance()
totalTickets := lotteryTotalTicketCount(weekStart)
countdown := lotteryDrawCountdown()
text := fmt.Sprintf("🎟️ **Lottery Pot**\n\n"+
"Current pot: **€%d**\n"+
"Tickets sold this week: %d\n"+
"Next draw: Friday 23:59 UTC (%s)\n\n"+
"Pot is funded by rival duel shares and ticket sales.",
pot, totalTickets, countdown)
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error {
text := `🎟 **Lottery Prize Tiers**
| Match | Prize | Odds (approx.) |
|-------|-------|----------------|
| 5 of 5 | Jackpot (split among winners) | 1 in 142,506 |
| 4 of 5 | 5,000 (fixed) | 1 in 3,062 |
| 3 of 5 | 500 (fixed) | 1 in 141 |
| 2 of 5 | 25 (fixed) | 1 in 16 |
Tickets: 1 each. Max 100 per week. 5 numbers from 130.
Minimum 500 pot required for jackpot payout.`
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error {
history, err := lotteryLoadHistory(5)
if err != nil || len(history) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "🎟️ No draw history yet.")
}
var sb strings.Builder
sb.WriteString("🎟️ **Recent Draws**\n\n")
for _, h := range history {
sb.WriteString(fmt.Sprintf("**%s** — %s\n", h.DrawDate, formatLotteryNumbers(h.WinningNumbers)))
sb.WriteString(fmt.Sprintf(" Pot: €%d", h.PotTotal))
if h.JackpotWinners > 0 {
sb.WriteString(fmt.Sprintf(" | Jackpot: %d winner(s), €%d each", h.JackpotWinners, h.JackpotAmount))
}
if h.RolledOver > 0 {
sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver))
}
sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d\n\n",
h.Match4Winners, h.Match3Winners, h.Match2Winners))
}
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
// ── Number Generation ───────────────────────────────────────────────────────
func generateLotteryNumbers() []int {
// Partial Fisher-Yates on [1..30], take first 5, sort.
pool := make([]int, 30)
for i := range pool {
pool[i] = i + 1
}
for i := 0; i < 5; i++ {
j := i + rand.IntN(30-i)
pool[i], pool[j] = pool[j], pool[i]
}
nums := make([]int, 5)
copy(nums, pool[:5])
sort.Ints(nums)
return nums
}
func countMatches(ticket, winning []int) int {
winSet := make(map[int]bool, len(winning))
for _, n := range winning {
winSet[n] = true
}
count := 0
for _, n := range ticket {
if winSet[n] {
count++
}
}
return count
}
// ── Formatting Helpers ──────────────────────────────────────────────────────
func formatLotteryNumbers(nums []int) string {
parts := make([]string, len(nums))
for i, n := range nums {
parts[i] = strconv.Itoa(n)
}
return strings.Join(parts, " \u00b7 ")
}
func lotteryDrawCountdown() string {
now := time.Now().UTC()
// Find next Friday 23:59.
daysUntilFriday := (int(time.Friday) - int(now.Weekday()) + 7) % 7
if daysUntilFriday == 0 && (now.Hour() > 23 || (now.Hour() == 23 && now.Minute() >= 59)) {
daysUntilFriday = 7
}
nextDraw := time.Date(now.Year(), now.Month(), now.Day()+daysUntilFriday, 23, 59, 0, 0, time.UTC)
d := nextDraw.Sub(now)
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
mins := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
}
if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, mins)
}
return fmt.Sprintf("%dm", mins)
}

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