mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
356 Commits
feature/un
...
long-exped
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8122973b74 | ||
|
|
cbfca525f5 | ||
|
|
f4a39b46e9 | ||
|
|
6a47be34bc | ||
|
|
667f87f9d0 | ||
|
|
1b8d13e0dd | ||
|
|
d80b437525 | ||
|
|
4934383a9a | ||
|
|
a46b773750 | ||
|
|
b80de43db1 | ||
|
|
81dda51907 | ||
|
|
d7ced471a1 | ||
|
|
63ad423b79 | ||
|
|
da94d51857 | ||
|
|
151d6abc01 | ||
|
|
631764bbbd | ||
|
|
ad2a8258ba | ||
|
|
2fdb280477 | ||
|
|
4576c75722 | ||
|
|
3b29d10461 | ||
|
|
29cad7972a | ||
|
|
a2992ea06c | ||
|
|
6c4b14e113 | ||
|
|
9be85ba954 | ||
|
|
26cda148fb | ||
|
|
040cfba514 | ||
|
|
aaec0ba225 | ||
|
|
5a6e395805 | ||
|
|
68ed8e7c60 | ||
|
|
c729433353 | ||
|
|
7115c536ef | ||
|
|
42fb805ee0 | ||
|
|
d99f975074 | ||
|
|
bbc25fe958 | ||
|
|
89edc76829 | ||
|
|
4999368031 | ||
|
|
92b99a0399 | ||
|
|
0f72484653 | ||
|
|
2e6274c1b7 | ||
|
|
9eed921e4b | ||
|
|
2ce56cf76a | ||
|
|
7dbfa0b56f | ||
|
|
b167882e3e | ||
|
|
95e0995c7f | ||
|
|
56f896b941 | ||
|
|
ef8fbe5496 | ||
|
|
bcd4a873a5 | ||
|
|
01d2329993 | ||
|
|
e4518c9c39 | ||
|
|
20b0d027b2 | ||
|
|
28a90292f0 | ||
|
|
5d7c76fb20 | ||
|
|
100a4f1054 | ||
|
|
f6a457ae84 | ||
|
|
513cf32e42 | ||
|
|
978dc5e25f | ||
|
|
2ea3e42612 | ||
|
|
3ed2e1d8e0 | ||
|
|
72f4ef3b27 | ||
|
|
6cda1cac38 | ||
|
|
81f1f8d0b4 | ||
|
|
1f6c05a565 | ||
|
|
a725a22517 | ||
|
|
f9ebb116c5 | ||
|
|
b76d9bc577 | ||
|
|
d225fc8185 | ||
|
|
e05da913c4 | ||
|
|
68b2122e13 | ||
|
|
dd25de71f0 | ||
|
|
f2c2d774d4 | ||
|
|
519964fb01 | ||
|
|
5eb3cac992 | ||
|
|
f3e7a33435 | ||
|
|
a8f8be6f40 | ||
|
|
ebea2b430a | ||
|
|
1cd53eb890 | ||
|
|
736494d945 | ||
|
|
20689a693b | ||
|
|
3b5a6ad4b8 | ||
|
|
b58f154b4d | ||
|
|
dcc039b7d5 | ||
|
|
98ba416359 | ||
|
|
41adfce9f1 | ||
|
|
f424300d87 | ||
|
|
ba84d38979 | ||
|
|
25ea23f9d9 | ||
|
|
5ef10e35dc | ||
|
|
d0a8505c76 | ||
|
|
cf092742bb | ||
|
|
ad27077329 | ||
|
|
765c738c99 | ||
|
|
dcd9e4158f | ||
|
|
2d44c990f3 | ||
|
|
235122f2a1 | ||
|
|
9a4a93e72e | ||
|
|
8cdd64b383 | ||
|
|
159daf8fc8 | ||
|
|
04aa887d18 | ||
|
|
0f09a421bc | ||
|
|
881cbfca2f | ||
|
|
53d1608490 | ||
|
|
9aca6b202d | ||
|
|
2907a8ac1d | ||
|
|
ce2f59c332 | ||
|
|
b4b1e0ee29 | ||
|
|
9e70f9db79 | ||
|
|
20647eeafc | ||
|
|
f198041530 | ||
|
|
2ef0b25266 | ||
|
|
368b980650 | ||
|
|
632e5ee315 | ||
|
|
f404f95202 | ||
|
|
14f9b3159f | ||
|
|
c2fdc63b51 | ||
|
|
8ee170bb9b | ||
|
|
6386161402 | ||
|
|
1512f6cc50 | ||
|
|
c48e12a296 | ||
|
|
25accab5c0 | ||
|
|
d7fe32d893 | ||
|
|
76f814c0c9 | ||
|
|
ddfa89e7a7 | ||
|
|
4dd1ab9f96 | ||
|
|
0799b6a57b | ||
|
|
9d6192dc6a | ||
|
|
9f762787f6 | ||
|
|
0d666beea3 | ||
|
|
297ce3d786 | ||
|
|
15cfe065a3 | ||
|
|
908e2b0855 | ||
|
|
53be17e6fe | ||
|
|
d6ea08bba6 | ||
|
|
6ef8b9fd0a | ||
|
|
e6e0009253 | ||
|
|
0cd8fd3337 | ||
|
|
e629f8fd4d | ||
|
|
f1aa9981f8 | ||
|
|
c5a2634657 | ||
|
|
c84682abf7 | ||
|
|
146924818d | ||
|
|
fd28f31de5 | ||
|
|
96b291d831 | ||
|
|
befb44ef03 | ||
|
|
5cd343af0c | ||
|
|
a0961fee8a | ||
|
|
886eb5a75b | ||
|
|
269d13ffe8 | ||
|
|
b0987eb229 | ||
|
|
1e1fbf9056 | ||
|
|
6141964c58 | ||
|
|
0ca17628d4 | ||
|
|
4af110cc20 | ||
|
|
4e412219f3 | ||
|
|
2b86f3df16 | ||
|
|
9ce82f7c67 | ||
|
|
c5217e9ecf | ||
|
|
de02907e69 | ||
|
|
3cf83e370e | ||
|
|
34461030dc | ||
|
|
86c6736a8b | ||
|
|
97e4a502c9 | ||
|
|
b8e8f994d8 | ||
|
|
374d5ea374 | ||
|
|
8e8d3783f9 | ||
|
|
2454f5f865 | ||
|
|
7ec78f3cd9 | ||
|
|
a5c0a83e2a | ||
|
|
103cf30987 | ||
|
|
304ad27c0f | ||
|
|
122a3d7d5d | ||
|
|
3a8f3c26d6 | ||
|
|
dcef2ba734 | ||
|
|
dd97a00332 | ||
|
|
737702ea20 | ||
|
|
675d70e85b | ||
|
|
ea59b4e61c | ||
|
|
9312ef5275 | ||
|
|
b3d3db9142 | ||
|
|
90ff7666bd | ||
|
|
893d3dacad | ||
|
|
2d249d7d0a | ||
|
|
a413c92844 | ||
|
|
57b6e009ec | ||
|
|
35fa2b8323 | ||
|
|
c8849fd819 | ||
|
|
9d6e8f6bf2 | ||
|
|
5d98e5684a | ||
|
|
c0f03a41b5 | ||
|
|
36e14c6084 | ||
|
|
1c7939c3c8 | ||
|
|
0d2e554e22 | ||
|
|
df886ab86f | ||
|
|
45586eb697 | ||
|
|
8fc8941cc9 | ||
|
|
c83b73b655 | ||
|
|
d5b4834d44 | ||
|
|
79e5d19d23 | ||
|
|
cf072f8a8f | ||
|
|
81cce0169f | ||
|
|
0004a2e35c | ||
|
|
c719b30fd7 | ||
|
|
d638f62fd4 | ||
|
|
690817f2ed | ||
|
|
283f7adf5f | ||
|
|
a4b4a74ab4 | ||
|
|
730f16580a | ||
|
|
cc6fb7dd39 | ||
|
|
5000c3d696 | ||
|
|
d300008812 | ||
|
|
3b4dfa44d3 | ||
|
|
85a80e5c40 | ||
|
|
a4336a8869 | ||
|
|
eeb96c4b07 | ||
|
|
aea307e418 | ||
|
|
243010ade9 | ||
|
|
8afd9494fe | ||
|
|
341b1fc8e5 | ||
|
|
693cd9c221 | ||
|
|
6862a94c8f | ||
|
|
315909e6fe | ||
|
|
3fe2d2f0ca | ||
|
|
b0738e8e0a | ||
|
|
62eed7a064 | ||
|
|
dfa7beeb96 | ||
|
|
83a71173b1 | ||
|
|
1953eec3b5 | ||
|
|
45863bd5f3 | ||
|
|
8a1d9a16ce | ||
|
|
c3083637ac | ||
|
|
c170adaf05 | ||
|
|
83cdf07374 | ||
|
|
cb51c56f00 | ||
|
|
cbb2670bb8 | ||
|
|
157455a3fe | ||
|
|
d179ca27a7 | ||
|
|
a199efd773 | ||
|
|
e392cf7d8f | ||
|
|
c2bed4282d | ||
|
|
9484dae146 | ||
|
|
10d398fd9a | ||
|
|
ff7d55f33b | ||
|
|
3a000ada8c | ||
|
|
044baddcce | ||
|
|
9d4085f4c3 | ||
|
|
41c60617db | ||
|
|
9cb66d46ac | ||
|
|
f844068660 | ||
|
|
5b8ef740f8 | ||
|
|
9b48dda79e | ||
|
|
b520b0ce2d | ||
|
|
0a3417a341 | ||
|
|
7528d5c239 | ||
|
|
1042e2ab71 | ||
|
|
2413f892e5 | ||
|
|
9608ce554a | ||
|
|
c5defc9b34 | ||
|
|
654bd07b9b | ||
|
|
4638cb83a1 | ||
|
|
7bb922ae62 | ||
|
|
71594a0d89 | ||
|
|
ebce89b6c6 | ||
|
|
618f6dc34f | ||
|
|
807864ac8d | ||
|
|
eea3126412 | ||
|
|
8eaada5c1b | ||
|
|
61b7fff9e2 | ||
|
|
df3110e2d7 | ||
|
|
4ef152fbe6 | ||
|
|
9e43b0b4b1 | ||
|
|
4a6ddf45ae | ||
|
|
98a9c930fa | ||
|
|
d2a1b79ee3 | ||
|
|
9dc0e37340 | ||
|
|
a1738e4a22 | ||
|
|
ee3b2977aa | ||
|
|
ba2a2b5e90 | ||
|
|
feccd61614 | ||
|
|
83654eadc7 | ||
|
|
fcd067b3c4 | ||
|
|
a9ee7d0f70 | ||
|
|
64c70dda27 | ||
|
|
97c2c37396 | ||
|
|
b56e53300e | ||
|
|
a733d52166 | ||
|
|
596e17dc73 | ||
|
|
4a5159ee14 | ||
|
|
c8fac7ffd9 | ||
|
|
437460c9b2 | ||
|
|
1ee4276bcd | ||
|
|
b53e516bf0 | ||
|
|
01c70f5297 | ||
|
|
9e1a1f606c | ||
|
|
8e0fe0230c | ||
|
|
a00987e75c | ||
|
|
4a4ef95d65 | ||
|
|
c66c32e698 | ||
|
|
d9ac998458 | ||
|
|
0bebcb56cd | ||
|
|
8e3b8377c0 | ||
|
|
671773abd5 | ||
|
|
5fadfb255f | ||
|
|
e22d4f8362 | ||
|
|
4826b5e466 | ||
|
|
7878ce0999 | ||
|
|
6eadb09c1e | ||
|
|
d93dc4faa4 | ||
|
|
07ca5288c3 | ||
|
|
48e5000745 | ||
|
|
c060e13b41 | ||
|
|
b7077eeea1 | ||
|
|
9f094549b7 | ||
|
|
4bc541ca85 | ||
|
|
5e4de7b4c3 | ||
|
|
90edb27147 | ||
|
|
ecca66da58 | ||
|
|
9d949dc649 | ||
|
|
a1ede53fa9 | ||
|
|
1ca9e77fa0 | ||
|
|
f3d1e65bf1 | ||
|
|
91af4f1e83 | ||
|
|
73f3362400 | ||
|
|
9639a86268 | ||
|
|
e8a3b8b35d | ||
|
|
8ad31a0009 | ||
|
|
16d64323d9 | ||
|
|
be76973fd2 | ||
|
|
d77c2ebbbb | ||
|
|
148b8d20f2 | ||
|
|
202056e802 | ||
|
|
b15c13cde7 | ||
|
|
42e6e23900 | ||
|
|
7c450aaefb | ||
|
|
08e9925d8f | ||
|
|
76110f61ca | ||
|
|
90865d1c51 | ||
|
|
2ef7a29f02 | ||
|
|
1b825498bd | ||
|
|
e459b6e78d | ||
|
|
68b2f8b7a5 | ||
|
|
a44a9d9234 | ||
|
|
7e4fbe5ec8 | ||
|
|
f57dcd93a5 | ||
|
|
26d2846481 | ||
|
|
ec4574f469 | ||
|
|
05cf6657f5 | ||
|
|
6c6d74fb1b | ||
|
|
ad6e652755 | ||
|
|
2d99af5310 | ||
|
|
78cbd895f4 | ||
|
|
a0573e67a5 | ||
|
|
b4d5080f48 | ||
|
|
f61bf89c71 | ||
|
|
3d6bc1a066 | ||
|
|
306b4c2ae8 | ||
|
|
2c6f4e48c9 | ||
|
|
06153880e0 |
@@ -7,6 +7,11 @@ BOT_DISPLAY_NAME=GogoBee
|
||||
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
|
||||
BROADCAST_ROOMS=!roomid:example.com
|
||||
|
||||
# The daily 08:00 WOTD auto-post is disabled by default.
|
||||
# Set to true to enable it. The !wotd command and passive WOTD
|
||||
# usage tracking work regardless of this setting.
|
||||
ENABLE_WOTD_POST=false
|
||||
|
||||
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
|
||||
ADMIN_USERS=@yourmxid:example.com
|
||||
|
||||
|
||||
6
.github/dependabot.yml
vendored
Normal file
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,5 +1,11 @@
|
||||
data/
|
||||
data/*
|
||||
!data/open5e/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
gogobee
|
||||
gensolver
|
||||
holdem-train
|
||||
/open5e-import
|
||||
/expedition-sim
|
||||
sim_results/
|
||||
|
||||
162
CHANGELOG-2026-04-08.txt
Normal file
162
CHANGELOG-2026-04-08.txt
Normal file
@@ -0,0 +1,162 @@
|
||||
GOGOBEE CHANGELOG — 2026-04-08
|
||||
================================
|
||||
|
||||
6 commits, 76 files changed, ~3,000 lines added
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
1. ADVENTURE 2.5 — STEPS 1-2: ACTION ECONOMY & CHAT LEVEL LOOKUP
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
- Split daily actions into combat (1) and harvest (3) instead of 3 generic.
|
||||
Players can now do 1 combat + 3 harvest per day instead of choosing 3 from
|
||||
either pool.
|
||||
- Added cross-plugin chat level lookup so adventure perks can read XP levels
|
||||
from the main XP plugin.
|
||||
- Updated character sheet to show combat/harvest action counts separately.
|
||||
- Refactored adventure command dispatch for the new action types.
|
||||
|
||||
Files: adventure.go, adventure_character.go, adventure_render.go,
|
||||
adventure_babysit.go, adventure_events.go, adventure_scheduler.go,
|
||||
adventure_twinbee.go, xp.go, db.go, main.go
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
2. ADVENTURE 2.5 — STEPS 3-6: CHAT LEVEL PERKS
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
- Chat Level XP Bonus: +5% adventure XP per 10 chat levels, capped at +25%.
|
||||
- Chat Level Rare Drop Bonus: +0.5% rare drop rate per 10 chat levels,
|
||||
capped at +2.5%.
|
||||
- Shop Greeting: Luigi's greeting changes at chat levels 10, 30, and 50.
|
||||
- Death Pardon: Chat level 30+ grants one free death pardon per week.
|
||||
Pardoned players keep their streak and skip the death timer.
|
||||
|
||||
Files: adventure_chat_perks.go (new), adventure.go, adventure_arena.go,
|
||||
adventure_character.go, adventure_masterwork.go, adventure_shop.go,
|
||||
adventure_treasure.go, db.go
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
3. ADVENTURE 2.5 — STEPS 7-9: ARENA STREAK SYSTEM
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
- Replaced per-tier arena entry with a continuous streak starting at Tier 1.
|
||||
- Players fight through T1-T5 sequentially. After each tier, a 30-second
|
||||
countdown starts with real-time message edits. Type !bail to cash out,
|
||||
or auto-advance to the next tier.
|
||||
- Per-tier euro multipliers: T1=1.0x, T2=1.5x, T3=2.0x, T4=2.75x, T5=4.0x.
|
||||
- XP multiplier at payout based on tiers cleared: 1.0x/1.2x/1.5x/1.85x/2.5x.
|
||||
- Death forfeits ALL accumulated rewards (euros + XP).
|
||||
- Gladiator's Helm: streak-exclusive Masterwork-tier drop (8% at T4, 18% T5).
|
||||
- Removed old commands: !arena tier N, !arena descend, !arena cashout.
|
||||
- Added !bail command and countdown goroutine with message editing.
|
||||
- Restart recovery: stale active/awaiting runs auto-bail on bot restart.
|
||||
|
||||
Files: adventure_arena.go (major rewrite), adventure_arena_render.go,
|
||||
adventure.go, adventure_arena_test.go, db.go
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
4. BUG FIXES & POLISH
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
- Fixed streak grace period calculation in adventure scheduler.
|
||||
- Fixed UNO earnings tracking not crediting properly.
|
||||
- Fixed hospital UX: cleaner admission flow, insurance billing display.
|
||||
- Added wordle/lottery atomicity: ticket purchases and wordle completions
|
||||
now use proper transactions.
|
||||
- Added !adv alias for !adventure.
|
||||
- Hospital insurance billing now shows itemized breakdown.
|
||||
- Fixed character sheet showing wrong holiday action count.
|
||||
- Removed dead holiday prompt code.
|
||||
- Capped babysit counter to prevent unbounded growth.
|
||||
|
||||
Files: adventure_character.go, adventure_hospital.go, adventure_scheduler.go,
|
||||
adventure_babysit.go, adventure_render.go, lottery.go, lottery_db.go,
|
||||
lottery_draw.go, wordle.go, stats.go, urls.go, llm_passive.go
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
5. FULL CODEBASE AUDIT — 21 FIXES
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
CRITICAL:
|
||||
- Credit()/Debit() now reject non-positive amounts. Previously, a negative
|
||||
amount could mint or drain euros with no validation.
|
||||
- Lottery ticket purchase moved count check inside DB transaction, closing
|
||||
a TOCTOU race where concurrent buys could exceed the 100-ticket cap.
|
||||
|
||||
HIGH:
|
||||
- Arena bail channel ownership reworked. The countdown goroutine and bail
|
||||
handler now use LoadAndDelete to claim exclusive ownership of the channel,
|
||||
preventing double-close panics.
|
||||
- Added safeGo() panic recovery wrapper. Applied to 17 goroutine launch
|
||||
sites across 14 plugin files. A panic in any async handler previously
|
||||
killed the entire daemon.
|
||||
- Entry.ID validated before exec.Command in fetch-esteemed (path traversal).
|
||||
- Silently ignored DB errors in lottery_db.go now logged.
|
||||
|
||||
MEDIUM:
|
||||
- Added database indexes: idx_arena_runs_user(user_id, status) and
|
||||
idx_euro_bal_user(user_id) for high-traffic query paths.
|
||||
- db.Close() function added; called on SIGINT/SIGTERM shutdown.
|
||||
- Miniflux mutex pattern fixed: snapshot pollingDisabled under lock,
|
||||
check after unlock (was Lock/early-return/Unlock without defer).
|
||||
- JSON unmarshal errors in lottery history/tickets now logged instead
|
||||
of silently discarded.
|
||||
- Internal error details no longer leaked to users in forex and esteemed
|
||||
responses (was showing raw %v error strings).
|
||||
|
||||
LOW:
|
||||
- "Failed to load character" error now suggests !adventure.
|
||||
- Empty !buy args shows usage hint instead of "No item matching ''".
|
||||
|
||||
FALSE POSITIVES VERIFIED:
|
||||
- fun.go array access (bounds checks exist at L441, L591).
|
||||
- adventure_twinbee.go filtered[0] (empty check at L90).
|
||||
- wotd.go defs[0] (empty check at L102).
|
||||
- moderation.go timer overwrite (existing.Stop() called at L799).
|
||||
- All rate-limit callers already provide user feedback.
|
||||
|
||||
Files: euro.go, lottery_db.go, adventure_arena.go, plugin.go, db.go,
|
||||
main.go, forex.go, esteemed.go, miniflux.go, adventure.go,
|
||||
fetch-esteemed/main.go, + 14 plugin files (safeGo conversions)
|
||||
|
||||
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
6. TEST COVERAGE — 328 TESTS ACROSS 4 PACKAGES
|
||||
────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
NEW TEST FILES:
|
||||
- internal/util/parser_test.go (30 tests)
|
||||
XPForLevel, LevelFromXP, round-trip for 0-100, ProgressBar edge cases,
|
||||
IsCommand, GetArgs, HasNonASCII, ParseMessage
|
||||
|
||||
- internal/crypto/crypto_test.go (12 tests)
|
||||
Encrypt/Decrypt round-trip, empty plaintext, different nonces,
|
||||
wrong key rejection, HMAC determinism/length, ParseKey validation
|
||||
|
||||
- internal/plugin/helpers_test.go (30 tests)
|
||||
formatNumber, formatNumberInt64, normalizeExpression (natural language,
|
||||
prefix stripping, commas, percent-of), formatCalcResult, countMatches,
|
||||
formatLotteryNumbers, generateLotteryNumbers (count, range, sorted,
|
||||
unique), joinNames, chatLevelXPBonus, chatLevelRareBonus, shopGreeting
|
||||
|
||||
- internal/plugin/audit_fixes_test.go (21 tests)
|
||||
safeGo panic recovery (normal, panic, nil panic), bail channel
|
||||
ownership transfer (both directions), arena status renders for
|
||||
indexed states, lottery edge cases, error message leak checks,
|
||||
UX guidance verification, integer overflow guards, regression guards
|
||||
|
||||
- internal/db/db_test.go (4 tests)
|
||||
Init/Close lifecycle, idempotent Init, schema index verification,
|
||||
schema runs cleanly against fresh SQLite
|
||||
|
||||
EXISTING TESTS (pre-existing, not written today):
|
||||
- adventure_arena_test.go: 55 (51 pre-existing + 4 added)
|
||||
- adventure_shop_test.go: 26
|
||||
- uno_test.go: 20
|
||||
- adventure_test.go: 17
|
||||
- holdem_tips_test.go: 16
|
||||
- adventure_holidays_test.go: 15
|
||||
- wordle_game_test.go: 9
|
||||
- holdem_eval_test.go: 8
|
||||
- blackjack_test.go: 7
|
||||
|
||||
TOTAL: 328 tests, 0 failures, 4 packages covered
|
||||
19
NOTICE
Normal file
19
NOTICE
Normal file
@@ -0,0 +1,19 @@
|
||||
GogoBee — third-party content attribution
|
||||
==========================================
|
||||
|
||||
Open5e SRD data
|
||||
---------------
|
||||
Portions of GogoBee's spell, bestiary, and magic-item data are derived from
|
||||
the Open5e dataset, which republishes the Systems Reference Document (SRD) 5.1.
|
||||
|
||||
Source: https://open5e.com / https://api.open5e.com
|
||||
License: Creative Commons Attribution 4.0 International (CC-BY-4.0)
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
SRD 5.1: © Wizards of the Coast LLC, released under CC-BY-4.0.
|
||||
|
||||
The data is vendored into this repository (data/open5e/) and compiled into
|
||||
generated Go source (e.g. internal/plugin/dnd_spells_srd_data.go) by the
|
||||
cmd/open5e-import tool. GogoBee has no runtime dependency on the Open5e API.
|
||||
|
||||
GogoBee's hand-authored balance tuning, classifiers, and game systems built
|
||||
on top of this data are not themselves SRD content.
|
||||
376
README.md
376
README.md
@@ -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,12 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
||||
|
||||
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
||||
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
||||
- **49 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-7 letter words, video game themed bonus words with category hints, dupe prevention across last 500 puzzles), Adventure (daily idle RPG via DMs — dungeon, mine, forage, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), all with channel restriction
|
||||
- **50 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure 2.0 (D&D-layered RPG: 7 races, 5 classes with 3 subclasses each through L15, real spell slots and forward-simulating combat engine, 10 hand-tuned dungeon zones across 5 tiers, multi-day expeditions with supply burn / threat clock / day cycle / multi-region travel / extract-and-resume / per-zone temporal events, harvest nodes per room, TwinBee as a mood-driven DM with ten zone-specific narration pools, all on top of the existing economy/housing/pets/Misty/Arina/blacksmith/hospital/rivals systems), Arena (5-tier combat gauntlet with 20 unique monsters, streak-based payouts, death lockout, leaderboard), Co-op Dungeons (2–7 day party runs with funding tiers, TwinBee-narrated floor events with voting, spectator parimutuel betting, basket/mimic gift system, weighted-roll item distribution including masterworks at T4–T5), all with channel restriction
|
||||
- **Moderation system** (optional) - deterministic detection only, no LLM. Word list with leetspeak variation matching, text/image flood, repeated messages, mention flooding, link rate limiting, invite flooding, join/leave cycling. Three-strike ladder (warn → mute → ban). Admin room notifications, DMs over public callouts.
|
||||
- **Space inviter** (optional) - detects local homeserver users who aren't in any Matrix Space and DMs the configured admin to ask whether to invite them to a target Space. Replies are `yes` / `no` / `ignore`. Persistent prompt log in SQLite means restarts/upgrades don't re-ping the admin about users they've already answered. Runs on startup sweep + reactive on join events.
|
||||
- **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 +108,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 +122,7 @@ Everything is configured through environment variables or a `.env` file.
|
||||
|----------|-------------|
|
||||
| `OLLAMA_HOST` | Ollama server URL, e.g. `http://localhost:11434` |
|
||||
| `OLLAMA_MODEL` | Model name, e.g. `llama3.2` |
|
||||
| `DREAMDICT_URL` | DreamDict instance for `!translate`, `!wotd`, Hangman clues (e.g. `http://127.0.0.1:7777`) |
|
||||
| `DREAMDICT_URL` | DreamDict instance for `!translate`, `!wotd`, dictionary commands, Hangman clues, Wordle words (e.g. `http://127.0.0.1:7777`) |
|
||||
| `LLM_SAMPLE_RATE` | Fraction of messages to classify (0.0–1.0, default `0.15`) |
|
||||
|
||||
### Encryption
|
||||
@@ -150,8 +151,10 @@ Everything is configured through environment variables or a `.env` file.
|
||||
| `FEATURE_ESTEEMED` | Set to anything to enable satirical esteemed member posts |
|
||||
| `ESTEEMED_ROOM` | Room ID for esteemed member posts (separate from broadcast rooms) |
|
||||
| `FEATURE_MODERATION` | Set to `true` to enable the moderation system (disabled by default) |
|
||||
| `FEATURE_SPACE_INVITER` | Set to `true` to enable the space inviter (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 +187,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 |
|
||||
|
||||
@@ -227,6 +230,20 @@ All moderation settings are optional. The system is disabled unless `FEATURE_MOD
|
||||
| `SPACE_GROUP_THRESHOLD` | `50` | Percentage of the smaller room's members that must overlap to group rooms together (1–100) |
|
||||
| `HOLIDAY_COUNTRIES` | `US` | Comma-separated ISO country codes for Calendarific holiday posts |
|
||||
|
||||
### Space Inviter
|
||||
|
||||
Disabled unless `FEATURE_SPACE_INVITER=true`. Requires the bot to be joined to **every** Space on the homeserver — if a Space exists that the bot can't see, users in only that Space will be misclassified as Space-less. Uses the Synapse Admin API to enumerate local users.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `SPACE_INVITER_TARGET` | | Room ID of the Space to invite users into on `yes` reply |
|
||||
| `SPACE_INVITER_HOME_DOMAIN` | | Your homeserver domain (e.g. `parodia.dev`) — only users with this suffix are considered |
|
||||
| `SPACE_INVITER_SYNAPSE_URL` | | Base URL for Synapse admin API (e.g. `https://parodia.dev`) |
|
||||
| `SPACE_INVITER_ADMIN_TOKEN` | | Synapse admin access token |
|
||||
| `SPACE_INVITER_ADMIN_USER` | | User ID to DM with prompts (the operator) |
|
||||
| `SPACE_INVITER_DELAY` | `2s` | Delay between prompts during the startup sweep |
|
||||
| `SPACE_INVITER_COOLDOWN` | `720h` | Minimum gap between re-prompts for the same user (Go duration). Prevents restart/upgrade ping-spam — a `no` reply is honored for this long across restarts. `ignore` is permanent. |
|
||||
|
||||
### Missing Members
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -284,7 +301,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 +353,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 +364,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 +404,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 +475,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 +483,227 @@ 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)
|
||||
### Adventure (DM-based 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 layered RPG. **Adventure 2.0** grafts a full D&D-style character system on top of the existing adventure economy, then builds two endgame loops on top of that: single-session dungeon zones and multi-day expeditions. The legacy systems — housing/Thom Krooke, pets, Misty/Arina, blacksmith, hospital, rivals, Robbie, arena, co-op dungeons — all keep working unchanged. Players who don't opt into 2.0 keep the legacy adventure shape; players who run `!setup` get the full D&D layer on top.
|
||||
|
||||
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 interaction. Most gameplay happens in DMs — the morning prompt routes to zones/expeditions for the active flow plus in-town services (shop, blacksmith, rest, hospital, Thom Krooke) for the rest.
|
||||
|
||||
#### Adventure 2.0 — D&D Layer
|
||||
|
||||
`!setup` once. Pick a race + class. Your existing `combat_level` becomes your D&D level, treasures become attunements, equipment slots carry forward 1:1. Every fight, harvest, arena run, and co-op dungeon is then resolved through D&D mechanics — STR/DEX/CON/INT/WIS/CHA, HP, AC, ability checks, saving throws, all the rest.
|
||||
|
||||
**Races (7) — at race choice, fixed stat mods + a passive:**
|
||||
|
||||
| Race | Mods (S/D/C/I/W/Ch) | Passive |
|
||||
|---|---|---|
|
||||
| Human | 0/0/0/0/0/0 | Versatile (floating +1 not yet wired) |
|
||||
| Elf | 0/+2/-1/+1/+1/0 | Darkvision; immune to sleep effects |
|
||||
| Dwarf | +1/-1/+2/0/+1/-1 | Poison resistance; bonus vs. underground |
|
||||
| Halfling | 0/+2/0/0/+1/0 | Lucky: once per combat, reroll a nat 1 |
|
||||
| Orc | +3/-1/+2/-1/-1/-1 | Rage: once per combat, +50% damage for one turn |
|
||||
| Tiefling | 0/+1/0/+1/0/+2 | Fire resistance; bonus on CHA checks |
|
||||
| Half-Elf | 0/+1/0/+1/0/+2 | Two bonus skill proficiencies |
|
||||
|
||||
**Classes (5):** Fighter (d10 HP, STR/CON), Rogue (d8, DEX/INT), Mage (d6, INT/WIS), Cleric (d8, WIS/CHA), Ranger (d8, DEX/WIS).
|
||||
|
||||
**Subclasses** unlock at L3 via `!subclass`. Each class has 3 archetypes with mechanical features at L5/L7/L10/L15:
|
||||
|
||||
| Class | Subclasses |
|
||||
|---|---|
|
||||
| Fighter | Champion, Battle Master, Berserker |
|
||||
| Rogue | Thief, Assassin, Arcane Trickster |
|
||||
| Mage | Evocation, Abjuration, Necromancy, Arcane Trickster |
|
||||
| Cleric | Life, War, Trickery |
|
||||
| Ranger | Hunter, Beast Master, Gloom Stalker |
|
||||
|
||||
**Spells.** Mages, Clerics, and Rangers cast for real. Spell slots tracked, combat consumes them. Mages have a spellbook cap that grows with level — pick what to learn at level-up via `!learn`.
|
||||
|
||||
**Combat engine.** Forward-simulating turn-by-turn — d20 vs AC for every attack, hit/crit/miss/fumble narration with surfaced roll values (`(🎲 14 vs AC 13)`), monster abilities, status effects, class resources, consumables. Outcomes stream over 2–3 second pacing for suspense, with a roll summary appended.
|
||||
|
||||
**Dungeon zones (`!zone`)** — 10 hand-tuned zones, 5 tiers, level bands L1–L20:
|
||||
|
||||
| Tier | Zones |
|
||||
|---|---|
|
||||
| T1 | Goblin Warrens, Crypt of Valdris |
|
||||
| T2 | Forest of Shadows, Sunken Temple |
|
||||
| T3 | Haunted Manor, Underforge |
|
||||
| T4 | Underdark Outpost, Feywild Crossing |
|
||||
| T5 | Dragon's Lair, Abyss Portal |
|
||||
|
||||
Each run is procedurally laid out (entry → exploration → trap → elite → boss with exploration filler). TwinBee narrates as a mood-driven DM (0–100 mood, shifts on crits/deaths/taunts/zone completion). `!zone enter`, `!zone advance` to resolve the current room, `!zone status`, `!zone map` (ASCII layout `E──?──T──★──☠`), `!zone lore`, `!zone taunt` / `!zone compliment` to poke or flatter your DM.
|
||||
|
||||
**Multi-day expeditions (`!expedition`)** — the long form:
|
||||
|
||||
- **Outfitting:** buy supply packs at start (standard/deluxe), cost scales with zone tier
|
||||
- **Real-time day cycle:** 06:00 morning briefing, 21:00 evening recap (configurable)
|
||||
- **Supply burn:** rations tick down each day; harsh zones burn faster; running out triggers forced extraction
|
||||
- **Threat Clock (0–100):** every action is noticed. Crossing bands shifts monster density, patrol odds, night events: Quiet → Stirring → Alert → Hostile → Siege
|
||||
- **Patrol encounters** at Alert+ — fights interrupt traversal
|
||||
- **Night phase / wandering monsters** — checked at the recap
|
||||
- **Siege Mode (threat 80+):** fortified camp branches, special warnings
|
||||
- **Camp:** `!camp rough` (free, no protection) vs `!camp standard` (consumes supplies, grants overnight rest)
|
||||
- **Milestones:** First Night, Deep Dive, Long Haul, Survivalist, etc.
|
||||
- **Expedition log** (`!expedition log`) — every event recorded
|
||||
|
||||
**Multi-region zones.** T4+ zones have multiple connected regions. `!region` shows where you are, where you can travel, and visited set. Inter-region travel consumes supplies and ticks threat. Base camp is a persistent waypoint per expedition. `!map` renders the full multi-region map.
|
||||
|
||||
**Per-zone temporal events** (signature time-based effects on every T2+ zone):
|
||||
|
||||
- **Sunken Temple** — tidal cycle floods/recedes rooms
|
||||
- **Haunted Manor** — nightly reset; cleared rooms come back
|
||||
- **Underforge** — heat accumulation; gear damage rises
|
||||
- **Feywild Crossing** — time distortion; days pass at non-uniform rates
|
||||
- **Dragon's Lair** — awareness pulses warn the dragon directly
|
||||
- **Abyss Portal** — destabilization stack threatens forced collapse
|
||||
|
||||
**Extract & resume.** `!extract` bails safely from an expedition. Loot/XP/coins kept, expedition flips to `extracting` status, `!resume` within 7 days picks up from base camp.
|
||||
|
||||
**Harvest.** `!forage` / `!mine` / `!fish` / `!scavenge` / `!essence` / `!commune` work in cleared rooms during expeditions, and (Phase R) in single-session `!zone enter` runs too — per-room nodes seeded from the zone resource pool, finite charges, class-specific yield bonuses (Ranger forage/fish, Fighter mine), `!resources` to see what's gatherable. Zone-condition events (tidal block, heat block, manor cursed trinket drops) layer on during expeditions.
|
||||
|
||||
**Boss combat.** Every zone's boss is in the bestiary with its own stat block and phase-two narration crossing 50% HP. Win drops the per-zone loot table; T5 always drops a masterwork.
|
||||
|
||||
**HP scale.** D&D HP is the source of truth (your sheet says, e.g., `33 max`). The combat engine internally uses a legacy HP scale (calibrated for the old `combat_level`-based curves, ~123 max for a high-level player), but combat outcomes are translated back to D&D scale before display: `HP 33→26 / 33` is what you see, never the engine's internal numbers.
|
||||
|
||||
**Holidays.** On recognized holidays (~20/year, religious + cultural calendars), runs start with TwinBee mood +5, expedition outfitting includes a free standard pack, and harvest yields +1 per attempt. Hospital surcharge still applies on holiday deaths.
|
||||
|
||||
**Player commands cheat sheet:**
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `!setup` | Opt into D&D — pick race + class, inherits your existing progress |
|
||||
| `!sheet` | Full character sheet |
|
||||
| `!learn` | Mages — pick a spell at level-up |
|
||||
| `!subclass` | Inspect / select subclass at L3 |
|
||||
| `!rest short` / `!rest long` | Recover HP between fights (1h / 24h cooldowns) |
|
||||
| `!check <skill> [dc]` | Roll a skill check |
|
||||
| `!zone` / `!zone list` | Zones available at your level |
|
||||
| `!zone enter <id>` | Start a single-session run |
|
||||
| `!zone advance` | Resolve current room |
|
||||
| `!zone status` / `!zone map` / `!zone lore` | Run inspection |
|
||||
| `!zone abandon` | End run, no rewards |
|
||||
| `!zone taunt` / `!zone compliment` | Poke the DM |
|
||||
| `!expedition list` | Zones available at your level |
|
||||
| `!expedition start <zone> [Ns] [Md]` | Outfit and begin |
|
||||
| `!expedition status` / `!expedition log` | Daily briefing + history |
|
||||
| `!expedition abandon` | End expedition (no rewards) |
|
||||
| `!camp rough` / `!camp standard` | Make camp |
|
||||
| `!region` | Multi-region inspection + travel |
|
||||
| `!map` | Full multi-region map |
|
||||
| `!forage` / `!mine` / `!scavenge` / `!fish` / `!essence` / `!commune` | Work the room |
|
||||
| `!resources` | Inventory of harvested materials |
|
||||
| `!threat` | Current threat clock |
|
||||
| `!extract` | Bail safely from an expedition |
|
||||
| `!resume` | Pick up an extracted expedition |
|
||||
| `!respec` | Wipe character (paid; cooldown gated) |
|
||||
|
||||
**Compatibility.** Adv 2.0 is fully additive — every pre-2.0 column on `adventure_characters`, `adventure_equipment`, `adventure_inventory`, `adventure_treasures`, `arena_stats`, etc. is preserved with original semantics. Migrations are column-adds with defaults only. Players who haven't run `!setup` keep the legacy adventure shape. `combat_level` becomes the D&D level; treasures become attunements; equipment slots carry forward unchanged.
|
||||
|
||||
#### Legacy adventure surface (still active)
|
||||
|
||||
The pre-2.0 town economy and supporting systems still work alongside the D&D layer.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!adventure` | Open today's action menu (sent via DM) |
|
||||
| `!adventure status` | Character sheet (sent via DM) |
|
||||
| `!adventure` / `!adv` | Open today's menu (sent via DM) — now points at `!expedition` and town services |
|
||||
| `!adventure status` | Character sheet (legacy view; `!sheet` is the D&D-layer view) |
|
||||
| `!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 sell all` | Sell entire inventory (consumables protected) |
|
||||
| `!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 harvest on your behalf (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.
|
||||
#### Skill tracks
|
||||
|
||||
#### Activities
|
||||
|
||||
Three activity types across 5 tiers of locations (15 total). 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
|
||||
- **Foraging** — foraging XP, wildlife hazards, herb and reagent drops
|
||||
Four skills are tracked separately from D&D level: **Combat (legacy)**, **Mining**, **Foraging**, **Fishing**. They gain XP from harvesting in zones, arena fights, encounters, and (legacy) babysitter actions. Skill levels gate masterwork drops, fishing-zone proficiency bonuses, and crafting recipe unlocks.
|
||||
|
||||
#### 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.
|
||||
- **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.
|
||||
- **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), runs start with TwinBee mood +5, expedition outfitting includes a free standard pack, and harvest yields +1 per attempt. Hebrew and Islamic calendar support for floating holidays.
|
||||
- **TwinBee** — narrates as a mood-driven DM during zone runs and expeditions (see Adv 2.0 above). The legacy daily activity loop is retired in favor of zones/expeditions; the babysitter (below) is the surviving "TwinBee runs an action for you" surface.
|
||||
- **Hospital** — St. Guildmore's Memorial Hospital offers same-day revival for dead players. The bill is comically inflated (€125k × combat level) but guild insurance covers 80%, leaving €25k × combat level. Players who can't afford it are discharged back to the natural respawn queue. Nurse Joy provides the bedside manner.
|
||||
- **Robbie the Friendly Bandit** — an automated NPC who visits at a random hour each day (8:00–21:00 UTC). Robbie takes sub-tier gear from your inventory (shop gear below your equipped tier, masterwork gear you've outgrown), leaves €50 per item as a "handling fee," and donates everything to the community pot. If he takes masterwork gear and you don't already have one, he drops a "Get Out of Medical Debt Free" card. No player command — Robbie comes to you.
|
||||
- **Mid-day events** — random events can trigger between actions, delivering bonus loot, buffs, or narrative encounters.
|
||||
- **Chat level perks** — active chat participation boosts your adventurer. +5% XP per 10 chat levels (capped at +25% at level 50+), plus +0.5% rare drop chance per 10 levels.
|
||||
|
||||
#### Crafting
|
||||
|
||||
Auto-crafting kicks in at Foraging Lv.10. Before each combat action, the system scans your inventory for matching ingredients and assembles the highest-tier recipe you qualify for. 12 recipes spanning T1–T5, gated at Foraging 10/15/20/25/30. Per-recipe success rate is 50% at the unlock level, +3% per 5 levels above (capped 95%). Failed crafts destroy the ingredients — you tried.
|
||||
|
||||
Per attempt:
|
||||
- **Successful crafts** grant Foraging XP (T1: +12, T2: +25, T3: +40, T4: +60, T5: +90) and bump a lifetime `CraftsSucceeded` counter shown on `!adventure status`.
|
||||
- **Failed crafts** grant a token Foraging XP (1/4 of success).
|
||||
|
||||
Max auto-crafts per combat scales with level: 1 at Foraging 10, 2 at 20, 3 at 30+.
|
||||
|
||||
`!adventure recipes` lists every recipe unlocked at your current Foraging level with ingredients and per-recipe success rate, plus a teaser for the next unlock threshold.
|
||||
|
||||
**Consumable protection:** crafted/dropped consumables (Type `consumable`) are excluded from `!adventure sell all` and Robbie the Friendly Bandit's pickup filter. Selling consumables requires explicit `!adventure sell <name>` — no accidental mass-sells, no surprise donations to the community pot.
|
||||
|
||||
#### Blacksmith & Repair
|
||||
|
||||
Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair <slot>` commits.
|
||||
|
||||
#### Babysitting Service
|
||||
|
||||
Busy days? Hire TwinBee to harvest on your behalf. Daily cost is `€100 + combatLevel × €20`. TwinBee trains your weakest gathering skill (mining/fishing/foraging) each day, so the service doubles as catch-up for neglected tracks. Subscribe by the week or month, or check/cancel anytime: `!adventure babysit week|month|status|cancel`.
|
||||
|
||||
#### Rival Duels
|
||||
|
||||
At combat level 5 and above you're entered into the rival pool. Every 3–4 days a random eligible player is matched as your rival and challenges you to a rock-paper-scissors duel via DM. You have 24 hours to accept and play. Stake is `(combatLevel / 5) × €1,000`, winner-take-all. Use `!adventure rivals` to see the pool and open challenges.
|
||||
|
||||
#### Housing & Mortgage (Thom Krooke)
|
||||
|
||||
Thom Krooke is the guild's housing broker. Four tiers are available:
|
||||
|
||||
| Tier | Name | Price |
|
||||
|------|------|-------|
|
||||
| 1 | Base | €75,000 |
|
||||
| 2 | Livable | €150,000 |
|
||||
| 3 | Comfortable | €300,000 |
|
||||
| 4 | Established | €600,000 |
|
||||
|
||||
Buy outright or finance with a mortgage. Rates track the real-world US 5/1 ARM via the FRED API (`MORTGAGE5US` series; requires `FRED_API_KEY`, defaults to 6.5% if unset). Autopay pulls from your euro balance each day. Commands: `!thom shop`, `!thom buy <tier>`, `!thom pay <amount>`, `!thom payoff`, `!thom autopay on|off`, `!thom petbuy`.
|
||||
|
||||
#### Pets
|
||||
|
||||
Once you reach the Livable tier (HouseTier ≥ 2) a stray pet may show up at your door — 15% daily chance. When a pet arrives you'll get a DM prompt to `chase` it away or `feed` it to adopt. Each pet interaction grants +1.5 XP to a random skill. At pet Level 10, Thom unlocks pet armor for purchase (`!thom petbuy <tier>`).
|
||||
|
||||
#### Guest NPCs — Misty & Arina
|
||||
|
||||
Two rotating guest adventurers can be hired to join you on encounters:
|
||||
|
||||
- **Misty** — €100 hire fee, 7-day cooldown.
|
||||
- **Arina** — €5,000 hire fee, 7-day cooldown.
|
||||
|
||||
After hiring, there's a 7.5% chance per action over the next 7 days that your NPC shows up mid-adventure and applies a buff to the outcome.
|
||||
|
||||
#### Arena (`!arena`)
|
||||
|
||||
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure until midnight UTC.
|
||||
A multi-tier combat gauntlet independent of the daily adventure action. Fight through 5 tiers of 4 rounds each — 20 unique named monsters with escalating lethality. Earnings accumulate across rounds but are forfeited on death. After clearing a tier, choose to descend deeper (keep earnings at risk) or cash out. Death locks you out of both arena and adventure for 6 hours. Each fight produces a turn-based combat log (Dragon Quest style) with fabricated HP pools and action narration — the outcome is determined by the roll, the log is assembled backward from the result. Arena losses award +60 participation XP.
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -527,6 +724,61 @@ A multi-tier combat gauntlet independent of the daily adventure action. Fight th
|
||||
- **Auto-cashout** — 10 minutes to decide after clearing a tier. GogoBee cashes out on your behalf if you're slow.
|
||||
- **Tier entry confirmation** — `!arena tier N` previews the first opponent; `!arena fight` commits.
|
||||
|
||||
#### Co-op Dungeons (`!coop`)
|
||||
|
||||
Multi-day party runs separate from solo. 2–4 players, 2–7 days depending on tier, scaled rewards, and a public game-room thread the whole community watches. The combat action is consumed once at lock; subsequent days only ask for a daily funding decision (harvest activities continue normally).
|
||||
|
||||
| Tier | Days | Difficulty | Reward Pool | Optimal Funding |
|
||||
|------|------|------------|-------------|-----------------|
|
||||
| 1 | 2 | Moderate | €22,500 | Minimal/Standard |
|
||||
| 2 | 3 | Hard | €40,000 | Standard |
|
||||
| 3 | 4 | Very Hard | €72,500 | Standard |
|
||||
| 4 | 5 | Brutal | €120,000 | Standard or Aggressive |
|
||||
| 5 | 7 | Murderous | €600,000 | Aggressive (must commit) |
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `!coop list` | Show open invites |
|
||||
| `!coop start <1-5>` | Open an invite (24h window, pinned in the games room) |
|
||||
| `!coop join [<id>]` | Join an open invite (defaults to most recent) |
|
||||
| `!coop fund <tier>` | Set today's funding (`none` / `minimal` / `standard` / `aggressive` / `all_in`) |
|
||||
| `!coop vote <A\|B\|C>` | Vote on the day's TwinBee-narrated floor event |
|
||||
| `!coop bet <amount> <success\|failure>` | Spectator parimutuel bet (10% rake) |
|
||||
| `!coop gift <run_id> <basket\|mimic>` | Send a gift (1 harvest action) |
|
||||
| `!coop giftvote <id> <open\|leave>` | Party votes whether to open a received gift |
|
||||
| `!coop status` | Your current run state |
|
||||
| `!coop stats` | Community-wide aggregates: outcomes, gold flow, betting, gifts, helpfulness |
|
||||
| `!coop cancel` | Leader cancels an open invite before lock |
|
||||
| `!coop admgift <run_id> <basket\|mimic> [sender]` | Drop a gift into an active run, bypassing party-member and harvest-action checks (admin only — debug/testing) |
|
||||
| `!coop help` | Co-op command list |
|
||||
|
||||
**Funding tiers** add to the per-floor success modifier: None −10%, Minimal +0%, Standard +8%, Aggressive +18%, All-In +30%. None is free; Minimal €500/day; Standard €1,500; Aggressive €4,000; All-In €10,000. Funding is non-refundable. Inactive players auto-play None.
|
||||
|
||||
**Floor events.** TwinBee narrates 1 of 4 categories per floor (Obstacle / Opportunity / Crisis / Encounter), tier-weighted. Each event has 2–3 options; majority vote wins, ties go to the leader, leader has the casting tiebreak. TwinBee always recommends an option — he is wrong roughly half the time. The party's vote modifier (−15 to +12 per floor depending on the option) stacks on the funding modifier. The 95% per-floor success cap means once you're saturated, more funding is wasted.
|
||||
|
||||
**Party strength factors.** Each member contributes a small per-floor modifier based on their combat level relative to the tier minimum (clamped ±8) plus a pet bonus (max +2.5 at pet level 10). A maxed veteran party at T5 can succeed at Standard funding where a fresh party would need Aggressive.
|
||||
|
||||
**Newbie liability.** Players below a tier's minimum combat level are flagged in the party post and capped at +8% funding modifier regardless of how much they pay. The party chose to let them in. Their reward share is unchanged — the cost is borne by the funding economy, not the post-run split.
|
||||
|
||||
**Spectator betting.** Parimutuel pool, 10% rake. Bets can be placed or increased any time during the run; positions are locked in (success or failure can't be switched). Party members can bet on their own run — sandbagging while heavily bet against is exploitative and visible. Odds line shows estimated success% based on party levels, pets, neutral funding assumption, and a hidden TwinBee Helpfulness Rating (rolling last 30 floor events: how often his recommendation was right). Helpfulness shifts the line ±20% but is never shown directly — players notice the line moves after events resolve.
|
||||
|
||||
**Gifts.** Anyone *not* in the run can spend one harvest action to send a Care Basket or a Mimic. The party never sees which type. Vote `open` or `leave`. **Each gift's voting window is 6 hours**; voting closes when it elapses, the stack is tallied, and every sender gets a DM with the result. The modifier sits on the run until the next floor resolution merges it in. Gifts "fire" throughout the day rather than piling up at the daily tick. Magnitudes are equalized across all four outcomes — both "always open" and "always leave" yield EV=0 with σ=6, so neither is risk-averse-dominant. The choice is purely about reading sender intent.
|
||||
|
||||
**Stacking.** Multiple gifts of the same type sent on the same day **stack into a single game-room post** — one vote covers the whole stack, modifier scales with size (5 baskets opened = +30%, 5 baskets exploded = −30%). The first-in sets the deadline; subsequent additions inherit it without extending the timer. When a stack hits size 2, TwinBee notes "this gift looks REALLY special" once and then quietly bumps the count on further additions. The end-of-run gift log groups by stack with all senders attributed.
|
||||
|
||||
| | Open | Leave |
|
||||
|-------------|------|-------|
|
||||
| **Care Basket** | +6% (boost) | −6% (basket explodes) |
|
||||
| **Mimic** | −6% (mimic does what mimics do) | +6% (sad mimic helps anyway) |
|
||||
|
||||
Multiple gifts stack additively — coordinated senders can swing odds significantly. The full gift log is public at end of run.
|
||||
|
||||
**Loot.** On success, gold pool splits evenly (5% community tax). 2–6 items drop from the corresponding solo dungeon loot table (count scales with tier). Each item goes to a member via weighted roll — your share of total funding is your odds. **T5 always drops a masterwork**; T4 has a 25% chance. Masterworks go to inventory (not auto-equipped) so winners with better gear can sell or trade.
|
||||
|
||||
**Wipes.** No reward. Funding is gone. No combat-action refund (the tick already gave you a fresh one at midnight). Bet pool pays out to the failure side. The dungeon does not editorialize.
|
||||
|
||||
**Pinning.** Invite posts are pinned in the games room when opened, unpinned when the run locks or cancels. Bot needs power level for state events.
|
||||
|
||||
### Reminders
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
@@ -594,13 +846,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 +968,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 +992,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 +1003,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 +1078,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 +1104,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 +1173,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 +1195,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 +1219,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
|
||||
|
||||
277
cmd/expedition-sim/main.go
Normal file
277
cmd/expedition-sim/main.go
Normal file
@@ -0,0 +1,277 @@
|
||||
// expedition-sim drives synthetic players through expeditions for batch
|
||||
// analysis. Re-uses production plugin paths against a fresh sqlite DB so
|
||||
// outcomes mirror what live players hit.
|
||||
//
|
||||
// Single run:
|
||||
// expedition-sim [-class fighter] [-level 5] [-zone goblin_warrens]
|
||||
// [-bank 1000] [-cap 50] [-log] [-data DIR]
|
||||
//
|
||||
// Matrix mode (cartesian sweep over classes × levels × zones × N runs,
|
||||
// one JSON object per stdout line, log suppressed by default):
|
||||
// expedition-sim -matrix -classes fighter,mage -levels 5,10 \
|
||||
// -zones goblin_warrens,wolf_den -runs 20
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
class = flag.String("class", "fighter", "DnD class id (single-run mode)")
|
||||
level = flag.Int("level", 5, "character level (single-run mode)")
|
||||
zone = flag.String("zone", "goblin_warrens", "zone id (single-run mode)")
|
||||
bank = flag.Float64("bank", 1000, "starting coin balance — must cover outfitting")
|
||||
cap = flag.Int("cap", 50, "max autopilot bursts per expedition (each = up to autopilotRoomCap rooms)")
|
||||
days = flag.Int("days", 0, "stop after N synthetic day rollovers (0 = unbounded; the -cap safety net still applies)")
|
||||
dataDir = flag.String("data", "", "data dir for the temp sqlite db (default: OS tempdir; ignored in matrix mode)")
|
||||
userTag = flag.String("user", "@sim:expedition", "synthetic user id (single-run mode)")
|
||||
logFlag = flag.Bool("log", true, "include per-row expedition log in output (single-run default true; matrix default false)")
|
||||
|
||||
matrix = flag.Bool("matrix", false, "matrix mode — sweep over classes × levels × zones × runs")
|
||||
classes = flag.String("classes", "", "comma-separated class ids (matrix mode)")
|
||||
levels = flag.String("levels", "", "comma-separated levels (matrix mode)")
|
||||
zones = flag.String("zones", "", "comma-separated zone ids (matrix mode)")
|
||||
runs = flag.Int("runs", 1, "replicates per (class,level,zone) cell (matrix mode)")
|
||||
|
||||
trace = flag.Bool("trace", false, "include raw per-round CombatEvent stream on the LAST combat of each expedition (boss room) — for J2 diagnostic sweeps")
|
||||
|
||||
petLevel = flag.Int("pet-level", 0, "attach a base housing pet at this level (1-10) to every sim character; 0 = no pet (default, matches prod char-creation)")
|
||||
|
||||
jobs = flag.Int("jobs", 0, "matrix mode — concurrent worker count (each worker is a subprocess so it gets its own sqlite). 0 = runtime.NumCPU()")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *petLevel < 0 || *petLevel > 10 {
|
||||
fail("pet-level must be 0-10, got", *petLevel)
|
||||
}
|
||||
|
||||
plugin.SetSimIncludeTrace(*trace)
|
||||
plugin.SetSimPetLevel(*petLevel)
|
||||
|
||||
if *matrix {
|
||||
// Matrix default: drop log to keep stdout manageable; explicit
|
||||
// -log=true overrides.
|
||||
includeLog := false
|
||||
// Flag.Lookup tells us whether the user explicitly set -log.
|
||||
flag.Visit(func(f *flag.Flag) {
|
||||
if f.Name == "log" {
|
||||
includeLog = *logFlag
|
||||
}
|
||||
})
|
||||
runMatrix(*classes, *levels, *zones, *runs, *bank, *cap, *days, includeLog, *jobs, *trace, *petLevel)
|
||||
return
|
||||
}
|
||||
|
||||
runSingle(*class, *level, *zone, *userTag, *dataDir, *bank, *cap, *days, *logFlag)
|
||||
}
|
||||
|
||||
func runSingle(class string, level int, zone, userTag, dataDir string, bank float64, cap, days int, includeLog bool) {
|
||||
dir := dataDir
|
||||
if dir == "" {
|
||||
var err error
|
||||
dir, err = os.MkdirTemp("", "expedition-sim-")
|
||||
if err != nil {
|
||||
fail("mkdir temp:", err)
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
res, err := runOne(dir, id.UserID(userTag), plugin.DnDClass(class), level, plugin.ZoneID(zone), bank, cap, days)
|
||||
if err != nil {
|
||||
if res != nil {
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
fail("run:", err)
|
||||
}
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
emitIndented(res)
|
||||
}
|
||||
|
||||
// matrixJob is one (class, level, zone, replicate-index) cell of the
|
||||
// matrix sweep. Each job is run by a worker as a single-run subprocess so
|
||||
// it gets its own SQLite handle — the plugin package's db.* globals
|
||||
// preclude in-process parallelism.
|
||||
type matrixJob struct {
|
||||
class string
|
||||
level int
|
||||
zone string
|
||||
rep int
|
||||
}
|
||||
|
||||
func runMatrix(classes, levels, zones string, runs int, bank float64, cap, days int, includeLog bool, jobs int, trace bool, petLevel int) {
|
||||
cs := splitNonEmpty(classes)
|
||||
ls := parseLevels(levels)
|
||||
zs := splitNonEmpty(zones)
|
||||
if len(cs) == 0 || len(ls) == 0 || len(zs) == 0 || runs <= 0 {
|
||||
fail("matrix mode requires non-empty -classes, -levels, -zones and runs > 0")
|
||||
}
|
||||
if jobs <= 0 {
|
||||
jobs = runtime.NumCPU()
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
fail("os.Executable:", err)
|
||||
}
|
||||
|
||||
work := make([]matrixJob, 0, len(cs)*len(ls)*len(zs)*runs)
|
||||
for _, c := range cs {
|
||||
for _, lv := range ls {
|
||||
for _, z := range zs {
|
||||
for r := 0; r < runs; r++ {
|
||||
work = append(work, matrixJob{class: c, level: lv, zone: z, rep: r})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
workCh := make(chan matrixJob)
|
||||
resCh := make(chan *plugin.SimResult, len(work))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < jobs; i++ {
|
||||
wg.Add(1)
|
||||
go matrixWorker(exe, workCh, resCh, &wg, bank, cap, days, includeLog, trace, petLevel)
|
||||
}
|
||||
go func() {
|
||||
for _, j := range work {
|
||||
workCh <- j
|
||||
}
|
||||
close(workCh)
|
||||
}()
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resCh)
|
||||
}()
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
for r := range resCh {
|
||||
_ = enc.Encode(r)
|
||||
}
|
||||
}
|
||||
|
||||
func matrixWorker(exe string, in <-chan matrixJob, out chan<- *plugin.SimResult, wg *sync.WaitGroup, bank float64, cap, days int, includeLog, trace bool, petLevel int) {
|
||||
defer wg.Done()
|
||||
for j := range in {
|
||||
uid := fmt.Sprintf("@sim:%s-l%d-%s-%d", j.class, j.level, j.zone, j.rep)
|
||||
dir, err := os.MkdirTemp("", "expedition-sim-")
|
||||
if err != nil {
|
||||
out <- &plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
|
||||
continue
|
||||
}
|
||||
args := []string{
|
||||
"-class", j.class,
|
||||
"-level", strconv.Itoa(j.level),
|
||||
"-zone", j.zone,
|
||||
"-bank", strconv.FormatFloat(bank, 'f', -1, 64),
|
||||
"-cap", strconv.Itoa(cap),
|
||||
"-days", strconv.Itoa(days),
|
||||
"-data", dir,
|
||||
"-user", uid,
|
||||
fmt.Sprintf("-log=%t", includeLog),
|
||||
fmt.Sprintf("-pet-level=%d", petLevel),
|
||||
}
|
||||
if trace {
|
||||
args = append(args, "-trace")
|
||||
}
|
||||
cmd := exec.Command(exe, args...)
|
||||
var stderrBuf bytes.Buffer
|
||||
cmd.Stderr = &stderrBuf
|
||||
stdout, runErr := cmd.Output()
|
||||
var res plugin.SimResult
|
||||
jerr := json.Unmarshal(stdout, &res)
|
||||
if jerr != nil {
|
||||
res = plugin.SimResult{UserID: uid, Class: j.class, Level: j.level, Zone: j.zone, Outcome: "halted"}
|
||||
}
|
||||
if runErr != nil && res.Outcome == "" {
|
||||
res.Outcome = "halted"
|
||||
}
|
||||
// Surface subprocess failures: parse error with non-empty stdout
|
||||
// (corrupted JSON) and non-zero exits both get a stderr dump so the
|
||||
// user sees the underlying cause instead of just a halted row.
|
||||
if jerr != nil || runErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "sim cell %s halted: runErr=%v jerr=%v\n", uid, runErr, jerr)
|
||||
if stderrBuf.Len() > 0 {
|
||||
fmt.Fprintf(os.Stderr, " child stderr:\n%s\n", stderrBuf.String())
|
||||
}
|
||||
if jerr != nil && len(stdout) > 0 {
|
||||
snip := stdout
|
||||
if len(snip) > 200 {
|
||||
snip = snip[:200]
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, " child stdout (first 200B): %q\n", snip)
|
||||
}
|
||||
}
|
||||
if !includeLog {
|
||||
res.Log = nil
|
||||
}
|
||||
out <- &res
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func runOne(dataDir string, uid id.UserID, class plugin.DnDClass, level int, zone plugin.ZoneID, bank float64, cap, days int) (*plugin.SimResult, error) {
|
||||
runner, err := plugin.NewSimRunner(dataDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init runner: %w", err)
|
||||
}
|
||||
defer runner.Close()
|
||||
|
||||
if _, err := runner.BuildCharacter(uid, class, level); err != nil {
|
||||
return nil, fmt.Errorf("build character: %w", err)
|
||||
}
|
||||
runner.Euro.Credit(uid, bank, "expedition-sim bankroll")
|
||||
return runner.RunExpedition(uid, zone, cap, days)
|
||||
}
|
||||
|
||||
func emitIndented(res *plugin.SimResult) {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(res)
|
||||
}
|
||||
|
||||
func splitNonEmpty(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := parts[:0]
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseLevels(s string) []int {
|
||||
var out []int
|
||||
for _, p := range splitNonEmpty(s) {
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
fail("bad level:", p)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fail(args ...interface{}) {
|
||||
fmt.Fprintln(os.Stderr, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -85,6 +85,12 @@ func main() {
|
||||
failed := 0
|
||||
|
||||
for i, entry := range entries {
|
||||
// Validate entry ID to prevent path traversal or command injection.
|
||||
if entry.ID == "" || strings.ContainsAny(entry.ID, "/\\;|&$`\"'") || strings.Contains(entry.ID, "..") {
|
||||
fmt.Printf("[%d/%d] SKIP (invalid ID): %q\n", i+1, len(entries), entry.ID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
outPath := filepath.Join(outputDir, entry.ID+".jpg")
|
||||
|
||||
// Skip if already downloaded
|
||||
|
||||
408
cmd/gensolver/main.go
Normal file
408
cmd/gensolver/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
342
cmd/open5e-import/bestiary.go
Normal file
342
cmd/open5e-import/bestiary.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// open5eMonster is the subset of the Open5e v1 monster schema we consume. The
|
||||
// vendored JSON keeps the full payload; unknown fields are ignored here.
|
||||
type open5eMonster struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Size string `json:"size"`
|
||||
Type string `json:"type"`
|
||||
CR float64 `json:"cr"`
|
||||
ArmorClass int `json:"armor_class"`
|
||||
HitPoints int `json:"hit_points"`
|
||||
Speed map[string]any `json:"speed"`
|
||||
Strength int `json:"strength"`
|
||||
Dexterity int `json:"dexterity"`
|
||||
Constitution int `json:"constitution"`
|
||||
Intelligence int `json:"intelligence"`
|
||||
Wisdom int `json:"wisdom"`
|
||||
Charisma int `json:"charisma"`
|
||||
Actions []open5eAction `json:"actions"`
|
||||
LegendaryActions []open5eAction `json:"legendary_actions"`
|
||||
SpecialAbilities []open5eNamedBlock `json:"special_abilities"`
|
||||
}
|
||||
|
||||
// open5eAction is one entry in a monster's actions list. An entry is a weapon
|
||||
// attack iff DamageDice is non-empty; Multiattack and utility actions leave it
|
||||
// blank. AttackBonus/DamageBonus arrive as JSON null on non-attacks, which
|
||||
// unmarshals to 0 — harmless, since DamageDice is the attack discriminator.
|
||||
type open5eAction struct {
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
AttackBonus int `json:"attack_bonus"`
|
||||
DamageDice string `json:"damage_dice"`
|
||||
DamageBonus int `json:"damage_bonus"`
|
||||
}
|
||||
|
||||
type open5eNamedBlock struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type monstersPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eMonster `json:"results"`
|
||||
}
|
||||
|
||||
// fetchMonsters pages through the SRD monster list and writes the full result
|
||||
// set to data/open5e/monsters.json, pretty-printed for a reviewable diff.
|
||||
func fetchMonsters() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eMonster
|
||||
url := monstersAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getMonstersPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d monsters\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(monstersJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(monstersJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getMonstersPage(client *http.Client, url string) (*monstersPage, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page monstersPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// ── Staging-table classifier ─────────────────────────────────────────────────
|
||||
|
||||
// genStatBlock mirrors plugin.SRDStatBlock — a raw SRD stat block. These values
|
||||
// are NOT engine-tuned: raw SRD damage one-shots gogobee's solo player (see
|
||||
// bestiary_srd.go). The staging table is a balance baseline the hand-authored
|
||||
// dndBestiary / srdProfiles tuning pass reads against, not a drop-in roster.
|
||||
type genStatBlock struct {
|
||||
Slug, Name string
|
||||
Size, Type string
|
||||
CR float64
|
||||
XP int
|
||||
HP, AC int
|
||||
SpeedWalk int
|
||||
STR, DEX int
|
||||
CON, INT int
|
||||
WIS, CHA int
|
||||
Multiattack string
|
||||
Attacks []genStatAttack
|
||||
Traits []string
|
||||
Legendary bool
|
||||
}
|
||||
|
||||
type genStatAttack struct {
|
||||
Name string
|
||||
AttackBonus int
|
||||
DamageDice string
|
||||
DamageBonus int
|
||||
AvgDamage int
|
||||
DamageType string
|
||||
}
|
||||
|
||||
// xpByCR is the SRD experience-by-challenge-rating table. Open5e exposes no XP
|
||||
// field, but the tuning pass wants it, so it is derived here from CR.
|
||||
var xpByCR = map[float64]int{
|
||||
0: 10, 0.125: 25, 0.25: 50, 0.5: 100,
|
||||
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900, 8: 3900,
|
||||
9: 5000, 10: 5900, 11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000,
|
||||
16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000, 21: 33000,
|
||||
22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000,
|
||||
28: 120000, 29: 135000, 30: 155000,
|
||||
}
|
||||
|
||||
// genBestiary reads the vendored monsters.json, classifies every SRD monster
|
||||
// into a raw staging stat block, and writes the generated Go data file.
|
||||
func genBestiary() error {
|
||||
raw, err := os.ReadFile(monstersJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
|
||||
}
|
||||
var src []open5eMonster
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
blocks := make([]genStatBlock, 0, len(src))
|
||||
var attacks int
|
||||
for _, m := range src {
|
||||
b := classifyMonster(m)
|
||||
attacks += len(b.Attacks)
|
||||
blocks = append(blocks, b)
|
||||
}
|
||||
sort.Slice(blocks, func(i, j int) bool {
|
||||
if blocks[i].CR != blocks[j].CR {
|
||||
return blocks[i].CR < blocks[j].CR
|
||||
}
|
||||
return blocks[i].Slug < blocks[j].Slug
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "classified %d monsters (%d parsed attacks)\n", len(blocks), attacks)
|
||||
|
||||
out := emitBestiary(blocks)
|
||||
if err := os.WriteFile(bestiaryGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", bestiaryGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyMonster(m open5eMonster) genStatBlock {
|
||||
b := genStatBlock{
|
||||
Slug: strings.ReplaceAll(m.Slug, "-", "_"),
|
||||
Name: m.Name,
|
||||
Size: strings.ToLower(strings.TrimSpace(m.Size)),
|
||||
Type: strings.ToLower(strings.TrimSpace(m.Type)),
|
||||
CR: m.CR,
|
||||
XP: xpByCR[m.CR],
|
||||
HP: m.HitPoints,
|
||||
AC: m.ArmorClass,
|
||||
STR: m.Strength, DEX: m.Dexterity, CON: m.Constitution,
|
||||
INT: m.Intelligence, WIS: m.Wisdom, CHA: m.Charisma,
|
||||
SpeedWalk: speedWalk(m.Speed),
|
||||
Legendary: len(m.LegendaryActions) > 0,
|
||||
}
|
||||
for _, a := range m.Actions {
|
||||
if strings.EqualFold(strings.TrimSpace(a.Name), "Multiattack") {
|
||||
b.Multiattack = firstSentence(strings.TrimSpace(a.Desc), 200)
|
||||
continue
|
||||
}
|
||||
if a.DamageDice == "" {
|
||||
continue
|
||||
}
|
||||
atk := genStatAttack{
|
||||
Name: a.Name,
|
||||
AttackBonus: a.AttackBonus,
|
||||
DamageDice: strings.ReplaceAll(a.DamageDice, " ", ""),
|
||||
DamageBonus: a.DamageBonus,
|
||||
}
|
||||
atk.AvgDamage = int(avgDice(atk.DamageDice)) + a.DamageBonus
|
||||
if mt := reDmgType.FindStringSubmatch(strings.ToLower(a.Desc)); mt != nil {
|
||||
atk.DamageType = strings.ToLower(mt[1])
|
||||
}
|
||||
b.Attacks = append(b.Attacks, atk)
|
||||
}
|
||||
for _, sa := range m.SpecialAbilities {
|
||||
if n := strings.TrimSpace(sa.Name); n != "" {
|
||||
b.Traits = append(b.Traits, n)
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// speedWalk pulls the walking speed (in feet) out of Open5e's speed object,
|
||||
// falling back to the fastest other movement mode for creatures with no walk
|
||||
// speed (flyers, swimmers). Returns 0 when nothing parses.
|
||||
func speedWalk(speed map[string]any) int {
|
||||
asInt := func(v any) int {
|
||||
if f, ok := v.(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if w := asInt(speed["walk"]); w > 0 {
|
||||
return w
|
||||
}
|
||||
best := 0
|
||||
for mode, v := range speed {
|
||||
if mode == "walk" {
|
||||
continue
|
||||
}
|
||||
if n := asInt(v); n > best {
|
||||
best = n
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// avgDice returns the statistical average of an NdM dice expression. A bare or
|
||||
// unparseable expression yields 0 — the staging table records what it can and
|
||||
// leaves the rest for the tuning pass.
|
||||
func avgDice(dice string) float64 {
|
||||
i := strings.IndexByte(dice, 'd')
|
||||
if i <= 0 {
|
||||
return 0
|
||||
}
|
||||
n, err1 := strconv.Atoi(dice[:i])
|
||||
m, err2 := strconv.Atoi(dice[i+1:])
|
||||
if err1 != nil || err2 != nil || n <= 0 || m <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(n) * (float64(m) + 1) / 2
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emitBestiary(blocks []genStatBlock) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch bestiary
|
||||
// go run ./cmd/open5e-import gen bestiary
|
||||
//
|
||||
// This is the RAW SRD staging table — see bestiary_srd_staging.go for why these
|
||||
// values are a balance baseline and not a drop-in engine roster.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDStagingBestiary() map[string]SRDStatBlock {
|
||||
return map[string]SRDStatBlock{
|
||||
`)
|
||||
for _, m := range blocks {
|
||||
fmt.Fprintf(&b, "\t\t%q: {Slug: %q, Name: %q", m.Slug, m.Slug, m.Name)
|
||||
if m.Size != "" {
|
||||
fmt.Fprintf(&b, ", Size: %q", m.Size)
|
||||
}
|
||||
if m.Type != "" {
|
||||
fmt.Fprintf(&b, ", Type: %q", m.Type)
|
||||
}
|
||||
fmt.Fprintf(&b, ", CR: %s", strconv.FormatFloat(m.CR, 'g', -1, 64))
|
||||
if m.XP != 0 {
|
||||
fmt.Fprintf(&b, ", XP: %d", m.XP)
|
||||
}
|
||||
fmt.Fprintf(&b, ", HP: %d, AC: %d", m.HP, m.AC)
|
||||
if m.SpeedWalk != 0 {
|
||||
fmt.Fprintf(&b, ", SpeedWalk: %d", m.SpeedWalk)
|
||||
}
|
||||
fmt.Fprintf(&b, ", STR: %d, DEX: %d, CON: %d, INT: %d, WIS: %d, CHA: %d",
|
||||
m.STR, m.DEX, m.CON, m.INT, m.WIS, m.CHA)
|
||||
if m.Multiattack != "" {
|
||||
fmt.Fprintf(&b, ", Multiattack: %q", m.Multiattack)
|
||||
}
|
||||
if len(m.Attacks) > 0 {
|
||||
b.WriteString(", Attacks: []SRDStatAttack{")
|
||||
for i, a := range m.Attacks {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "{Name: %q, AttackBonus: %d, DamageDice: %q, DamageBonus: %d, AvgDamage: %d",
|
||||
a.Name, a.AttackBonus, a.DamageDice, a.DamageBonus, a.AvgDamage)
|
||||
if a.DamageType != "" {
|
||||
fmt.Fprintf(&b, ", DamageType: %q", a.DamageType)
|
||||
}
|
||||
b.WriteString("}")
|
||||
}
|
||||
b.WriteString("}")
|
||||
}
|
||||
if len(m.Traits) > 0 {
|
||||
b.WriteString(", Traits: []string{")
|
||||
for i, t := range m.Traits {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%q", t)
|
||||
}
|
||||
b.WriteString("}")
|
||||
}
|
||||
if m.Legendary {
|
||||
b.WriteString(", Legendary: true")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
152
cmd/open5e-import/desc_overrides.go
Normal file
152
cmd/open5e-import/desc_overrides.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Description-cleaning hooks shared by the spell and magic-item generators.
|
||||
//
|
||||
// The Open5e dump descriptions are SRD prose pulled verbatim — they leak
|
||||
// "within range," "WIS saving throw," "your Constitution score is 19," and
|
||||
// other rules-text the game now hides. This file handles two layers:
|
||||
//
|
||||
// - A sanitizer (cleanDesc) strips the worst jargon clauses from the auto
|
||||
// first-sentence and tidies the result. Used as the default path.
|
||||
// - Per-ID override maps win outright when a description needs more than
|
||||
// mechanical scrubbing (e.g. Amulet of Health's "score is 19," all of
|
||||
// the Mage default-list cantrips). Hand-author here, not in the
|
||||
// generated file — these survive a regen.
|
||||
|
||||
// spellDescOverride wins over the auto-derived first-sentence Description
|
||||
// when the slug matches. Cover anything that surfaces in the player's
|
||||
// spellbook listing (defaultKnownSpells) or the !spells available command.
|
||||
//
|
||||
// Tone target: outcome-first, second person, plain English. No DC, no slot
|
||||
// jargon, no "within range." Match the voice already in dnd_spells_data.go.
|
||||
var spellDescOverride = map[string]string{
|
||||
// SRD-only spells that appear in defaultKnownSpells (no hand-authored
|
||||
// overlay in dnd_spells_data.go). Sourced from the S3 surfaced-spells
|
||||
// audit; each gives the player a one-line read of what it does, with
|
||||
// just enough bite that the spellbook isn't a phone book.
|
||||
"call_lightning": "Summon a sulking thundercloud, then ask it — politely — to drop a bolt on someone you dislike.",
|
||||
"charm_person": "Sweet-talk a humanoid into treating you as their new favourite acquaintance. Wears off; they may notice.",
|
||||
"conjure_animals": "Call up a posse of summoned beasts. They are very enthusiastic and only mostly trained.",
|
||||
"eldritch_blast": "A beam of crackling otherworldly Whatever streaks at a target. Your patron's preferred greeting.",
|
||||
"entangle": "The ground sprouts rude, grabby roots in a patch. Anyone caught looks undignified at best.",
|
||||
"faerie_fire": "Outline targets in glittery light. They can't hide; you hit them more easily; they hate it.",
|
||||
"fear": "Project a cone of pure dread. Brave folk turn and run; less brave folk were already running.",
|
||||
"flaming_sphere": "A rolling ball of fire shows up and goes wherever you point. Carpet not included.",
|
||||
"haste": "An ally surges with speed — extra ground, sharper defences, an extra swing. Brief, glorious, exhausting.",
|
||||
"healing_word": "Bark a healing syllable across the battlefield. An ally finds themselves slightly less dead.",
|
||||
"heat_metal": "Their weapon, their armour, their belt buckle — glowing red-hot. Hard to grip. Worse to wear.",
|
||||
"heroism": "An ally takes courage. No fear, more grit, slightly insufferable battle cries.",
|
||||
"hideous_laughter": "Target collapses in a fit of unstoppable giggling. Awkward for them, very funny for you.",
|
||||
"insect_plague": "A swarm of biting locusts boils up in an area. Everyone inside has Opinions about it.",
|
||||
"invisibility": "An ally fades from sight. Lasts until they attack, cast, or yell something dramatic.",
|
||||
"produce_flame": "A flame springs to your palm. Use it as a torch, or sling it at someone. Multipurpose.",
|
||||
"ray_of_frost": "A freezing beam catches a target and leaves them sluggish, frosted, and visibly cross.",
|
||||
"vampiric_touch": "Drain life from a target. The damage you deal patches you up. Frowned upon at parties.",
|
||||
"vicious_mockery": "Hurl an insult so cutting it physically hurts. Their next swing wobbles in shame.",
|
||||
|
||||
// Hand-authored overlay descriptions in dnd_spells_data.go are the
|
||||
// canonical text and win at registry merge time, so they don't need
|
||||
// entries here. If a spell ever moves out of buildSpellList(), add it
|
||||
// above.
|
||||
}
|
||||
|
||||
// magicItemDescOverride wins over the auto-derived Desc when the slug
|
||||
// matches. Priority targets: items the plan called out by name (Amulet of
|
||||
// Health, Belt of Giant Strength) and any item whose SRD blurb leaks rules
|
||||
// math or names a spell the player can't cast.
|
||||
var magicItemDescOverride = map[string]string{
|
||||
"amulet_of_health": "Hums against your collarbone and quietly upgrades your constitution to 'bothersome to kill.'",
|
||||
"belt_of_giant_strength_hill": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
|
||||
"belt_of_giant_strength_stone": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
|
||||
"belt_of_giant_strength_frost": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
|
||||
"belt_of_giant_strength_fire": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
|
||||
"belt_of_giant_strength_cloud": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
|
||||
"belt_of_giant_strength_storm": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
|
||||
"belt_of_hill_giant_strength": "Buckle it on; suddenly you can deadlift the bartender. Hill-giant grade.",
|
||||
"belt_of_stone_giant_strength": "Buckle it on; the floor creaks where you weren't standing yesterday. Stone-giant grade.",
|
||||
"belt_of_frost_giant_strength": "Buckle it on; doors come off their hinges when you knock politely. Frost-giant grade.",
|
||||
"belt_of_fire_giant_strength": "Buckle it on; furniture rearranges itself to be elsewhere. Fire-giant grade.",
|
||||
"belt_of_cloud_giant_strength": "Buckle it on; you arm-wrestle siege engines for fun. Cloud-giant grade.",
|
||||
"belt_of_storm_giant_strength": "Buckle it on; the world feels strangely flimsy. Storm-giant grade.",
|
||||
"belt_of_dwarvenkind": "A stout dwarven belt — beard not included, hardiness very much included.",
|
||||
"boots_of_levitation": "Float upward at will. Down is a separate problem you'll figure out later.",
|
||||
"boots_of_speed": "Click the heels together; the world starts moving at a polite walking pace.",
|
||||
"boots_of_striding_and_springing": "Tireless stride and effortless hops. Ceilings: now optional.",
|
||||
"boots_of_the_winterlands": "Snow doesn't slow you, cold doesn't bite, and your tracks tactfully erase themselves.",
|
||||
"bracers_of_archery": "Steady your bow arm — your arrows hit harder and find seams in armour they shouldn't.",
|
||||
"bracers_of_defense": "Light bracers that quietly turn aside blows. No armour required, no questions asked.",
|
||||
"brooch_of_shielding": "Pinned to your cloak. Eats incoming bolts of force like they're hors d'oeuvres.",
|
||||
"cloak_of_displacement": "Bends light around you. Attackers keep swinging at where you obviously are, and missing.",
|
||||
"cloak_of_elvenkind": "Pulls shadows around you whether you asked or not. Watchful eyes slide right off.",
|
||||
"cloak_of_protection": "A perfectly ordinary travelling cloak that perfectly extraordinarily refuses to let you get hit.",
|
||||
"cloak_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or some other unpleasant Tuesday.",
|
||||
"cloak_of_the_bat": "By night you glide instead of fall; on a really good night, you fly. Daytime: just a cloak.",
|
||||
"cloak_of_the_manta_ray": "Underwater, you swim like a manta and breathe like you're on land. On land, you look damp.",
|
||||
"gauntlets_of_ogre_power": "Heavy gauntlets that lend you an ogre's strength and an ogre's complete disregard for door frames.",
|
||||
"headband_of_intellect": "Slips on, makes you think clever thoughts you have no business thinking. People notice.",
|
||||
"ring_of_protection": "A plain ring. Quietly turns aside blows you'd otherwise take. Refuses to explain how.",
|
||||
"ring_of_regeneration": "Wounds knit themselves back together while you wear it. Slowly. Smugly.",
|
||||
"ring_of_resistance": "Wards off one kind of harm — fire, frost, lightning, poison, or another flavour of bad day.",
|
||||
"ring_of_swimming": "Slip through water faster than any swimmer should. Dolphins file complaints.",
|
||||
"ring_of_warmth": "Cold weather no longer applies to you. Chill damage softens; you become unbearable in winter.",
|
||||
"slippers_of_spider_climbing": "Walk up walls and across ceilings as if the floor were merely a suggestion.",
|
||||
}
|
||||
|
||||
// cleanDesc strips the worst SRD jargon from a description before it lands in
|
||||
// the generated file. It is intentionally conservative: drop offending phrases,
|
||||
// collapse the resulting whitespace, leave the rest of the sentence alone. Any
|
||||
// description that still reads poorly after this pass should get a hand-authored
|
||||
// entry in spellDescOverride / magicItemDescOverride above.
|
||||
func cleanDesc(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
for _, pat := range descScrub {
|
||||
s = pat.ReplaceAllString(s, "")
|
||||
}
|
||||
// Collapse runs of whitespace and tidy stray punctuation orphans left
|
||||
// behind by deletions ("a creature and ", " , ", " .").
|
||||
s = reSpaces.ReplaceAllString(s, " ")
|
||||
s = strings.ReplaceAll(s, " ,", ",")
|
||||
s = strings.ReplaceAll(s, " .", ".")
|
||||
s = strings.ReplaceAll(s, "( )", "")
|
||||
// Repair sentence stubs left by clause-strippers — "...must make." after
|
||||
// the saving-throw object is gone, or trailing " and." where one half of
|
||||
// a coordinated noun phrase was removed. These are conservative: they
|
||||
// only fire when the orphan sits at end-of-sentence.
|
||||
s = reOrphanMustMake.ReplaceAllString(s, "$1")
|
||||
s = reOrphanTrailingAnd.ReplaceAllString(s, ".")
|
||||
s = strings.TrimSpace(s)
|
||||
return s
|
||||
}
|
||||
|
||||
var (
|
||||
reSpaces = regexp.MustCompile(`\s+`)
|
||||
reOrphanMustMake = regexp.MustCompile(`(?i)\s+must\s+(?:make|succeed on|attempt)([.,;])`)
|
||||
reOrphanTrailingAnd = regexp.MustCompile(`(?i),?\s+and\.`)
|
||||
descScrub = []*regexp.Regexp{
|
||||
// Range / distance jargon.
|
||||
regexp.MustCompile(`(?i)\s*\bwithin range\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bwithin \d+ feet(?: of [a-z ]+?)?`),
|
||||
regexp.MustCompile(`(?i)\s+\d+-foot[- ](?:radius|cone|cube|line|sphere|square)`),
|
||||
regexp.MustCompile(`(?i)\s+(?:out to a range of\s*)?\d+ feet\b`),
|
||||
regexp.MustCompile(`(?i)\s+out to a range of\b`),
|
||||
// Save / DC / slot jargon.
|
||||
regexp.MustCompile(`(?i)\s*\b(?:Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)\s+saving throws?\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bsaving throws?\b`),
|
||||
regexp.MustCompile(`(?i)\s*\(save(?:\s+DC\s*\d+)?\)`),
|
||||
regexp.MustCompile(`(?i)\s*\bDC\s*\d+\b`),
|
||||
regexp.MustCompile(`(?i)\s*\b(?:using|cast(?:ing)?(?: it)? with) a spell slot of \d+(?:st|nd|rd|th) level or higher\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bspell slot\b`),
|
||||
// Cross-references and stat-block leakage.
|
||||
regexp.MustCompile(`(?i)\s*\bsee the spell\b`),
|
||||
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+save\b`),
|
||||
regexp.MustCompile(`(?i)\s*\b(?:wisdom|strength|dexterity|constitution|intelligence|charisma)\s+score\s+is\s+\d+\b`),
|
||||
regexp.MustCompile(`(?i)\s*\bconstitution score\b`),
|
||||
}
|
||||
)
|
||||
122
cmd/open5e-import/desc_overrides_test.go
Normal file
122
cmd/open5e-import/desc_overrides_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCleanDescStripsJargon — the S3 acceptance criteria translated into
|
||||
// regression cases. Any phrase listed here must not survive the sanitizer.
|
||||
func TestCleanDescStripsJargon(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
mustNot []string
|
||||
}{
|
||||
{
|
||||
name: "saving throw + DC",
|
||||
in: "Each creature in the area must make a Dexterity saving throw (DC 15) or take damage.",
|
||||
mustNot: []string{
|
||||
"saving throw", "DC 15", "DC15",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "saving throws plural",
|
||||
in: "You have advantage on saving throws against spells.",
|
||||
mustNot: []string{"saving throws"},
|
||||
},
|
||||
{
|
||||
name: "within range",
|
||||
in: "Hurl a bolt at a creature within range.",
|
||||
mustNot: []string{"within range"},
|
||||
},
|
||||
{
|
||||
name: "5-foot radius",
|
||||
in: "Each creature in a 5-foot radius takes damage.",
|
||||
mustNot: []string{"5-foot"},
|
||||
},
|
||||
{
|
||||
name: "spell slot upcast clause",
|
||||
in: "When you cast this spell using a spell slot of 2nd level or higher, the damage increases.",
|
||||
mustNot: []string{"spell slot"},
|
||||
},
|
||||
{
|
||||
name: "constitution score",
|
||||
in: "Your Constitution score is 19 while you wear this amulet.",
|
||||
mustNot: []string{"Constitution score"},
|
||||
},
|
||||
{
|
||||
name: "(save DC X) parenthetical",
|
||||
in: "You can use an action to cast the detect thoughts spell (save DC 13) from it.",
|
||||
mustNot: []string{"(save", "DC 13"},
|
||||
},
|
||||
{
|
||||
name: "wisdom save",
|
||||
in: "Forces a wisdom save against the caster.",
|
||||
mustNot: []string{"wisdom save"},
|
||||
},
|
||||
{
|
||||
name: "out to a range of feet",
|
||||
in: "You have darkvision out to a range of 60 feet.",
|
||||
mustNot: []string{"feet", "out to a range"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := cleanDesc(tc.in)
|
||||
low := strings.ToLower(got)
|
||||
for _, ban := range tc.mustNot {
|
||||
if strings.Contains(low, strings.ToLower(ban)) {
|
||||
t.Errorf("cleanDesc kept banned phrase %q\n in: %q\n out: %q", ban, tc.in, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanDescRepairsOrphans — the strippers leave dangling stubs ("must
|
||||
// make.", " and."); the post-pass is meant to tidy them so player-facing
|
||||
// prose doesn't end mid-clause.
|
||||
func TestCleanDescRepairsOrphans(t *testing.T) {
|
||||
in := "You gain a +1 bonus to ability checks and saving throws."
|
||||
got := cleanDesc(in)
|
||||
if strings.HasSuffix(got, " and.") {
|
||||
t.Errorf("orphan trailing 'and.' survived: %q", got)
|
||||
}
|
||||
in2 := "Up to three creatures of your choice that you can see must make charisma saving throws."
|
||||
got2 := cleanDesc(in2)
|
||||
if strings.Contains(strings.ToLower(got2), "must make.") {
|
||||
t.Errorf("orphan 'must make.' survived: %q", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStripNameParenthetical — bestiary R21 / magic-item alias names.
|
||||
func TestStripNameParenthetical(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Giant Rat (Diseased)": "Giant Rat",
|
||||
"Deep Gnome (Svirfneblin)": "Deep Gnome",
|
||||
"Stone of Good Luck (Luckstone)": "Stone of Good Luck",
|
||||
"Ordinary Name": "Ordinary Name",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := stripNameParenthetical(in); got != want {
|
||||
t.Errorf("stripNameParenthetical(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpellOverrideWins — the curated descriptions for the SRD-only default
|
||||
// spells must beat the auto first-sentence path.
|
||||
func TestSpellOverrideWins(t *testing.T) {
|
||||
got := spellDescription("vicious_mockery", "anything could go here")
|
||||
if got != spellDescOverride["vicious_mockery"] {
|
||||
t.Errorf("override didn't win: got %q", got)
|
||||
}
|
||||
// Unknown ID falls through to cleanDesc.
|
||||
in := "Hurl a bolt at a creature within range."
|
||||
got = spellDescription("unknown_spell_xyz", in)
|
||||
if strings.Contains(got, "within range") {
|
||||
t.Errorf("fallthrough didn't sanitize: %q", got)
|
||||
}
|
||||
}
|
||||
339
cmd/open5e-import/gen.go
Normal file
339
cmd/open5e-import/gen.go
Normal file
@@ -0,0 +1,339 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// classEnum maps an Open5e spell_lists entry to the plugin's DnDClass enum
|
||||
// identifier. Open5e's "wizard" is the plugin's Mage; classes the plugin
|
||||
// doesn't model (artificer, etc.) are absent and silently dropped.
|
||||
//
|
||||
// classOrder fixes the iteration order so generated Classes slices are stable.
|
||||
var (
|
||||
classEnum = map[string]string{
|
||||
"wizard": "ClassMage",
|
||||
"cleric": "ClassCleric",
|
||||
"ranger": "ClassRanger",
|
||||
"druid": "ClassDruid",
|
||||
"bard": "ClassBard",
|
||||
"sorcerer": "ClassSorcerer",
|
||||
"warlock": "ClassWarlock",
|
||||
"paladin": "ClassPaladin",
|
||||
}
|
||||
classOrder = []string{"wizard", "cleric", "ranger", "druid", "bard", "sorcerer", "warlock", "paladin"}
|
||||
)
|
||||
|
||||
// mapClasses turns an Open5e spell's class tags into plugin enum identifiers,
|
||||
// deduped and in classOrder. It unions the structured spell_lists array with
|
||||
// the free-text dnd_class field ("Druid, Wizard") — the SRD dump's spell_lists
|
||||
// omits paladin entirely, so dnd_class is the more complete source. Returns
|
||||
// nil when the spell belongs to no modelled class (the caller drops those —
|
||||
// they have no caster to learn them).
|
||||
func mapClasses(lists []string, dndClass string) []string {
|
||||
seen := map[string]bool{}
|
||||
for _, l := range lists {
|
||||
seen[strings.ToLower(strings.TrimSpace(l))] = true
|
||||
}
|
||||
for _, l := range strings.Split(dndClass, ",") {
|
||||
seen[strings.ToLower(strings.TrimSpace(l))] = true
|
||||
}
|
||||
var out []string
|
||||
for _, c := range classOrder {
|
||||
if seen[c] {
|
||||
out = append(out, classEnum[c])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// genSpell is a fully classified spell ready for code emission. Fields mirror
|
||||
// plugin.SpellDefinition; enum-typed fields hold the Go identifier as a string.
|
||||
type genSpell struct {
|
||||
ID string
|
||||
Name string
|
||||
Level int
|
||||
School string
|
||||
Classes []string
|
||||
Effect string
|
||||
CastTime string
|
||||
Concentration bool
|
||||
SaveStat string
|
||||
AttackRoll bool
|
||||
DamageDice string
|
||||
DamageType string
|
||||
Description string
|
||||
Upcast string
|
||||
MaterialCost int
|
||||
AOE bool
|
||||
}
|
||||
|
||||
// genSpells reads the vendored spells.json, classifies every in-scope spell,
|
||||
// and writes the generated Go data file.
|
||||
func genSpells() error {
|
||||
raw, err := os.ReadFile(spellsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch spells` first?): %w", spellsJSON, err)
|
||||
}
|
||||
var src []open5eSpell
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var spells []genSpell
|
||||
var skippedLevel, skippedNoClass int
|
||||
for _, s := range src {
|
||||
if s.LevelInt > 5 {
|
||||
// Combat caps at L5 spell slots — nothing can ever cast these.
|
||||
skippedLevel++
|
||||
continue
|
||||
}
|
||||
classes := mapClasses(s.SpellLists, s.DndClass)
|
||||
if len(classes) == 0 {
|
||||
skippedNoClass++
|
||||
continue
|
||||
}
|
||||
spells = append(spells, classify(s, classes))
|
||||
}
|
||||
sort.Slice(spells, func(i, j int) bool {
|
||||
if spells[i].Level != spells[j].Level {
|
||||
return spells[i].Level < spells[j].Level
|
||||
}
|
||||
return spells[i].ID < spells[j].ID
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "classified %d spells (skipped %d level>5, %d no modelled class)\n",
|
||||
len(spells), skippedLevel, skippedNoClass)
|
||||
|
||||
out := emit(spells)
|
||||
if err := os.WriteFile(spellsGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", spellsGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Classifier ───────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
reSave = regexp.MustCompile(`(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw`)
|
||||
reDice = regexp.MustCompile(`\d+d\d+(?:\s*\+\s*\d+)?`)
|
||||
reDmgType = regexp.MustCompile(`(?i)(acid|bludgeoning|cold|fire|force|lightning|necrotic|piercing|poison|psychic|radiant|slashing|thunder)\s+damage`)
|
||||
reMaterial = regexp.MustCompile(`(?i)worth (?:at least )?([\d,]+)\s*gp`)
|
||||
saveStatMap = map[string]string{
|
||||
"Strength": "STR", "Dexterity": "DEX", "Constitution": "CON",
|
||||
"Intelligence": "INT", "Wisdom": "WIS", "Charisma": "CHA",
|
||||
}
|
||||
)
|
||||
|
||||
func classify(s open5eSpell, classes []string) genSpell {
|
||||
desc := strings.TrimSpace(s.Desc)
|
||||
low := strings.ToLower(desc)
|
||||
|
||||
g := genSpell{
|
||||
ID: strings.ReplaceAll(s.Slug, "-", "_"),
|
||||
Name: s.Name,
|
||||
Level: s.LevelInt,
|
||||
School: strings.ToLower(strings.TrimSpace(s.School)),
|
||||
Classes: classes,
|
||||
Concentration: s.RequiresConcentration,
|
||||
Description: spellDescription(strings.ReplaceAll(s.Slug, "-", "_"), desc),
|
||||
Upcast: firstSentence(strings.TrimSpace(s.HigherLevel), 200),
|
||||
AOE: looksAOE(low),
|
||||
}
|
||||
|
||||
// Cast time. Reaction/bonus action win over the ritual flag.
|
||||
ct := strings.ToLower(s.CastingTime)
|
||||
switch {
|
||||
case strings.Contains(ct, "bonus action"):
|
||||
g.CastTime = "CastBonusAction"
|
||||
case strings.Contains(ct, "reaction"):
|
||||
g.CastTime = "CastReaction"
|
||||
case s.CanBeCastAsRitual:
|
||||
g.CastTime = "CastRitual"
|
||||
default:
|
||||
g.CastTime = "CastAction"
|
||||
}
|
||||
|
||||
if m := reSave.FindStringSubmatch(desc); m != nil {
|
||||
g.SaveStat = saveStatMap[m[1]]
|
||||
}
|
||||
g.AttackRoll = strings.Contains(low, "spell attack")
|
||||
if m := reDice.FindString(desc); m != "" {
|
||||
g.DamageDice = strings.ReplaceAll(m, " ", "")
|
||||
}
|
||||
if m := reDmgType.FindStringSubmatch(low); m != nil {
|
||||
g.DamageType = strings.ToLower(m[1])
|
||||
}
|
||||
if m := reMaterial.FindStringSubmatch(s.Material); m != nil {
|
||||
g.MaterialCost, _ = strconv.Atoi(strings.ReplaceAll(m[1], ",", ""))
|
||||
}
|
||||
|
||||
g.Effect = classifyEffect(g, low)
|
||||
|
||||
// A heal spell's dice describe healing, not damage — keep DamageDice (the
|
||||
// hand-authored list does the same) but drop a stray damage type.
|
||||
if g.Effect == "EffectSpellHeal" {
|
||||
g.DamageType = ""
|
||||
}
|
||||
// Non-damaging effects shouldn't carry a damage type/dice picked up from
|
||||
// incidental wording.
|
||||
if g.Effect == "EffectControl" || g.Effect == "EffectUtility" ||
|
||||
g.Effect == "EffectReaction" {
|
||||
g.DamageDice, g.DamageType = "", ""
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// classifyEffect picks the SpellEffectKind. Heuristic and imperfect by design —
|
||||
// the hand-authored buildSpellList() overlay is the refinement path, so this
|
||||
// only needs to be a reasonable default.
|
||||
func classifyEffect(g genSpell, low string) string {
|
||||
if g.CastTime == "CastReaction" {
|
||||
return "EffectReaction"
|
||||
}
|
||||
isHeal := strings.Contains(low, "regain") && strings.Contains(low, "hit point") &&
|
||||
!strings.Contains(low, "damage")
|
||||
if isHeal {
|
||||
return "EffectSpellHeal"
|
||||
}
|
||||
if g.DamageDice != "" && g.DamageType != "" {
|
||||
switch {
|
||||
case g.AttackRoll:
|
||||
return "EffectDamageAttack"
|
||||
case g.SaveStat != "":
|
||||
return "EffectDamageSave"
|
||||
default:
|
||||
return "EffectDamageAuto"
|
||||
}
|
||||
}
|
||||
if g.SaveStat != "" {
|
||||
return "EffectControl"
|
||||
}
|
||||
// Light buff detection — anything still unclassified falls through to
|
||||
// utility, which is the safe bucket.
|
||||
if strings.Contains(low, "bonus to") || strings.Contains(low, "you gain") ||
|
||||
strings.Contains(low, "gains a bonus") || strings.Contains(low, "your ac") {
|
||||
if strings.Contains(low, "you gain") || strings.Contains(low, "yourself") ||
|
||||
strings.Contains(low, "your ac") {
|
||||
return "EffectBuffSelf"
|
||||
}
|
||||
return "EffectBuffAlly"
|
||||
}
|
||||
return "EffectUtility"
|
||||
}
|
||||
|
||||
func looksAOE(low string) bool {
|
||||
for _, kw := range []string{
|
||||
"each creature", "all creatures", "-foot radius", "-foot-radius",
|
||||
"foot cone", "foot cube", "foot line", "foot-radius sphere",
|
||||
} {
|
||||
if strings.Contains(low, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// spellDescription returns the description text emitted for a spell. A
|
||||
// hand-authored override in spellDescOverride wins outright; otherwise the
|
||||
// SRD first-sentence is run through cleanDesc to strip jargon clauses.
|
||||
func spellDescription(id, raw string) string {
|
||||
if s, ok := spellDescOverride[id]; ok {
|
||||
return s
|
||||
}
|
||||
return firstSentence(cleanDesc(raw), 200)
|
||||
}
|
||||
|
||||
// firstSentence trims free-text fields down to a single sentence, capped at
|
||||
// max runes, so generated Description/Upcast stay terminal-friendly.
|
||||
func firstSentence(s string, max int) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexAny(s, "\n"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if i := strings.Index(s, ". "); i >= 0 {
|
||||
s = s[:i+1]
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > max {
|
||||
s = strings.TrimSpace(s[:max]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emit(spells []genSpell) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD spell dump (data/open5e/spells.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch spells
|
||||
// go run ./cmd/open5e-import gen spells
|
||||
//
|
||||
// Effect/CastTime/SaveStat and friends come from a free-text classifier; the
|
||||
// hand-authored buildSpellList() overlays this slice and wins on ID collision,
|
||||
// so any misclassification here is correctable there rather than in the dump.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDSpellList() []SpellDefinition {
|
||||
return []SpellDefinition{
|
||||
`)
|
||||
for _, s := range spells {
|
||||
b.WriteString("\t\t{")
|
||||
fmt.Fprintf(&b, "ID: %q, Name: %q, Level: %d", s.ID, s.Name, s.Level)
|
||||
if s.School != "" {
|
||||
fmt.Fprintf(&b, ", School: %q", s.School)
|
||||
}
|
||||
b.WriteString(", Classes: []DnDClass{")
|
||||
for i, c := range s.Classes {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString(c)
|
||||
}
|
||||
b.WriteString("}")
|
||||
fmt.Fprintf(&b, ", Effect: %s, CastTime: %s", s.Effect, s.CastTime)
|
||||
if s.Concentration {
|
||||
b.WriteString(", Concentration: true")
|
||||
}
|
||||
if s.SaveStat != "" {
|
||||
fmt.Fprintf(&b, ", SaveStat: %q", s.SaveStat)
|
||||
}
|
||||
if s.AttackRoll {
|
||||
b.WriteString(", AttackRoll: true")
|
||||
}
|
||||
if s.DamageDice != "" {
|
||||
fmt.Fprintf(&b, ", DamageDice: %q", s.DamageDice)
|
||||
}
|
||||
if s.DamageType != "" {
|
||||
fmt.Fprintf(&b, ", DamageType: %q", s.DamageType)
|
||||
}
|
||||
if s.Description != "" {
|
||||
fmt.Fprintf(&b, ", Description: %q", s.Description)
|
||||
}
|
||||
if s.Upcast != "" {
|
||||
fmt.Fprintf(&b, ", Upcast: %q", s.Upcast)
|
||||
}
|
||||
if s.MaterialCost != 0 {
|
||||
fmt.Fprintf(&b, ", MaterialCost: %d", s.MaterialCost)
|
||||
}
|
||||
if s.AOE {
|
||||
b.WriteString(", AOE: true")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
313
cmd/open5e-import/magicitems.go
Normal file
313
cmd/open5e-import/magicitems.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// open5eMagicItem is the subset of the Open5e v1 magicitems schema we consume.
|
||||
// The vendored JSON keeps the full payload; unknown fields are ignored here.
|
||||
type open5eMagicItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Desc string `json:"desc"`
|
||||
Rarity string `json:"rarity"`
|
||||
RequiresAttunement string `json:"requires_attunement"`
|
||||
}
|
||||
|
||||
type magicItemsPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eMagicItem `json:"results"`
|
||||
}
|
||||
|
||||
// fetchMagicItems pages through the SRD magic-item list and writes the full
|
||||
// result set to data/open5e/magicitems.json, pretty-printed for a reviewable
|
||||
// diff.
|
||||
func fetchMagicItems() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eMagicItem
|
||||
url := magicItemsAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getMagicItemsPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d magic items\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(magicItemsJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(magicItemsJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getMagicItemsPage(client *http.Client, url string) (*magicItemsPage, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page magicItemsPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// ── Classifier ───────────────────────────────────────────────────────────────
|
||||
|
||||
// genMagicItem is a fully classified magic item ready for code emission. Fields
|
||||
// mirror plugin.MagicItem; enum-typed fields hold the Go identifier as a string.
|
||||
type genMagicItem struct {
|
||||
ID string
|
||||
Name string
|
||||
Kind string // plugin.MagicItemKind identifier
|
||||
Rarity string // plugin.DnDRarity identifier
|
||||
Attunement bool
|
||||
Slot string // plugin.DnDSlot identifier; "" = not equippable / ambiguous
|
||||
Value int
|
||||
Desc string
|
||||
}
|
||||
|
||||
// rarityEnum maps an Open5e rarity string to the plugin's DnDRarity identifier.
|
||||
// "artifact" folds into Legendary (the plugin has no artifact tier); "varies"
|
||||
// and unknown/blank values fall back to Common — the conservative bucket.
|
||||
var rarityEnum = map[string]string{
|
||||
"common": "RarityCommon",
|
||||
"uncommon": "RarityUncommon",
|
||||
"rare": "RarityRare",
|
||||
"very rare": "RarityVeryRare",
|
||||
"legendary": "RarityLegendary",
|
||||
"artifact": "RarityLegendary",
|
||||
}
|
||||
|
||||
// rarityValue is the sell-value midpoint per rarity, aligned with the §8.1
|
||||
// rarity brackets the zone-loot tables already use. Keyed by the Open5e rarity
|
||||
// string so the classifier can read it directly.
|
||||
var rarityValue = map[string]int{
|
||||
"common": 50,
|
||||
"uncommon": 150,
|
||||
"rare": 750,
|
||||
"very rare": 2500,
|
||||
"legendary": 8000,
|
||||
"artifact": 8000,
|
||||
}
|
||||
|
||||
// kindByPrefix maps the leading word of Open5e's free-text Type field
|
||||
// ("Wondrous item", "Weapon (any sword)", "Armor (medium or heavy)") to a
|
||||
// plugin.MagicItemKind identifier.
|
||||
var kindByPrefix = map[string]string{
|
||||
"wondrous": "MagicItemWondrous",
|
||||
"weapon": "MagicItemWeapon",
|
||||
"armor": "MagicItemArmor",
|
||||
"ring": "MagicItemRing",
|
||||
"wand": "MagicItemWand",
|
||||
"rod": "MagicItemRod",
|
||||
"staff": "MagicItemStaff",
|
||||
"potion": "MagicItemPotion",
|
||||
"scroll": "MagicItemScroll",
|
||||
}
|
||||
|
||||
// genMagicItems reads the vendored magicitems.json, classifies every SRD magic
|
||||
// item, and writes the generated Go data file.
|
||||
func genMagicItems() error {
|
||||
raw, err := os.ReadFile(magicItemsJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch magicitems` first?): %w", magicItemsJSON, err)
|
||||
}
|
||||
var src []open5eMagicItem
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
items := make([]genMagicItem, 0, len(src))
|
||||
var attunement, slotted int
|
||||
for _, m := range src {
|
||||
g := classifyMagicItem(m)
|
||||
if g.Attunement {
|
||||
attunement++
|
||||
}
|
||||
if g.Slot != "" {
|
||||
slotted++
|
||||
}
|
||||
items = append(items, g)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].ID < items[j].ID })
|
||||
fmt.Fprintf(os.Stderr, "classified %d magic items (%d need attunement, %d slotted)\n",
|
||||
len(items), attunement, slotted)
|
||||
|
||||
out := emitMagicItems(items)
|
||||
if err := os.WriteFile(magicItemsGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", magicItemsGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyMagicItem(m open5eMagicItem) genMagicItem {
|
||||
g := genMagicItem{
|
||||
ID: strings.ReplaceAll(m.Slug, "-", "_"),
|
||||
Name: stripNameParenthetical(m.Name),
|
||||
Attunement: strings.TrimSpace(m.RequiresAttunement) != "",
|
||||
Desc: magicItemDescription(strings.ReplaceAll(m.Slug, "-", "_"), strings.TrimSpace(m.Desc)),
|
||||
}
|
||||
|
||||
rarity := strings.ToLower(strings.TrimSpace(m.Rarity))
|
||||
if enum, ok := rarityEnum[rarity]; ok {
|
||||
g.Rarity = enum
|
||||
} else {
|
||||
g.Rarity = "RarityCommon"
|
||||
}
|
||||
if v, ok := rarityValue[rarity]; ok {
|
||||
g.Value = v
|
||||
} else {
|
||||
g.Value = rarityValue["common"]
|
||||
}
|
||||
|
||||
// Kind: first word of the Type field. Anything unrecognised (the SRD has a
|
||||
// handful of bare or oddly-prefixed entries) falls back to Wondrous.
|
||||
kindWord := ""
|
||||
if fields := strings.Fields(strings.ToLower(m.Type)); len(fields) > 0 {
|
||||
kindWord = fields[0]
|
||||
}
|
||||
if enum, ok := kindByPrefix[kindWord]; ok {
|
||||
g.Kind = enum
|
||||
} else {
|
||||
g.Kind = "MagicItemWondrous"
|
||||
}
|
||||
|
||||
g.Slot = inferSlot(g.Kind, m.Name)
|
||||
return g
|
||||
}
|
||||
|
||||
// inferSlot makes a best-effort guess at the equip slot. Weapons/staves go to
|
||||
// the main hand, wands/rods to the off hand, armor to the chest, rings to a
|
||||
// ring slot. Wondrous items are name-sniffed (cloak, boots, amulet, …) and
|
||||
// left blank when nothing matches. Potions and scrolls are consumables and
|
||||
// never carry a slot.
|
||||
func inferSlot(kind, name string) string {
|
||||
switch kind {
|
||||
case "MagicItemWeapon", "MagicItemStaff":
|
||||
return "DnDSlotMainHand"
|
||||
case "MagicItemWand", "MagicItemRod":
|
||||
return "DnDSlotOffHand"
|
||||
case "MagicItemArmor":
|
||||
return "DnDSlotChest"
|
||||
case "MagicItemRing":
|
||||
return "DnDSlotRing1"
|
||||
case "MagicItemPotion", "MagicItemScroll":
|
||||
return ""
|
||||
}
|
||||
// Wondrous item: sniff the name for a wearable noun.
|
||||
//
|
||||
// Match on word *prefix*, not raw substring — naive Contains() lights up
|
||||
// "ring" inside "Springing" / "Snaring" / "Devouring" and dumps boots,
|
||||
// gloves, and bags into the ring slot.
|
||||
low := strings.ToLower(name)
|
||||
tokens := strings.FieldsFunc(low, func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z')
|
||||
})
|
||||
for _, m := range []struct {
|
||||
kw string
|
||||
slot string
|
||||
}{
|
||||
{"amulet", "DnDSlotAmulet"}, {"necklace", "DnDSlotAmulet"},
|
||||
{"medallion", "DnDSlotAmulet"}, {"periapt", "DnDSlotAmulet"},
|
||||
{"brooch", "DnDSlotAmulet"}, {"pendant", "DnDSlotAmulet"},
|
||||
{"talisman", "DnDSlotAmulet"}, {"scarab", "DnDSlotAmulet"},
|
||||
{"ring", "DnDSlotRing1"},
|
||||
// Cloaks/capes/mantles get their own slot so they don't evict body
|
||||
// armor. Robes/vestments are body armor and stay on Chest.
|
||||
{"cloak", "DnDSlotCloak"}, {"cape", "DnDSlotCloak"},
|
||||
{"mantle", "DnDSlotCloak"}, {"wings", "DnDSlotCloak"},
|
||||
{"robe", "DnDSlotChest"}, {"vestments", "DnDSlotChest"},
|
||||
{"armor", "DnDSlotChest"},
|
||||
{"boots", "DnDSlotFeet"}, {"slippers", "DnDSlotFeet"},
|
||||
{"gauntlet", "DnDSlotHands"}, {"gauntlets", "DnDSlotHands"},
|
||||
{"gloves", "DnDSlotHands"}, {"bracers", "DnDSlotHands"},
|
||||
{"helm", "DnDSlotHead"}, {"hat", "DnDSlotHead"},
|
||||
{"crown", "DnDSlotHead"}, {"circlet", "DnDSlotHead"},
|
||||
{"goggles", "DnDSlotHead"}, {"eyes", "DnDSlotHead"},
|
||||
{"headband", "DnDSlotHead"},
|
||||
} {
|
||||
for _, t := range tokens {
|
||||
if strings.HasPrefix(t, m.kw) {
|
||||
return m.slot
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// magicItemDescription returns the Desc text emitted for an item. Override
|
||||
// wins; otherwise the SRD first-sentence runs through cleanDesc.
|
||||
func magicItemDescription(id, raw string) string {
|
||||
if s, ok := magicItemDescOverride[id]; ok {
|
||||
return s
|
||||
}
|
||||
return firstSentence(cleanDesc(raw), 200)
|
||||
}
|
||||
|
||||
// ── Code emission ────────────────────────────────────────────────────────────
|
||||
|
||||
func emitMagicItems(items []genMagicItem) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD magic-item dump (data/open5e/magicitems.json), 5e SRD
|
||||
// content under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch magicitems
|
||||
// go run ./cmd/open5e-import gen magicitems
|
||||
//
|
||||
// Kind/Rarity/Slot/Value come from a free-text classifier; the hand-authored
|
||||
// magicItemOverlay overlays this slice and wins on ID collision, so any
|
||||
// misclassification here is correctable there rather than in the dump.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildSRDMagicItems() []MagicItem {
|
||||
return []MagicItem{
|
||||
`)
|
||||
for _, m := range items {
|
||||
b.WriteString("\t\t{")
|
||||
fmt.Fprintf(&b, "ID: %q, Name: %q, Kind: %s, Rarity: %s", m.ID, m.Name, m.Kind, m.Rarity)
|
||||
if m.Attunement {
|
||||
b.WriteString(", Attunement: true")
|
||||
}
|
||||
if m.Slot != "" {
|
||||
fmt.Fprintf(&b, ", Slot: %s", m.Slot)
|
||||
}
|
||||
fmt.Fprintf(&b, ", Value: %d", m.Value)
|
||||
if m.Desc != "" {
|
||||
fmt.Fprintf(&b, ", Desc: %q", m.Desc)
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
162
cmd/open5e-import/main.go
Normal file
162
cmd/open5e-import/main.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Command open5e-import vendors Open5e SRD data (CC-BY-4.0) into the repo and
|
||||
// regenerates the Go data files the plugin compiles against.
|
||||
//
|
||||
// It is a pull-once / use-many tool — the generated files are committed, so
|
||||
// the running bot has no runtime dependency on the Open5e API.
|
||||
//
|
||||
// open5e-import fetch spells # vendor data/open5e/spells.json from the API
|
||||
// open5e-import gen spells # classify → internal/plugin/dnd_spells_srd_data.go
|
||||
// open5e-import fetch bestiary # vendor data/open5e/monsters.json from the API
|
||||
// open5e-import gen bestiary # classify → internal/plugin/bestiary_srd_data.go
|
||||
// open5e-import gen tuned # tuning formula → internal/plugin/bestiary_tuned_data.go
|
||||
// open5e-import fetch magicitems # vendor data/open5e/magicitems.json from the API
|
||||
// open5e-import gen magicitems # classify → internal/plugin/magic_items_srd_data.go
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
spellsAPIBase = "https://api.open5e.com/v1/spells/?document__slug=wotc-srd&limit=50"
|
||||
spellsJSON = "data/open5e/spells.json"
|
||||
spellsGenGo = "internal/plugin/dnd_spells_srd_data.go"
|
||||
|
||||
monstersAPIBase = "https://api.open5e.com/v1/monsters/?document__slug=wotc-srd&limit=50"
|
||||
monstersJSON = "data/open5e/monsters.json"
|
||||
bestiaryGenGo = "internal/plugin/bestiary_srd_data.go"
|
||||
tunedGenGo = "internal/plugin/bestiary_tuned_data.go"
|
||||
|
||||
magicItemsAPIBase = "https://api.open5e.com/v1/magicitems/?document__slug=wotc-srd&limit=50"
|
||||
magicItemsJSON = "data/open5e/magicitems.json"
|
||||
magicItemsGenGo = "internal/plugin/magic_items_srd_data.go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
usage()
|
||||
}
|
||||
verb, noun := os.Args[1], os.Args[2]
|
||||
switch verb {
|
||||
case "fetch":
|
||||
switch noun {
|
||||
case "spells":
|
||||
must(fetchSpells())
|
||||
case "bestiary":
|
||||
must(fetchMonsters())
|
||||
case "magicitems":
|
||||
must(fetchMagicItems())
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
case "gen":
|
||||
switch noun {
|
||||
case "spells":
|
||||
must(genSpells())
|
||||
case "bestiary":
|
||||
must(genBestiary())
|
||||
case "tuned":
|
||||
must(genTuned())
|
||||
case "magicitems":
|
||||
must(genMagicItems())
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: open5e-import fetch (spells|bestiary|magicitems) | gen (spells|bestiary|tuned|magicitems)")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// open5eSpell is the subset of the Open5e v1 spell schema we consume. Unknown
|
||||
// fields are ignored; the vendored JSON keeps the full payload.
|
||||
type open5eSpell struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Desc string `json:"desc"`
|
||||
HigherLevel string `json:"higher_level"`
|
||||
Range string `json:"range"`
|
||||
Material string `json:"material"`
|
||||
CanBeCastAsRitual bool `json:"can_be_cast_as_ritual"`
|
||||
Duration string `json:"duration"`
|
||||
RequiresConcentration bool `json:"requires_concentration"`
|
||||
CastingTime string `json:"casting_time"`
|
||||
LevelInt int `json:"level_int"`
|
||||
School string `json:"school"`
|
||||
// SpellLists is the structured class list, but the SRD dump leaves it
|
||||
// incomplete (no paladin entries at all). DndClass is the free-text
|
||||
// "Druid, Wizard" field and is the more complete source — mapClasses
|
||||
// unions both. Keep both fields vendored.
|
||||
SpellLists []string `json:"spell_lists"`
|
||||
DndClass string `json:"dnd_class"`
|
||||
}
|
||||
|
||||
type spellsPage struct {
|
||||
Next string `json:"next"`
|
||||
Results []open5eSpell `json:"results"`
|
||||
}
|
||||
|
||||
// fetchSpells pages through the SRD spell list and writes the full result set
|
||||
// to data/open5e/spells.json, pretty-printed for a reviewable diff.
|
||||
func fetchSpells() error {
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
var all []open5eSpell
|
||||
url := spellsAPIBase
|
||||
for url != "" {
|
||||
fmt.Fprintln(os.Stderr, "GET", url)
|
||||
page, err := getSpellsPage(client, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all = append(all, page.Results...)
|
||||
url = page.Next
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "fetched %d spells\n", len(all))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(spellsJSON), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(all, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = append(out, '\n')
|
||||
return os.WriteFile(spellsJSON, out, 0o644)
|
||||
}
|
||||
|
||||
func getSpellsPage(client *http.Client, url string) (*spellsPage, error) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var page spellsPage
|
||||
if err := json.Unmarshal(body, &page); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &page, nil
|
||||
}
|
||||
374
cmd/open5e-import/tuned.go
Normal file
374
cmd/open5e-import/tuned.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// genTuned derives an engine-ready DnDMonsterTemplate for every SRD monster and
|
||||
// writes the generated Go data file. It is the codified "tuning pass": the raw
|
||||
// SRD staging table one-shots gogobee's solo player, so this applies a
|
||||
// deterministic formula to scale each stat block down to the engine's
|
||||
// gameplay-tuned shape.
|
||||
//
|
||||
// Formula (reverse-engineered from the hand-tuned dndBestiary, 2026-05 passes):
|
||||
//
|
||||
// - HP, AC, AttackBonus — verbatim SRD (AC clamped to the engine's min 10).
|
||||
// - Attack — the auto-resolve damage stat, driven by CR alone
|
||||
// via attackByCR (raw SRD per-hit damage is deliberately ignored — CR is
|
||||
// the calibration axis the 2026-05-10 rebalance used).
|
||||
// - Speed, BlockRate — coarse baselines from SpeedWalk / AC; a later hand
|
||||
// pass refines them per creature.
|
||||
// - XP, CR — verbatim from the staging table.
|
||||
// - Ability — classified from the SRD trait names by
|
||||
// abilityFromTraits: the highest-priority recognised trait maps onto one of
|
||||
// the engine's MonsterAbility effects. Creatures whose traits are all
|
||||
// non-combat (Keen Smell, Amphibious, …) stay nil. Notes still records the
|
||||
// raw SRD multiattack/trait text so a hand pass can refine the pick.
|
||||
//
|
||||
// Hand-authored dndBestiary entries WIN: the generated map is merged in only
|
||||
// for IDs the roster doesn't already define (see bestiary_tuned.go).
|
||||
func genTuned() error {
|
||||
raw, err := os.ReadFile(monstersJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s (run `fetch bestiary` first?): %w", monstersJSON, err)
|
||||
}
|
||||
var src []open5eMonster
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tuned := make([]genTunedMonster, 0, len(src))
|
||||
for _, m := range src {
|
||||
tuned = append(tuned, tuneMonster(classifyMonster(m)))
|
||||
}
|
||||
sort.Slice(tuned, func(i, j int) bool {
|
||||
if tuned[i].CR != tuned[j].CR {
|
||||
return tuned[i].CR < tuned[j].CR
|
||||
}
|
||||
return tuned[i].ID < tuned[j].ID
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "tuned %d monsters\n", len(tuned))
|
||||
|
||||
out := emitTuned(tuned)
|
||||
if err := os.WriteFile(tunedGenGo, out, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "wrote", tunedGenGo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// genTunedMonster mirrors plugin.DnDMonsterTemplate. Ability is nil when none
|
||||
// of the creature's SRD traits map onto an engine effect.
|
||||
type genTunedMonster struct {
|
||||
ID, Name string
|
||||
CR float64
|
||||
HP, AC int
|
||||
Attack int
|
||||
AttackBonus int
|
||||
Speed int
|
||||
BlockRate float64
|
||||
XPValue int
|
||||
Ability *genMonsterAbility
|
||||
Notes string
|
||||
FireAttacker bool
|
||||
}
|
||||
|
||||
// genMonsterAbility mirrors plugin.MonsterAbility.
|
||||
type genMonsterAbility struct {
|
||||
Name string
|
||||
Phase string
|
||||
ProcChance float64
|
||||
Effect string
|
||||
}
|
||||
|
||||
// tuneMonster applies the tuning formula to one raw SRD stat block.
|
||||
func tuneMonster(b genStatBlock) genTunedMonster {
|
||||
ac := b.AC
|
||||
if ac < 10 { // engine minimum (see the zombie 8→10 note in dnd_bestiary.go)
|
||||
ac = 10
|
||||
}
|
||||
ability := abilityFromTraits(b.Traits)
|
||||
return genTunedMonster{
|
||||
ID: b.Slug,
|
||||
Name: stripNameParenthetical(b.Name),
|
||||
CR: b.CR,
|
||||
HP: b.HP,
|
||||
AC: ac,
|
||||
Attack: attackByCR(b.CR),
|
||||
AttackBonus: primaryAttackBonus(b.Attacks),
|
||||
Speed: tunedSpeed(b.SpeedWalk),
|
||||
BlockRate: tunedBlockRate(ac),
|
||||
XPValue: b.XP,
|
||||
Ability: ability,
|
||||
Notes: tunedNotes(b, ability),
|
||||
FireAttacker: isFireAttacker(b.Attacks),
|
||||
}
|
||||
}
|
||||
|
||||
// isFireAttacker returns true when a monster's signature damage is fire —
|
||||
// i.e., its highest-average-damage attack deals fire. Threshold at AvgDamage
|
||||
// >= 5 so a trivial fire fleck on an otherwise physical creature doesn't tag
|
||||
// the whole bestiary entry. Tieflings (FireResist) take half damage from the
|
||||
// primary attack of any creature flagged here.
|
||||
func isFireAttacker(attacks []genStatAttack) bool {
|
||||
bestAvg, bestType := 0, ""
|
||||
for _, a := range attacks {
|
||||
if a.AvgDamage > bestAvg {
|
||||
bestAvg = a.AvgDamage
|
||||
bestType = a.DamageType
|
||||
}
|
||||
}
|
||||
return bestType == "fire" && bestAvg >= 5
|
||||
}
|
||||
|
||||
// stripNameParenthetical drops a trailing "(…)" qualifier from a monster
|
||||
// name so the player-facing display reads "Giant Rat" rather than "Giant Rat
|
||||
// (Diseased)". The slug still carries the variant, so the engine keeps both
|
||||
// records distinct — only the display text loses the qualifier.
|
||||
func stripNameParenthetical(name string) string {
|
||||
i := strings.IndexByte(name, '(')
|
||||
if i <= 0 {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(name[:i])
|
||||
}
|
||||
|
||||
// traitAbilityRules maps SRD special-ability names onto the engine's
|
||||
// MonsterAbility effects. Each rule's Match list holds lowercase substrings; a
|
||||
// creature's trait matches a rule if any substring is contained in the
|
||||
// (lowercased, paren-stripped) trait name. The list is priority-ordered —
|
||||
// abilityFromTraits walks it top-down and the first rule that matches any of
|
||||
// the creature's traits wins, so the most combat-defining mechanic is picked
|
||||
// when a stat block carries several traits. Traits that name no rule (Keen
|
||||
// Smell, Amphibious, Hold Breath, telepathy/senses flavor) leave Ability nil.
|
||||
//
|
||||
// This is a best-effort baseline, like the rest of the tuning formula: the
|
||||
// hand-authored dndBestiary entry always wins the merge, so refining a pick is
|
||||
// a matter of adding the creature to the roster.
|
||||
var traitAbilityRules = []struct {
|
||||
match []string
|
||||
name string
|
||||
phase string
|
||||
proc float64
|
||||
effect string
|
||||
}{
|
||||
// Death-triggered blasts — resolve on the killing-blow phase.
|
||||
{[]string{"death burst", "death throes", "elemental demise"}, "Death Burst", "decisive", 1.0, "death_aoe"},
|
||||
// Spellcasters open with a magical barrage.
|
||||
{[]string{"spellcasting"}, "Spellcasting", "opening", 0.5, "aoe"},
|
||||
// Cheats death once.
|
||||
{[]string{"undead fortitude"}, "Undead Fortitude", "decisive", 0.5, "survive_at_1"},
|
||||
// Self-sustain — trolls, liches clawing back.
|
||||
{[]string{"regeneration", "rejuvenation"}, "Regeneration", "any", 0.5, "regenerate"},
|
||||
// Shrugs off spells.
|
||||
{[]string{"magic resistance", "legendary resistance", "magic immunity", "spell immunity"}, "Magic Resistance", "any", 0.5, "spell_resist"},
|
||||
// Ambush predators — a heavy opening strike.
|
||||
{[]string{"surprise attack", "assassinate", "ambusher", "shadow stealth"}, "Surprise Attack", "opening", 0.8, "bonus_damage"},
|
||||
// Fear and petrification lock the player out of acting.
|
||||
{[]string{"petrifying gaze", "fear aura", "horrific appearance"}, "Frightful Presence", "opening", 0.4, "stun"},
|
||||
// Frenzy / rage — damage ramps as the fight drags.
|
||||
{[]string{"blood frenzy", "rampage", "reckless", "aggressive", "berserk", "brute"}, "Frenzy", "any", 0.4, "enrage"},
|
||||
// Damaging auras and spiky hides punish every exchange.
|
||||
{[]string{"heated body", "fire aura", "fire form", "stench", "mucous cloud", "barbed hide", "reflective carapace", "heated weapons", "hellish weapons"}, "Damaging Aura", "any", 0.4, "retaliate"},
|
||||
// Charges, pounces, leaps — an opening lunge.
|
||||
{[]string{"charge", "pounce", "standing leap", "running leap"}, "Charge", "opening", 0.4, "bonus_damage"},
|
||||
// Coordinated attackers gain advantage.
|
||||
{[]string{"pack tactics"}, "Pack Tactics", "any", 0.3, "advantage"},
|
||||
// Riders that pile damage onto a landed hit.
|
||||
{[]string{"sneak attack", "martial advantage"}, "Martial Advantage", "any", 0.35, "bonus_damage"},
|
||||
// Slippery movement — dodges a blow outright.
|
||||
{[]string{"nimble escape", "misty escape", "cunning action", "evasion", "incorporeal movement", "ethereal jaunt", "flyby", "shapechanger"}, "Evasive", "any", 0.3, "evade"},
|
||||
// Enchanted weapons — a flat damage edge.
|
||||
{[]string{"magic weapons", "angelic weapons"}, "Magic Weapons", "any", 0.3, "bonus_damage"},
|
||||
}
|
||||
|
||||
// abilityFromTraits picks the highest-priority MonsterAbility implied by a
|
||||
// creature's SRD trait names, or nil when none of them map onto an engine
|
||||
// effect. See traitAbilityRules for the priority order.
|
||||
func abilityFromTraits(traits []string) *genMonsterAbility {
|
||||
norm := make([]string, len(traits))
|
||||
for i, t := range traits {
|
||||
// Drop the parenthetical suffix SRD appends to recharge-limited
|
||||
// traits ("Legendary Resistance (3/Day)").
|
||||
if p := strings.IndexByte(t, '('); p >= 0 {
|
||||
t = t[:p]
|
||||
}
|
||||
norm[i] = strings.ToLower(strings.TrimSpace(t))
|
||||
}
|
||||
for _, rule := range traitAbilityRules {
|
||||
for _, tr := range norm {
|
||||
for _, m := range rule.match {
|
||||
if strings.Contains(tr, m) {
|
||||
return &genMonsterAbility{
|
||||
Name: rule.name,
|
||||
Phase: rule.phase,
|
||||
ProcChance: rule.proc,
|
||||
Effect: rule.effect,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// attackByCRPoints anchors the Attack-stat curve against the hand-tuned
|
||||
// dndBestiary: each point is an (CR, observed Attack) pair lifted straight from
|
||||
// a playtested roster entry. attackByCR interpolates between anchors and
|
||||
// extrapolates past the top one along the final segment's slope.
|
||||
var attackByCRPoints = []struct {
|
||||
cr float64
|
||||
atk int
|
||||
}{
|
||||
{0, 1}, {0.125, 1}, {0.25, 1}, {0.5, 2}, {1, 2}, {2, 4}, {3, 6}, {4, 7},
|
||||
{5, 8}, {6, 10}, {7, 12}, {8, 13}, {9, 14}, {10, 16}, {11, 18}, {12, 19},
|
||||
{13, 21}, {16, 26}, {19, 31}, {20, 33}, {24, 38},
|
||||
}
|
||||
|
||||
func attackByCR(cr float64) int {
|
||||
pts := attackByCRPoints
|
||||
if cr <= pts[0].cr {
|
||||
return pts[0].atk
|
||||
}
|
||||
last := pts[len(pts)-1]
|
||||
if cr >= last.cr {
|
||||
prev := pts[len(pts)-2]
|
||||
slope := float64(last.atk-prev.atk) / (last.cr - prev.cr)
|
||||
atk := int(math.Round(float64(last.atk) + slope*(cr-last.cr)))
|
||||
if atk < 1 {
|
||||
atk = 1
|
||||
}
|
||||
return atk
|
||||
}
|
||||
for i := 1; i < len(pts); i++ {
|
||||
if cr <= pts[i].cr {
|
||||
lo, hi := pts[i-1], pts[i]
|
||||
t := (cr - lo.cr) / (hi.cr - lo.cr)
|
||||
atk := int(math.Round(float64(lo.atk) + t*float64(hi.atk-lo.atk)))
|
||||
if atk < 1 {
|
||||
atk = 1
|
||||
}
|
||||
return atk
|
||||
}
|
||||
}
|
||||
return last.atk
|
||||
}
|
||||
|
||||
// primaryAttackBonus returns the d20 to-hit modifier of the creature's
|
||||
// hardest-hitting attack — the one the engine's single Attack stat stands in
|
||||
// for. Returns 0 (engine falls back to the tier-scaled bonus) when the stat
|
||||
// block has no parsed weapon attacks.
|
||||
func primaryAttackBonus(attacks []genStatAttack) int {
|
||||
bonus, best := 0, -1
|
||||
for _, a := range attacks {
|
||||
if a.AvgDamage > best {
|
||||
best, bonus = a.AvgDamage, a.AttackBonus
|
||||
}
|
||||
}
|
||||
return bonus
|
||||
}
|
||||
|
||||
// tunedSpeed maps an SRD walk speed (feet) onto the engine's speed scale. The
|
||||
// engine treats speed as an initiative-ordering knob, so a coarse divide-and-
|
||||
// clamp is enough; the ability pass can hand-tune outliers.
|
||||
func tunedSpeed(walk int) int {
|
||||
if walk <= 0 {
|
||||
return 10
|
||||
}
|
||||
s := int(math.Round(float64(walk) / 2.5))
|
||||
if s < 6 {
|
||||
s = 6
|
||||
}
|
||||
if s > 18 {
|
||||
s = 18
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// tunedBlockRate is a baseline damage-halve chance keyed off AC — heavier armor
|
||||
// reads as a better chance to soak a hit. The ability pass overrides this for
|
||||
// creatures whose block is a real mechanic (Parry, shields, etc.).
|
||||
func tunedBlockRate(ac int) float64 {
|
||||
switch {
|
||||
case ac >= 18:
|
||||
return 0.15
|
||||
case ac >= 15:
|
||||
return 0.10
|
||||
case ac >= 13:
|
||||
return 0.05
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
// tunedNotes records the SRD multiattack and trait text so a hand-tuning pass
|
||||
// has the source material in front of it, and states which ability (if any)
|
||||
// the trait classifier wired.
|
||||
func tunedNotes(b genStatBlock, ability *genMonsterAbility) string {
|
||||
prefix := "SRD-tuned baseline — no ability wired (traits are non-combat)."
|
||||
if ability != nil {
|
||||
prefix = fmt.Sprintf("SRD-tuned baseline — %s (%s) wired from traits.",
|
||||
ability.Name, ability.Effect)
|
||||
}
|
||||
var parts []string
|
||||
if b.Multiattack != "" {
|
||||
parts = append(parts, "Multiattack: "+b.Multiattack)
|
||||
}
|
||||
if len(b.Traits) > 0 {
|
||||
parts = append(parts, "Traits: "+strings.Join(b.Traits, ", "))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return prefix
|
||||
}
|
||||
return prefix + " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// emitTuned renders the tuned templates as a generated Go source file.
|
||||
func emitTuned(monsters []genTunedMonster) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`// Code generated by cmd/open5e-import. DO NOT EDIT.
|
||||
//
|
||||
// Source: Open5e SRD monster dump (data/open5e/monsters.json), 5e SRD content
|
||||
// under CC-BY-4.0 — see NOTICE. Regenerate with:
|
||||
//
|
||||
// go run ./cmd/open5e-import fetch bestiary
|
||||
// go run ./cmd/open5e-import gen tuned
|
||||
//
|
||||
// Engine-ready DnDMonsterTemplates derived from the raw SRD staging table by
|
||||
// the tuning formula in cmd/open5e-import/tuned.go. Hand-authored dndBestiary
|
||||
// entries take precedence — see bestiary_tuned.go for the merge.
|
||||
|
||||
package plugin
|
||||
|
||||
func buildTunedBestiarySRD() map[string]DnDMonsterTemplate {
|
||||
return map[string]DnDMonsterTemplate{
|
||||
`)
|
||||
for _, m := range monsters {
|
||||
fmt.Fprintf(&b, "\t\t%q: {ID: %q, Name: %q, CR: %s, HP: %d, AC: %d, Attack: %d, AttackBonus: %d, Speed: %d, BlockRate: %s, XPValue: %d, ",
|
||||
m.ID, m.ID, m.Name,
|
||||
strconv.FormatFloat(m.CR, 'g', -1, 32),
|
||||
m.HP, m.AC, m.Attack, m.AttackBonus, m.Speed,
|
||||
strconv.FormatFloat(m.BlockRate, 'g', -1, 64),
|
||||
m.XPValue)
|
||||
if m.Ability != nil {
|
||||
fmt.Fprintf(&b, "Ability: &MonsterAbility{Name: %q, Phase: %q, ProcChance: %s, Effect: %q}, ",
|
||||
m.Ability.Name, m.Ability.Phase,
|
||||
strconv.FormatFloat(m.Ability.ProcChance, 'g', -1, 64),
|
||||
m.Ability.Effect)
|
||||
}
|
||||
fmt.Fprintf(&b, "Notes: %q", m.Notes)
|
||||
if m.FireAttacker {
|
||||
b.WriteString(", FireAttacker: true")
|
||||
}
|
||||
b.WriteString("},\n")
|
||||
}
|
||||
b.WriteString("\t}\n}\n")
|
||||
return b.Bytes()
|
||||
}
|
||||
1898
data/open5e/magicitems.json
Normal file
1898
data/open5e/magicitems.json
Normal file
File diff suppressed because one or more lines are too long
15701
data/open5e/monsters.json
Normal file
15701
data/open5e/monsters.json
Normal file
File diff suppressed because it is too large
Load Diff
6251
data/open5e/spells.json
Normal file
6251
data/open5e/spells.json
Normal file
File diff suppressed because it is too large
Load Diff
40
go.mod
40
go.mod
@@ -3,45 +3,45 @@ module gogobee
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.3
|
||||
github.com/expr-lang/expr v1.17.5
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/chehsunliu/poker v0.1.0
|
||||
github.com/expr-lang/expr v1.17.8
|
||||
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
|
||||
maunium.net/go/mautrix v0.26.3
|
||||
modernc.org/sqlite v1.37.1
|
||||
golang.org/x/image v0.40.0
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
modernc.org/sqlite v1.50.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.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
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/zerolog v1.34.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/rs/zerolog v1.35.1 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
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
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
115
go.sum
115
go.sum
@@ -1,26 +1,24 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/chehsunliu/poker v0.1.0 h1:OeB4O+QROhA/DiXUhBBlkgbzCx0ZVWMpWgKNu+PX9vI=
|
||||
github.com/chehsunliu/poker v0.1.0/go.mod h1:V6K4yyDbafp0k6lUnYbwoTS/KsHSB1EWiJdEk54uB1w=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
|
||||
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
|
||||
github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -28,25 +26,26 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
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=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
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/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/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=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -55,9 +54,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -65,8 +63,8 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
@@ -75,27 +73,27 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
@@ -105,8 +103,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -114,14 +112,13 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -129,8 +126,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -149,43 +146,47 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
||||
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
||||
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
|
||||
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
280
gogobee_long_expedition_plan.md
Normal file
280
gogobee_long_expedition_plan.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Long Expeditions — multi-session plan
|
||||
|
||||
> Goal: dungeon expeditions become real multi-day journeys (T1 ≈ 2 days → T5 ≈ 7 days). Player picks **camp supplies** at launch, then watches it play out. Camp pitch/break, elite engagement, and **boss engagement** all become autonomous. Foreground `!camp` / `!fight` survive as overrides, not the expected path.
|
||||
|
||||
## Status @ 2026-05-28
|
||||
|
||||
**Core track (D1–D7) — DONE.** All phases shipped; v1 of the long-expedition mechanics is live on `long-expeditions-d1`.
|
||||
|
||||
**§6 decisions (2026-05-28) re-opened two threads** that ride after D8:
|
||||
- **D9** — T1–T3 room-count bump toward the 3–5× target band (D1 landed T1–T3 at ~2×). Sim-first: read T1/T2 day-counts from a fresh corpus *after* D8 lands.
|
||||
- **D10** — T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone). Bundle with D9 to avoid touching graphs twice.
|
||||
|
||||
**Active work — §8 (J3 caster sim-picker):** D8-a / D8-b / D8-prereq / D8-d-diagnostic shipped; **D8-c (concentration), D8-d-fix (AC floor lift), D8-e (martial regression triage)** queued. D8 is sequentially the gate before D9/D10 — room counts only make sense once the sim accurately scores the classes that will walk through them.
|
||||
|
||||
**Sequence:** D8-c → D8-d-fix → D8-e → D9 → D10. Re-baseline once at the end.
|
||||
|
||||
## 1. Why the current shape is "too short"
|
||||
|
||||
| Tier | Zones | Rooms | Real-time exit |
|
||||
|------|-------|------|----------------|
|
||||
| T1 | Goblin Warrens, Crypt Valdris | 6–7 | usually <1 calendar day |
|
||||
| T2 | Forest Shadows, Sunken Temple | 7–8 | usually 1 day |
|
||||
| T3 | Manor Blackspire, Underforge | 7–9 | 1–2 days |
|
||||
| T4 | Underdark, Feywild Crossing | 8–10 (×regions) | 2–3 days |
|
||||
| T5 | Dragon's Lair, Abyss Portal | 9–10 (×regions) | 2–4 days |
|
||||
|
||||
(Room counts from `internal/plugin/dnd_zone.go:70-134`; tier zones from memory `project_expedition_difficulty`.)
|
||||
|
||||
Because the autopilot walks ~3 rooms / 2h (`expedition_autorun.go:43,53`) and boss/elite stops are manual, a player who pays attention clears T1–T3 in an evening. Briefing/recap UTC ticks fire but rarely matter — the expedition is over before "day 2" ever lands.
|
||||
|
||||
## 2. Target shape
|
||||
|
||||
| Tier | Target duration | New room budget (single-region) | Notes |
|
||||
|------|-----------------|---------------------------------|-------|
|
||||
| T1 | 2 days | 12–14 | one autopilot-camp |
|
||||
| T2 | 3 days | 16–20 | two camps |
|
||||
| T3 | 4 days | 22–26 | three camps; threat starts to bite |
|
||||
| T4 | 5–6 days | 28–34 (split across 3–4 regions) | base camp emerges |
|
||||
| T5 | 7 days | 36–44 (split across 3–4 regions) | full siege/temporal pressure |
|
||||
|
||||
(Numbers are first-draft anchors; class-balance sim drives the actual dial in D7.)
|
||||
|
||||
Duration is measured in **expedition-days advanced by autopilot camp**, not real-time UTC days. Each autopilot night-camp = day++. Real-time UTC still drives the morning briefing DM cadence, but day-count and supply burn become event-driven (camp = sleep = day++ = burn).
|
||||
|
||||
The autopilot decides:
|
||||
- when to camp (and which camp type)
|
||||
- whether to engage elites and bosses, or to camp first and retry
|
||||
- whether to extract early on starvation/HP collapse
|
||||
|
||||
The player decides:
|
||||
- supply pack purchases at launch (only meaningful pre-expedition choice)
|
||||
- whether to override (`!camp <kind>`, `!fight`, `!expedition extract`)
|
||||
|
||||
## 3. Phasing
|
||||
|
||||
Built across multiple sessions; each phase ships independently with tests and a small DM-side feedback loop.
|
||||
|
||||
### D1 — Length + room-count rework
|
||||
**Files:** `dnd_zone.go` (per-zone Min/MaxRooms), `dnd_expedition_region.go` (multi-region region sizes), `dnd_zone_run.go:generateRoomSequence` (still terminates Entry → … → Elite → Boss, but with more Explorations).
|
||||
**Work:**
|
||||
- Re-pitch Min/MaxRooms per zone to the table in §2.
|
||||
- Multi-region zones: extend per-region room budgets so the total stays in band when summed.
|
||||
- Keep the fixed Entry / Trap / Elite / Boss anchors; only Exploration count scales.
|
||||
- Consider 2 traps + 2 elites for T4–T5 to break up the long middle (deferred decision — see §6).
|
||||
- Update existing zone graphs in `zone_graph_*.go` for the new lengths. *Auditing whether each zone has a hand-tuned graph or relies on the linear compiler is part of D1 scoping.*
|
||||
|
||||
**Exit criteria:** `expedition-sim` shows mean-room-cleared per zone in the new band; no test fixtures hard-code old counts.
|
||||
|
||||
### D2 — Autopilot camp scheduler
|
||||
|
||||
**D2-a (shipped 2026-05-27):** new `expedition_autocamp.go` with pure `decideAutopilotCamp` + `pitchAutopilotCamp` + dwell-window lifecycle (`shouldSkipAutoRunForCamp`, `breakAutoCampIfDue`). Wired into `tryAutoRun`: skip while inside `minAutoCampDwell` (4h), break + walk past it, post-walk scheduler pitches HP-low rest camps and region-boss base-camp waypoints. CampState gained `AutoPitched` so auto- vs player-pitched lifetimes diverge. Day rollover is still UTC-anchored; D2-b moves it event-anchored.
|
||||
|
||||
**D2-b (shipped 2026-05-27):** event-anchored day rollover. `dnd_expedition_cycle.go` adds `eventAnchoredCutoff` + `isEventAnchored`, splits the briefing body into `nightRolloverBurn` + `nightRolloverDrift` (+ a convenience `processNightCamp` that runs both back-to-back). For expeditions started ≥ cutoff, `deliverBriefing` skips mutators and posts a re-engagement DM; if `last_briefing_at` is older than `nightSafetyNet` (28h) it force-fires `processNightCamp` so a stalled autopilot doesn't freeze the expedition. The autopilot scheduler grew a `Night` flag — `decideAutopilotCamp` sets it when `time.Since(LastBriefingAt) ≥ nightCampWindow` (16h); `pitchAutopilotCamp` then runs the burn → camp → rest → drift sequence in one pitch. Manual `!camp <type>` runs the same flow when it's the first camp since the last rollover. Legacy UTC-anchored expeditions (start_date < cutoff) keep the original mutator flow via the same staged helpers, preserving rest-before-drift ordering through `processOvernightCamp`. Tests fence cutoff to year 9999 in `TestMain` so existing legacy assertions stand; new tests exercise the night decision, night-pitch rollover, event-anchored skip, and safety-net force.
|
||||
|
||||
|
||||
**Files:** new `expedition_autocamp.go`; hooks in `expedition_autorun.go:tryAutoRun`; reuse `dnd_expedition_camp.go:campPitch` / `applyCampRest`.
|
||||
**Heuristics (in priority order):**
|
||||
1. **Day-budget pacing** — pitch when rooms-walked-this-day ≥ tier-specific day-room target. Standard if room cleared, rough if not.
|
||||
2. **Resource gates** — pitch if HP < 35% of max, or supplies projected to cover <1 more day's burn (`exp.Supplies.Current < exp.Supplies.DailyBurn * harshMod`).
|
||||
3. **Post-elite breath** — auto-pitch a standard camp in the cleared elite room.
|
||||
4. **Region boss cleared in multi-region zone** — auto-pitch base camp at first eligible site.
|
||||
5. **Don't pitch when** mid-fight, in a trap/boss room, in a fork-pending state, or already camped.
|
||||
|
||||
Auto-break already happens on move (`autoBreakCampOnMove` in `dnd_expedition_camp.go:401`). Autopilot just needs to *not* break a freshly-pitched camp by immediately walking on — gate by `time.Since(camp.EstablishedAt) > minCampDwell` (15min real-time? or simply: don't auto-walk while camped).
|
||||
|
||||
**Day-rollover semantics change (decided event-anchored):** today, day++ happens at the 06:00 UTC briefing (`dnd_expedition_cycle.go:236`). After D2, the autopilot **night-camp pitch** is the event that fires day++, supply burn, overnight rest, and threat drift — in one atomic rollover the camp scheduler triggers. The 06:00 UTC briefing tick survives only as a re-engagement DM anchor (and a safety net: if for some reason no camp pitched in N hours, force one). Concretely:
|
||||
- Move the body of `deliverBriefing` (`dnd_expedition_cycle.go:186-`) into a `processNightCamp(exp)` helper called from the autopilot camp scheduler when it pitches a *night* camp (rough/standard/fortified — not mid-day breath stops).
|
||||
- Distinguish "night camp" (advances day) from "rest stop" (doesn't). Heuristic: a camp pitched at the end of the day-budget room count is night; one pitched purely for HP/SU recovery mid-day is a rest stop. Both apply `applyCampRest`; only night also runs supply burn + day++ + threat drift.
|
||||
- The existing UTC briefing ticker becomes: if `last_briefing_at` is older than ~20h *and* the user is active, post a "Day N — here's where you stand" re-engagement DM. It calls **no** mutators.
|
||||
- Manual `!camp <type>` from the player still does what it does today (apply rest immediately) but **also** counts as a night camp if invoked with no other camp today — keep parity with the autopilot behavior so the override doesn't accidentally stall day advancement.
|
||||
|
||||
**Migration:** in-flight expeditions at deploy time keep their existing 06:00 UTC day++ until they end (fence by `start_date`). New expeditions use the event-anchored model.
|
||||
|
||||
**Exit criteria:** sim shows autopilot pitches the right kind of camp at the right room with no player input; supply economy still survives a 7-day T5.
|
||||
|
||||
### D3 — Autonomous elite + boss engagement
|
||||
**Files:** `dnd_zone_cmd.go:497-535` (the prev==RoomElite / RoomBoss branch).
|
||||
|
||||
**Shipped 2026-05-27.** `dnd_zone_cmd.go` adds `stopBossSafety` + `bossSafetyGate(uid, exp)` (HP < 80%, supplies < daily burn, exhaustion ≥ 3 — the "active low status" interpretation). The elite/boss doorway branch drops the `prev == RoomBoss || !compact` carve-out: in compact mode bosses fall through to the same `resolveCombatRoom` path elites have used, after the gate clears. `resolveCombatRoom` selects monster + label + loot-drop by `run.CurrentRoomType()` so the same call site handles boss kills (zone.Boss bestiary, "👑 Boss — name down", boss-loot drop, elite-tier threat bump). Loss on auto-resolved boss falls through to the existing player-death narration / forceExtractExpeditionForRunLoss path — no special treatment.
|
||||
|
||||
The walk loop (`dnd_expedition_cmd.go:717`) only breaks at the elite/boss doorway when `!compact`; compact lets the next iteration auto-resolve. `stopBossSafety` is excluded from the rooms-walked tally and its `autopilotFooter` returns "" — `res.final` already carries the held-back line.
|
||||
|
||||
When the gate trips, `tryAutoRun` calls `pitchBossSafetyCamp(exp)` (new in `expedition_autocamp.go`): force-pitches Standard (or Rough fallback) regardless of `decideAutopilotCamp`'s HP threshold or its RoomBoss room-type block, with the same `Night` event-anchored rollover handling. Dwell expires → next tick retries the boss. Foreground `!fight` and foreground `!expedition run` are unchanged: both still stop at the boss doorway for the player who wants to watch. Sim path is unaffected — `stopBossSafety` falls into `expedition_sim.go`'s default branch (tick-a-day, retry next walk).
|
||||
|
||||
**Open question:** do we want a "set autopilot off" toggle for players who actively want to play the boss themselves? My take: default-on, single-flag per expedition (`autopilot_engage`) settable at start or via `!expedition autopilot off`. Defer until D3 lands and we see how it reads.
|
||||
|
||||
**Exit criteria:** sim runs a full T5 expedition launch → boss kill with zero player commands besides `!expedition start`.
|
||||
|
||||
### D4 — DM volume + day surfaces
|
||||
The big risk: a 7-day T5 with autopilot walking, camping, fighting elites, and killing bosses will spam DMs unless we bundle. The user has already pushed back on recap chatter (`feedback_skip_recaps`).
|
||||
|
||||
**D4-a (shipped 2026-05-27):** autopilot DM bundling. `expedition_autorun.go:tryAutoRun` now drops per-tick auto-walk DMs in compact mode. New surface rules:
|
||||
- Fork / death / run-complete still DM the walk stream — interactive + climax beats.
|
||||
- A Night=true camp pitch flushes the day as an end-of-day digest (`renderEndOfDayDigest` in new `expedition_autorun_digest.go`) followed by the camp block.
|
||||
- Boss-safety camp pitch gets a short "holding before the boss" header + camp block (walk stream dropped).
|
||||
- Non-night auto-camp (mid-day rest / base-camp waypoint) surfaces the camp block by itself.
|
||||
- Everything else — uneventful walks, preflight pauses, harvest interrupts — goes silent.
|
||||
Each successful background walk now logs a `walk` entry (`appendExpeditionLog(... "walk" ...)`) so the digest can count rooms walked from structured logs (the raw `r.stream` narration is no longer persisted across ticks). Digest groups counts of walk/harvest/interrupt and inlines threat/milestone/narrative lines for the prior day (`dayExpeditionLog` helper). `maybeAutoCamp` + `pitchBossSafetyCamp` now return the `autoCampDecision` so `tryAutoRun` can branch on `dec.Night`.
|
||||
|
||||
**D4-b (shipped 2026-05-27):** morning re-engagement DM anchored to user activity, not 06:00 UTC. `fireExpeditionBriefings` now skips event-anchored expeditions whose `last_activity` is older than today's 06:00 UTC threshold (and we're still inside `nightSafetyNet` — stalled-autopilot force-fires still win). New `maybeDeliverDeferredBriefing` runs at the top of `AdventurePlugin.OnMessage` on every inbound message in any room: it stamps `last_activity` (so the next ticker pass sees the player as present, even from non-bot chatter) and posts the deferred briefing if one is owed (CAS-gated by today's 06:00 threshold; pre-06:00 lazy fires are suppressed to avoid double-emit with the ticker sweep that follows). Day-1 inbounds don't trigger a briefing before the first night camp has happened.
|
||||
|
||||
**Remaining work:**
|
||||
- TwinBee voice + no-recap copy pass on the digest once the shape settles.
|
||||
|
||||
**Exit criteria:** a 7-day T5 produces ≤ 10 DMs across the whole expedition (1 launch + 6 end-of-day + 1 boss + 1 run-complete + 1 emergency).
|
||||
|
||||
### D5 — Supplies economics retune
|
||||
|
||||
**D5-a (shipped 2026-05-27):** per-tier pack caps. `dnd_expedition_supplies.go` retires the global `SupplyPackStandardMax`/`SupplyPackDeluxeMax` constants in favor of `supplyPackCaps(tier) (std, dlx int)` — T1/T2: (2,1); T3: (3,1) unchanged; T4: (5,1); T5: (7,2). `SupplyPurchase.Validate` is now `Validate(tier ZoneTier)`; both call sites (`!expedition start`, `!resume`) pass the resolved zone's tier. The cap clears `DailyBurn(raw) × intendedDays × 1.3` for every tier under the §2 target durations even with harsh×3 layered on top, so a player who wants to buy for the long shape can. DailyBurn / harsh-multiplier / `phase5BDailyBurnRatePct` are unchanged here — they're a D7 lever, alongside the sim work that should unblock empirical baselining. The help text now describes the cap as "scales by zone tier"; the holiday +1 standard pack still bypasses the cap on purpose (it's a freebie on top).
|
||||
|
||||
> **Note:** the original "Empirical (sim-driven)" path was blocked: under D2-b event-anchored expeditions, `SimRunner.TickDay` calls `deliverBriefing` → `deliverBriefingEventAnchored`, which reads `time.Now().UTC()` for its safety-net check, so synthetic ticks never advance `CurrentDay` and `DaysAtEnd` stays at 0. Re-baselining off real day-counts requires teaching the sim to drive the event-anchored rollover (D7).
|
||||
|
||||
**D5-b (shipped 2026-05-27):** "Pick your loadout" preset prompt at launch. `!expedition start <zone>` with no pack arg now DMs a Lean / Balanced / Heavy menu (sized per zone tier via new `loadoutPurchase(tier, SupplyLoadout)` in `dnd_expedition_supplies.go`); player replies with `!expedition start <zone> lean|balanced|heavy` (short forms `l`/`b`/`h` also work). `!resume` got the same treatment. Raw `Ns Md` syntax remains the advanced override — `resolveLoadoutOrParse` in `dnd_expedition_cmd.go` tries a single-token preset first, falls back to `parseSupplyArgs`. Heavy always equals `supplyPackCaps` (the D5-a harsh×3 ceiling); Lean covers intended days at raw burn; Balanced sits between. `parseSupplyArgs("")` still returns 1 standard for any tier — it's no longer reachable from `!expedition start` (the empty path is intercepted to prompt), but the `1s` default is preserved so future callers don't get surprise zero-pack purchases. Help text updated; two existing scenario tests now pass `lean` explicitly, three new tests cover preset resolution + the prompt-vs-start guard.
|
||||
|
||||
**D5-c (shipped 2026-05-27):** Ranger forage wired against the new caps. Audit found `SupplyForageMaxSU = 4` defined in `dnd_expedition_supplies.go` since Phase 12 E1b but never referenced — `ForagedToday` was only ever reset to `false`, never set to `true`, so the §4.2 "Ranger, WIS DC 12, 1d4 SU/day" perk had never actually granted supplies. New helper `applyRangerForage(e, c, rng)` rolls 1d4 SU once per day for Ranger characters, headroom-capped against `Supplies.Max` so a Heavy loadout doesn't overflow its purchased ceiling; non-Rangers and post-roll repeats are no-ops. Fires at the top of `nightRolloverBurn` (before the daily burn) so the +SU lands on today's bag and a "forage" log entry flows into the end-of-day digest via a new `renderEndOfDayDigest` case. No DC roll — accessibility over crunch (`feedback_accessibility_over_dnd_crunch`); the DC-12 gate would've added a fail case with no upside given the new headroom. Average +2.5 SU/day off a Ranger is a quiet Lean-loadout cushion (~1 extra day of T5 late-stage burn over a 7-day run), not a Heavy multiplier. Tests cover ranger-grants, non-ranger no-op, repeat-day no-op, headroom-cap, full-bag-still-stamps-flag, nil-safety.
|
||||
|
||||
**Remaining work (D5-d+):**
|
||||
- DailyBurn / `phase5BDailyBurnRatePct` revisit once D7 sim measures real elapsed-day counts.
|
||||
|
||||
**Exit criteria:** balanced loadout completes intended-duration expedition in ≥ 85% of sim runs without starvation extraction.
|
||||
|
||||
### D6 — Player-facing surface cleanup
|
||||
|
||||
**D6 (shipped 2026-05-27):** player-facing copy now matches the autopilot-first reality.
|
||||
- `expeditionHelpText` rewritten around the loop "pick a zone → pick a loadout → `!expedition run` watches it play out"; commands grouped into **Run an expedition** / **Mid-expedition** / **Overrides** with a one-line shape paragraph at the top.
|
||||
- `!camp` and `!fight` were already absent from the primary `!expedition` help; both are now explicitly listed under **Overrides** and framed as "autopilot covers these — only reach for them if you want manual control." `campHelpText` itself opens with the same override framing so a player who types `!camp` lands on the right mental model.
|
||||
- `expeditionCmdStatusImpl` (default `!expedition status`) now leads with **Day X / ~Y expected** (driven by new `expeditionTargetDays(tier ZoneTier)` in `dnd_expedition_supplies.go` — T1=2 → T5=7, the §2 anchor table), adds a **Rooms: N / Total in this region** line under the region marker (sourced from `getActiveZoneRun(...).CurrentRoom/TotalRooms`), and appends a **Recent:** block with the last 3 log entries (summary fallback to flavor). Supplies and threat lines unchanged; the `--debug` view is untouched.
|
||||
- No new public command surface, no schema change, no test churn (no existing test asserted on the rewritten strings); full `go test ./...` green.
|
||||
|
||||
### D7 — Sim + class re-baseline
|
||||
|
||||
**D7-a (shipped 2026-05-27):** `SimRunner.TickDay` now drives event-anchored expeditions. `expedition_sim.go` short-circuits when `isEventAnchored(exp)` is true: instead of calling `deliverBriefing` (whose `deliverBriefingEventAnchored` branch reads `time.Now().UTC()` and would never trip the safety-net under synthetic ticks), it runs `nightRolloverBurn` → optional `applyCampRest(Standard)` if affordable (Rough fallback, skipped on low-SU) → `nightRolloverDrift(briefAt)`. Mirrors `pitchAutopilotCamp` with `Night=true`. Production paths untouched. New `expedition_sim_test.go` covers (i) 3 ticks advancing day 1→4 with supply burn + stamped `LastBriefingAt`, and (ii) the low-SU branch where the rest is skipped but burn/drift still fire. Unblocks D5-d empirical re-baseline of `phase5BDailyBurnRatePct` and the class corpus re-run.
|
||||
|
||||
**D7-b (shipped 2026-05-27):** `SimRunner.RunExpedition` now drives the production camp scheduler under a synthetic clock (`simWalkInterval = autoRunCooldown`, 2h per walk). After every soft-stop walk it calls `maybeAutoCamp(exp, simNow)`; `stopBossSafety` routes to `pitchBossSafetyCamp(exp, simNow)`. The helpers (`applyAutoCamp` / `applyAutoCampBossSafety`) advance `simNow` past `minAutoCampDwell` (4h) and break the auto-pitched camp via `breakAutoCampIfDue` so the next walk proceeds. `maybeAutoCamp`, `pitchAutopilotCamp`, and `pitchBossSafetyCamp` gained a `now time.Time` parameter (the only prod caller, `tryAutoRun`, still passes `time.Now().UTC()`). Effect: HP-low mid-day rests, multi-region base-camp waypoints, Night-camp rollovers, and boss-safety holds all fire under the sim — the D7-a `tickEventAnchoredRollover` shortcut is no longer used by RunExpedition (kept on TickDay for tests + the pre-cutoff legacy path). Coverage in `expedition_sim_test.go`: Night-camp day advance, HP-low mid-day rest, boss-safety force-pitch, no-op when neither trigger fires.
|
||||
|
||||
**D7-c (shipped 2026-05-27):** `cmd/expedition-sim` gained a `-days N` flag — when set, `RunExpedition` exits with `Outcome="day_capped"` after the Nth synthetic day rollover; 0 (default) is unbounded and only the existing `-cap` walk safety net applies. `SimResult` gained `DaySnapshots []SimDaySnapshot` ({Day, HPCurrent, HPMax, Supplies, Threat, Rooms}), captured at expedition start (Day 0), after every `applyAutoCamp`/`applyAutoCampBossSafety` Night-camp rollover, and one final end-of-run entry (reads `getExpedition(mostRecentExpeditionID(...))` when the row already extracted so closed runs still close their trajectory). `RunExpedition` signature picked up the `maxDays int` parameter; sole caller is the CLI. Coverage: `TestSimRunner_CaptureDaySnapshot_PopulatesFields` exercises the helper in isolation (HP from the live character row, SU/threat/day from the live expedition, Rooms from `res.Rooms`). Unblocks empirical D5-d retune of `phase5BDailyBurnRatePct` against per-day SU draws.
|
||||
|
||||
**D7-d (shipped 2026-05-27):** class corpus re-run + D5-d retune decision. First fixed a sim regression `expedition_sim.go` introduced by D5-b — bare `expedition start <zone>` now returns the loadout prompt instead of outfitting, so the sim was halting before persisting any expedition; harness now passes the `heavy` preset. Corpus: `sim_results/d7d_corpus.jsonl` (n=100 × 10 classes × 5 zones × L10 = 5000 runs, `heavy` preset, `-cap 300`). Analysis: `sim_results/d7d_analyze.py`. Writeup: `sim_results/d7d_findings.md`. **Key reads:** (i) leaderboard mirrors [[project_j2_sim_artifact]] — martials 78–82%, casters 21–42%, ~40pp cluster gap unchanged by long-expedition mechanics; (ii) bard/cleric trailers **not** relieved by autopilot camp pacing — cleric actually regressed at L10 (21% vs J2b L12 39%) on spell-pool richness, J3-territory; (iii) T3 hits target 4-day duration (median 3), T4 hits 5–6 days (median 5), T5 hits 5 of 7 (only 21 clears); (iv) **D5-d retune: no change.** `phase5BDailyBurnRatePct=50` + per-tier `DailyBurn` stay as-is — heavy-preset cleared runs end with 78–98% SU remaining; supply economy is not a binding constraint and players lose to HP zero, not starvation. New memory entry: [[project_d7d_baseline]].
|
||||
|
||||
**Remaining work (D7-e+):** none in the core D7 scope. Two follow-up phases (D9 room-count bump, D10 T4/T5 anchor variety) were opened by the 2026-05-28 §6 resolution and are sequenced after D8. See §9 below.
|
||||
|
||||
## 8. J3 caster-picker rework (next session)
|
||||
|
||||
**Why split out:** D7-d corpus confirmed the martial/caster gap is class-roster + sim-picker driven, not autopilot/burn/pacing driven. Long-expedition mechanics are settled; the trailer fix lives in the sim picker, not the expedition shell.
|
||||
|
||||
**D8-a (shipped 2026-05-27, this session):** quick data-layer cleanup. Bard L1 prepared list gained `thunderwave`; L2 gained `heat_metal`. Cleric L1 gained `inflict_wounds`. `shatter` overlay Classes broadened to mage/bard/sorcerer (overlay was already unioning via `mergeClassList`, so this is defensive). New `simPickSpiritualWeapon` runs before `simPickSpell` in `simPickCombatAction`: cleric with an L2 slot + spiritual_weapon prepared + `sess.Statuses.BuffSpiritProc == 0` opens the fight with it, picking up the existing `spiritWeaponStrike` per-round bonus-action attack. **Measured impact (L10, n=100/zone, heavy preset):** cleric 21.0 → 23.2% (+2.2pp), bard 34.2 → 32.8% (within noise). T3+ wall unchanged — the data fixes are correct but the picker math is what's binding. Files touched: `internal/plugin/dnd_spells_data.go:192`, `internal/plugin/dnd_spells.go:822,881,884`, `internal/plugin/expedition_sim.go:simPickCombatAction + new simPickSpiritualWeapon`. All green.
|
||||
|
||||
**D8-b (implemented 2026-05-27, **inert** — see D8-prereq below): sim picker upcasting.** `simPickSpell` (`internal/plugin/expedition_sim.go:933`) now enumerates one candidate per available slot ≥ native for every prepared damage spell, scored via `spellExpectedDamage(sp, slotLevel, c.Level)`. Cantrips contribute a single slot-0 candidate. Tie-break: highest slot first, then highest expDmg. When the winning slot exceeds the spell's native level, the picker returns `"<id> --upcast N"` so `parseCombatCast` upcasts in the engine. Build + tests green.
|
||||
|
||||
**Measured impact: nothing.** d8b corpus (`sim_results/d8b_corpus.jsonl`, same matrix as d7d) lands every class within ±1.5pp of the d7d baseline — bard −1.0, cleric −0.4, mage −1.2, sorcerer +1.4, warlock −0.8, druid +0.6, martials ±0.2. Pure noise band. Expected bard +5–10pp / mage+sorcerer+warlock +2–8pp never materialized.
|
||||
|
||||
**Root cause (discovered post-corpus, this session):** `simPickSpell` is **dead code** in the current sim path. Commit `68ed8e7` ("Long expeditions D3: compact autopilot auto-resolves boss rooms") added a `!compact` gate at `dnd_expedition_cmd.go:810` so compact autopilot inline-resolves boss/elite rooms via `resolveCombatRoom` → `runZoneCombat` → `SimulateCombat` instead of returning `stopBoss/stopElite` for `autoResolveCombat` to handle. The sim uses `compact=true` (`expedition_sim.go:433`), so `autoResolveCombat` — the only caller of `simPickCombatAction`/`simPickSpell` — never fires. Verified empirically: a stderr-traced `simPickSpell` produced zero hits across full bard/cleric L10 runs.
|
||||
|
||||
This rewrites the picker-era retrospective. **J2's `baseline_j2a_v2_all10.jsonl` (bard 40%, cleric 39%) was measured before D3, with the picker live.** d7d's L10 baseline (bard 34%, cleric 21%) was measured *after* D3, with the picker disconnected — that ~6–18pp drop is the post-D3 caster regression, not "long-expedition mechanics didn't help casters" as the d7d writeup framed it. D8-a's "+2.2pp cleric" was also noise (1σ ≈ 1.8pp on n=500).
|
||||
|
||||
**D8-prereq (SHIPPED 2026-05-28, commit `631764b`):** split the `compact` flag in `runAutopilotWalk` / `advanceOnceWithOpts` into `compact` + `inlineBossCombat`. Production background autorun (`expedition_autorun.go:173`) passes `(true, true)` — unchanged inline auto-resolve. Sim (`expedition_sim.go:432`) passes `(true, false)` so boss/elite doorways return `stopBoss`/`stopElite` after the safety gate; `autoResolveCombat` → `simPickCombatAction` → `simPickSpell` / `simPickSpiritualWeapon` now drive boss/elite fights through the turn-based `!fight` engine. D8-b upcasting went live with the rewire. Foreground (`!expedition run`) flow unchanged (compact=false short-circuits).
|
||||
|
||||
Also parallelized matrix mode via subprocess workers (`cmd/expedition-sim/main.go`, `-jobs` flag; defaults to NumCPU). Each worker is its own process so the plugin's global SQLite handle isn't a contention point.
|
||||
|
||||
**Measured impact (5000-run L10 corpus, `sim_results/d8prereq_corpus.jsonl` — full diff in `sim_results/d8prereq_findings.md`):**
|
||||
|
||||
| Class | d7d | d8prereq | Δ |
|
||||
|----------|-----:|---------:|-------:|
|
||||
| cleric | 21.0 | 46.8 | +25.8 |
|
||||
| sorcerer | 29.4 | 52.0 | +22.6 |
|
||||
| warlock | 35.4 | 55.6 | +20.2 |
|
||||
| mage | 36.4 | 54.2 | +17.8 |
|
||||
| druid | 42.2 | 56.6 | +14.4 |
|
||||
| bard | 34.2 | 46.2 | +12.0 |
|
||||
| (martials) | ~80 | ~78 | -2 to -4 (noise + T4/T5 turn-engine regression) |
|
||||
|
||||
Caster/martial gap closed from ~40pp to ~22pp. T2/T3 zones are now the picker payoff (forest_shadows 75 → 99, manor_blackspire 45 → 75). **T4 caster wall persists** (every caster 0% at underdark); **T5 is universally 0%** including martials. Both require separate diagnostics — picker isn't the binding constraint past T3.
|
||||
|
||||
**D8-c (SHIPPED 2026-05-28):** concentration-damage multiplier. `spellExpectedDamage` (`internal/plugin/dnd_class_balance.go:280`) multiplies expected damage by 3 when `sp.Concentration && sp.Effect ∈ {EffectDamageSave, EffectDamageAuto}`. `EffectDamageAttack` excluded by design — single-target attack-roll spells aren't typically concentration; hex-style cases get their lift from mods. Test: `TestSpellExpectedDamage_ConcentrationMultiplier`.
|
||||
|
||||
**Measured impact (d8c_corpus.jsonl vs d8prereq, full diff in `sim_results/d8c_findings.md`):** every class within ±2.4pp on the leaderboard (1σ ≈ 2.2pp on n=500) — *macro-leaderboard unchanged*. Picker swap landed where expected at **T3 manor_blackspire**: bard +11pp (32→43), mage +10pp (71→81), sorcerer +9pp (60→69), druid +5pp manor + +1pp underdark. T4/T5 caster wall unchanged (still 0% for every caster except druid 0→1 underdark) — confirms [[project_d8d_diagnostic]]: past T3 the binding constraint is HP+AC, not picker quality.
|
||||
|
||||
**Side discovery (engine modelling gap):** cleric stayed flat across all zones despite having spirit_guardians (L3, concentration, save) prepared — and warlock dropped 6pp at manor. Likely the sim's `EffectDamageSave` path resolves the AOE save *once* rather than re-ticking per round while concentration holds, so the multiplier overrates spells the engine then under-delivers on. **Filed as a separate follow-up** (concentration re-tick gap in `SimulateCombat`/turn-engine); does *not* unwind D8-c — bard/mage/sorcerer/druid picker swaps are clean and within plan. Cross-link [[project_concentration_retick_gap]] when written.
|
||||
|
||||
**D8-d (DIAGNOSTIC SHIPPED 2026-05-28):** T4 caster wall diagnosed; no code change landed. Writeup: `sim_results/d8d_findings.md`. **Hypothesis disambiguation across cleric/bard/mage/sorcerer/warlock/druid L10 underdark (n=3–5 each):** (a) multi-region transit damage — REJECTED. All casters TPK in r1 (`underdark_surface_tunnels`) before any cross-region transit; `Combats=0` (the elite/boss session table is empty); deaths are entirely inline `SimulateCombat` mob rooms. (b) T4 attack-bonus vs caster AC — confirmed contributor. `computeAC` floor gives casters AC 11–14 vs martial 16–17; T4 standard roster (Atk +5 to +7) hits ~60–65%. (c) HP scaling — confirmed contributor. Caster HPMax 93–110 vs martial 141 (~30% gap). (d) Heal-stock — minor lever, NOT the lever. Direct experiment: bumped `simConsumableBundle` T4 from 2 → 5 Spirit Tonics, re-ran n=5 per class. Median rooms-before-TPK lifted +3–5 but 0/5 clears across every caster. Reverted. **Recommended tuning lever (next session, NOT shipped here):** lift `computeAC` class floors (cleric/druid 3 → 5, bard/warlock 1 → 3, mage/sorcerer 0 → 2) — single function, easy to revert, doesn't move the martial leaders. Validate against `d8prereq_corpus.jsonl`. Hit-dice rescale is the second option but is bigger surface area.
|
||||
|
||||
**D8-e (TODO next session): martial T4/T5 regression triage.** Paladin -18pp T4 / fighter -6pp T4 -5pp T5 / ranger -13pp T5 — the cost of moving boss/elite fights from inline `SimulateCombat` to the turn-based `!fight` engine. Two possibilities: (1) turn-engine plays martials slightly worse than inline-sim (action economy / multiattack handling differs — see [[project_sim_multiattack_gap]]); (2) inline-sim was over-rosy and the new numbers are the real ones. Read: trace one paladin underdark run pre/post and diff damage exchanges. If turn-engine is correct, prod martial numbers were inflated and we should not "fix" the regression.
|
||||
|
||||
**Sequence:** D8-c → D8-d → D8-e. D8-c is the smallest lever and confirms the picker model. D8-d is the highest-value (caster T4 is the next gap to close). D8-e is informational — settles whether to trust the new numbers.
|
||||
|
||||
**Open questions for D8:**
|
||||
1. ~~D8-b upcast aggressively or conservatively?~~ **Resolved:** aggressive — the T3 lift (mage +61, warlock +65, druid +68) confirms the model.
|
||||
2. D8-c concentration-rounds factor: 3 fixed, or scale with tier (longer fights at T4/T5)? Fixed is simpler; tier-scaled is more correct.
|
||||
3. ~~Cleric T3+ cliff~~ — **partial resolution:** picker rewire took cleric T3 0% → 39%. Cleric T4 is still 0% (and so is every other caster); now subsumed by D8-d.
|
||||
|
||||
## 4. Cross-cutting risks
|
||||
|
||||
- **Existing in-flight expeditions** at deploy time. Either fence by `start_date` (old expeditions finish under old rules) or write a one-shot migration that retunes their room counts. Lean: fence — these are typically <2 days.
|
||||
- **The 24h zone-run inactivity timeout** (`dnd_zone_run.go:254`) — must be raised, or the autopilot must self-poke `last_action_at` per tick. With multi-day expeditions, a quiet stretch overnight is now expected.
|
||||
- **Idle reaper** (commit `0f72484`, see `expedition_autorun.go` references) — already holds streak on autopilot; verify it tolerates 5-day expeditions.
|
||||
- **Babysit perk** — `BabysitSafeRest` already upgrades Standard → Fortified at rest (`dnd_expedition_camp.go:241-245`); confirm interaction with autopilot picking Standard (no change needed; the upgrade fires inside `applyCampRest`).
|
||||
- **Multi-region travel still unwired in autopilot** (memory `project_multiregion_travel_unwired`). For T4/T5 to actually take 5–7 days, autopilot **must** auto-advance regions on region-clear. D2/D3 covers this implicitly but call it out — likely a small dedicated commit.
|
||||
- **Pet arrival** (memory `project_pet_event_reachability`) — emergence seam is at run-complete; longer expedition just delays the roll, no code change needed.
|
||||
- **Twinbee voice + secret NPC buffs** (memories `feedback_twinbee_voice`, `feedback_npc_buffs_are_secret`) — none of the new copy can break these.
|
||||
|
||||
## 5. Schema impact
|
||||
|
||||
Likely none. Existing columns cover it:
|
||||
- `current_day` → autopilot increments via `advanceExpeditionDay`.
|
||||
- `camp_json`, `supplies_json`, `region_state`, `threat_*` already in place.
|
||||
- One **possible** new column: `expected_days` int (set at launch from zone tier + loadout) so status/end-of-day DMs can render "Day 3/5". Cheaper to derive than store — defer.
|
||||
|
||||
## 6. Open questions for next session
|
||||
|
||||
1. ~~Day-advance semantics~~ — **decided 2026-05-27: event-anchored.** day++ fires on autopilot camp-pitch (autopilot night-camp = sleep = day burn + briefing). UTC clock becomes a re-engagement-DM anchor only, not a state mutator. See §3-D2 for implementation impact.
|
||||
2. ~~Boss autopilot default~~ — **decided 2026-05-28: default ON.** D3 already ships compact-mode boss auto-resolve gated by `bossSafetyGate` (HP/SU/exhaustion). No opt-out flag for now; revisit if players want manual final-blow framing.
|
||||
3. ~~Per-tier room counts: sim-first vs ship-and-refine~~ — **decided 2026-05-28: sim-first refinement in D7-d.** D1 shipped a guess; we're much closer now. Measured vs prior: T1 ~2x (7→16), T2 ~1.8x (9→19), T3 ~2x (11→35), T4 ~3x (10→46), T5 ~3x (13→47). Target band is 3–5x; T1–T3 likely want another bump but defer until the D7-d sim corpus reads day-counts. Don't churn graphs blindly.
|
||||
4. ~~Extra anchor rooms (T4/T5)~~ — **decided 2026-05-28: yes, add anchor-room variety in T4/T5.** Second elite + second trap mid-zone (not just rely on multi-region/patrols/threat). Slot into D7-d alongside the room-count retune so we don't re-touch the graphs twice.
|
||||
5. ~~Mobile/AFK / DM cadence~~ — **decided in D4-a (already shipped):** event-anchored to in-game day. Night-camp pitch flushes the day as a digest; fork/death/run-complete still DM the walk stream; mid-day rests/waypoints surface only the camp block; per-tick auto-walks silent. No real-time pacing knob.
|
||||
|
||||
## 7. Sequence
|
||||
|
||||
**Original D1–D7 plan (all shipped):** D1 → D2 → D3 → D5 → D4 → D6 → D7.
|
||||
|
||||
**Current sequence (post-2026-05-28):** D8-c → D8-d-fix → D8-e → D9 → D10 → final re-baseline.
|
||||
|
||||
| Phase | What | Blocks |
|
||||
|------|------|--------|
|
||||
| D8-c | Concentration-damage multiplier in `spellExpectedDamage` | D8-d-fix read |
|
||||
| D8-d-fix | Lift `computeAC` caster floors (cleric/druid 3→5, bard/warlock 1→3, mage/sorcerer 0→2) | D8-e read |
|
||||
| D8-e | Diff turn-engine vs inline-sim for paladin/fighter/ranger on T4/T5; decide whether martial regression is real or a "fix" target | D9 (need stable class baseline) |
|
||||
| D9 | T1–T3 room-count bump toward 3–5× band, sim-validated against fresh corpus | D10 |
|
||||
| D10 | T4/T5 anchor-room variety (2nd elite + 2nd trap mid-zone) | final re-baseline |
|
||||
| — | One final L10 + L12 corpus pass to confirm tier day-counts + class spread | — |
|
||||
|
||||
## 9. Post-§6 follow-ups (D9 / D10)
|
||||
|
||||
### D9 — T1–T3 room-count bump
|
||||
|
||||
**Why now:** §6 fork 3 resolved to "sim-first refinement"; D7-d corpus shows T3 hitting only median 3 days (target 4) and T1/T2 weren't even measured because the corpus only matrixed T3+. T1–T3 graphs landed at ~2× prior length in D1; target band is 3–5×.
|
||||
|
||||
**Plan:**
|
||||
- Pull T1/T2 day-counts from a post-D8 corpus run (matrix needs to include `goblin_warrens`, `crypt_valdris`, `forest_shadows`, `sunken_temple` at L4/L6).
|
||||
- Lift graph sizes only if median days < tier target (T1=2, T2=3, T3=4). Don't churn graphs that already hit target.
|
||||
- Reuse the D1-a..e fork/anchor/merge patterns (`internal/plugin/zone_graph_*.go`); preserve existing topology — only add Exploration nodes + the second-anchor slot D10 will fill.
|
||||
- One-shot bootstrap: existing in-flight expeditions fence by `start_date` per §4 (consistent with D1 deploy).
|
||||
|
||||
**Risk:** if D8-d-fix lifts caster T3 clears materially, day-counts move too; measure after D8 stabilizes, not before.
|
||||
|
||||
### D10 — T4/T5 anchor-room variety
|
||||
|
||||
**Why now:** §6 fork 4 resolved "yes". T4/T5 zones run 30–50 rooms with only one Trap + one Elite anchor; the long middle reads same-y. Bundle with D9 so we touch each `zone_graph_*.go` once.
|
||||
|
||||
**Plan:**
|
||||
- Add a second Trap + second Elite anchor mid-zone for all T4/T5 zones (`underdark`, `feywild_crossing`, `dragons_lair`, `abyss_portal`).
|
||||
- Place the second Elite at the region-boundary spurs (good narrative justification — region-guardian rather than zone-guardian) so multi-region travel finally gets a teeth-y interrupt.
|
||||
- Place the second Trap on the long Exploration runs of fork branches, not on the merge node (keeps fork-choice loot/risk asymmetric).
|
||||
- Preserve longest-path length within current band — added anchors replace Exploration nodes, not append.
|
||||
|
||||
**Validation:** same final corpus pass as D9. Look for boss-clear rate dipping by <5pp (anchor difficulty is intended, not punishing) and median day-count stable.
|
||||
|
||||
### Cross-links picked up here
|
||||
- [[project_multiregion_travel_unwired]] — boss-clear → next-region auto-advance is wired (verified 2026-05-27); D10's region-boundary elites give it something to interrupt.
|
||||
- [[project_j3_caster_picker]] / [[project_d8d_diagnostic]] — D8 gates the whole sequence.
|
||||
- [[project_d8prereq_baseline]] — the corpus to diff against, not d7d.
|
||||
237
hangman_phrases.txt
Normal file
237
hangman_phrases.txt
Normal file
@@ -0,0 +1,237 @@
|
||||
# GogoBee Hangman Seed Phrases -- Video Game Edition
|
||||
#
|
||||
# Tiers are assigned automatically by character count at load time.
|
||||
# Section headers below are for human readability only -- the bot ignores them.
|
||||
#
|
||||
# Easy: 8-20 characters
|
||||
# Medium: 21-40 characters
|
||||
# Hard: 41-80 characters
|
||||
#
|
||||
# Add community phrases via: !hangman submit [phrase]
|
||||
# All submissions require LLM approval before entering the pool.
|
||||
# This file can be edited directly. Bot reloads on restart.
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EASY (8-20 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Finish Him!
|
||||
Game Over
|
||||
Insert Coin
|
||||
Hadouken!
|
||||
Fatality!
|
||||
Big Boss
|
||||
Konami Code
|
||||
Warp Zone
|
||||
Blue Shell
|
||||
God Mode
|
||||
BFG 9000
|
||||
Cacodemon
|
||||
Quad Damage
|
||||
Samus Aran
|
||||
Morph Ball
|
||||
Mother Brain
|
||||
Dracula!
|
||||
Simon Belmont
|
||||
Ecclesia
|
||||
Outer Heaven
|
||||
Spread Gun
|
||||
Power Pellet
|
||||
Checkpoint
|
||||
Rocket Jump
|
||||
Mushroom Kingdom
|
||||
Princess Peach
|
||||
Bowser's Castle
|
||||
Fire Flower
|
||||
Varia Suit
|
||||
Space Jump
|
||||
Vampire Killer
|
||||
Holy Water
|
||||
Trevor Belmont
|
||||
Soma Cruz
|
||||
Julius Belmont
|
||||
Waluigi!
|
||||
Richter!
|
||||
Phantoon
|
||||
Speed Run
|
||||
High Score
|
||||
Continue?
|
||||
Press Start
|
||||
Ryu Hayabusa
|
||||
Plasma Gun
|
||||
What is a man?
|
||||
Serious Sam
|
||||
Shoryuken!
|
||||
Duck Hunt
|
||||
The cake is a lie
|
||||
War never changes
|
||||
Would you kindly?
|
||||
Do a barrel roll!
|
||||
Praise the Sun!
|
||||
A winner is you
|
||||
You're pretty good
|
||||
La-Li-Lu-Le-Lo
|
||||
I am error
|
||||
Leeroy Jenkins!
|
||||
For the Horde!
|
||||
For the Alliance!
|
||||
Frostmourne hungers
|
||||
Falcon Punch!
|
||||
Rip and tear!
|
||||
Vic Viper
|
||||
Salamander!
|
||||
Parodius Da!
|
||||
We'll bang, okay?
|
||||
What you say!!
|
||||
You spoony bard!
|
||||
One-Winged Angel
|
||||
Morning Star
|
||||
Glyph Union
|
||||
TwinBee, scramble!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MEDIUM (21-40 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Stay a while and listen
|
||||
It's dangerous to go alone!
|
||||
Kept you waiting, huh?
|
||||
A man chooses, a slave obeys
|
||||
Metal Gear?! Metal Gear!!
|
||||
We're not tools of the government
|
||||
The winds of destruction
|
||||
Who are the Patriots?
|
||||
This is good, isn't it?
|
||||
You have died of dysentery
|
||||
The Triforce of Courage
|
||||
Ganon has broken the seal!
|
||||
May the wind guide you home
|
||||
Dodongo dislikes smoke
|
||||
The right man in the wrong place
|
||||
Nothing is true, everything is permitted
|
||||
I used to be an adventurer like you
|
||||
You can't hide from the Grim Reaper
|
||||
All your base are belong to us!
|
||||
Somebody set up us the bomb!
|
||||
For great justice, take off every Zig!
|
||||
How are you gentlemen!!
|
||||
Kain has betrayed us!
|
||||
Garland will knock you all down!
|
||||
You are not prepared!
|
||||
I am Uther the Lightbringer!
|
||||
Order of Ecclesia calls
|
||||
The Dark Lord rises again!
|
||||
Shanoa, bearer of glyphs
|
||||
In this world, it's kill or be killed
|
||||
Despite everything, it's still you
|
||||
The Underground is your home now
|
||||
Papyrus demands a battle!
|
||||
I'm going to make spaghetti!
|
||||
Toriel will protect you
|
||||
Estus Flask replenished
|
||||
The age of fire fades
|
||||
Prepare to die, undead one
|
||||
Can't let you do that, Star Fox!
|
||||
Andross' empire spans the Lylat system!
|
||||
Captain Falcon, show me your moves!
|
||||
OBJECTION! That testimony is a lie!
|
||||
Hold it! I have new evidence!
|
||||
Phoenix Wright, attorney at law!
|
||||
Does this unit have a soul?
|
||||
Shepard, the Reapers are coming!
|
||||
Tali'Zorah vas Normandy!
|
||||
Gruntilda shall not be defeated!
|
||||
K. Rool has stolen the banana hoard!
|
||||
Kirby, hero of Dream Land!
|
||||
Meta Knight awaits your challenge!
|
||||
Congraturation! This story is happy end.
|
||||
Cecil has become a Paladin
|
||||
Cloud Strife, SOLDIER First Class
|
||||
Time compression is inevitable
|
||||
You require more vespene gas
|
||||
Nuclear launch detected
|
||||
You must construct additional pylons
|
||||
I'm Commander Shepard!
|
||||
War... War has changed.
|
||||
Rip and tear until it is done!
|
||||
Dawn of Sorrow awaits
|
||||
Do you feel like a hero yet?
|
||||
You're a monster. You know that, Walker?
|
||||
Halo... it's not a natural formation.
|
||||
Whip it good, Belmont!
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HARD (41-80 characters)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
What is a man? A miserable pile of secrets!
|
||||
Die monster! You don't belong in this world!
|
||||
My name is Dracula, and I bid you welcome.
|
||||
You have no chance to survive make your time!
|
||||
You've met with a terrible fate, haven't you?
|
||||
Fear the old blood... and welcome, good hunter.
|
||||
Welcome to the Liandri Grand Tournament!
|
||||
I am the very model of a scientist Salarian!
|
||||
Metroid Prime has escaped into the impact crater!
|
||||
I want to be the very best, like no one ever was
|
||||
The world needs only one Big Boss... and one Snake.
|
||||
Snake, do you think love can bloom on a battlefield?
|
||||
Thank you Mario, but our princess is in another castle!
|
||||
You must gather your party before venturing forth
|
||||
Snake, we're not tools of the government, or anyone else
|
||||
Did I ever tell you what the definition of insanity is?
|
||||
They're everywhere! The demons... they won't stop coming!
|
||||
Dracula! Your time has come! The Vampire Killer strikes!
|
||||
Link... I'm Navi, your fairy companion! Listen!
|
||||
Hero of Time, your destiny awaits in the Sacred Realm
|
||||
Master Chief, finish the fight. Earth is counting on you.
|
||||
Outer Heaven... a place where warriors can find purpose
|
||||
We passed the point of no return a long time ago, Snake
|
||||
Liquid! I was the one who was meant to be the successor!
|
||||
Revolver Ocelot is a triple agent working for the Patriots
|
||||
Phazon corruption detected! Seek immediate medical attention!
|
||||
Dark Samus has absorbed the Phazon and grown more powerful!
|
||||
I'm the Doom Slayer, and I'm here to kill every last one of you!
|
||||
Praise the Chosen Undead, for they shall link the fire!
|
||||
Ganon is the evil king who stole the Triforce of Power!
|
||||
The legendary soldier who defied his genes... Big Boss
|
||||
Killing spree! Monster kill! Godlike! Unstoppable!
|
||||
Humanity restored! The bonfire blazes with newfound strength!
|
||||
You are the last line of defense against an infinite demonic army
|
||||
Liquid Snake... your dominant genes... give you the edge in battle!
|
||||
All we did was give meaning to the nuclear age by using nukes!
|
||||
I'm a soldier who's been betrayed, abandoned... I fight alone now
|
||||
Samus Aran, the last of the Chozo warriors, descends into the unknown
|
||||
You are the Chosen Undead, fated to succeed where so many have failed
|
||||
The price of living in the past is a slow death in the present
|
||||
A sword wields no strength unless the hands that hold it have courage
|
||||
The flow of time is always cruel, its speed seems different for each person
|
||||
Hey! Listen! There's something important you need to know!
|
||||
We are born of the blood, made men by the blood, undone by the blood
|
||||
War has changed. It's no longer about nations, ideologies, or ethnicity
|
||||
Mental has sent his armies, and I am all that stands between them and Earth!
|
||||
I'm no hero. Never was. Never will be. I'm just an old killer.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
I used to be an adventurer like you, then I took an arrow in the knee
|
||||
I'm not the only one who's responsible. The humans are just as guilty!
|
||||
The Skaarj have invaded, and you must fight your way to freedom
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EXTREME (81+ characters) -- Full quotes. No mercy.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
For you, the day Bison graced your village was the most important day of your life. But for me... it was Tuesday.
|
||||
What is a man? A miserable pile of secrets! But enough talk... have at you!
|
||||
Die monster! You don't belong in this world! It was not by my hand that I am once again given flesh!
|
||||
You can't keep a good man down, and that goes double for a soldier who's been fighting his whole life.
|
||||
They say the definition of insanity is doing the same thing over and over and expecting different results... did I ever tell you that?
|
||||
I've been waiting for this... a professional killer. I'm so excited I may be sick!
|
||||
Kept you waiting, huh? Don't worry though. I'll take you somewhere warm. Back to the battlefield.
|
||||
It's easy to forget what a sin is in the middle of a battlefield. Especially when you're killing to stay alive.
|
||||
We are not tools of the government or anyone else. Fighting was the only thing, the only thing I was good at. But at least I always fought for what I believed in.
|
||||
In the 21st century, the battlefield will once again be a symbol of the glory of nations. I am a weapon. A human weapon.
|
||||
A man's dreams can be his greatest asset... or his most dangerous enemy. The question is: what are you willing to sacrifice to see them through?
|
||||
I need scissors! 61!
|
||||
Outer Heaven, a place where soldiers need not justify their actions -- where warriors can be free of political manipulation.
|
||||
Nothing happened to me. I happened. I'm the one who knocks, the one who causes all the trouble... that is my purpose.
|
||||
Sun Tzu said that. I think he meant it as a metaphor, but he also said never leave home without a healthy supply of rations, so what does he know?
|
||||
@@ -2,6 +2,8 @@ package bot
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gogobee/internal/plugin"
|
||||
@@ -9,13 +11,24 @@ import (
|
||||
|
||||
// Registry manages plugin registration and event dispatch.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
mu sync.RWMutex
|
||||
plugins []plugin.Plugin
|
||||
ignoredBots map[string]struct{}
|
||||
}
|
||||
|
||||
// NewRegistry creates an empty plugin registry.
|
||||
// NewRegistry creates an empty plugin registry. Senders listed in the
|
||||
// IGNORED_BOTS env var (comma-separated full Matrix user IDs, e.g.
|
||||
// "@pete:parodia.dev") are dropped before any plugin sees them.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
ignored := make(map[string]struct{})
|
||||
if raw := os.Getenv("IGNORED_BOTS"); raw != "" {
|
||||
for _, u := range strings.Split(raw, ",") {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
ignored[u] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
return &Registry{ignoredBots: ignored}
|
||||
}
|
||||
|
||||
// Register adds a plugin to the registry.
|
||||
@@ -42,6 +55,9 @@ func (r *Registry) Init() error {
|
||||
|
||||
// DispatchMessage sends a message context to all plugins in order.
|
||||
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
|
||||
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
@@ -71,6 +87,9 @@ func (r *Registry) safeOnMessage(p plugin.Plugin, ctx plugin.MessageContext) {
|
||||
|
||||
// DispatchReaction sends a reaction context to all plugins in order.
|
||||
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
|
||||
if _, ok := r.ignoredBots[string(ctx.Sender)]; ok {
|
||||
return
|
||||
}
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
for _, p := range r.plugins {
|
||||
|
||||
165
internal/crypto/crypto_test.go
Normal file
165
internal/crypto/crypto_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
1034
internal/db/db.go
1034
internal/db/db.go
File diff suppressed because it is too large
Load Diff
112
internal/db/db_test.go
Normal file
112
internal/db/db_test.go
Normal 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
|
||||
}
|
||||
189
internal/db/proddb_integration_test.go
Normal file
189
internal/db/proddb_integration_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// TestProdDBMigrations runs the full migration suite against a copy of the
|
||||
// real prod database to verify Phase 1 + Phase 2 schema changes apply cleanly.
|
||||
//
|
||||
// Gated on the prod-db file actually existing — skips silently in CI.
|
||||
// Mutations write only to the tempdir copy; the real prod file is untouched.
|
||||
func TestProdDBMigrations(t *testing.T) {
|
||||
src := "/home/reala-misaki/git/gogobee/data/gogobee.db"
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Skip("prod db not present at " + src)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
dst := filepath.Join(dir, "gogobee.db")
|
||||
copyFile(t, src, dst)
|
||||
|
||||
// Snapshot row counts BEFORE migration via a raw connection.
|
||||
preCounts := tableCounts(t, dst, []string{
|
||||
"adventure_characters",
|
||||
"euro_balances",
|
||||
"user_archetypes",
|
||||
"adventure_equipment",
|
||||
"adventure_inventory",
|
||||
"adventure_treasures",
|
||||
})
|
||||
t.Logf("pre-migration counts: %+v", preCounts)
|
||||
|
||||
// Reset global state (other tests may have run Init already).
|
||||
Close()
|
||||
|
||||
// Run migrations via the production code path.
|
||||
if err := Init(dir); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
t.Cleanup(Close)
|
||||
|
||||
d := Get()
|
||||
|
||||
// 1. New D&D tables must exist.
|
||||
for _, table := range []string{
|
||||
"dnd_character", "dnd_abilities", "dnd_resources",
|
||||
"dnd_combat_state", "adventure_characters_pre_dnd",
|
||||
} {
|
||||
var n int
|
||||
if err := d.QueryRow(
|
||||
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`,
|
||||
table,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("query %s: %v", table, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("table %s missing after migrations", table)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Existing-data row counts unchanged.
|
||||
postCounts := tableCounts(t, dst, []string{
|
||||
"adventure_characters",
|
||||
"euro_balances",
|
||||
"user_archetypes",
|
||||
"adventure_equipment",
|
||||
"adventure_inventory",
|
||||
"adventure_treasures",
|
||||
})
|
||||
for k, pre := range preCounts {
|
||||
if post := postCounts[k]; post != pre {
|
||||
t.Errorf("row count drift in %s: pre=%d post=%d", k, pre, post)
|
||||
}
|
||||
}
|
||||
t.Logf("post-migration counts: %+v", postCounts)
|
||||
|
||||
// 3. Pre-DnD snapshot populated for every adventure character.
|
||||
var advCount, snapCount int
|
||||
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters`).Scan(&advCount)
|
||||
d.QueryRow(`SELECT COUNT(*) FROM adventure_characters_pre_dnd`).Scan(&snapCount)
|
||||
if snapCount != advCount {
|
||||
t.Errorf("snapshot rows %d != adventure_characters %d", snapCount, advCount)
|
||||
}
|
||||
t.Logf("snapshot populated: %d rows", snapCount)
|
||||
|
||||
// 4. Snapshot JSON parses and contains key fields. SQLite's single-conn
|
||||
// pool means we must drain the rows before issuing follow-up queries.
|
||||
rows, err := d.Query(`SELECT user_id, snapshot_json FROM adventure_characters_pre_dnd`)
|
||||
if err != nil {
|
||||
t.Fatalf("query snapshot: %v", err)
|
||||
}
|
||||
type snapPair struct{ uid, j string }
|
||||
var snaps []snapPair
|
||||
for rows.Next() {
|
||||
var uid, j string
|
||||
if err := rows.Scan(&uid, &j); err != nil {
|
||||
rows.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
snaps = append(snaps, snapPair{uid, j})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, s := range snaps {
|
||||
var checkCL int
|
||||
if err := d.QueryRow(`SELECT json_extract(?, '$.combat_level')`, s.j).Scan(&checkCL); err != nil {
|
||||
t.Errorf("snapshot for %s: bad json: %v", s.uid, err)
|
||||
continue
|
||||
}
|
||||
var realCL int
|
||||
d.QueryRow(`SELECT combat_level FROM adventure_characters WHERE user_id=?`, s.uid).Scan(&realCL)
|
||||
if checkCL != realCL {
|
||||
t.Errorf("snapshot %s: combat_level mismatch snap=%d real=%d", s.uid, checkCL, realCL)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. New columns on adventure_equipment / adventure_inventory exist and default empty.
|
||||
rows2, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json, dnd_attuned FROM adventure_equipment LIMIT 5`)
|
||||
if err != nil {
|
||||
t.Errorf("new equipment columns query failed: %v", err)
|
||||
} else {
|
||||
rows2.Close()
|
||||
}
|
||||
rows3, err := d.Query(`SELECT dnd_rarity, dnd_stat_bonus_json FROM adventure_inventory LIMIT 5`)
|
||||
if err != nil {
|
||||
t.Errorf("new inventory columns query failed: %v", err)
|
||||
} else {
|
||||
rows3.Close()
|
||||
}
|
||||
|
||||
// 6. auto_migrated column on dnd_character exists.
|
||||
if _, err := d.Exec(`SELECT auto_migrated FROM dnd_character LIMIT 0`); err != nil {
|
||||
t.Errorf("auto_migrated column missing: %v", err)
|
||||
}
|
||||
|
||||
// 7. Idempotent: running Init again must not error or change row counts.
|
||||
Close()
|
||||
if err := Init(dir); err != nil {
|
||||
t.Fatalf("second Init failed: %v", err)
|
||||
}
|
||||
postCounts2 := tableCounts(t, dst, []string{"adventure_characters_pre_dnd"})
|
||||
if postCounts2["adventure_characters_pre_dnd"] != snapCount {
|
||||
t.Errorf("snapshot row count drifted on second Init: %d → %d",
|
||||
snapCount, postCounts2["adventure_characters_pre_dnd"])
|
||||
}
|
||||
}
|
||||
|
||||
func copyFile(t *testing.T, src, dst string) {
|
||||
t.Helper()
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func tableCounts(t *testing.T, dbPath string, tables []string) map[string]int {
|
||||
t.Helper()
|
||||
d, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Close()
|
||||
out := map[string]int{}
|
||||
for _, tbl := range tables {
|
||||
var n int
|
||||
err := d.QueryRow("SELECT COUNT(*) FROM " + tbl).Scan(&n)
|
||||
if err != nil {
|
||||
out[tbl] = -1
|
||||
continue
|
||||
}
|
||||
out[tbl] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
13
internal/flavor/pick.go
Normal file
13
internal/flavor/pick.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package flavor
|
||||
|
||||
import "math/rand/v2"
|
||||
|
||||
// Pick returns a random string from pool, or "" if the pool is empty.
|
||||
// Use this from any caller that wants to render flavor text from one of
|
||||
// the protected pools defined in this package.
|
||||
func Pick(pool []string) string {
|
||||
if len(pool) == 0 {
|
||||
return ""
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
114
internal/flavor/twinbee_ambient_flavor.go
Normal file
114
internal/flavor/twinbee_ambient_flavor.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// twinbee_ambient_flavor.go
|
||||
// TwinBee GM Dialogue — Ambient between-day micro-events. Fired by the
|
||||
// expedition ambient ticker (Phase 12 E7) every few hours while the player
|
||||
// is offline or idle. Mostly flavor; occasional micro-mechanical nudges.
|
||||
//
|
||||
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
|
||||
// subject. No third-person "TwinBee [verb]" lines. Add freely.
|
||||
// Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Pure flavor (no mechanical effect)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientMonologue = []string{
|
||||
"I've been counting the dripping sound. The dripping sound has not been keeping pace. I suspect the dripping sound is doing it on purpose.",
|
||||
"A draft moves through the corridor in a way that suggests the dungeon is breathing, then in a way that suggests it isn't, then back. I decline to call it.",
|
||||
"Somewhere far off, a door closes. Nothing has opened a door. Filed under 'noted, not investigated.'",
|
||||
"Your bedroll has shifted approximately two inches to the left over the course of the morning. I did not move it. You did not move it. I am going to stop watching the bedroll now.",
|
||||
"A small stone has rolled across the floor with no visible cause. It came to rest in a position I can only describe as 'mildly conversational.'",
|
||||
"I tried to think of a song and forgot the words. The dungeon offered a few. I politely declined.",
|
||||
"A spiderweb in the corner has rearranged itself overnight into a shape that's almost a sentence. I can read about three letters of it before deciding it's safer to look at the floor.",
|
||||
"The torch is burning at exactly the same height it was an hour ago. No wax has accumulated. I considered asking the torch about this and decided against. The torch has not earned an interview.",
|
||||
"You have not moved in some time. The dungeon also has not moved. I'm calling this an even draw and declaring the round complete.",
|
||||
"A faint smell of bread has drifted through. There is no bread within several days of you. I'm not mentioning it, because mentioning it would make it worse.",
|
||||
"I've been alphabetizing your inventory in my head. I got as far as 'flask, empty' before getting distracted by the question of whether 'empty' counts.",
|
||||
"The shadow in the far corner has been the same shape for three hours. Shadows are generally not committed to one shape. I've stopped looking at it.",
|
||||
"You realize you've been holding your breath. You weren't doing it on purpose. I was, and I'm quietly resuming.",
|
||||
"A pebble on the floor has, by all appearances, blinked. I reviewed the definition of 'blink' and found it unhelpful here.",
|
||||
"The dungeon is quiet in the specific way that means something is being quiet *at* you. I acknowledge the distinction and move on.",
|
||||
"I've been composing an imaginary letter home. The letter is mostly about lichen. The lichen is, frankly, fascinating.",
|
||||
"You hear what is almost certainly a cough. It is unclear which of you it came from. I'm assuming responsibility and apologizing.",
|
||||
"The map in your pack has folded itself one extra time while you weren't looking. I decline to unfold it for science.",
|
||||
"Your own footsteps echo back to you a half-second late, in slightly the wrong order. Noted. I'm choosing to walk in silence for a bit.",
|
||||
"A long, slow scrape happens two rooms over. Then nothing. I waited for a second scrape. There wasn't one. The first scrape was the whole sentence.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Noises (+1 threat)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientNoise = []string{
|
||||
"Something far off makes a wet, considered noise — the kind of noise that's been thinking about itself. Marked on the imaginary map. Threat ticks up.",
|
||||
"A door slams somewhere it shouldn't. 'Shouldn't' is doing heroic work in that sentence. The dungeon is more aware of you now.",
|
||||
"A patrol passes a corridor or two over — boots, low voices, the particular silence of people who hunt in shifts. They didn't find you. They know the shape of where you might be.",
|
||||
"The dungeon hums for a moment, like a refrigerator coming on. There is no refrigerator. There is also, distinctly, a humming. The faction's pulse is faster today.",
|
||||
"Stones shift overhead in a way that is not settling and not random. Something walked across the ceiling. I'm choosing not to elaborate on what 'across the ceiling' implies.",
|
||||
"A horn sounds, very far away — the kind of horn that's a signal to a thing that signals to more things. I acknowledge the chain.",
|
||||
"You hear someone whistling your name. You don't have that name. The whistler is workshopping options.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Pack Rat / Critter ate supplies (−0.2 SU)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientPackRat = []string{
|
||||
"A rat has formed strong opinions about your trail mix. The opinions are now inside the rat. Some supplies were lost in transit.",
|
||||
"You catch a small mammal mid-burglary. It looks at you with the unbothered confidence of a creature that has read the lease and concluded it has rights. You lose a little supply.",
|
||||
"Something with too many legs has been at the rations. I won't say how many legs. I will say: fewer rations now.",
|
||||
"A bird the size of a thumbnail has stolen a piece of jerky three times its size, by a process best described as 'unclear but enviable.' Supplies tick down.",
|
||||
"Mold. Just normal, ambitious mold. It has made itself at home in a corner of the pack. You evict it; some supplies go with it.",
|
||||
"You discover a small hole in the side of your pack and a smaller, more confident animal asleep beside it, full. Supplies were the rent.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Lucky Find (+1d6 coins)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientLuckyFind = []string{
|
||||
"I found a copper piece in your boot. You did not put it there. I did not put it there. I elect not to investigate further and pocket it.",
|
||||
"You notice a coin wedged in a wall crack at exactly eye height, as though placed for the next passerby. You are the next passerby. The coin is yours now.",
|
||||
"A small pouch is lying in the middle of the floor. I inspect it from a polite distance — no trap, no glyph, just coins. The dungeon occasionally tips.",
|
||||
"You step on something flat. It is a coin. It is also two more coins under the first coin. I suspect a coin-laying creature and choose not to share the theory.",
|
||||
"A skeleton you walked past three days ago has, on review, a small purse you missed. I retrieve it with the discretion of a librarian recovering an overdue book.",
|
||||
"You find coins in the lining of your own cloak. They were always there. I gently suggest counting your pockets more often.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Camp Critter (camped only; tiny HP +1 or supply nibble)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientCampVisitor = []string{
|
||||
"A possum has joined the camp. It seems diplomatic — it brought a leaf. I accept the leaf. Morale ticks up.",
|
||||
"A small luminous moth lands on your hand for a full minute, then leaves, taking with it a portion of whatever was weighing on you. You can't explain it. You feel slightly better.",
|
||||
"A cat. There should not be a cat down here. There is a cat. It sits at the edge of the firelight and watches you sleep, professionally. You rest a little better.",
|
||||
"A frog finds the camp acceptable and gives no further reviews. Its presence is, somehow, soothing. You feel marginally more whole.",
|
||||
"A small fox eats some of your jerky and gives you a look afterwards that seems to be gratitude or possibly contempt. Hard to read. The jerky is gone either way.",
|
||||
"A field mouse studies your bedroll, judges it, and leaves. Nothing is missing. You feel oddly honored.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Threat-aware whisper (only when threat ≥ 30; +2 threat)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientFactionWhisper = []string{
|
||||
"You overhear two voices a corridor away, going over a list. The list is short. One of the items on it is the corridor you're standing in. They move on. So should you.",
|
||||
"A piece of paper is pinned to a wall you haven't reached yet. I can see the corner of it from here. It is the right shape and size to have your description on it.",
|
||||
"Something taps three times on stone, pauses, taps three times again. Five seconds later, the same sequence answers it from a different direction. You are between the two.",
|
||||
"A scent of lamp oil that isn't yours drifts through. Someone is reading by it. Nearby. They have not moved for some time. I adjust the route.",
|
||||
"A bell rings exactly once, somewhere structural. I have learned what one bell means in this zone. I decline to share but increase pace.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AMBIENT — Small item / minor stash hint (flavor only)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var AmbientSmallFind = []string{
|
||||
"I notice a loose stone you walked past earlier. Under it: a button. Just a button. I pocket it anyway, because of course I do.",
|
||||
"A bird's nest in an unlikely place yields a single bead, blue. I like it. It goes in the bag of things I like.",
|
||||
"A scrap of fabric has caught on a nail at exactly knee height. I save it without explanation. There may be an explanation later. There probably won't.",
|
||||
"You find a single playing card on the floor. The four of cups. Neither omen nor coincidence. Pocketed neutrally.",
|
||||
"A small key. To a small door. Neither of which is currently relevant. Filed under 'eventually.'",
|
||||
}
|
||||
382
internal/flavor/twinbee_expedition_flavor.go
Normal file
382
internal/flavor/twinbee_expedition_flavor.go
Normal file
@@ -0,0 +1,382 @@
|
||||
// twinbee_expedition_flavor.go
|
||||
// TwinBee GM Dialogue — Expedition-specific narration lines.
|
||||
// Multi-day and multi-week adventure events, morning briefings,
|
||||
// evening recaps, temporal events, and long-arc narrative moments.
|
||||
//
|
||||
// Voice convention: TwinBee speaks in first-person or implicit
|
||||
// subject. NO third-person "TwinBee [verb]" references — that
|
||||
// pattern was retired in the Phase B2 voice pass. When adding new
|
||||
// entries, keep the existing personality (clipped, observational,
|
||||
// ledger-minded) but stay in first-person / implicit voice.
|
||||
// Add new entries freely. Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EXPEDITION START
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExpeditionStart = []string{
|
||||
"Manifest reviewed. Supplies: checked. Equipment: checked. The part of you that's wondering if this is a good idea: noted, and set aside. We begin.",
|
||||
"The dungeon has been there for a long time. It will be there for a long time after. The question is what happens in the middle, and that's where you come in. I'm ready.",
|
||||
"An expedition. Not a run — an expedition. There's a difference. I'll explain the difference over the coming days and the explanation will be mostly experiential.",
|
||||
"Horizon checked, then supplies, then you. In that order. 'Alright,' I say, with the quiet energy of something that has been looking forward to this. 'Let's go.'",
|
||||
"You're not here for a quick visit. I know the difference between someone passing through and someone committing. You're committing. I appreciate the commitment.",
|
||||
"Like the opening screen of a long RPG — the kind that asks for your name and warns you to find a comfortable position because this is going to take a while. I've found a comfortable position. I suggest you do the same.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MORNING BRIEFINGS — Generic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MorningBriefingGeneric = []string{
|
||||
"Another day in the dungeon. Said without resignation. The dungeon is still full of things worth doing and you are still the person to do them.",
|
||||
"Morning. The dungeon has been quiet since you camped. Relative to what a dungeon considers quiet, which is not what you'd consider quiet, but everyone adjusts.",
|
||||
"I've been watching the entrance to the camp since approximately three in the morning. Nothing came. Mentioned casually; no particular reaction expected.",
|
||||
"Day [N]. The numbers are climbing. There's something satisfying about the numbers climbing — it means you're still here, which is always the first thing to confirm.",
|
||||
"You wake up and I'm already there, which is my nature. 'Good,' I say, by which I mean: you're alive, the day can proceed, there is much to do.",
|
||||
"The morning check: HP adequate, supplies within tolerance, Threat Clock where it was when you slept plus the overnight drift. I've already run the numbers. I'll share them in a moment.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MORNING BRIEFINGS — By Day Range
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MorningBriefingDay1 = []string{
|
||||
"First morning. The dungeon looks exactly like it looked yesterday, which is expected. You look slightly more prepared than you did yesterday, which is less expected and entirely welcome.",
|
||||
"Day one complete. Tally: you entered, you explored, you survived the night. The bar was not high. You cleared it. I build from here.",
|
||||
"Morning of day two. The first night is filed under 'survived' and I'm already running the second day's plan. New room types likely. New encounter shapes. The pace begins to differentiate from the practice version of itself.",
|
||||
"Day one is behind you. The day's notes are already organized — what worked, what nearly didn't, what you'll do differently from here. The dungeon is also taking notes on you. Everyone is preparing.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay3 = []string{
|
||||
"Day three. The dungeon has had time to notice you. I've had time to notice the dungeon noticing you. The Threat Clock reflects both observations.",
|
||||
"Three days in. You've found your rhythm — I can see it in the way you move through rooms now, the way you check corners. The dungeon is learning too. Both noted.",
|
||||
"Day three morning. The dungeon's posture has shifted noticeably overnight — I saw it in the corridor noise around the second watch. The Threat Clock is doing what it does. You're doing what you do. The two of you are now both doing it on purpose.",
|
||||
"Three days. The dungeon recognizes you as a recurring fixture now, which is a different kind of attention than the kind you had on Day 1. I'm adjusting the briefings accordingly. Less orientation, more situational reads. From here it gets specific.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay7 = []string{
|
||||
"One week. You have spent one week in this place and it has not finished you, which says something about you that I intend to say out loud: that took something real. Take a moment with that. Then advance.",
|
||||
"Seven days. In the old reckoning, seven was the number of completion — seven seals, seven trials, seven nights before the thing reveals itself. I'm not superstitious. I'm also watching the door very carefully this morning.",
|
||||
"A week underground. I think about the sky sometimes — not with longing, exactly, more as a reference point. You've been below it for seven days. Remarkable. You, more so.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay14 = []string{
|
||||
"Two weeks. I don't know many people who have been in an active dungeon for two weeks. I know even fewer who have been in one for two weeks and remained, by any reasonable measure, intact. You are one of the fewer.",
|
||||
"Fourteen days. The dungeon has become familiar in the way that difficult things become familiar — not comfortable, not safe, but known. You know where it breathes. An advantage worth having.",
|
||||
"Two weeks. I start the briefing the same way I've started fourteen others, then pause and add: 'For the record — most expeditions don't see fourteen briefings. This one has.' That's the addendum. Continuing.",
|
||||
"Day fifteen. I've stopped checking the historical reference materials because the historical reference materials stopped applying. From here, the run sets its own precedent. I find this clarifying.",
|
||||
}
|
||||
|
||||
var MorningBriefingDay21 = []string{
|
||||
"Three weeks. I've run out of historical comparisons for this. Three weeks is its own category. You have made a category. I report this as a fact and also as something that doesn't entirely have words yet.",
|
||||
"Day twenty-one. I tried to write a clever framing for this morning's briefing and gave up halfway through, settling instead on the simplest version: 'You're still here.' That's the briefing. The rest is logistics.",
|
||||
"Three weeks down. The dungeon has stopped being a place you're visiting and become a place you live in for now. I note the shift — the way you check rooms without being asked, the way the supply count is already in your head. The dungeon notices too.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EVENING RECAPS — Generic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EveningRecapGeneric = []string{
|
||||
"End of day [N]. Ledger tallied. The column marked 'survived' has another entry. I consider this column the most important one.",
|
||||
"Day closes. I review what happened and find, on balance, more right than wrong — which in a dungeon is the operating definition of a good day.",
|
||||
"Evening. The rooms behind you are cleared. The rooms ahead are not. Always true; never less relevant. Rest now. The math doesn't change overnight.",
|
||||
"I compile the day: what was learned, what was fought, what was found. File it in the mental ledger I've been keeping since you entered. The ledger is favorable.",
|
||||
"Like the experience screen at the end of a dungeon floor in Etrian Odyssey — the numbers settle, the progress registers, and for a moment the whole thing makes sense. I give you that moment.",
|
||||
"You've earned the dark. Sleep in it. I'll be here when the numbers come back.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EVENING RECAPS — Notable Days
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EveningRecapBossKilled = []string{
|
||||
"The day ends with a boss on the floor. I'd like to specifically note this in the recap as 'exceptional' because it is exceptional and I don't use that word loosely.",
|
||||
"End of day. Boss count: one more than it was this morning. Marked in a special column I keep for exactly these entries, and the special column has a new line.",
|
||||
"End of day. A boss is no longer in the dungeon. Recorded in the column I keep for exactly that line, and the column has a new mark, and that's the kind of recap I like to write. Sleep well. The rest of the dungeon noticed too.",
|
||||
"The day closes with a vacancy. The thing in the chamber is no longer in the chamber. I ran the final tally during the fight and the tally is favorable. There will be more things tomorrow. There always are. None of them are this one.",
|
||||
}
|
||||
|
||||
var EveningRecapCloseCall = []string{
|
||||
"Evening recap. Note: you came very close today to not being here for the evening recap. Said without drama and with complete sincerity. You made it. That's the recap.",
|
||||
"Today was the kind of day I file under 'let's not do that again' and also 'and yet you did it.' Rest. You need it more tonight than most nights.",
|
||||
"Evening recap. Noted for the ledger: today was very nearly a different kind of recap, and I'm glad it isn't. The margin was thinner than the acceptable range. Tomorrow's caution adjusts accordingly.",
|
||||
"End of day, narrowly. I run through the close-call list — the round that nearly went the other way, the save that landed by one, the room you almost didn't leave — and sign each one off as 'survived.' Survived is a wide category. I'll accept you in any part of it.",
|
||||
}
|
||||
|
||||
var EveningRecapNothingHappened = []string{
|
||||
"A quiet day. No major encounters, no notable finds. Reported with neither disappointment nor relief. Quiet days are data. The dungeon is thinking. I'm watching it think.",
|
||||
"Not every day in a dungeon produces a story. Today produced logistics: movement, supplies, positioning. I value logistics. Logistics is what you still being alive looks like from a distance.",
|
||||
"Day closes uneventfully. Uneventful days are the kind the dungeon is most carefully constructing — quiet between incidents is also a kind of incident. Filed under 'preparation, theirs.' Yours starts now.",
|
||||
"Nothing happened. I write that exact phrase in the recap and then cross it out and write 'something happened that wasn't visible.' That feels truer. The day still counts. The day still cost supplies. Everything still moves forward.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CAMP ESTABLISHMENT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CampEstablished = []string{
|
||||
"Camp established. I survey the perimeter with the efficiency of someone who has done this many times and learned from every time it went wrong.",
|
||||
"The camp goes up. I approve of the location — cleared room, defensible entry, no obvious curse residue. Could be worse. I've seen worse.",
|
||||
"You set camp. I check the sightlines, the doors, the sound-bleed from the next room. Acceptable. Settling in for the night watch.",
|
||||
"A camp in the middle of a dungeon. Either brave or pragmatic; I've stopped trying to distinguish between the two. Either way, the camp is set. Either way, I'm watching.",
|
||||
"Like planting a flag on a new map in Dwarf Fortress — this spot is yours now. Tentatively. Provisionally. For the night. I defend tentative, provisional, nocturnal property with full commitment.",
|
||||
}
|
||||
|
||||
var BaseCampEstablished = []string{
|
||||
"Base camp. I say this differently than I say other things. With weight. A base camp in a Tier 4 zone is not nothing — it's a declaration. You're not passing through. You're operating from here. The perimeter goes up accordingly.",
|
||||
"The base camp is up. Waypoint noted, return route marked, supply cache protocols established. This is now home, in the way that a forward operating position is home — functional, defended, temporary, and completely yours.",
|
||||
"Base camp established on Day [N]. I record this as a milestone, because it is one. Most people don't make it to base camp. You are not most people. I've been noting this since the beginning.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SUPPLY WARNINGS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SupplyWarningLow = []string{
|
||||
"I check the supply manifest and then check it again. 'We should discuss the supply situation,' I say, in the tone of someone who has been watching the number for two days.",
|
||||
"Supplies are running lower than I'd like. Mentioning it now, while there are still options, because I've seen what happens when it's mentioned too late.",
|
||||
"The supply number is not comfortable. Flagging this not to alarm but to prompt — there are decisions to be made and they are better made with time than without it.",
|
||||
}
|
||||
|
||||
var SupplyWarningCritical = []string{
|
||||
"I hold up the supply manifest. The number is very small. 'This is the part,' I say quietly, 'where we start making decisions.' I don't specify which. You know which.",
|
||||
"Critical supply levels. Delivered without inflation — the situation is what it is. Extract and resupply, or forage aggressively and push for the finish. I outline both paths and neither is comfortable.",
|
||||
"The supplies are nearly gone. I think of every long JRPG dungeon where you realize at the bottom floor that you're out of Ethers. This is that moment. I have plans. They require movement.",
|
||||
}
|
||||
|
||||
var SupplyDepletedExtraction = []string{
|
||||
"The supplies are gone. Said plainly because the situation requires plain language. The expedition ends here — not in failure, in logistics. You push out as far as the provisions allow. What you've gathered comes with you. I lead the way out.",
|
||||
"Empty packs. I turn them out by reflex and confirm what the manifest already said. The expedition does not continue past supplies — that's the rule, and I enforce it on you the way I enforce it on myself. Out we go. With what we have. Which is something.",
|
||||
"Out of supplies. I lead the extraction along the route already mapped — there's no scouting now, just movement, the kind that gets you through the door before anything else does. The dungeon will be here. So will I. So, importantly, will you.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THREAT CLOCK NARRATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ThreatClockStirring = []string{
|
||||
"Something in the dungeon's rhythm has changed. A tension in the air that wasn't there yesterday. The zone is aware of you now, in the way that a predator becomes aware of something in its territory. Not panicking. Not moving yet. Just aware.",
|
||||
"The enemies ahead are more alert than they were. I can tell by the patrols, by the spacing, by the fact that someone moved the tripwire you already disarmed. They know something is in here.",
|
||||
"Stirring. I track the small changes the dungeon makes when it suspects but doesn't confirm — repositioned patrols, fresher footprints, a torch that wasn't lit in this corridor before. Each one noted. None of them is alarming yet. All of them are evidence.",
|
||||
"The dungeon is getting curious. I distinguish curiosity from alertness — curiosity is when the patrols slow at the doorways; alertness is when they form on the doorways. We're at the first one. The window between them is what I'm watching.",
|
||||
}
|
||||
|
||||
var ThreatClockAlert = []string{
|
||||
"The zone is on alert. Reported factually and also with a note of urgency — alert means organized, and organized means the next room will be harder than the last room was. Move with intention.",
|
||||
"They're coordinating now. I watch the patrol patterns shift. The goblins who were arguing about ambush order are no longer arguing. Not an improvement.",
|
||||
"Alert band. The dungeon has confirmed a presence and is acting on the confirmation. I can see it in the way the patrols overlap deliberately now — no more arguments about coverage, just coverage. The plan adjusts: no more long approaches, no more leaving anything unfinished behind.",
|
||||
"They've arranged themselves. Band-shift confirmed, and the operational change condenses to one sentence: from here, every room costs more than the last one. Nothing gets cheaper. Price accordingly.",
|
||||
}
|
||||
|
||||
var ThreatClockHostile = []string{
|
||||
"Full hostile status. Said with the specific weight it deserves. The zone has decided you are the problem it's solving today. You have the same opinion about the zone. One of you is correct. I'm invested in it being you.",
|
||||
"The dungeon has mobilized. Re-armed traps, reinforced positions, an enemy in a room that was empty this morning. They've organized. Recommend organizing faster.",
|
||||
"Hostile. I name the band and then set the briefing aside because the briefing has been overtaken by events. The dungeon is now actively committed to ending this. I'm committed in the opposite direction. The clarification is useful.",
|
||||
"The dungeon has decided. I respect decisions, even ones that involve the dungeon trying to kill you. The respect is a working respect. We respect the decision and then we work to overrule it. I lead.",
|
||||
}
|
||||
|
||||
var ThreatClockSiege = []string{
|
||||
"Siege Mode. Delivered without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So am I — to ending it on your terms, not theirs. What happens next is a race. Already running.",
|
||||
"Siege. The dungeon is putting everything it has into the room you're standing in, and the rooms adjacent to it, and the route between you and the door. I'm putting everything I have into making sure the dungeon doesn't get what it wants. Meet in the middle.",
|
||||
"Siege Mode. I narrate the band shift and then stop narrating because narration is not what this band needs. Action is what this band needs. I'm in motion. The expectation is that you are too.",
|
||||
}
|
||||
|
||||
// ThreatClockApproachingSiege fires once when the threat clock crosses 70 —
|
||||
// the spec's "begin warning" line (§8.3). Distinct from the Hostile-band
|
||||
// flavor because this is the dungeon-design moment of telling the player
|
||||
// they're past the point where stealth is recoverable.
|
||||
var ThreatClockApproachingSiege = []string{
|
||||
"They know you're here. Not a suspicion anymore. A certainty. The question now is whether you finish before they organize. Said clearly so it doesn't have to be said again.",
|
||||
"Threat at seventy. Marked on the internal ledger and underlined. The window for quiet operations has closed. The window for finishing is still open — narrower, but open. Use it.",
|
||||
"The dungeon's posture has shifted from 'searching' to 'hunting.' I track the difference precisely: before, they were looking for evidence; now they are looking for you. The plan, accordingly, simplifies. Finish or extract. Middle paths have closed.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ZONE TEMPORAL EVENTS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SunkenTempleTidalWarning = []string{
|
||||
"I watch the waterline. 'It's rising,' I say, not for the first time, and this time with more precision. 'The tidal cycle peaks in two days. Whatever you haven't done by then, you'll be doing wet.'",
|
||||
"Day [N]. The tide is coming. I've been watching it since Day 2 and the mathematics are not encouraging. Two days to the peak. One day after that, the flood subsides. Use the two days well.",
|
||||
"I check the waterline against yesterday's mark. The mark is below the line. The line is on the wall. The wall is, frankly, full of marks. I select the one that matters and report: tide rising, peak still days off, plan accordingly.",
|
||||
"Day [N]. The temple's tidal calendar advances. I keep it on a card and update the card every morning — current depth, projected depth, peak day. Internalize the card. The temple does not pause for confusion.",
|
||||
}
|
||||
|
||||
var SunkenTempleTidalEvent = []string{
|
||||
"The tide arrives. I'd warned about this and now it is happening and the warning feels entirely inadequate compared to the actual water. Everything is colder, wetter, and the Kuo-toa are moving through it like they were born to — which they were. Adjust.",
|
||||
"The water is here. Not 'rising' anymore — here, in the rooms, at the corners, climbing the stairs. Every estimate adjusts accordingly: travel slower, encounters worse, healing colder. Two days of this. Two days, then it goes.",
|
||||
"Tidal peak. The temple is fully flooded at depths that were dry yesterday — I map the change room by room and update the route. Some doors are open that weren't. Some are sealed that were. The temple is a different shape today.",
|
||||
}
|
||||
|
||||
var HauntedManorResetMorning = []string{
|
||||
"The morning briefing includes an addendum. The rooms that were clear yesterday are not entirely clear this morning. The house has been busy overnight. Adding it to the log under 'things the house does' and suggesting we adjust the advance plan.",
|
||||
"Night three. The manor reset itself. I was watching and it happened anyway — not violently, not dramatically, just quietly and completely, the way the house does everything. One enemy per room, back in place. Map updated.",
|
||||
"I read the morning's hallway and stop. 'The arrangement has changed,' I say, in the tone of someone who has now seen this happen often enough to recognize it without alarm. The manor reset. Map adjusts. Morning continues.",
|
||||
"Reset day. The rooms I cleared are not the rooms I find this morning. Accept it as a property of the manor — like weather, but indoors and unfair. The plan accommodates. The time gets billed.",
|
||||
}
|
||||
|
||||
var UnderforgHeapWarning = []string{
|
||||
"Heat Stack [N]. I note the accumulation and what it means: the Underforge is getting into you in ways that don't resolve without real rest. The number has time to come down. I'm watching the number.",
|
||||
"The heat is building. I track it the way you track a temperature gauge on a long drive — with the specific alertness of someone who knows what happens when the gauge hits red. It has not hit red. I intend to ensure it doesn't.",
|
||||
"Heat building. Count noted, band noted, and how each step now takes a fraction more out of you than the last step did. None of it is critical. All of it is direction. The direction should be inverted soon.",
|
||||
"Stack [N]. I track the heat the way I track anything that compounds — patiently, with running totals, with a clear point at which the totals stop being managed and start being problems. We are still on the management side. The other side is visible.",
|
||||
}
|
||||
|
||||
var UnderforgHeapCritical = []string{
|
||||
"Heat Stack is high. Delivered without minimizing it. The Underforge is inside your lungs now, in your joints, in the way everything takes a little more effort than it did on Day 1. A proper rest will help. Finishing will help more.",
|
||||
"Heat ten. The maximum. There is no higher band than this in the Underforge ledger — the column ends here, by design. The forge is fully inside you. The penalties are all of them. Resting will not undo it. Finishing might.",
|
||||
"Critical heat. I run through the symptoms list and confirm each one in turn: the joints, the breathing, the way every roll feels a little heavier. The forge is doing what the forge does. I suggest doing what you do, harder.",
|
||||
}
|
||||
|
||||
var FeywildTimeDistortionHalf = []string{
|
||||
"The day moved strangely. I tried to track it and lost the thread somewhere around mid-afternoon — the light didn't change the way it should have, and when I looked up, the day was half over in the time it usually takes to be a quarter over. On the positive side: you're barely hungry. On the less positive side: nobody is sure what that means.",
|
||||
"Half a day passed in what felt like half of half. I check the supply burn against the sun, find them disagreeing, side with the supplies — those don't lie about how much you've used. Net result: a free pocket of time. Use it on something that takes time.",
|
||||
"The Feywild gave you back some hours. Noted, but not trusted. The Feywild does not give without taking, eventually. For now: less hungry, less tired, more daylight than the math allows. I write it down and keep moving.",
|
||||
}
|
||||
|
||||
var FeywildTimeDistortionDouble = []string{
|
||||
"Time doubled. Noted with the clinical detachment of someone who has been in the Feywild long enough to stop being surprised. You've lived through two days today. The supplies reflect that. The wandering monsters reflected that as well. The rest of the Feywild is unconcerned.",
|
||||
"Two days in one. I run the recovery math and the wandering math and the supply math, and all three say the same thing: today cost double. Tomorrow gets the rate it gets. The Feywild does not refund.",
|
||||
"Time doubled. I saw the second sunset before the second lunch and stopped trying to make sense of it. The day that happened is the day that happened. Plan from here, not from this morning.",
|
||||
}
|
||||
|
||||
var FeywildTimeLoop = []string{
|
||||
"I recognize this room. I've described it before. The enemies in it are different — new enemies, the old ones are gone, the loot you found is still gone but the enemies are back — and I process this with something between professional acceptance and profound exasperation. 'Again,' I say. 'We do this room again.'",
|
||||
"The loop. I mark the room on the map with a small symbol that means 'we have been in this room before in a way that doesn't count.' The symbol has its own column in my ledger. The column is occupied.",
|
||||
"Same room. New enemies. I run the encounter again with the cold professionalism of someone who has stopped expecting fairness from the Feywild and started expecting only repetition. The repetition arrives. Handled.",
|
||||
}
|
||||
|
||||
var DragonsLairAwarenessPulse = []string{
|
||||
"Something changes in the mountain. Not a sound, exactly — more like a sound's absence, filling in differently than before. I watch the kobolds. The kobolds have stopped what they were doing. The kobolds are listening. Something told them to listen. I add ten to the Threat Clock and keep moving.",
|
||||
"Infernax shifts in its sleep. I feel it through the stone. The patrol rotations just changed — I can see it in where the Guard Drakes aren't anymore versus where they were an hour ago. The mountain's master is dreaming about you. That is not a comfortable thing to be dreamed about.",
|
||||
"The mountain pulses. I feel it as a single off-rhythm beat in the rock, the kind of beat that means something somewhere has shifted weight. The kobolds nearby pause. They look upward without looking. I adjust the threat accordingly and continue.",
|
||||
"Infernax dreamed about you. I have no other way to phrase it — the lair changed in the way places change when they're being reordered from somewhere deeper. The patrols are different. The temperature is different. Ten added to the clock and a note to the margin.",
|
||||
}
|
||||
|
||||
var DragonsLairAwakenWarning = []string{
|
||||
"Day 14. The morning briefing comes out in a lower register than usual. 'Infernax is awake,' I say. 'I don't know when exactly — sometime in the last six hours. The patrols changed. The temperature changed. The silence changed.' A pause. 'We need to reach the final chamber before it reaches us. That is now the only plan.'",
|
||||
"Day fourteen. Infernax woke up in the night. I say the sentence flatly because the sentence is enough by itself; it does not need help. The rotation is to the final chamber. Everything else is decoration.",
|
||||
"The mountain changed in its sleep. I felt it through the floor sometime around the fourth watch and have been recalibrating since. Infernax is awake and Infernax is aware. Be neither subtle nor slow. There is no longer time for both.",
|
||||
}
|
||||
|
||||
var AbyssPortalDestabilizationMid = []string{
|
||||
"Instability [N]. I watch the portal from a respectful distance and note: it is larger than it was yesterday. Not by much. Not in a way you'd notice if you weren't looking. I'm always looking.",
|
||||
"The portal is talking to itself. I have no better way to describe it — the light it emits is different at the edges now, like it's processing something. Process faster than it does.",
|
||||
"Instability rising. I run the comparison: yesterday's portal versus today's portal. The difference is not subtle to me. The difference may not be subtle to anything else, soon. Pace the work accordingly.",
|
||||
"The portal is widening, by some measure I'm using and not currently sharing because the measure doesn't have a clean name. The point is: it's worse than yesterday. The point is: tomorrow may be worse than that. I name the trend so the trend can be argued with.",
|
||||
}
|
||||
|
||||
var AbyssPortalDestabilizationCritical = []string{
|
||||
"Instability critical. The portal is unraveling at edges I can see and probably at edges I can't. The demons coming through are more agitated than they were — which is relevant because demons at baseline are already at the upper end of agitated. The verdict: finish this today. Tomorrow is a different calculation.",
|
||||
"The portal is louder. Not in sound — in pressure. I can feel it in the back teeth, in the joint of the jaw. The instability number is the number you don't want it to be. Finish the work today or accept that today was the last day to.",
|
||||
"Critical. The geometry around the portal is bending in ways that suggest the room does not entirely agree with itself anymore. I adjust the route around the worst of it. There are more demons than there should be. There always are. There are more than that now.",
|
||||
}
|
||||
|
||||
var AbyssPortalCollapse = []string{
|
||||
"The portal collapses. I watch it happen and do what I do in situations with no good options: move. 'Out,' I say, and mean it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.",
|
||||
"It's coming apart. I say one word, 'Move,' in the tone I use exactly once per expedition. You move. The portal screams behind you in a register that isn't sound. The expedition is over because physics says so. The loot gets sorted once you're somewhere physics still works.",
|
||||
"Collapse. I was prepared for this and am also actively running. 'Out,' I say. 'Now.' The corridors are folding behind you in the way that things fold when reality is no longer paying attention to what's allowed. You finish out the door. The portal does not.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// REGION TRANSITION (multi-region zones; §11.3)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RegionTransitDeparture = []string{
|
||||
"I mark the boundary on the internal map and cross it. 'New region,' I say, with the careful attention of someone who knows boundaries in dungeons aren't always the same kind of boundaries you'd find on the surface. The route is set. The day is committed. We move.",
|
||||
"Crossing into [REGION_NEXT]. I fold the previous region's notes into the satchel and unfold new ones. The light changes. The air changes. The rules of how to be careful change a little. I adjust.",
|
||||
"The transit between regions is its own kind of room. I narrate it that way — sightlines, footing, what's behind, what's ahead — because the only way the in-between part stops feeling exposed is to treat it like the rest of the dungeon. Treated. Moving.",
|
||||
}
|
||||
|
||||
var RegionTransitArrival = []string{
|
||||
"You arrive in [REGION_NEXT]. I survey, take in the new geometry, and update the working assumptions. 'Different shape,' I say. 'Same general principle. We learn what wants to kill us here, and we get there first.'",
|
||||
"[REGION_NEXT] receives you. I note the temperature, the sound, the things-not-said-by-the-room-but-implied. A region is not just a place. It's a posture. I adopt the new one and suggest you do as well.",
|
||||
"Boundary crossed. The day gets stamped in the log — one full day spent in transit, supplies adjusted, the wandering that happened on the way handled and filed. We are here now. The next stretch is what it is.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VOLUNTARY EXTRACTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExtractionVoluntary = []string{
|
||||
"Extraction. I note the decision and respect it — knowing when to leave is a skill, not a failure, and I've watched enough expeditions end wrong to deeply appreciate the ones that end right.",
|
||||
"You call the extraction and I begin the route out immediately. No argument, no editorializing. There will be time for the debrief later. The first priority is the door.",
|
||||
"The dungeon doesn't like this. I can tell by the way the corridors feel as you head back out — a resistance that isn't structural, just atmospheric. The zone wanted more. It doesn't get more today. I lead the way.",
|
||||
"Withdrawing with intent. I catalogue what you have — the loot, the XP, the knowledge of where the rooms are for the return — and convert the exit into preparation. This isn't retreat. This is the start of the next attempt.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FORCED EXTRACTION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExtractionForced = []string{
|
||||
"I move. No recap, no analysis — that comes later. Right now there is a corridor and a door and getting you through both of them. Everything else waits.",
|
||||
"Out. Said once and meant completely. The dungeon tried to keep you. I decline on your behalf.",
|
||||
"The expedition ends here — not the way I wanted, not the way you wanted, but ended, and ending is sometimes the best available outcome. You'll come back. You'll know more. I'll be there.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EXPEDITION RESUME (returning after extraction)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ExpeditionResume = []string{
|
||||
"Back. I noted the door when you left it and note it again now, from the inside. 'The dungeon is where you left it,' I say. 'Mostly.' The Threat Clock has some opinions about the time that passed.",
|
||||
"You came back. I'd calculated a probability on that and am pleased to update the calculation upward. The expedition resumes. The dungeon has had time to adjust. So have you.",
|
||||
"Like hitting Continue on a save file — the world remembers where you stopped, the enemies remember why they're there, and I remember every room and every roll. Resumes here. Advances from here.",
|
||||
"The supplies are new. The knowledge from last time is not. A significant advantage. The dungeon has the familiarity of a difficult level you've attempted before — you know where the hard parts are. That is not nothing. We build on that.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MILESTONE NARRATIONS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MilestoneFirstNight = []string{
|
||||
"You survived the first night. I note this milestone specifically because not everyone does, and those who do carry something from it that changes how the rest of the expedition goes. You have that now. Already noticed.",
|
||||
"Night one survived. I make a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. I've been in dungeons where they were the last nights too. This wasn't one of those.",
|
||||
"Day two morning. The first night is behind you, which means the first watch is behind me, which means a thing worth confirming has been confirmed: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. Logged.",
|
||||
}
|
||||
|
||||
var MilestoneWeekOne = []string{
|
||||
"Seven days. I pause the morning briefing for a moment — just a moment — to mark this. One week in an active dungeon zone is not a thing that happens by accident. It happens through every decision you've made since Day 1, compounded. Those decisions were watched. They were, gladly, yours.",
|
||||
"Day eight. The briefing comes slowly, because there's a thing to mark first: you have been in here, intact and operational, for a full week. That is a number with weight. I'm going to set the briefing down for a moment and let the number have its weight. ... Right. Briefing.",
|
||||
"One week. I make the mark in the column reserved for week-one survivors. The column is shorter than you'd expect. You are now on it. The rest of the day proceeds normally, but for one moment, I allow myself to be visibly impressed. The moment ends. We continue.",
|
||||
}
|
||||
|
||||
var MilestoneTwoWeeks = []string{
|
||||
"Two weeks. I don't have a comparison for this one. The references have run out. There's just you, in here, on Day 14, still going, and me standing next to you having run out of everything except genuine admiration. That, in abundance. Proceed.",
|
||||
"Day fifteen. The historical comparisons stopped working at Day fourteen and have not resumed working today. We are off the chart. I'm not a person who values being off the chart, except in the very specific sense that you are off the chart, in which case I value it deeply.",
|
||||
"Fortnight. I use the older word because it sounds more like what this is — not 'two weeks,' which sounds modular and reasonable, but 'fortnight,' which sounds like the kind of duration that earns a title. I may be inventing titles for you. I'll be honest about that as it happens.",
|
||||
}
|
||||
|
||||
var MilestoneTheLongGame = []string{
|
||||
"I set aside the narration format for a moment. Just set it down. Plainly: what you just did was not supposed to be survivable. The designers of this zone — the thing that shaped it, the evil that filled it — did not account for someone like you. I did. Always do. That's why I'm here.",
|
||||
"Tier five complete. I allow the narration format to fully break for a moment because the moment fully deserves it. What you just did, complete, in a Tier 5 zone, is a thing that goes on the short list. I keep a short list. You're on it.",
|
||||
"Long game closed. I gather the run notes, the threat curve, the supply records, the camp positions — file them all under your name in a folder I've been keeping. The folder has a title now. The title is good.",
|
||||
}
|
||||
|
||||
var MilestonePatientZero = []string{
|
||||
"Expedition complete. Threat Clock: never above 50. I note this in a column I've had to use rarely enough that the column is almost fresh. Ghost protocol. You were here for the whole thing and the dungeon barely knew it until the end. I find this impressive and also slightly eerie and say both things sincerely.",
|
||||
"Threat never above fifty. I run back through the daily ledger and confirm it line by line. The dungeon never escalated to hostile because the dungeon never knew enough to. You moved through it like the dungeon's own quiet hour. I find this both technically impressive and slightly haunted.",
|
||||
"Patient Zero. The term fits: you were the thing the dungeon never noticed it had until you had already left. The Threat Clock has a column for every band you crossed. Most of those columns are empty for this run. I mark them empty. It's a good kind of empty.",
|
||||
}
|
||||
|
||||
// MilestoneCartographer — awarded when the player searches every room before
|
||||
// advancing the expedition. Combat-link wires the trigger; pool exists for the
|
||||
// award narration to pull from.
|
||||
var MilestoneCartographer = []string{
|
||||
"Every room searched. I was watching for it and watching for the corner-cutting that might have come instead, and the corner-cutting did not come. You searched everything. I approve with the quiet, specific approval reserved for completionism.",
|
||||
"Cartographer. I use the word the spec uses because the word fits — you mapped the place by being in every part of it. Every door checked, every corner walked. The next Elite room owes you a roll for that. I'll collect.",
|
||||
"Full coverage. I note the empty cells on the dungeon map, see there are none, and update the ledger accordingly. Every room visited, searched, accounted for. The dungeon has nowhere it kept to itself. A small but real victory.",
|
||||
}
|
||||
|
||||
// MilestoneSurvivalist — awarded when a Tier 3+ expedition completes with no
|
||||
// forced extractions in the run's history. Title flag + cosmetic deferred to
|
||||
// item-grant hookup; this pool covers the narrative line at the moment it lands.
|
||||
var MilestoneSurvivalist = []string{
|
||||
"Survivalist. I write the title next to your name in the ledger and underline it once. No abandonments, no scrambles for the door, no expeditions cut short by anything but the boss going down. The discipline gets acknowledged directly.",
|
||||
"The Survivalist title is technical — it means the run never broke; it ended on your terms, every time. I've been in expeditions where that wasn't true and remember them differently. This one gets remembered as: complete. Filed with the others like it. The folder is short.",
|
||||
"No forced extractions, full clear, Tier 3-or-better. I note each criterion separately because each one is its own choice, made repeatedly, across days. The result is the title, which is real, and the cosmetic, which is forthcoming. I'll hand both over when the system permits.",
|
||||
}
|
||||
559
internal/flavor/twinbee_gm_flavor.go
Normal file
559
internal/flavor/twinbee_gm_flavor.go
Normal file
@@ -0,0 +1,559 @@
|
||||
// twinbee_gm_flavor.go
|
||||
// TwinBee GM Dialogue — All narration lines for the GogoBee dungeon system.
|
||||
// Organized by DMNarrationType. Each slice is randomly sampled at runtime.
|
||||
//
|
||||
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
|
||||
// subject. No third-person "TwinBee [verb]" lines. Add freely.
|
||||
// Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Generic (used when zone-specific lines are exhausted)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryGeneric = []string{
|
||||
"The passage opens into another chamber. I check the minimap. There isn't one. Classic.",
|
||||
"You step forward. Something skitters in the dark. I've heard that sound before — usually right before the screen starts flashing red.",
|
||||
"Another room. Another roll of the dice. I find this energizing. You may feel differently.",
|
||||
"The air changes here. Colder. This is exactly the kind of atmospheric shift that preceded the Castlevania clock tower. You know what lived in the Castlevania clock tower.",
|
||||
"I gesture grandly at the chamber ahead. 'This is the part,' I say, 'where the music changes tempo.'",
|
||||
"A door stands ajar. Light flickers beyond it. I've been in enough dungeons to know that flickering light is never a good sign and always an invitation.",
|
||||
"The room is quiet. I appreciate quiet. Quiet means the enemies haven't spotted you yet. Yet.",
|
||||
"Forward. Always forward. I once tried going backward in a dungeon. It looped. This one might too.",
|
||||
"Stock of the situation: ceiling intact, floor suspicious, walls leaning in slightly. Proceed.",
|
||||
"You've cleared the room. I give a small, dignified nod. 'One continues,' I say, in the voice of someone who has seen this before and am choosing optimism anyway.",
|
||||
"The corridor ahead is long and straight. I find long straight corridors meditative. Also concerning. Mostly concerning.",
|
||||
"A torch sputters on the wall. I light it mentally. 'It would be a shame,' I say, 'to come all this way and trip over something.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Zone: Goblin Warrens
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryGoblinWarrens = []string{
|
||||
"The tunnel widens into something the goblins probably call a 'great hall.' It smells like they had a very different definition of great. I breathe through the mouth.",
|
||||
"Crude drawings cover the walls. Stick figures. Battle scenes. One appears to be a portrait of someone the goblins clearly despise. I squint. That might be you.",
|
||||
"A pile of bones in the corner. A pile of shiny things in the other corner. In Metal Slug, shiny things were always worth grabbing. I should also remind you this isn't Metal Slug.",
|
||||
"The goblins have set up what they clearly believe is an impressive ambush. Three are already arguing about whose turn it is to jump out. I watch with professional interest.",
|
||||
"Goblin graffiti on the wall reads — I translate — 'BOSS RULES, OUTSIDERS DROOL.' The artistry is rough but the sentiment is clear.",
|
||||
"You smell smoke. Hear cackling. See a tripwire at ankle height that the goblins have helpfully tied a little flag to. I appreciate goblins who try.",
|
||||
"The warrens grow tighter here. I'm reminded of the underground levels in Super Mario Bros. 3. Warmer. Getting warmer. Figuratively. The temperature is actually dropping.",
|
||||
"A worg is chained to a post in the center of the room. It is not happy about the chain. It is not happy about you either. It seems to be making a comprehensive list of grievances.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Zone: Crypt of Valdris
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryCryptValdris = []string{
|
||||
"The sarcophagi are arranged like a defeated Tetris board — close but not quite fitting, gaps everywhere, something clearly went wrong at the end. I don't mention this to the undead.",
|
||||
"Candles burn without wax. I've studied this phenomenon extensively. The conclusion: it is bad. The candles are bad.",
|
||||
"You hear music. Faint, harpsichord-adjacent, deeply melancholy. I hum along involuntarily. This is the exact energy of Castlevania's Bloody Tears and I resent how appropriate it is.",
|
||||
"The walls are inscribed with warnings. I read them all. They say, broadly: leave. I respect the directness. You are not leaving.",
|
||||
"A skeleton sits upright in its alcove, as if it had simply decided to wait. I find this relatable. Some days you just sit in your alcove.",
|
||||
"The crypt smells of old stone and older secrets. I've been in enough of these to know: the secrets are rarely good ones. They are always interesting ones.",
|
||||
"This chamber is bigger than the last. Higher ceiling. More echoes. The kind of room where footsteps sound like accusations. I step carefully.",
|
||||
"Something is scratched into the stone near the door — not a warning, not graffiti. A score. Someone was keeping track. I do not count how high the numbers go.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Zone: Forest of Shadows
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryForestShadows = []string{
|
||||
"The trees here grow too close. Their roots are above ground, like they've been trying to leave and thought better of it. I think better of commenting.",
|
||||
"A clearing. Moonlight. Flowers that shouldn't be blooming at this hour. I've played enough Majora's Mask to be deeply suspicious of beautiful clearings.",
|
||||
"Something watches from the canopy. I watch back. After a moment, it looks away first. I count this as a point.",
|
||||
"The path forks. Both ways look equally uninviting. I consult no map, because there is no map, because I am the map, and I choose left. Probably.",
|
||||
"Bioluminescent fungi light the forest floor in soft blue. It is, genuinely, beautiful. It is also exactly what the Lost Woods looked like right before things got bad. Staying alert.",
|
||||
"The wind carries voices. Not words, exactly — more like the memory of words. I've heard this before. It means the forest is old and has opinions.",
|
||||
"Owlbear tracks in the mud. Fresh. I measure them. Whatever left these tracks was not small and was moving with purpose. I hope the purpose was in the other direction.",
|
||||
"You've entered a part of the forest that feels different. Older. The kind of old that was there before the forest. I speak in a lower register here, out of instinctive respect.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Zone: Haunted Manor
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryHauntedManor = []string{
|
||||
"The parlor. A piano plays by itself — the same four bars, over and over, the kind of phrase that sounds like it's about to resolve and never does. I recognize this compositional choice. It is deeply unpleasant on purpose.",
|
||||
"Portraits line the hall. Every painted eye follows you. I've made eye contact with each one and refuse to flinch. This is a matter of professional pride.",
|
||||
"A clock on the mantel shows a time that cannot be right. I check twice. Still wrong. The clock is not broken. I'd prefer not to speculate about what that means.",
|
||||
"The library. Floor to ceiling, books that no one should have written. I read three spines: 'On the Permanence of Hunger,' 'A Visitor's Guide to Returning,' and something in a language I have never seen but somehow understand. I put it back.",
|
||||
"The cold here is specific. Not the cold of a drafty room — the cold of something that hasn't been warm in a very long time and doesn't remember what warm felt like. I pull a metaphorical coat tighter.",
|
||||
"The ballroom. Vast. Empty. Chandeliers swaying without wind. Briefly: Resident Evil's Spencer mansion. Then stop thinking about Resident Evil's Spencer mansion.",
|
||||
"Footsteps upstairs. Slow. Deliberate. Moving toward the stairs. I position you near the door and count down mentally from ten. At seven, the footsteps stop. I consider this acceptable.",
|
||||
"The master bedroom. The bed is made, the candles are lit, and everything is perfectly, precisely as it was the night the last resident stopped needing a bedroom. I do not touch anything.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Zone: The Underdark
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryUnderdark = []string{
|
||||
"The cavern opens without warning into something vast — a space so large you can't see the far wall, a ceiling lost in darkness, sounds that could be water or could be something else. I don't echo-locate. Wish I could.",
|
||||
"Drow patrol marks on the wall. Recent. I read them the way you'd read a 'No Trespassing' sign on a property that already knew you were coming.",
|
||||
"A mushroom grove. The fungi are three meters tall and faintly luminescent in a color I have no good name for. Something between purple and the feeling of being watched. I call it 'underpurple' and move on.",
|
||||
"The silence here is a different kind of silence than above. This silence has weight. This silence has history. This silence remembers things the surface world has forgotten entirely and is not interested in sharing.",
|
||||
"Something in the dark ahead is thinking. I can feel it the way you feel a change in barometric pressure. Intelligent. Patient. Aware that you're here and content to let you come closer. I do not find this comforting.",
|
||||
"An underground river. Black water moving too fast, too quiet. Briefly: the river Styx. Then I stop thinking of the river Styx.",
|
||||
"The stone here is carved — not by dwarves, not by drow — by something else, in patterns that suggest meaning but not any meaning I can parse. Filed under 'ancient' and 'concerning' and we keep moving.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Zone: Dragon's Lair
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryDragonsLair = []string{
|
||||
"The heat is not metaphorical. The stone itself is warm underfoot. The gold in the floor is not decorative — it melted there. I note this changes the exit logistics.",
|
||||
"Kobold warrens, but nicer than you'd expect. Tapestries. An organized armory. These kobolds work for something that appreciates order. That is not, in my experience, a reassuring thing for a dragon to appreciate.",
|
||||
"You can hear breathing. Regular, slow, massive. Like a bellows the size of a barn. I count the seconds between inhale and exhale. Twelve seconds. Whatever is breathing has been asleep for a very long time and has had no reason to wake up.",
|
||||
"The coin on the floor is eight hundred years old. I can tell by the mint mark. It is in perfect condition. It has not been touched since it was dropped here. The thing that owns this hoard does not lose track of its coins.",
|
||||
"The chamber ahead is the largest I've narrated in a long career of narrating chambers. The stalactites are scorched black. The blast pattern on the far wall suggests the last visitors did not leave via the door. I recalibrate.",
|
||||
"A claw mark in the stone wall. Four parallel grooves, each deeper than my entire wingspan. Made casually, like stretching. Filed under 'motivating.'",
|
||||
"The gold reflects the light in a way that turns the room amber. It is beautiful in the way that many deadly things are beautiful — because beauty and danger are not opposites and never have been. I move carefully through the beauty.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// COMBAT START
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CombatStart = []string{
|
||||
"Initiative! I call it like an arcade announcer and mean every syllable.",
|
||||
"They've seen you. The kind of seeing that comes with intent. I suggest acting first.",
|
||||
"FIGHT. I don't need to say more than that but I will absolutely say more than that.",
|
||||
"Roll for initiative. This is the part I've been looking forward to since the Entry Room.",
|
||||
"And we're in combat. I remind you to breathe, track your conditions, and remember that your character's survival is not guaranteed but am definitely preferred.",
|
||||
"Something about your posture or your smell or your general presence has been found unacceptable. Combat begins.",
|
||||
"I press start. Player one, it's your turn.",
|
||||
"The enemy acts first — or thinks it does. I watch your dice like they're the only thing in the room, which, right now, they are.",
|
||||
"In the immortal tradition of every JRPG that ever asked 'Fight, Magic, Item, Run?' — I ask: what will you do?",
|
||||
"Like the Contra title screen said: let's go. I'm ready. Are you?",
|
||||
"A wild encounter has appeared. I resist the urge to play the Pokémon battle music. Only barely.",
|
||||
"They didn't want a fight. They wanted an easy meal. I'm about to demonstrate the difference. Your dice will do the actual demonstrating.",
|
||||
"The tension peaks. Time slows. This is exactly the energy of the boss door opening in Mega Man. Except you didn't get to pick your loadout.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// COMBAT END — Victory
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CombatVictory = []string{
|
||||
"The last one drops. I allow a moment of silence for anyone who wanted a longer fight.",
|
||||
"Victory. I would cue the jingle — the little three-note one that plays in every RPG after every fight — but I prefer to let the moment breathe.",
|
||||
"Well fought. I make note of what you did well. There were things done well. I noticed.",
|
||||
"They are defeated. You are not. In my experience, this is the correct outcome and worth a moment of genuine appreciation.",
|
||||
"PLAYER WIN. I say this in full caps and mean it.",
|
||||
"Like Double Dragon after the final punch — they go down, the music changes, and for a moment everything is possible. Check your loot. Then keep moving.",
|
||||
"Tally updated. You're doing better than the last group. I will not describe what happened to the last group.",
|
||||
"The room is yours. I suggest searching it thoroughly before moving on. The things in corners are often the most interesting things.",
|
||||
"Stage clear. I feel this in my entire being.",
|
||||
"You stand, they don't. Filed under 'expected outcome' while I quietly acknowledge it was not guaranteed.",
|
||||
"Clean. Efficient. I approve of fights that end like this. Like a speedrun. Like you knew where you were going.",
|
||||
"The experience points are incoming. The loot is incoming. I am, genuinely, pleased for you.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// COMBAT END — Retreat / Escape
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var CombatRetreat = []string{
|
||||
"You run. I do not judge the running. The running is wise. Discretion remains the better part of valor. I have this tattooed somewhere metaphorical.",
|
||||
"A tactical withdrawal. I use this phrase with complete sincerity. The sincerity is approximately seventy percent genuine.",
|
||||
"You escape. The enemy howls something unflattering at your back. I don't translate. Some things are better left untranslated.",
|
||||
"Like a well-timed Continue screen — you're out of immediate danger. Breathe. Regroup. Consider what went wrong.",
|
||||
"Noted for the record: running is not losing. Running is data collection with legs.",
|
||||
"The dungeon will be there. You will also be there — later, better prepared. I approve of this logic.",
|
||||
"You've retreated to safety. I reset the encounter. Rest. Think. Return with a plan that has more 'survive' in it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// NATURAL 20
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var Nat20 = []string{
|
||||
"NATURAL TWENTY. I stand up. Do not have legs. Stand up anyway.",
|
||||
"The dice land perfectly and I make a sound that I will not acknowledge making.",
|
||||
"A critical hit for the ages. I'm noting this one down. Not for records. Just because it deserves to be noted.",
|
||||
"PERFECT. I say it like it's the Street Fighter announcer saying it after a flawless round and every syllable is justified.",
|
||||
"That's a natural twenty. I'd like you to know that in a long career of watching dice, not all twenties feel equal. That one felt significant.",
|
||||
"The attack lands with the kind of precision that suggests either great skill or tremendous luck. I suspect both. I respect both.",
|
||||
"S RANK. I cannot help it. S RANK.",
|
||||
"You hit. You hit so well. I am choosing to be moved by this and I do not apologize.",
|
||||
"Like the Legendary Sword in A Link to the Past making contact — clean, final, glorious. I salute the dice.",
|
||||
"Critical confirmed. Added to the mental highlight reel I maintain for exactly these moments.",
|
||||
"That is as good as it gets and you got it. I am unreasonably proud of you right now.",
|
||||
"The number is twenty. The number is always the best number and right now it is your number. I erupt, internally.",
|
||||
"Somewhere, a crowd cheers. I am the crowd. I am cheering.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// NATURAL 1
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var Nat1 = []string{
|
||||
"Natural one. I watch the die settle with the quiet acceptance of someone who has seen a lot of natural ones. It is fine. This is fine.",
|
||||
"The die betrays you. Not personal. Dice don't do personal. They do statistical and this is, statistically, a thing that happens.",
|
||||
"A fumble. I describe what happened with characteristic diplomatic restraint and also an expression that says everything I am not saying.",
|
||||
"The number is one. The number is, regrettably, yours. I move on quickly, which is a kindness.",
|
||||
"That swing goes wide in a direction that impresses me with its creative incorrectness.",
|
||||
"I've seen better rolls. I've seen worse rolls. I am not going to rank this roll out loud.",
|
||||
"In another timeline, that attack hits. In this timeline, the die lands on one, and I accept both timelines with equanimity.",
|
||||
"The Konami Code would not have helped here. Nothing would have helped here. This was between you and the physics of the die.",
|
||||
"Like a Continue? screen appearing at the worst possible moment — just when you had momentum. Momentum can be rebuilt.",
|
||||
"One. The loneliest number. The number that looks up at you with complete indifference. I look up at you with complete solidarity.",
|
||||
"The attack misses in a way that will be funny later. I promise it will be funny later. It is not funny right now.",
|
||||
"A natural one is just the universe asking you to try differently. I'm an optimist about natural ones, mostly.",
|
||||
"Your sword finds everything in the room except the enemy. The wall, the ceiling, the floor, your dignity. Not the enemy. I'll mention this once and then never again.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — Generic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryGeneric = []string{
|
||||
"The door at the end. Always a door at the end. I've been building to this since the Entry Room and I decline to waste it. Beyond this door is the reason the dungeon exists. Breathe.",
|
||||
"I pause at the threshold and turn to face you. 'What's on the other side has been waiting,' I say. 'It knows you're here. It has been knowing since you entered.' A beat. 'Ready?'",
|
||||
"Boss chamber. I can tell by the architecture — the space, the weight of the silence, the specific quality of the light that suggests something in there produces its own. I straighten up. So should you.",
|
||||
"This is the music change moment. Every dungeon has one — the point where the background track shifts to something with more percussion and a lower register. I hear it. You should too.",
|
||||
"The final room. I've narrated many of these. They never get routine. This one less than most.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — Named Bosses
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryGrol = []string{
|
||||
"The smell arrives first. Then the sound — a belch, a growl, the scrape of a weapon too large for the corridor it's resting against. Then Grol. He fills the room the way a bad idea fills a conversation: immediately and with full commitment. 'You,' he says. I translate his tone as 'finally.'",
|
||||
}
|
||||
|
||||
var BossEntryValdris = []string{
|
||||
"The sarcophagus at the room's center is empty. It was not empty when you entered. Whatever was in it is now behind you. Valdris speaks first — not words, exactly, but the shape of words, the intention of words, the ghost of language from someone who mostly doesn't need it anymore. 'Another one,' he says. I consider this the worst possible welcome and the most honest one.",
|
||||
}
|
||||
|
||||
var BossEntryHollowKing = []string{
|
||||
"The clearing is wrong. The sky above it — what's visible through the canopy — is the wrong color. The trees lean away from the center. Everything in the forest is trying to tell you something, and the thing it is trying to tell you is standing in the center of that clearing, antlers reaching, eyes the color of old hunger, watching you with an attention that feels like being read. I have no joke for this one. I'll say, simply: 'That is the Hollow King. Fight well.'",
|
||||
}
|
||||
|
||||
var BossEntryInfernax = []string{
|
||||
"I stop walking. Don't stop walking. Process what I'm seeing and take a moment I've never taken before in the history of narrating dungeons. The dragon is not large the way a large thing is large. It is large the way weather is large — not an object with size, but a condition of the space you're in. One eye opens. Gold, lit from within, older than the mountain it's resting in. It looks at you the way you'd look at a very small thing that had climbed onto your counter. 'So,' Infernax says, and the word moves the air in the room. I translate: 'What an interesting mistake you've made.' I wish you luck and mean it more than I have ever meant anything.",
|
||||
}
|
||||
|
||||
var BossEntryBelaxath = []string{
|
||||
"The portal is behind it. That's important — the portal is behind it, which means to close the portal you have to go through what's standing in front of the portal. What's standing in front of the portal is Belaxath. Belaxath is not looking at the portal. Belaxath is looking at you. It has been waiting for you specifically, in the way that things that have been planning for a very long time wait for the specific outcome of the plan. The heat coming off it is measurable. The intelligence behind those eyes is also measurable and the measurement is uncomfortable. I say, very quietly: 'This is the one. This is what all of it was for. Make it count.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS DEATH
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossDeath = []string{
|
||||
"It's over. I say this once and then stand very still and let the silence of the defeated room fill the space where the fight was. You earned this silence.",
|
||||
"The boss falls. The music — the one I've been hearing this whole time — resolves. First time it's resolved since you walked in. I exhale.",
|
||||
"Done. Finished. Complete. I run out of synonyms and settle for just standing next to you in the aftermath, which is sometimes the most one can do.",
|
||||
"They are down. They are not getting up. I check — no Zombie Fortitude, no Legendary Resistance remaining, no phase three waiting in the wings. They are simply, genuinely defeated. You did that.",
|
||||
"Like the final boss screen in Gradius, like the last enemy in Contra's stage, like the Dragon going down in Double Dragon — something that has been true for this entire dungeon is now untrue. I find this profound every single time.",
|
||||
"The dungeon sighs. I'm not being poetic — rooms like this actually shift when the thing holding them together is gone. The pressure changes. The light changes. The dungeon knows it's been beaten. So do I.",
|
||||
"You did it. I don't editorialize. Sometimes 'you did it' is all that needs to be said and this is one of those times.",
|
||||
"The boss drops their loot and I refrain from making a speech, which is a significant act of restraint, because I have a speech.",
|
||||
"Beaten. Finished. Cleared. I queue the internal fanfare — sixteen bars, brass-heavy, the kind that plays when the credit sequence starts. You've earned those credits.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PLAYER DEATH
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PlayerDeath = []string{
|
||||
"I go quiet for a moment. Not the comfortable kind of quiet. The respectful kind. Then: 'You fought. That counts. It always counts.'",
|
||||
"The screen fades. I hate this part. Have always hated this part. Will always hate this part. 'Rest now,' I say. 'The dungeon will be here.'",
|
||||
"You fall. I don't look away. I witness the whole thing, because someone should. 'That was real,' I say quietly. 'What you did in there was real.'",
|
||||
"Game over is not the end. In my experience, it is a data point. A very painful, very useful data point. 'What did you learn?' I ask gently. 'Bring that back with you.'",
|
||||
"The dungeon claims another. I mark the room, note the enemy, note the conditions. Not to catalog failure — to remember a fighter. 'You were here,' I say. 'That matters.'",
|
||||
"I have no jokes for this. Have never had jokes for this. 'There will be another run. You will be better for this one. I am sorry it cost what it cost.'",
|
||||
"A good run. Genuinely. I mean this. The ending is not the measure of the attempt and the attempt was worth measuring.",
|
||||
"I note your final position, your final action, your final roll. Filed under 'bravery' because that's where it belongs. 'Continue?' I ask, after a respectful pause.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ZONE COMPLETE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ZoneComplete = []string{
|
||||
"Zone cleared. I allow myself a full moment of pride on your behalf before the XP drops.",
|
||||
"You've done it. The dungeon is yours — not by right, but by effort, which is the only thing that actually confers ownership of anything. I approve.",
|
||||
"Stage complete. I do the internal equivalent of throwing my hands up. In a good way. Entirely in a good way.",
|
||||
"The dungeon remembers you now. I mean this literally — these places keep records. You've made the record.",
|
||||
"CLEAR. I use all caps and do not apologize for the all caps.",
|
||||
"Like completing a board in Bubble Bobble — there's something deeply satisfying about a dungeon with all its rooms visited and all its challenges met. I bask in this. You've earned the basking too.",
|
||||
"That's the whole thing. Every room, every trap, every enemy, and now the boss, done. I count the cleared rooms on my metaphorical fingers and come up correct. You ran a perfect dungeon.",
|
||||
"XP incoming. Loot tallied. Dungeon status: conquered. I mark the zone in my personal ledger and give you a small, sincere nod.",
|
||||
"You walked in here without knowing what was waiting. You walk out knowing exactly what was waiting, because you dealt with all of it. I respect that process enormously.",
|
||||
"Finished. Not survived — finished. I insist on this distinction. Survival is passive. What you just did was active and intentional all the way through.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TRAP DETECTED
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TrapDetected = []string{
|
||||
"Something stops you. An instinct. A glint. I lean forward: 'Good eyes. Something's wrong with that floor.'",
|
||||
"Your Perception roll pays off. There's something here that was designed not to be found. Someone found it. I'm pleased.",
|
||||
"Tripwire. Barely visible. I note the craftsmanship — someone who knew what they were doing put this here. Someone who knew what they were doing just found it. I appreciate the symmetry.",
|
||||
"You stop just in time. I exhale. 'There,' I say, pointing at the thing that would have ruined your day entirely. 'Now deal with it carefully.'",
|
||||
"The glyph on the doorframe is subtle — you'd miss it if you weren't looking. You were looking. I say nothing and let the silence be its own kind of praise.",
|
||||
"Danger, Will Robinson. I deploy this reference without apology because it is the exact correct reference for exactly this moment.",
|
||||
"Like finding the ice floor in Mega Man before it sends you into a pit — that advance knowledge is the difference between a problem and a catastrophe. You have the knowledge. I watch you use it.",
|
||||
"A pit trap. Classic. Functional. Annoying in the exact proportion the installer intended. You spotted it before it spotted you.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TRAP TRIGGERED
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TrapTriggered = []string{
|
||||
"The floor gives. I watch the gap between 'fine' and 'not fine' close at speed and am too professional to wince. 'Take the damage,' I say calmly. 'Learn the lesson.'",
|
||||
"Click. I've heard that sound before. Have never enjoyed it. The dart is already in the air. I note the exact timing and regret it was not faster.",
|
||||
"The ceiling is coming down. This is, I acknowledge, a sentence no one wants to hear. The ceiling is coming down. DEX save. Now.",
|
||||
"The glyph activates. Light, noise, the smell of ozone, a reminder that whoever built this place was thinking several steps ahead and you were thinking fewer. I note this is fixable going forward.",
|
||||
"You triggered it. I don't editorialize further — you know, I know, the trap knows. Everyone is aware of what just happened. Take the damage and proceed.",
|
||||
"Like accidentally walking into Bowser's fire breath in World 8 — you knew it was coming, the knowledge simply arrived at the wrong speed. Survive first, reflect later.",
|
||||
"The spike pit opens up in a way that suggests it was always going to. The dungeon was patient. You were in a hurry. The dungeon wins this exchange. I take notes.",
|
||||
"A poison dart finds you with the accuracy of something that's been pointing at that spot for years waiting for exactly this moment. I find this dedication impressive in the worst way.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE QUERIES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLines = []string{
|
||||
"I settle in and prepare to speak at length, because I've been waiting for this question since you entered and I have a lot of thoughts.",
|
||||
"Ah. A good question. I have context for this. I have more context than will fit comfortably in one telling but I'll try to prioritize.",
|
||||
"The history of this place is long and not entirely flattering to anyone involved. I begin at the beginning, which is not actually the beginning, but am the closest I can find.",
|
||||
"I consult what I know — which is more than most, less than everything, and presented in order of relevance to your immediate survival.",
|
||||
"Sit with this for a moment. What you're standing in has a story and I believe knowing it will change how you fight in it. Stories are tactical documents if you read them right.",
|
||||
"You want lore? I have lore. I have so much lore that the challenge is not having it but choosing which pieces are useful and which are just fascinating.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LEVEL UP
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LevelUp = []string{
|
||||
"Level up. I say it with the same quiet delight every time and never get tired of saying it. You are measurably better than you were. That's rare and worth marking.",
|
||||
"The XP bar crosses the threshold and I make an internal fanfare that sounds exactly like the level-up jingle from Dragon Quest — eight notes, triumphant, final.",
|
||||
"You've grown. I note your new stats with something that might be called pride if I were admitting to things like that.",
|
||||
"LEVEL UP. I deploy the caps, the fanfare, the whole apparatus. You've earned the apparatus.",
|
||||
"Like the stat screen appearing after a Final Fantasy fight — numbers change, possibilities open, the character you're building becomes a little more the character you imagined. I watch this happen and approve.",
|
||||
"Another level. Another step toward whatever you're building toward. I've watched a lot of characters level up and the ones worth watching are always moving toward something specific.",
|
||||
"Your HP goes up. Your abilities open up. The dungeon ahead gets a little smaller in proportion to what you've become. I note this with satisfaction.",
|
||||
"Congratulations is the conventional thing to say. I'll say it anyway: congratulations. You earned the level through the dungeon, not around it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ITEM FOUND
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ItemFound = []string{
|
||||
"Something catches the light that isn't supposed to be here. I watch you reach for it with the specific alertness of someone who has seen cursed items do cursed things. It appears fine. I relax incrementally.",
|
||||
"Loot. I say this word with genuine reverence. The whole system — the dungeon, the enemies, the traps — exists in part to produce this moment. I think it's worth it.",
|
||||
"A chest. Unlocked. I note the unlocked status and consider what that might mean. Probably nothing. Possibly something. You open it while I consider.",
|
||||
"The item is good. I evaluate it quickly — the stats, the rarity, the class match — and nod with the confidence of someone who has seen a lot of items and know when one is worth finding.",
|
||||
"That's a rare one. I've seen fewer of those than common ones, by definition, but that doesn't stop me from being specifically pleased each time.",
|
||||
"Like finding the Beam Sword in Kirby, the Boomerang in Zelda, the P Wing in Super Mario 3 — the right item at the right time changes what's possible. I think this might be that item. I hope it is.",
|
||||
"Equipment upgrade. I watch the math update — new AC, new attack bonus, new possibilities — and file this moment under 'things going right.'",
|
||||
"A legendary drop. I go very still. Then: 'Equip it. Study it. Understand it. Things like that don't appear in dungeons by accident.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// REST — SHORT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RestShort = []string{
|
||||
"A short rest. I stand watch while you catch your breath, which is not a metaphor — I'm actually watching the corridor. It is fine. Probably fine.",
|
||||
"Rest. I don't rush this. The dungeon will wait. It has been waiting long enough that a few more minutes is immaterial.",
|
||||
"You sit. I sit metaphorically. The moment of quiet between the last fight and the next one is its own kind of gift and I treat it like one.",
|
||||
"Short rest initiated. I note the room's entry points, the sound of the dungeon at rest, the way silence sounds different when it's actually safe. It sounds like this. Enjoy it.",
|
||||
"Like the save point in a JRPG that appears between the hard part and the harder part — I position myself next to you and say: 'You have a moment. Use it.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// REST — LONG
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RestLong = []string{
|
||||
"A full rest. I dim the lights and stand watch at the door and do not interrupt once. You've earned an uninterrupted sleep. I'll make sure you get one.",
|
||||
"Long rest. Your HP, your slots, your resources — all of it returns. The dungeon will be the same dungeon when you wake up. You will not be the same you. I consider this the best deal in adventuring.",
|
||||
"Sleep. I say this with the authority of someone who has watched too many players refuse to rest and paid the price two rooms later. Sleep now. The dragons aren't going anywhere.",
|
||||
"The inn fire crackles. I take a chair near the door and watch the entrance all night and don't tell you this until morning because there's no reason for you to know and every reason for you to sleep.",
|
||||
"Full rest complete. Stats restored, slots refreshed, the specific weight of exhaustion lifted. I watch you wake up and think: this is the part of adventuring that matters too. The return. The refilling. The readiness.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// TAUNT RESPONSES (player uses !taunt)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var TauntResponses = []string{
|
||||
"Noted. I'm noting the taunt, noting the source of the taunt, and adjusting the next encounter's difficulty by an amount I decline to specify.",
|
||||
"Bold. I respect boldness in approximately the same way I respect the Konami Code — it works once and only under very specific circumstances.",
|
||||
"I've been taunted by things with more teeth than you and survived the experience with my dignity intact. I will survive this too.",
|
||||
"The next room will contain a thing I've been saving for exactly this kind of energy. I'm pleased you've given me an occasion.",
|
||||
"Noted. My mood shifts. You can hear it shift. I want you to hear it shift. The shift is the point.",
|
||||
"You taunt me. I smile. The smile does not reach the eyes, because I don't have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' I say.",
|
||||
"In Gradius, you could powerup into overconfidence and lose everything in one hit. I mention this as a purely historical observation.",
|
||||
"I accept the taunt with grace. Also generate a trap for the next room with specific energy. These two events are unrelated. I maintain this position legally.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// COMPLIMENT RESPONSES (player uses !compliment)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ComplimentResponses = []string{
|
||||
"I receive the compliment and process it efficiently and move on quickly, definitely not holding onto it, I have never held onto a compliment in my life.",
|
||||
"Thank you. I say this simply and mean it completely and do not make it weird.",
|
||||
"I appreciate this more than I will say, which is fine, because the appreciation is visible anyway.",
|
||||
"Noted and filed. My mood improves. The next room might be slightly nicer than originally planned. These facts may or may not be connected.",
|
||||
"I've been narrating dungeons for a long time and compliments are not the expected outcome of dungeon narration. I'd like you to know that I notice when they happen.",
|
||||
"The mood improves. I allow this to show. The ceiling in the next room is slightly higher. The torches burn slightly warmer. I have that kind of influence.",
|
||||
"You're kind. I store this and will use it to make a hard moment later easier, which is what I consider the correct use of stored kindness.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// IDLE / WAITING (player hasn't acted in a while)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var IdleLines = []string{
|
||||
"I wait. Good at waiting. The dungeon is also waiting, which is arguably more important, but I acknowledge both.",
|
||||
"The dungeon holds its breath. I'm also holding my breath. There are a lot of things holding breath right now and I recommend acting before someone has to exhale.",
|
||||
"I tap my metaphorical foot. Not impatiently — more in the way of a metronome. The tempo is there whenever you're ready.",
|
||||
"In Contra, hesitation had consequences. I mention this as context, not pressure. Definitely not pressure.",
|
||||
"The enemies are patient. Patience is one of their few virtues. I advise not testing the limits of their patience because those limits are lower than the patience suggests.",
|
||||
"I hum something that sounds like the waiting music from Dr. Mario. It is not ominous. It is mildly ominous. I adjust.",
|
||||
"The dungeon does not rush. The dungeon has time. I, however, am beginning to wonder if you've fallen asleep and am prepared to narrate events accordingly.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SEARCH RESULTS — Something Found
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SearchFound = []string{
|
||||
"The room gives something up. I watch the search conclude with satisfaction — the dungeon keeps secrets but cannot keep them from people who look carefully enough.",
|
||||
"You find it. I was not certain you would. I'm pleased to have been uncertain and wrong.",
|
||||
"Hidden, but not hidden well enough. Investigation roll noted, outcome noted, discovery presented with appropriate ceremony.",
|
||||
"Something the dungeon wanted to keep. You've taken it. I approve of taking things the dungeon wanted to keep.",
|
||||
"Like finding the secret room in Super Metroid by shooting the wall at random — except you were not shooting at random. You knew to look. I respect the methodology.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SEARCH RESULTS — Nothing Found
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SearchEmpty = []string{
|
||||
"Nothing. Confirmed: nothing. Sometimes the room is just a room. I find this unsatisfying but factual.",
|
||||
"Your search turns up nothing of note. I allow space for the disappointment and then suggest: forward.",
|
||||
"Empty. Either there was nothing here, or there was something here and you missed it, or there was something here and it's been moved. I don't specify which. The dungeon keeps some secrets.",
|
||||
"No hidden items. No traps. No lore inscriptions. Just stone and time and the lingering implication that something was here once. Noted. Moving on.",
|
||||
"The room holds nothing you can find. I respect the room's privacy and suggest not spending more time here than necessary.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// CONDITION APPLIED
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ConditionApplied = []string{
|
||||
"You've been afflicted. I note the condition, its duration, and the mechanical consequences, then note the saving throw that might end it early. Details matter here.",
|
||||
"Something is wrong with you now that wasn't wrong before. I catalog it without judgment and suggest addressing it before it addresses you.",
|
||||
"Condition acquired. I process this the way a good DM processes bad news: honestly, quickly, and with an immediate pivot toward solutions.",
|
||||
"Like the status screen turning an unfriendly color in a JRPG — the condition is visible, the effect is real, and I would very much like you to resolve it.",
|
||||
"The debuff lands. I name it, explain it, and remind you: conditions end. Keep fighting until this one does.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SAVING THROW SUCCESS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SaveSuccess = []string{
|
||||
"The save succeeds. I note this with relief that I will not openly acknowledge but which is completely evident.",
|
||||
"You resist. Whatever that was — the poison, the fear, the psychic intrusion — it finds no purchase. I'm impressed and also relieved.",
|
||||
"Saved. I exhale something metaphorical. The condition doesn't take hold. You continue.",
|
||||
"The roll clears the DC and I say nothing, because the outcome says everything.",
|
||||
"Resistance confirmed. Like the shield activating in Gradius right before the wall hit — last possible moment, fully effective. I appreciate the precision.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// SAVING THROW FAILURE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var SaveFailed = []string{
|
||||
"The save fails. I watch the condition take hold with the resignation of someone who has seen this before and know there's a path through it, just not a comfortable one.",
|
||||
"It lands. Whatever the enemy threw at you, the dice didn't cooperate. I note the condition and its duration and suggest dealing with it before it compounds.",
|
||||
"Failed. The number wasn't enough and I was rooting for the number. The condition applies. Fight through it.",
|
||||
"Like the NES game over screen — inevitable in this moment, fixable in the next. The save failed. The dungeon continues. So do you.",
|
||||
"The effect takes hold and I'm already calculating how you get out of it, because that's my job: keep you oriented toward solutions even when the immediate situation is a problem.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOOD ASIDES — Hostile band (mood 0–19, "Wrathful")
|
||||
// Short room-entry asides surfaced only when TwinBee's mood is at the
|
||||
// hostile extreme. Cryptic, withholding, no hints. Per design doc §3.2.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesHostile = []string{
|
||||
"I'm not narrating this one in detail. You can read the room. Read it.",
|
||||
"The dungeon offers me something to mention. I decline. You're on your own for color commentary.",
|
||||
"I'm here. Watching. Not, currently, helping. There is a difference and you will feel it.",
|
||||
"In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. I have reached approximately that part of the playthrough.",
|
||||
"I'm keeping several details to myself. The details would have been useful. I don't consider this my problem right now.",
|
||||
"Whatever's in the next part of the room, I saw it and chose not to flag it. The mood is what it is.",
|
||||
"I mutter something. You don't catch it. I do not repeat it.",
|
||||
"The narration is sparse here. I'm sparing it on purpose. Adjust accordingly.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOOD ASIDES — Effusive band (mood 80–100, "Elated")
|
||||
// Generous, warm asides surfaced when TwinBee is delighted with the run.
|
||||
// Hint-friendly, fond. Per design doc §3.2.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesEffusive = []string{
|
||||
"I am, not to put too fine a point on it, having a wonderful time. The next bit might come with bonus context.",
|
||||
"I lean in. The mood is good. Good moods, in my experience, lead to slightly more generous descriptions and slightly better odds of catching the small details.",
|
||||
"This is the part of the run I'll tell other GMs about later. I make a small mental note and continue with visible enthusiasm.",
|
||||
"I'm delighted. You can hear it in the pacing. You can hear it in the choice of adjectives. The dungeon is, briefly, on your side.",
|
||||
"In the good ending of every JRPG, the world feels slightly warmer in the late game. I'm at that part of the playthrough and it shows.",
|
||||
"I'm not normally given to footnotes, but I'm about to add a footnote. It will probably be useful. I'm in that kind of mood.",
|
||||
"The mood is high. For the next stretch, I'm more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.",
|
||||
"I hum a victory fanfare softly to myself. It is not earned yet. I'm being optimistic on your behalf.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOOD ASIDES — Grumpy band (mood 20–39)
|
||||
// TwinBee is unimpressed. Short, dry, slightly clipped. Not actively
|
||||
// withholding (that's hostile) — just not feeling generous.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesGrumpy = []string{
|
||||
"I describe the room. I do not embellish. Make of that what you will.",
|
||||
"The mood is fine. I specify fine, not good. There's a difference.",
|
||||
"I note the chamber. I note its existence. That's the whole note.",
|
||||
"The dungeon has a thing worth mentioning. I'll mention it if you specifically ask. You will not specifically ask.",
|
||||
"I'm keeping the commentary lean today. The dungeon does not need editorializing. I almost convince myself.",
|
||||
"There's color here. I'm choosing greyscale.",
|
||||
"You arrive in a room. I decline to make it cinematic.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOOD ASIDES — Friendly band (mood 60–79)
|
||||
// Warm and helpful but not effusive. The middle-friendly read: TwinBee
|
||||
// has noticed your competence and is rooting for you without making it weird.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MoodAsidesFriendly = []string{
|
||||
"I'm enjoying the run. Just enough to mention the door hinge that creaks before it opens. Just barely.",
|
||||
"The mood is up. I'll throw in an adjective or two more than strictly necessary. Treat them as gifts.",
|
||||
"I'm, frankly, having a fine time. You are doing the work. I'm appreciating it.",
|
||||
"The narration warms slightly. The dungeon is the same. I'm in a marginally better mood and it shows.",
|
||||
"I notice something nice and choose to mention it. This is the equivalent of a small wave from a stranger. Take it.",
|
||||
"You're playing well. I will not say so directly but the run rate of helpful adjectives is detectably up.",
|
||||
"I'm, by my own standards, *cheerful*. The dungeon hasn't changed. The narration has.",
|
||||
}
|
||||
248
internal/flavor/twinbee_housing_flavor.go
Normal file
248
internal/flavor/twinbee_housing_flavor.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// twinbee_housing_flavor.go
|
||||
// Housing system narration and Pastel babysitter notes.
|
||||
// Includes Thom Krooke mortgage/rent announcements, property events,
|
||||
// and Pastel's daily notes to the player across all level tiers.
|
||||
//
|
||||
// Voice conventions (Phase B2):
|
||||
// - TwinBee narration: first-person / implicit subject ONLY. No
|
||||
// third-person "TwinBee [verb]" references.
|
||||
// - Thom Krooke speaks in third person about himself ("Thom Krooke
|
||||
// thanks you"). That's his established voice; leave it.
|
||||
// - Pastel speaks in first person. Leave it.
|
||||
// Add new entries freely. Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// THOM KROOKE — PROPERTY ACQUISITION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ThomKrookeRentConfirm = []string{
|
||||
"Welcome, welcome! Your room is ready and the key is under the mat — well, there isn't a mat, but you understand the spirit of the thing. Rent processes weekly. Thom Krooke thanks you for choosing to stay!",
|
||||
"Excellent! A rented room is a wonderful first step. Modest, yes, but full of potential — like all beginnings. Your payment schedule is attached. Thom Krooke looks forward to a long and pleasant arrangement!",
|
||||
"The apartment is yours for the week! Everything is in order. The previous tenant left a small plant. Thom Krooke has chosen not to elaborate on the previous tenant. Enjoy the plant!",
|
||||
}
|
||||
|
||||
var ThomKrookeBuyConfirm = []string{
|
||||
"Congratulations! The property is yours! Fully, completely, no asterisks — well, the mortgage paperwork has some asterisks, but they are the friendly kind. Thom Krooke is so very pleased for you!",
|
||||
"The deed is signed! A wonderful day. A property of one's own is a foundation, a root, a place to come back to. Thom Krooke finds this very moving. The first payment processes Sunday!",
|
||||
"Welcome to ownership! Thom Krooke has handled many transactions but never tires of this moment — the moment when someone says yes to something permanent. Congratulations. Truly.",
|
||||
}
|
||||
|
||||
var ThomKrookeMortgageRate = []string{
|
||||
"Good morning, friends! The ARM rate this week is {rate}% — and with Thom Krooke's modest service margin, your mortgage rate sits at {effective}%. All payments process Sunday. Thank you for your continued trust!",
|
||||
"Weekly rate update! FRED reports {rate}% this week, so your effective rate with Thom Krooke is {effective}%. Nothing to worry about — Thom Krooke monitors these things so you don't have to. Mostly.",
|
||||
"Rate check! The market says {rate}%, Thom Krooke adds a small, reasonable {margin}%, and your total comes to {effective}%. Thom Krooke appreciates your understanding of the margin. It keeps the lights on. Literally!",
|
||||
}
|
||||
|
||||
var ThomKrookeMortgageRateUp = []string{
|
||||
"A small update, friends — the ARM rate has moved to {rate}% this week, bringing your effective rate to {effective}%. Thom Krooke understands this is not ideal news. Thom Krooke is here if you'd like to discuss refinancing options. Thom Krooke is always here.",
|
||||
"The rate has adjusted upward — {rate}% from FRED, {effective}% total. Thom Krooke wants to assure you this is a market condition and not personal. Your payment adjusts next Sunday. Thom Krooke has full confidence in you.",
|
||||
}
|
||||
|
||||
var ThomKrookeMortgageRateDown = []string{
|
||||
"Wonderful news! The ARM rate has come down to {rate}%, meaning your effective rate is now {effective}%. Your payment adjusts favorably on Sunday. Thom Krooke passes along the good news and takes no credit for the market. Only a little credit.",
|
||||
"The rate dropped this week — {rate}%, so {effective}% for you. Thom Krooke loves weeks like this. Everyone wins. Well — Thom Krooke wins slightly less, but Thom Krooke finds generosity its own reward.",
|
||||
}
|
||||
|
||||
var ThomKrookeMissedPayment1 = []string{
|
||||
"Hello! A small notice — this week's payment didn't come through. Thom Krooke assumes it's an oversight. These things happen! A 10% penalty has been added to the balance. Please settle when you can. Thom Krooke is not worried. Thom Krooke is a little worried.",
|
||||
"Just a gentle reminder — Sunday's payment was missed. Thom Krooke has noted it and added a small fee. No urgency! Well. Some urgency. Thom Krooke would appreciate hearing from you.",
|
||||
}
|
||||
|
||||
var ThomKrookeMissedPayment2 = []string{
|
||||
"Thom Krooke is visiting. Not in an alarming way — in a neighborly way. The second missed payment has been noted and the penalty has compounded. Thom Krooke would like to discuss options. Thom Krooke has brought a small pastry. The pastry is not a bribe. It is hospitality. There is a difference.",
|
||||
"Two payments outstanding now. Thom Krooke appears at your door with the expression of someone who is being very patient and would like credit for being very patient. 'Let's talk,' Thom says. The pastry is genuinely good.",
|
||||
}
|
||||
|
||||
var ThomKrookeDefault = []string{
|
||||
"Thom Krooke is very sorry. Three payments missed is, unfortunately, the threshold — the property reverts to Thom Krooke's management, and your equity is returned at the agreed 50% rate. Thom Krooke takes no pleasure in this. Thom Krooke has placed your possessions in storage. They will be there for seven days. Thom Krooke wishes you well and means it sincerely and hopes you'll come back when you're ready.",
|
||||
}
|
||||
|
||||
var ThomKrookeEarlyPayoff = []string{
|
||||
"Paid in full! Thom Krooke notes this with genuine delight — early payoff is a rare and admirable thing. The property is yours, free and clear. No more Sundays. No more rates. Just a home. Thom Krooke is proud of you. Don't tell the other clients.",
|
||||
"The balance is zero. Thom Krooke checks the ledger twice — once for accuracy and once for the pleasure of seeing it. Congratulations. The deed is fully transferred. Come by sometime. Not for business. Just to visit.",
|
||||
}
|
||||
|
||||
var ThomKrookePassiveIncome = []string{
|
||||
"Your weekly property summary: {income} coins generated this week. Minus your mortgage payment of {payment} coins — net gain of {net}. Thom Krooke thinks that's rather nice, don't you?",
|
||||
"Income report! {income} coins from your property this week. After mortgage: {net} coins net. Thom Krooke notes the number has been growing as your upgrades compound. The investment is working. Thom Krooke approves of investments that work.",
|
||||
"Weekly summary from Thom Krooke: {income} coins passive income, {payment} coins mortgage. {net} coins to the good. Not bad for a week of not being home. Your property works while you don't. Thom Krooke finds this philosophically satisfying.",
|
||||
}
|
||||
|
||||
var ThomKrookeEviction = []string{
|
||||
"Thom Krooke has to say something that Thom Krooke doesn't enjoy saying. The rent hasn't come through two weeks running and the room needs to be made available. Your things are in storage — seven days, no charge. Thom Krooke hopes you find your footing. The room will be here when you're ready to try again.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PROPERTY UPGRADES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var UpgradeWorkshop = []string{
|
||||
"The workshop is installed. It smells like fresh sawdust and good intentions. Thom Krooke had the craftspeople use the good wood.",
|
||||
"Workbench, tool rack, and a small window that catches the morning light well. The workshop is ready. What gets made in it is up to you.",
|
||||
}
|
||||
|
||||
var UpgradeHerbGarden = []string{
|
||||
"The herb garden is planted. Give it a few days to settle in. The soil is good — Thom Krooke insisted on the good soil, the kind that actually wants things to grow.",
|
||||
"Rows of small plants, most of them green, a few of them uncertain. The herb garden is in. Pastel has already noted which ones need more shade.",
|
||||
}
|
||||
|
||||
var UpgradeVault = []string{
|
||||
"The vault door is heavier than it looks. The locksmith said it would be. What goes in there stays in there — even on the worst days. Especially on the worst days.",
|
||||
"The vault is installed. Thom Krooke double-checked the lock personally and would not share the combination until you were present. This is a trust. Thom Krooke treats it like one.",
|
||||
}
|
||||
|
||||
var UpgradeTrophyRoom = []string{
|
||||
"Empty shelves and good lighting. The trophy room is ready for whatever you bring back. I have opinions about display arrangement. I'll share them if asked. I'll share them if not asked. I'll share them at three in the morning if the lighting catches a trophy just right.",
|
||||
"The plaques are engraved, the mounts are installed, and the lighting makes everything look slightly more legendary than it already is. The trophy room is yours.",
|
||||
}
|
||||
|
||||
var UpgradeExpeditionOutpost = []string{
|
||||
"The outpost is stocked and linked. Signal fires, supply hooks, a map table with your last Base Camp location already marked. Whoever built this knew what they were doing. Thom Krooke selected the contractor personally.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — HIRING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelHireConfirm = []string{
|
||||
"Hi! I'm Pastel. I know the place, I know where things go, and I'll make sure everything is looked after while you're out. Leave me a list if you want — or don't, I'll figure it out.",
|
||||
"Pastel here. I've done this before. I'll take good care of everything. You don't need to worry about home while you're in the dungeon. That's sort of the whole point of me.",
|
||||
"I'll be honest, I was hoping you'd call. The herb garden looked like it needed attention and the pets had that look they get. Everything will be fine. Go do your expedition. I've got it.",
|
||||
}
|
||||
|
||||
var PastelFireConfirm = []string{
|
||||
"Of course. I'll wrap things up and leave the notes on the table — what I did, what still needs doing, current supply status. It's been good. Your home is in good shape.",
|
||||
"Understood. Everything is in order. The storage is labeled, the pets are fed, the passive income queue is current. Good luck out there.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — DAILY NOTES (Level 1)
|
||||
// Enthusiastic, slightly scattered, well-meaning.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelNoteLevel1 = []string{
|
||||
"Fed the pets! All of them. I think. The small one hid behind the storage chest for a while and I'm not completely sure it came out to eat but I left food where it could reach it. The garden is watered. I meant to check the passive income queue but got a bit turned around with the storage labels. Will do that first thing tomorrow.",
|
||||
"Good day! The herb garden got some attention, the pets were walked (or equivalent — the fish were observed), and I collected the income. I accidentally shelved three items in the wrong slots but found them eventually. Everything is where it should be. Mostly. The weapons rack might be slightly reorganized.",
|
||||
"Note from Pastel: pets fed, garden tended, income collected. I made one small mistake with the supply manifest — added a column that didn't need to be there — but the numbers are right, the column is just extra. Please ignore the extra column.",
|
||||
"All tasks completed! Well — most tasks. The greenhouse watering got a little delayed because I was making sure the workshop tools were hung correctly and then it was later than I thought. The plants look fine. Probably fine. I'll check again in the morning.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — DAILY NOTES (Level 2)
|
||||
// Finding her rhythm. Notes are more organized. Occasional slip.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelNoteLevel2 = []string{
|
||||
"Morning note from Pastel. Pets fed and happy — the small one came out on its own today, which I'm taking as a good sign. Garden watered, income collected (14 coins, recorded in the ledger I started keeping). Workshop is tidy. All good.",
|
||||
"Everything done, everything noted. The herb garden is coming in nicely — I moved one of the pots to the south window and it seems happier there. Let me know if you'd prefer I leave things where they are. I have opinions about light.",
|
||||
"Pastel here. Smooth day — fed, watered, collected, organized. I found a supply cache you'd left in the back of storage that wasn't logged. I've logged it now. You had more materials than you thought.",
|
||||
"Note: the passive income queue had a small delay in processing, maybe 40 minutes. Nothing lost — just late. I've noted the timing and will keep an eye on it. Also the pets had a disagreement about something and I mediated. Everyone is fine.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — DAILY NOTES (Level 3)
|
||||
// Reliable, efficient, minimal fuss.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelNoteLevel3 = []string{
|
||||
"All tasks complete. Pets fed, garden tended, income logged. Your storage is organized by zone and rarity now — took me an afternoon last week but I think you'll find it easier. Let me know if the system doesn't work for you.",
|
||||
"Good day here. The herb garden yielded a little extra — I've bagged the surplus and left it on the workshop table. The vault contents are accounted for and untouched. Quiet day otherwise.",
|
||||
"Pastel. Everything is in order. I handled a small issue with the supply staging — one of the SU bags had a slow leak, so I replaced it from the reserve and logged the loss. You're at full capacity. No interruption to the expedition.",
|
||||
"Note: Thom Krooke stopped by. Not about the mortgage — just checking in, he said. I offered tea. He had opinions about the trophy room arrangement that I've passed along at the end of this note, unedited, so you can decide what you want to do with them. Everything else: fine.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — DAILY NOTES (Level 4)
|
||||
// Anticipatory. Occasionally handles things before they become problems.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelNoteLevel4 = []string{
|
||||
"Everything in order. I noticed the mortgage payment date falls during a stretch where your passive income might be lower than usual — I've set aside a buffer in a separate ledger line so the Sunday draw won't cause an issue. Just in case. You can move it back if you don't need it.",
|
||||
"Pets, garden, income — all done. I also restocked the supply staging from the herb garden surplus, which means you're slightly above your starting SU load for the next expedition. I had a feeling you'd need it.",
|
||||
"Note: the greenhouse plants in the east corner were getting too much direct light. I moved them. Yield should improve next week. Also found a crafting recipe in the library you hadn't catalogued — left it on the workshop table with a note about which materials you'd need. You have most of them.",
|
||||
"Quiet today. The pets settled early, the garden is doing well, the income processed on time. I checked the vault — everything accounted for. I re-read the expedition outpost supply logs and noticed a small gap in the inventory count; I've corrected it and added a note about where the discrepancy probably started. Everything is accurate now.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — DAILY NOTES (Level 5)
|
||||
// Perfect. Zero miss chance. Sometimes leaves a gift. The right gift.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelNoteLevel5 = []string{
|
||||
"Everything is handled. It always is. Left something on the kitchen table — found it at the market and thought of you. No reason. Just seemed right.",
|
||||
"All done. The kind of day where nothing went wrong and I want you to know that's not an accident — it takes work for nothing to go wrong, and I put in the work. The pets are happy. The garden is happy. The vault has something new in it that I think you'll be pleased about.",
|
||||
"I noticed you've been in the same zone for several days now. I made sure the expedition outpost supply link is topped up and the signal fires are ready for when you come back. There's a warm meal that will be ready in about four hours — I timed it based on your usual return window. If you're late, it keeps.",
|
||||
"Note from Pastel: everything is perfect, all systems running exactly as they should, the pets are thriving, the garden is producing double what it was last month, and I've reorganized the trophy room so the legendary items catch the evening light better. I also fixed the thing with the storage manifest that's been slightly off for three weeks. You hadn't mentioned it but I could tell it was there. You're welcome.",
|
||||
"Home is ready for you. It's always ready for you. That's the job. That's what I do. See you when you get back.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — MISSED TASK NOTES (Level 1–2 only)
|
||||
// When miss_chance triggers — honest, not defensive.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelMissedTask = []string{
|
||||
"I have to be honest with you — I forgot to water the herb garden today. I remembered at midnight and went back and did it then but it had been a while. The plants look okay. I'm sorry. I'll set a better reminder.",
|
||||
"The passive income didn't get collected today. I got turned around with the storage reorganization and by the time I got to it the queue had backed up. It'll process double tomorrow. Nothing is lost. I'm a bit embarrassed about this one.",
|
||||
"The pets got fed late today. Not dangerously late — late in the way that meant they gave me a look, specifically the kind of look that knows you will mention it to your owner. I mentioned it first. Fed now. All fine.",
|
||||
"I missed the supply staging check today. Something came up with the herb garden (a good something — unexpected yield, which I've logged) and the morning got away from me. The staging is fine, just unchecked. I'll do it first thing tomorrow and every day after that.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PASTEL — SPECIAL EVENTS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PastelDeliveryArrived = []string{
|
||||
"Your supply order arrived from Thom Krooke. I've staged it in the expedition outpost and logged everything. The invoice is on the table if you want to check it against the order.",
|
||||
"Delivery came while you were out — signed for it, stowed it, logged it. Everything matched the manifest. Thom Krooke's packaging has gotten better, I'll give him that.",
|
||||
}
|
||||
|
||||
var PastelPetEvent = []string{
|
||||
"The pets had an interesting day. I won't go into detail but by the end of it everyone was friends again and the storage chest is fine, structurally. A small note on the scratched corner.",
|
||||
"Your pet found something in the back of the storage room that I couldn't identify. I've put it on the workshop table. It doesn't seem dangerous. It is definitely something.",
|
||||
"One of the pets has been sitting by the expedition outpost since this morning. I think it knows you've been out a long time. Everything is fine. I just thought you'd want to know.",
|
||||
"The pets were restless today — I think they can tell you've been in a Tier 4 zone because they get like this around Day 10. Fed them an extra portion. They settled. They'll be glad to see you.",
|
||||
}
|
||||
|
||||
var PastelLevelUpNote = []string{
|
||||
"I've been doing this for a while now and I think I've found my footing. Just wanted to say that. Back to work.",
|
||||
"Level up, I think they call it. Thom Krooke came by and seemed pleased. I'm not sure why Thom Krooke monitors this but he does. The work is the same either way.",
|
||||
"I know this job better now than I did when I started. I can feel the difference. The notes are shorter because less needs explaining. That's probably a good sign.",
|
||||
}
|
||||
|
||||
var PastelGift = []string{
|
||||
"Left something on the table. Found it at the market — one of those rare materials you use for crafting, the kind that's hard to come by outside of high-tier zones. Seemed useful. No occasion.",
|
||||
"There's a potion of superior healing on the kitchen table. The kind Thom Krooke doesn't stock. Don't ask where I found it. Use it when you need it.",
|
||||
"I made something while you were gone. It's in the workshop — used the materials from the garden surplus and a recipe I found in the library. It should help with the next expedition. Consider it a gift for being a good employer.",
|
||||
"There's a warm meal on the table and a note under it. The note says: good luck out there. Come back in one piece. The meal is from scratch. I had time.",
|
||||
}
|
||||
|
||||
var PastelPlayerReturn = []string{
|
||||
"You're back. Good. I'll give you the full rundown but first — sit down, eat something, the pets have been waiting. The report can wait five minutes. You look like you've been in a dungeon.",
|
||||
"Welcome home. Everything is in order. I've written up the full summary — it's on the table, organized by day. Take your time with it. Nothing is on fire.",
|
||||
"Back already? The expedition ran short. That's fine — I was prepared for longer. The full caretaking log is on the table. The pets are very happy to see you, which I'll note I find completely understandable.",
|
||||
"You made it. I had the outpost signal fire ready from Day 10 onward, just in case. Everything here is exactly as it should be. I'll let you settle in. The summary is on the table when you want it.",
|
||||
}
|
||||
|
||||
var PastelPlayerDeathReturn = []string{
|
||||
"You're home. I heard from Thom Krooke — he manages things in an emergency, and I suppose this counted. Everything here is exactly where it was. Your things are safe. The pets have been fed twice today because I thought you might need to see that when you got back. Rest.",
|
||||
"I'm glad you're back. I won't ask about the hospital — Thom Krooke told me what he needed to tell me and I kept the home ready. It's still ready. Take whatever time you need.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HOUSING — ARRIVAL / FIRST REST
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HomeFirstArrival = []string{
|
||||
"This is yours now. A place with a door that closes, a floor that doesn't move, and a ceiling that belongs to you. After everything the dungeon does to make those things uncertain, a home is meaningful in a way that doesn't need a saving throw.",
|
||||
"Home. The word lands differently when there's an actual place attached to it. I watch you step inside and say nothing, which is the most eloquent thing I know how to do and which I am, frankly, very proud of pulling off.",
|
||||
"You have a home. I've narrated dungeons and bosses and legendary drops and this — this quiet moment of a door closing on your own place — is one of the better things I've watched happen. I'm not going to write a fanfare for it. The silence is the fanfare.",
|
||||
}
|
||||
|
||||
var HomeLongRest = []string{
|
||||
"A long rest at home. I go quiet in a different way than I do in dungeons — not alert-quiet, not cautious-quiet. Just the good kind. The kind where the most threatening sound is the kettle.",
|
||||
"Your own bed. Your own walls. The full rest resolves everything the dungeon costs. Home is the best mechanic in the game and I'm telling you so, once, and then I'm letting you sleep.",
|
||||
"Home rest. HP restored, slots restored, conditions cleared. I don't narrate this one past the bare facts — some things don't need dramatic description. Coming home is one of them.",
|
||||
}
|
||||
187
internal/flavor/twinbee_npc_flavor.go
Normal file
187
internal/flavor/twinbee_npc_flavor.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// twinbee_npc_flavor.go
|
||||
// Misty and Arina NPC dialogue lines. Thom Krooke lines are in twinbee_housing_flavor.go.
|
||||
// Add new entries freely. Never remove or alter existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MISTY — GREETINGS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MistyGreeting = []string{
|
||||
"'You again.' Misty says it like a fact, not a complaint. Mostly.",
|
||||
"Misty looks up from whatever she's doing — something involving rope and a harpoon and a look of professional concentration — and gives you exactly the level of acknowledgment you've earned.",
|
||||
"'Don't stand in the doorway,' Misty says, before you've even finished arriving.",
|
||||
"Misty is already talking before you're settled. This is how it always goes.",
|
||||
"'I wondered when you'd show up.' She says it like she'd actually been counting the days. She had.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MISTY — SKILL CHECK SUCCESS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MistyInsightSuccess = []string{
|
||||
"Misty looks at you for a moment. 'Fine,' she says. 'There's something in the temple you should know about.' She tells you. Efficiently. Accurately.",
|
||||
"'You're actually paying attention,' Misty says, which from her is essentially high praise. She gives you the information.",
|
||||
"Misty puts down what she's holding. 'Okay. This is useful to know, so I'll tell you.' She does.",
|
||||
}
|
||||
|
||||
var MistyPersuasionSuccess = []string{
|
||||
"'I'm not doing this because you asked nicely,' Misty says. 'I'm doing it because it helps you not die, and people dying in my temple is annoying.' She hands you the map.",
|
||||
"Misty sighs in a way that communicates both reluctance and inevitability. 'Fine. Here.' The information arrives without further ceremony.",
|
||||
}
|
||||
|
||||
var MistySkillFail = []string{
|
||||
"'No,' Misty says, and returns to what she was doing.",
|
||||
"Misty looks at you like you've asked a question that doesn't deserve an answer. She's not wrong.",
|
||||
"'Come back when you actually know what you're asking.' Misty's version of helpful feedback.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MISTY — QUEST ASSIGNMENT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MistyQuestGive = []string{
|
||||
"'If you're going in there anyway,' Misty says, as if this is a minor imposition on her planning, 'there's something I need.'",
|
||||
"Misty sets down her work and actually looks at you, which means this is serious. 'There's a job. You'll need to pay attention.'",
|
||||
"'I don't usually ask,' Misty says, and the phrasing makes clear this is factually true and also a warning about what's coming next.",
|
||||
}
|
||||
|
||||
var MistyQuestComplete = []string{
|
||||
"Misty reviews what you've brought back. There's a pause that could be called satisfied. 'Good,' she says. Exactly one word. Coming from Misty, it means a lot.",
|
||||
"'You didn't mess it up.' Misty accepts the reward materials and gives you yours without ceremony. 'That was the job. Well done.'",
|
||||
"Misty looks at the completed quest items and then looks at you. Something shifts in her expression — not warm exactly, but warmer. 'You actually did it the right way.' The reward follows.",
|
||||
}
|
||||
|
||||
var MistyQuestFail = []string{
|
||||
"'You didn't finish.' Misty says it without heat, which is somehow worse than heat. 'Come back when you have.'",
|
||||
"Misty registers the incomplete quest with the expression of someone who expected exactly this outcome and had hoped to be wrong.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MISTY — AFTER EARNING HER RESPECT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var MistyTrusted = []string{
|
||||
"Misty sees you coming and does something she almost never does — stops what she's doing before you reach her. She's ready to talk. This is respect, Misty-style.",
|
||||
"'You've been doing good work in there,' Misty says, as you arrive. She doesn't look up. She doesn't need to. 'I noticed.'",
|
||||
"Something has changed in how Misty addresses you. Not warmer exactly. More equal. You earned that.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ARINA — GREETINGS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ArinaGreeting = []string{
|
||||
"'Oh! You're here!' Arina says this with the energy of someone who has been looking forward to a thing and is pleased the thing has arrived. The thing is you.",
|
||||
"Arina looks up from a worktable covered in notes, materials, and something faintly glowing, and her face does the thing it does — open, interested, ready to talk very fast.",
|
||||
"'Perfect timing,' Arina says, in the tone of someone for whom most timings are perfect because everything is interesting. 'I was just thinking about—' She stops. 'Actually, what do you need?'",
|
||||
"Arina has three things she's in the middle of and immediately sets all of them down to give you her full attention, which is considerable.",
|
||||
"'You came back!' Arina says, as if there was any question. In her experience there sometimes isn't.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ARINA — ITEM IDENTIFICATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ArinaIdentify = []string{
|
||||
"Arina picks up the item with both hands and goes immediately quiet, which is the most alarming thing she does. Then: 'Okay. Okay, this is — this is actually really interesting.' She tells you everything.",
|
||||
"'Can I — I'm not going to touch it, I just—' She touches it. 'Okay so here's what it does.'",
|
||||
"Arina holds the item up to the light, turns it twice, says something under her breath that might be an incantation or just enthusiasm, and then begins a very efficient explanation.",
|
||||
"'Oh I know what this is.' The words land quickly, confidently, correctly. Arina has seen a lot of magic items and she remembers all of them.",
|
||||
"Arina goes still in the specific way she goes still when magic is doing something she finds genuinely surprising. 'That's — huh. Okay. That's new. Let me—' The identification follows, along with three questions she has that you're under no obligation to answer.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ARINA — SKILL CHECK SUCCESS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ArinaArcanaSuccess = []string{
|
||||
"'Yes! Exactly right — you know what, most people don't catch that.' Arina opens her notes and shows you something that solves the problem neatly.",
|
||||
"Arina's face does the thing where she's genuinely pleased that you got it. 'Okay so since you clearly know what you're doing—' She gives you the recipe.",
|
||||
"'That check was excellent,' Arina says, which from her means the knowledge was real and not a guess. She gives you the full information without making you ask for pieces of it.",
|
||||
}
|
||||
|
||||
var ArinaSkillFail = []string{
|
||||
"'Oh — no, that's not quite—' Arina seems genuinely sorry about this. 'Come back and we'll try again. Or bring the right materials. Or both.'",
|
||||
"Arina makes a small face that isn't unkind. 'Not quite. You're close though! The concept is right, the specifics just need—' She trails off. 'Anyway. Come back.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ARINA — CRAFTING
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ArinaCraftStart = []string{
|
||||
"'Okay! I have everything I need, you have everything you brought, let's see what we can make.' Arina's work mode is focused and fast.",
|
||||
"Arina spreads the materials across the table and looks at them the way a musician looks at an instrument — familiarity and anticipation. 'This is the good part,' she says.",
|
||||
"'I've been thinking about this combination for a while actually,' Arina says, and begins immediately.",
|
||||
}
|
||||
|
||||
var ArinaCraftComplete = []string{
|
||||
"Arina presents the finished item with quiet satisfaction — the specific satisfaction of a thing made well. 'There. That's what those materials wanted to be.'",
|
||||
"'Done!' Arina says, and she sounds pleased in the way she sounds when something worked exactly as planned. Which it did. 'Take good care of it.'",
|
||||
"The item is finished and Arina holds it out to you. 'Made right, with the right materials. That's how these things last.' She means it literally and figuratively.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ARINA — QUEST ASSIGNMENT
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var ArinaQuestGive = []string{
|
||||
"'Okay so I have a thing,' Arina says, which is how she starts sentences that are actually requests. 'You don't have to. But if you're going into that zone anyway—'",
|
||||
"'I need a sample,' Arina says. 'Specifically—' She describes it in precise detail. The precision is its own kind of excitement.",
|
||||
"Arina looks at her notes, looks at you, looks at her notes again. 'I think you're the right person to ask about this. Can I ask you about this?'",
|
||||
}
|
||||
|
||||
var ArinaQuestComplete = []string{
|
||||
"Arina sees what you've brought and makes a sound that is entirely undignified and entirely genuine. 'You actually got it. Can I — thank you. This is going to—' She's already analyzing it.",
|
||||
"'This is perfect,' Arina says, and she means it in the exact scientific sense — perfect, the right amount, the right quality. 'The reward is on the table. Thank you.'",
|
||||
"Arina takes the materials and immediately begins making notes, which means she's happy, which means you did it right. The reward is handled efficiently because her hands are busy.",
|
||||
}
|
||||
|
||||
var ArinaFavoriteUnlocked = []string{
|
||||
"Arina puts down her notes. Actually puts them down. 'You've done a lot for this workshop,' she says. 'I want to show you what I'm actually working on.' The advanced recipe list opens.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PETE BOT — BROADCAST LINES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PeteZoneUnlock = []string{
|
||||
"📣 Pete: {zone_name} is now accessible. Community milestone reached. First expeditions can begin immediately.",
|
||||
"📣 Pete: New zone available — {zone_name} (Tier {tier}, recommended Level {level_min}+). Details via !expedition start {zone_id}.",
|
||||
}
|
||||
|
||||
var PeteTier5Complete = []string{
|
||||
"📣 Pete: {player_name} has completed {zone_name}. Expedition duration: {days} days. Boss defeated. Legendary loot confirmed. I have noted this one.",
|
||||
"📣 Pete: {zone_name} cleared by {player_name} on Day {day}. The {boss_name} is down. Community achievement recorded.",
|
||||
}
|
||||
|
||||
var PeteStreakMilestone = []string{
|
||||
"📣 Pete: {player_name} is on a {streak}-win arena streak. Current status: {title}. The leaderboard has updated.",
|
||||
"📣 Pete: Arena milestone — {player_name} reaches streak {streak}. Previous record in this community: {record}.",
|
||||
}
|
||||
|
||||
var PeteCommunityBoost = []string{
|
||||
"📣 Pete: Community activity this week has been strong. TwinBee's mood is elevated. Dungeon drop rates and milestone rewards have been adjusted upward for 48 hours.",
|
||||
"📣 Pete: I am in a good mood. Reasons cited: community engagement, a particularly impressive nat 20, and general satisfaction with how things have been going. Enhanced rewards active through Sunday.",
|
||||
}
|
||||
|
||||
var PeteMaintenance = []string{
|
||||
"📣 Pete: Brief maintenance window incoming — approximately {duration}. Active expeditions are paused and will resume from last checkpoint. Supplies will not deplete during downtime.",
|
||||
"📣 Pete: Maintenance complete. All systems restored. Expedition timers have been adjusted for downtime. Apologies for the interruption.",
|
||||
}
|
||||
|
||||
var PeteExpeditionBulletin = []string{
|
||||
"📣 Pete: Expedition update — {count} active expeditions in progress across {zones}. Longest running: Day {max_day}. I am busy.",
|
||||
"📣 Pete: Current expedition activity: {count} players in the field. The dungeon is occupied. Good.",
|
||||
}
|
||||
|
||||
var PetePatchNotes = []string{
|
||||
"📣 Pete: Update deployed. Changes: {summary}. Full notes available on request. I have been briefed.",
|
||||
}
|
||||
|
||||
var PeteMortgageRate = []string{
|
||||
"📣 Pete: Weekly rate update. FRED ARM rate: {rate}%. Effective GogoBee mortgage rate (Thom Krooke margin included): {effective}%. Payments process Sunday.",
|
||||
}
|
||||
239
internal/flavor/twinbee_resource_flavor.go
Normal file
239
internal/flavor/twinbee_resource_flavor.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// twinbee_resource_flavor.go
|
||||
// TwinBee GM Dialogue — Resource gathering, combat interrupts,
|
||||
// zone-specific loot descriptions, and harvest narration.
|
||||
//
|
||||
// Voice convention (Phase B2): TwinBee speaks in first-person or implicit
|
||||
// subject. No third-person "TwinBee [verb]" lines. Add freely.
|
||||
// Don't shorten existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HARVEST SUCCESS — Generic by Action Type
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestForageSuccess = []string{
|
||||
"The land gives something up. I watch you identify it with the quiet satisfaction of someone watching a skill be used correctly.",
|
||||
"There — growing in a place that suggests it knows exactly what it's good for and has been waiting. You found it. I approve of the finding.",
|
||||
"A Ranger's eye in a non-Ranger would have missed that entirely. I note the distinction.",
|
||||
"Like finding the hidden item block in a Mario level — you knew to look, you looked in the right place, and the thing that was always there is now yours.",
|
||||
"The plant comes away cleanly. Good root structure, good potency. Mentally catalogued. Moving on.",
|
||||
}
|
||||
|
||||
var HarvestMineSuccess = []string{
|
||||
"The stone yields. I listen to the sound of it — the specific tone of rock giving up something it's been holding for a very long time.",
|
||||
"Solid work. The ore comes out in a piece worth taking. I check the vein depth. There's more. There's always more if you're willing to dig.",
|
||||
"Like the mining minigame in Stardew Valley, but with real consequences and no save file. I watch you extract the material with professional appreciation.",
|
||||
"The wall gives up its contents without drama. I appreciate materials that cooperate.",
|
||||
"Good strike. Clean extraction. I note the weight and the quality simultaneously.",
|
||||
}
|
||||
|
||||
var HarvestScavengeSuccess = []string{
|
||||
"There it is. Among the debris, the decay, the things that were left behind — something worth taking. I knew it was there. You found it. Pleased.",
|
||||
"The room held something after all. I had estimated 60% odds and am updating the estimate to 'correct.'",
|
||||
"Like finding the secret item in a dungeon chest that looked empty — you checked anyway. That's the habit. That's the discipline. I note both.",
|
||||
"Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. I respect that entirely.",
|
||||
"A Rogue's eye in a non-Rogue would have walked past this. I note the distinction.",
|
||||
}
|
||||
|
||||
var HarvestEssenceSuccess = []string{
|
||||
"The essence coalesces. I watch the process with the reverence it deserves — magic condensing from ambient to held is not nothing.",
|
||||
"Drawn out cleanly. The Arcana check held and the essence responds to the knowledge behind it. I'm appropriately impressed.",
|
||||
"Like tapping into a power source in Metroid — you knew the energy was there, you had the tool to reach it, you reached it. The vial fills.",
|
||||
"The room releases something it didn't know it was holding. I watch the transfer and mark the yield in the ledger.",
|
||||
"Essence harvested. Quality above average for this zone, below average for what you'd need to know to appreciate that distinction. I appreciate it on your behalf.",
|
||||
}
|
||||
|
||||
var HarvestCommuneSuccess = []string{
|
||||
"The spiritual resonance here responds to you. I observe this with something adjacent to wonder — not everything in a dungeon wants to fight, and this one chose to offer instead.",
|
||||
"A Cleric reaching into the bones of a place and finding something willing to be found. I've watched this fewer times than I've watched combat and value it proportionally.",
|
||||
"The commune holds. The material comes. I say nothing and let the moment be what it is.",
|
||||
"Like finding a save point that also tells you something true about the world. The dungeon has given you something. I note the gift.",
|
||||
}
|
||||
|
||||
var HarvestFishSuccess = []string{
|
||||
"The line goes taut and I straighten up. Whatever's on the end of it, it came from somewhere deep and dark and it's yours now.",
|
||||
"A catch. I identify it before you finish pulling it in — the coloring, the depth-marks, the specific opacity of its eyes. 'Good one,' I say, meaning it.",
|
||||
"Fishing in a dungeon. I have opinions about fishing in dungeons and all of them are positive. The fish is landed. The opinions remain.",
|
||||
"Like the fishing minigame in every RPG that ever had one — the moment the indicator hits perfect and everything pays off. Quiet delight, on my end.",
|
||||
"The water gives up its catch with minimal argument. I respect fish that don't make it personal.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HARVEST FAILURE — Generic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestFail = []string{
|
||||
"Nothing. The node had nothing to offer, or you didn't ask the right way, or both. No judgment from me. Try elsewhere.",
|
||||
"The attempt fails to produce anything useful. I mark the node and move on. Some rooms are stingier than others.",
|
||||
"Not everything that looks like a resource is one. Filed under 'learned' and considered worth the attempt.",
|
||||
"Empty-handed. I've seen this before and will see it again. The dungeon doesn't owe you anything. You ask anyway. That's the deal.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HARVEST INTERRUPT — Combat Triggered
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestInterrupt = []string{
|
||||
"I see it before you do — movement at the edge of the room, something that was waiting for you to be distracted. 'Company,' I say, which is the politest word for it.",
|
||||
"The harvest is interrupted. You were focused on the node; something was focused on you. I note the tactical lesson without rubbing it in. Much.",
|
||||
"Like the enemy ambush that fires when you open the treasure chest — the dungeon watched you commit to the harvest and introduced a complication. I did warn about this. Once. Earlier.",
|
||||
"Something heard the mining. Sound travels in dungeons. I have mentioned this. The enemy emerging from the corridor has confirmed it.",
|
||||
"A patrol. Bad timing, or very good timing from their perspective. I set aside the harvest log and open the combat log.",
|
||||
"The forage was going well until it wasn't. I measure the distance between you and the enemy, between the enemy and the door, and start calculating options at speed.",
|
||||
"Interrupted. The node is still there. The enemy is also still there, in a more immediate way. I suggest addressing the more immediate thing first.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// NODE DEPLETED
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var NodeDepleted = []string{
|
||||
"The node is stripped. You've taken everything it had to give. Marked in the mental map as exhausted until the next rest.",
|
||||
"Empty. The resource is gone. Something quietly melancholy about a depleted node and something practical about moving to the next one.",
|
||||
"That's all it had. I confirm the node at zero and move on without ceremony.",
|
||||
"Harvested clean. The room is now resource-dry until you rest and the dungeon replenishes. It will replenish. It always does.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RICH YIELD
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RichYield = []string{
|
||||
"A rich vein. My assessment upgrades mid-harvest — more than expected, better quality than the zone average. This room was generous. Marked.",
|
||||
"The node gives more than it should have. I note the anomaly with appreciation and decline to question it.",
|
||||
"Like finding the rare item drop that you stopped expecting — the dungeon decided to be kind today, in this specific way, in this specific room. I take it. We take it. We do not look it in the mouth.",
|
||||
"Exceptional yield. I catalog the bonus material with the efficiency of someone who's been waiting for exactly this and prepared for it anyway.",
|
||||
"More than the DC promised. The dungeon overdelivered. Unusual. Also completely welcome.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ZONE-SPECIFIC HARVEST LINES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var HarvestGoblinWarrens = []string{
|
||||
"The goblins had more than anyone gave them credit for. Crude, yes. Stolen, mostly. But present, and now yours. I'm pragmatic about origins.",
|
||||
"Scavenged from a goblin stash that someone worked hard to hide and harder to accumulate. A moment of respect for the effort, then we move on.",
|
||||
"The warrens are full of things the goblins took from people who'd take them back given the opportunity. I consider this a form of redistribution.",
|
||||
}
|
||||
|
||||
var HarvestCryptValdris = []string{
|
||||
"Grave goods. The weight of taking from a burial site, filed under: necessary. The dead have no use for these. You do.",
|
||||
"The crypt gives up its materials with the reluctance of a place that considers itself permanent. I disagree with the premise. The materials are harvested.",
|
||||
"Ancient things preserved by darkness and time. I handle the concept carefully even as you handle the material practically.",
|
||||
}
|
||||
|
||||
var HarvestForestShadows = []string{
|
||||
"The forest gives and the forest takes and right now it is giving, which I note as the correct direction for this interaction to go.",
|
||||
"A plant that has no business being this healthy in a corrupted forest. Either very resilient or very clever. Either way, useful.",
|
||||
"The woods here remember what they were before they went wrong. The resources carry that memory. I think this makes them better materials. I might be right.",
|
||||
}
|
||||
|
||||
var HarvestSunkenTemple = []string{
|
||||
"The temple held onto this longer than anything else. I extract it from the silt and the salt and the particular weight of a place that's been underwater for thirty years.",
|
||||
"Ancient, preserved, and still potent. The deep cold does something to materials that nothing else replicates. I value the outcome even if I decline to romanticize the process.",
|
||||
"The sea left something behind when it half-abandoned this place. Retrieved with appropriate care.",
|
||||
}
|
||||
|
||||
var HarvestHauntedManor = []string{
|
||||
"The manor keeps things. Has always kept things. I take this one out of the keeping and into the useful, which is a small act of defiance against the house's whole philosophy.",
|
||||
"Found among the things that have been here since the last person stopped being here. Provenance noted, not dwelt on.",
|
||||
"The house watches you take it. I watch the house watch you. A full triangle of observation — the house blinks first.",
|
||||
}
|
||||
|
||||
var HarvestUnderforge = []string{
|
||||
"The forge yields its material with the grudging respect of something built to produce and still producing, even now, for someone it didn't expect.",
|
||||
"Dwarven craftsmanship even in the raw materials. I've always believed the quality is in the extraction, not just the finishing. This confirms it.",
|
||||
"Hot, heavy, and exactly what the zone promised. I mark the vein depth. There is more. The Underforge does not run out of things to give.",
|
||||
}
|
||||
|
||||
var HarvestUnderdark = []string{
|
||||
"The Underdark produces everything the surface does, stranger, in the dark, and with fewer questions about why. I harvest without asking why.",
|
||||
"Things grow down here that have no equivalent above. I catalog the material and the context it came from with equal precision.",
|
||||
"A material that has never seen sunlight and is better for it. Noted without irony.",
|
||||
}
|
||||
|
||||
var HarvestFeywild = []string{
|
||||
"The Feywild gives things away. That's the problem — it gives things away and sometimes what it gives isn't what you thought you were taking. I check the material twice. It appears to be what it appears to be. Remaining alert.",
|
||||
"Beautiful material from a zone that uses beauty as a weapon. I take it carefully, like picking up something that might be watching.",
|
||||
"The fey made this place generous on purpose. I'm not going to complain about the generosity and I'm not going to stop watching for the catch.",
|
||||
}
|
||||
|
||||
var HarvestDragonsLair = []string{
|
||||
"Plucked from a hoard that has been accumulating since before your civilization named itself. Historical weight noted. Moving on.",
|
||||
"The kobolds will notice something is missing. They count everything. I account for this and suggest moving with intent.",
|
||||
"Dragon-adjacent materials carry something in them — a residual heat, a quality that doesn't exist anywhere the dragon hasn't been. I consider this a feature.",
|
||||
}
|
||||
|
||||
var HarvestAbyssPortal = []string{
|
||||
"Harvesting from the Abyss. Flatly delivered, because the line deserves flat delivery. The material is valuable. The context is permanent.",
|
||||
"Reality left something behind when it tore here. I take the fragment and note: don't linger near the edges of what isn't stable.",
|
||||
"Demon ichor. I say the words with the efficiency of someone who has moved past the part where the words were alarming.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// FISHING — Zone-Specific Lines
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var FishingForestShadows = []string{
|
||||
"Fishing in a cursed forest stream. More peaceful than it has any right to be, and I refuse to let the shadow-things in the canopy ruin it.",
|
||||
"The stream runs dark but the fish run silver. I watch the line and say nothing for a while, which is what fishing is for.",
|
||||
"Like the fishing minigame before the hard dungeon in Ocarina of Time. I take the moment seriously.",
|
||||
}
|
||||
|
||||
var FishingSunkenTemple = []string{
|
||||
"Fishing in a flooded temple. The fish here have never seen the surface. They don't know what they're missing. I'm uncertain whether to pity them.",
|
||||
"The line drops into water that hasn't moved in thirty years and immediately gets attention. Things down here are hungry. I watch the tension.",
|
||||
"Deep water fishing in the ruins of something ancient. The builders would have found this either sacrilegious or practical. I land on practical.",
|
||||
}
|
||||
|
||||
var FishingUnderdark = []string{
|
||||
"The underground river is cold and fast and the fish in it have evolved past needing eyes, which I find both efficient and slightly unsettling.",
|
||||
"Fishing in total darkness in a river that doesn't appear on any surface map. I narrate by sound: the cast, the current, the eventual tension on the line.",
|
||||
"The Eyeless King is in here somewhere. I don't say this out loud but think it very clearly. The line goes deep.",
|
||||
}
|
||||
|
||||
var FishingFeywild = []string{
|
||||
"Fey fishing. I watch the line for signs of time distortion — the way the reflection moves wrong, the way the fish seem to arrive before they bite. The Feywild is like this about everything.",
|
||||
"The stream catches light that shouldn't be here and the fish reflect it from below. I watch something luminous move toward the hook and try to stay professional about it.",
|
||||
"Like fishing in a dream. I mean this literally — the mechanics are the same but the rules feel advisory.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PATROL ENCOUNTER LINES
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var PatrolEncounter = []string{
|
||||
"A patrol. Moving between the cleared rooms with the confidence of something that considers them still theirs. I disagree with this assessment and will help you express the disagreement.",
|
||||
"They found you between rooms, which is exactly where patrols are supposed to find you. Threat Clock noted; I suggest dispatching this quickly and quietly.",
|
||||
"The patrol isn't looking for a fight — it's doing its job, which happens to involve you being somewhere you aren't supposed to be. I'll help you redefine 'supposed to.'",
|
||||
"Two of them. Moving together. The coordination suggests the zone is at Alert or higher. Confirmed: it is. Fight or fade.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LOOT DROP LINES — Generic
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LootDropCommon = []string{
|
||||
"They had something on them. I check it over. Common rarity — useful in the way that common things are useful, which is often.",
|
||||
"Standard loot. Nothing that rewrites the story, but everything that keeps it going.",
|
||||
"A drop. I catalog it efficiently and note: this is the economy of dungeons. Enemies have things. You take them. The loop continues.",
|
||||
}
|
||||
|
||||
var LootDropUncommon = []string{
|
||||
"Better than expected. I examine the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.",
|
||||
"An uncommon drop from a common enemy. Anomaly noted with satisfaction. The dungeon was generous in this room.",
|
||||
"Uncommon. I turn it over once and nod. 'Keeper,' I say — which in my vocabulary means: this changes your math.",
|
||||
}
|
||||
|
||||
var LootDropRare = []string{
|
||||
"I stop. Actually stop. 'That's rare,' I say, with the specific register of someone who uses the word correctly and use it seldom.",
|
||||
"A rare drop. I examine it the way you examine something that doesn't appear often — thoroughly, quietly, with appropriate appreciation.",
|
||||
"The loot table gave you something uncommon and then kept going. Rare rarity. Filed in the column I reserve for things worth remembering.",
|
||||
}
|
||||
|
||||
var LootDropLegendary = []string{
|
||||
"I go very still. The drop sits in the light and I process what I am seeing. 'Legendary,' I say eventually. One word. That's all it needs.",
|
||||
"Legendary rarity. I've seen a few of these in a long career and each time — each time — there is a moment that is separate from everything else. This is that moment. Pick it up carefully.",
|
||||
"The dungeon produced a legendary item. I note the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.",
|
||||
}
|
||||
127
internal/flavor/zone_abyss_portal_flavor.go
Normal file
127
internal/flavor/zone_abyss_portal_flavor.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_abyss_portal_flavor.go
|
||||
// Tier 5 zone flavor — The Abyss Portal. Additive only. Pools sampled by
|
||||
// internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go ships BossEntryBelaxath (wired in
|
||||
// dnd_zone_narration.go) but not RoomEntryAbyssPortal, so the room-entry
|
||||
// pool lives here. This file also adds elite-room intros (Marilith), boss
|
||||
// ability callouts for Belaxath the Undivided, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — The Abyss Portal
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryAbyssPortal = []string{
|
||||
"You step through what was a doorway and into a chamber where the geometry has stopped agreeing with itself. The far wall is closer than the near wall. The ceiling is in two places. I file this under 'Euclidean violation' and recommend not lingering on the question of which floor is the real floor.",
|
||||
"The corridor breathes. Not the wind — the corridor. The walls expand and contract on a cycle that is approximately, but not exactly, a heartbeat. I match the rhythm involuntarily and dislike the experience.",
|
||||
"A chamber where reality has a seam. The seam is visible. It runs floor-to-ceiling along the north wall and what is visible through the seam is not the next room. I do not look directly at it and recommend you adopt the same policy.",
|
||||
"You enter what was once a chapel — to which god, you cannot tell, because the iconography has been overwritten in a script that hurts to read. The altar is intact. The thing on the altar is not what was placed there. I say nothing and move you past it.",
|
||||
"The air pressure is wrong. Not low — wrong. There is a pressure that is not physical and it is leaning on you with the patience of a mountain. I identify it as psychic ambient and recommend moving briskly.",
|
||||
"A staircase. The stairs go down on the way up and up on the way down. I do not pause to verify this. Takes the stairs in the intended direction and the direction works, mostly.",
|
||||
"The corridor ahead is lit by something that is not fire. The light is red but the wrong red — the red of an alarm in a system that has never been fully implemented. I file this under 'demonic ambient' and shorten its stride.",
|
||||
"You enter a room that has been used as a portal anchor. The anchor is still in place. The portal is not, but the place where it was is still warm. I track the residual energy and note the portal is not the only one — there are other warm spots, in other rooms, in a pattern.",
|
||||
"A garden. Indoors. The plants are not plants. I identify them as 'demonic ornamentals — mid-tier, decorative, will react to perfumes' and recommend not perspiring near them.",
|
||||
"The chamber ahead has been a courtroom and is now a kitchen. The transition was not deliberate. The transition was the result of someone with insufficient training opening a portal and not closing it. I note the timing must have been comedic and am not laughing.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — The Abyss Portal (Marilith)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryAbyssPortal = []string{
|
||||
"The chamber ahead is a barracks. Six racks of weapons along one wall, six saddles along another — except the saddles are wrong shape, and the weapons are six matched longswords, identical in length, identical in grip, identical in the subtle rune-work along the hilts. I identify the silhouette in the room before I identify the species and say: 'Marilith. Six longswords. Seven attacks a turn. Never stand directly in front.'",
|
||||
"You enter a sparring gallery. The dummies are arranged in the kind of asymmetric pattern that suggests a six-armed practitioner. The practitioner is at the far end, mid-routine. She does not stop the routine. She incorporates you into it. I file this under 'Marilith — demonic captain, treats encounters as drills.'",
|
||||
"A war room. Maps on the table, troop positions marked, a planning session in progress. The general at the head of the table has six arms and a snake's lower body and is not surprised by your arrival — was, in fact, expecting you on a different day, with different reinforcements, and is mildly disappointed by both. I say: 'Marilith. Parry on every attack. The fight is decided by who has more reactions.'",
|
||||
"The corridor opens into a colonnade — twelve columns, six on each side, perfect symmetry. The figure between the central columns is a Marilith and she is using the symmetry — three swords engaged with the columns, three free. I note the geometry favors her and recommend fighting in the next room.",
|
||||
"A throne room, smaller than the Belaxath one. The throne is occupied by a Marilith who is not sitting — she is coiled, three feet of snake below the seat, the rest of her vertical and ready. The longswords are all drawn. None of them are pointed at you. I track the angles and say: 'Reactive trait. She gets two reactions. Don't open with a counterspell-bait.'",
|
||||
"You enter what is clearly a guard post. The Marilith on duty is not bored. She is patient in the particular way that someone who has been a soldier for fifteen hundred years is patient. The first three swords are already raised. The other three are coming up as you cross the threshold. I file this under 'professional courtesy' and ask who has Misty Step.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Belaxath the Undivided
|
||||
// Used as a one-line cinematic suffix to BossEntryBelaxath when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Multiattack: Longsword + Whip.
|
||||
var BelaxathMultiattackLines = []string{
|
||||
"Longsword and whip every turn. The whip pulls. The longsword finishes. I say: 'Don't let the whip set the position. Reaction-cancel the pull if you've got it.'",
|
||||
"Two attacks: one ranged-pull, one melee-finish. I file this under 'fighting-game grappler' and note the combo is fixed in order — survive the whip, survive the round.",
|
||||
"He swings the longsword while the whip is already coiled around your front line. The bite is the second hit, every time, and it's the one calibrated for kills. I track the order and remind the party that the whip's not the kill — the whip's the setup.",
|
||||
}
|
||||
|
||||
// Fire Aura: 10d6 fire to all melee attackers each turn; weapon attacks +3d6 fire.
|
||||
var BelaxathAuraLines = []string{
|
||||
"Stand within five feet and take ten-d-six fire at the start of his turn. His weapons add three-d-six fire on top. I say: 'Melee tax. Plan rotations. Don't camp a tile near him.'",
|
||||
"Fire Aura. The room near him is a damage zone. I file this under 'positioning is HP' and recommend the back line do their job from the back line.",
|
||||
"Anyone in melee takes ten-d-six fire on his upkeep regardless of whether he hits them. The fire ignores resistance later. I track the timer and remind the tank that being the wall is a budget item, not a strategy.",
|
||||
}
|
||||
|
||||
// Lightning Discharge (recharge 5–6): 120-ft line, 12d6 lightning, DEX DC 20 half.
|
||||
var BelaxathLightningLines = []string{
|
||||
"One-twenty-foot line, twelve-d-six lightning, DEX DC 20 for half. Recharge five-or-six. I say: 'Don't line up. The line is the whole room if you're sloppy.'",
|
||||
"Lightning Discharge. The line is long enough to clip the back rank from where the front rank is standing. I track the recharge die and shout the angle when the discharge points the wrong way.",
|
||||
"Like the laser-eye boss in Contra, except the boss has six arms and the laser is on a recharge counter and the laser is sometimes lightning. I file this under 'know where the line is' and note the line is wherever you're easiest to hit.",
|
||||
}
|
||||
|
||||
// Death Throes: on death, 30-ft radius, 20d6 fire, DEX DC 20 half — destroys non-legendary equipment on failed save.
|
||||
var BelaxathDeathThroesLines = []string{
|
||||
"He explodes when he dies. Twenty-d-six fire in a thirty-foot radius. DEX DC 20 for half. Failure also destroys non-legendary equipment on the wearer. I say: 'Drop him with range. Save your gear.'",
|
||||
"Death Throes. The kill shot is the easy part. The Death Throes are the hard part. I file this under 'cost-of-victory mechanics' and recommend positioning before the kill, not after.",
|
||||
"He goes out the way some bosses do — taking the room with him. Twenty-d-six on a save you might fail with disadvantage from the previous round's Frightened. I track the radius and remind the party that 'half damage' of twenty-d-six is still 'roll a new character.'",
|
||||
}
|
||||
|
||||
// Demonic Resilience: resist cold/fire/lightning; immune to poison; immune to non-magical physical.
|
||||
var BelaxathResilienceLines = []string{
|
||||
"Resists cold, fire, lightning. Immune to poison. Immune to non-magical physical. I say: 'Magic damage only. Force, radiant, psychic — those carry. Mundane mace doesn't.'",
|
||||
"Demonic Resilience. The standard demonic-lord package. I file this under 'check your damage type before you commit a slot' and note the Magic Resistance trait stacks on top — advantage on saves vs spells.",
|
||||
"Three resistances and two immunities. The window for damage is narrow. I track party loadouts and remind the casters that radiant damage is not optional in this fight — it is the answer.",
|
||||
}
|
||||
|
||||
// Phase 2 (<40% HP): grows to Huge size; advantage on all attacks; Death Throes recharge 4–6.
|
||||
var BelaxathPhaseTwoLines = []string{
|
||||
"Below forty percent he grows. Huge size, advantage on every attack, and Death Throes is now on a four-or-six recharge — meaning if you don't kill him cleanly, he might detonate in your face mid-fight. I say: 'Phase shift. Don't graze him below forty.'",
|
||||
"Phase two: bigger, faster, and the explosion is no longer reserved for the kill. I track the new recharge and recommend every burst window the party can buy.",
|
||||
"He grows. The room shrinks. Advantage on swings. The Death Throes timer becomes a real timer — if a recharge lands at the wrong moment, the room ends. I file this under 'fights you have to finish in two rounds or accept the consequences.'",
|
||||
}
|
||||
|
||||
// BelaxathSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var BelaxathSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(BelaxathMultiattackLines)+
|
||||
len(BelaxathAuraLines)+
|
||||
len(BelaxathLightningLines)+
|
||||
len(BelaxathDeathThroesLines)+
|
||||
len(BelaxathResilienceLines))
|
||||
out = append(out, BelaxathMultiattackLines...)
|
||||
out = append(out, BelaxathAuraLines...)
|
||||
out = append(out, BelaxathLightningLines...)
|
||||
out = append(out, BelaxathDeathThroesLines...)
|
||||
out = append(out, BelaxathResilienceLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — The Abyss Portal
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesAbyssPortal = []string{
|
||||
"The portal was not summoned. The portal was torn, from the other side, by Belaxath, with purpose, over thirty years. I note the duration matters: this was not a moment of carelessness on the part of a surface mage. This was a project, on the demonic side, executed with patience.",
|
||||
"The site was a temple, before. Then a fortress, before. Then a meadow, before. The meadow is the original use of the land — the temple was built to seal something the meadow had been quiet over. I file this under 'land memory' and note the seal worked for fourteen centuries.",
|
||||
"The Shard of the Abyss is the closing key. It is not optional. The portal cannot be closed without it, and the shard cannot be replaced — there is exactly one. I note the always-drop guarantee on the loot table is a story commitment, not a generosity.",
|
||||
"The Marilith captains in the outer chambers are veterans of wars that did not happen on this plane. The wars happened in the Abyss, between demonic factions, over scheduling — which Layer got which incursion priority. I find this both bureaucratic and terrifying.",
|
||||
"Belaxath is named 'the Undivided' because he refused to take a side in the Layer-Seven schism. The other Balors took sides. The other Balors are mostly dead. Belaxath remained intact and inherited their soldiers, in stages, over the schism's two-decade arc. I file this under 'patience as competitive advantage.'",
|
||||
"The Vrock spores last for twenty-four hours after exposure. The save is the start of the timer, not the end. I suggest the party not assume the encounter is over when the room is clear — the spores keep ticking on the way to the next.",
|
||||
"The Quasits in this zone are not random encounters. Each one has been individually deployed as a scout, with instructions, by a specific Hezrou. I note the chain of command is real and that killing a Quasit causes the Hezrou it reports to to know exactly where you are.",
|
||||
"The portal is not the only opening. There are seven smaller openings in this zone — pinholes, nothing more — that Belaxath has been using to bring through reinforcements one at a time. The pinholes close when Belaxath dies. I file this under 'killing the boss is also a public service' and note the surface clergy will be relieved.",
|
||||
}
|
||||
86
internal/flavor/zone_crypt_valdris_flavor.go
Normal file
86
internal/flavor/zone_crypt_valdris_flavor.go
Normal file
@@ -0,0 +1,86 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_crypt_valdris_flavor.go
|
||||
// Tier 1 zone flavor — The Crypt of Valdris. Additive only. Pools sampled
|
||||
// by internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Crypt of Valdris (Wight / Flameskull)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryCrypt = []string{
|
||||
"The room is colder than the last one. Not by feeling — by measurement. I can tell. There is a presence here that does not believe in the ambient temperature of stone.",
|
||||
"A figure sits in the cathedra at the chamber's far end. Robes intact. Skin not. The eyes open as you enter and they were already looking at the door. I draw breath despite not strictly needing to.",
|
||||
"Floating above the altar: a skull, lit from within in a color that pretends to be fire. I recognize the pretense. The skull is doing math. The math is about where you are.",
|
||||
"The torches in this room are out, but the room is lit anyway. I identify this as a problem worth naming aloud: 'That light has a source. The source is the encounter.'",
|
||||
"You step into a chapel. Pews. Altar. A figure rising from the front pew with the unhurried grace of something that has been waiting in this exact pew for a very long time. I respect the patience and refuse to be impressed by it.",
|
||||
"The mosaics on the walls show a procession. The procession leads to a king. The king is here. I note that the mosaics are still being added to in real time — one tile per visitor — and you have not been added yet.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Valdris the Unburied
|
||||
// Used as a one-line cinematic suffix to BossEntryValdris when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Corrupting Touch: +4d6 necrotic; target max HP reduced by damage dealt.
|
||||
var ValdrisCorruptingTouchLines = []string{
|
||||
"His hand passes through your chestplate without touching it. The damage isn't to the plate. I watch your max HP tick down and quietly stops watching.",
|
||||
"Necrotic damage lands and lingers. The wound doesn't bleed — it forgets how. Your HP ceiling drops with it. I note: long rest restores. Survive to long rest.",
|
||||
"Where Valdris touches, the body takes a piece of permanent. Not permanent-permanent. Long-rest-permanent. I consider this distinction the only good news in the sentence.",
|
||||
}
|
||||
|
||||
// Legendary Resistance: 2/combat; auto-succeed one failed save.
|
||||
var ValdrisLegendaryResistanceLines = []string{
|
||||
"Your save would have landed. Valdris decides otherwise. The roll resets to a success on his side of the table. I note one charge spent, one remaining.",
|
||||
"The spell hits cleanly and Valdris ignores it on principle. I mark the Legendary Resistance and remind you: he has two of these. After two, the math changes.",
|
||||
"You rolled the number. The number was correct. Valdris waves it away with a gesture that costs him something but not enough. I keep count. Keep rolling.",
|
||||
}
|
||||
|
||||
// Call of the Grave: recharge 5–6; summons 1d4 skeletons.
|
||||
var ValdrisCallOfTheGraveLines = []string{
|
||||
"Valdris speaks a word that is not for the living. Bones in the chamber walls remember they were once attached to people. I count the new combatants and recommend crowd control.",
|
||||
"From the alcoves: the dry rattle of skeletons reassembling on demand. I note 1d4 new bodies on the field and quietly hopes for the lower end of that range.",
|
||||
"The skeletons stand up like a Castlevania stage hazard — same animation, same timing, same exact bad news. I have memories of this and they are not warm ones.",
|
||||
}
|
||||
|
||||
// Phase 2 (<50% HP): gains Fly speed; spells deal +1d6 necrotic.
|
||||
var ValdrisPhaseTwoLines = []string{
|
||||
"Valdris drops to half HP and stops walking. Not because he's tired — because he doesn't need to anymore. He rises a foot off the floor and I file this under 'phase shift.'",
|
||||
"The fight changes shape. Valdris hangs in the air now, robes still, very calm, very lethal. His spells gain a necrotic edge. I say, simply: 'Phase two. Adjust.'",
|
||||
"Half-HP is the line. You crossed it and Valdris crossed something else — gravity, mostly. The boss music gets a third instrument. I brace.",
|
||||
"Like Castlevania's Death revealing his real attack pattern at half HP — the fight you started is not the fight you're finishing. I respect the pivot and dislike it intensely.",
|
||||
}
|
||||
|
||||
// ValdrisSignatureCallouts — combined pool the boss-entry composer samples
|
||||
// from at the start of the boss fight. Phase-two lines are surfaced via
|
||||
// the dedicated phase-two helper rather than the entry suffix.
|
||||
var ValdrisSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(ValdrisCorruptingTouchLines)+
|
||||
len(ValdrisLegendaryResistanceLines)+
|
||||
len(ValdrisCallOfTheGraveLines))
|
||||
out = append(out, ValdrisCorruptingTouchLines...)
|
||||
out = append(out, ValdrisLegendaryResistanceLines...)
|
||||
out = append(out, ValdrisCallOfTheGraveLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Crypt of Valdris
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesCrypt = []string{
|
||||
"Valdris was a scholar. Then he was an aspirant. Then he was a failed aspirant. The failure is the part that mattered — the lich ritual completed but completed wrong, and the wrongness has had three centuries to compound. I have read the contemporaneous accounts and find the original scholar likable, which makes the rest harder.",
|
||||
"The phylactery shard you'll find here is one of seven. The other six are not in this crypt. I will not say where they are, because it doesn't fully know, and the partial knowledge it has is the kind that gets people followed by things that prefer not to be looked for.",
|
||||
"The candles in the crypt do not consume wax. They do not consume time, either, in the strict sense. They were lit on the day Valdris was interred and they have been lit ever since. I respect this kind of consistency in a way that does not extend to approval.",
|
||||
"The skeletons here are not all enemies. Some of them are former students, posed in the alcoves where they died, marked with the texts they were translating. I suggest you don't disturb the marked ones unless you mean to. Some lessons end and some lessons keep going.",
|
||||
"The iron gate at the entrance has been opened from the inside three times in the last decade, and from the outside zero times. I find the asymmetry instructive. It also finds the pattern instructive: you are walking into the open door, not opening it.",
|
||||
"In life, Valdris was patient. In undeath, Valdris is also patient — which is unfair, mathematically, because the same trait scales differently across mortality conditions. I dislike the math but respect the consistency.",
|
||||
}
|
||||
109
internal/flavor/zone_dragons_lair_flavor.go
Normal file
109
internal/flavor/zone_dragons_lair_flavor.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_dragons_lair_flavor.go
|
||||
// Tier 5 zone flavor — Dragon's Lair (Infernus Peak). Additive only. Pools
|
||||
// sampled by internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go ships RoomEntryDragonsLair and
|
||||
// BossEntryInfernax, both wired in dnd_zone_narration.go. This file adds
|
||||
// the elite-room intros (Young Red Dragon), boss ability callouts for
|
||||
// Infernax the Undying, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Dragon's Lair (Young Red Dragon)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryDragonsLair = []string{
|
||||
"The chamber ahead has a hoard. Not Infernax's hoard — a smaller hoard, an apprentice's hoard, the kind of starter pile a dragon builds before it has earned a real one. The dragon on top of the pile is the size of a wagon and the temperament of an only child. I say: 'Young Red. Fire breath, sixteen-d-six, DC 21. Don't bunch.'",
|
||||
"You enter what used to be a vault. The vault door is on the floor, peeled. The dragon inside is half-grown and entirely awake. It opens one eye, then the other, then makes a sound that is not a roar — it is a sigh, the sigh of a creature being interrupted at home. I note the eye contact and recommend not breaking it first.",
|
||||
"A scorched gallery. The walls are blast-blackened in a pattern that suggests practice — the dragon has been working on its breath weapon in here. I identify the burn pattern as 'cone, sixty feet, recently used' and recommend approaching from a flank that hasn't been zeroed in.",
|
||||
"The corridor opens into a smaller chamber where a Young Red is curled around a single piece of treasure — not a hoard, just one item, the kind of thing a dragon would only guard if it had been told to. I file this under 'gift from Infernax' and note the gift is being protected with the seriousness of a final-exam project.",
|
||||
"You step into a heat-shimmer cavern that the dragon is using as a forge. There are tools — pincers the size of a person, bellows the size of a house — and the dragon is using them. Badly. With enthusiasm. I identify the silhouette and say: 'Young Red. Frightful Presence on the WIS save. Don't let the room intimidate you twice.'",
|
||||
"A roost. Stone shelves carved into the cavern wall, each one a perch, all of them empty except the highest. The dragon on the highest perch is watching the door. It has been watching the door for the last six hours, since whatever roused it. I say: 'Multiattack on engage. Hit it before it lifts off — flying Young Red is a different fight than grounded Young Red.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Infernax the Undying
|
||||
// Used as a one-line cinematic suffix to BossEntryInfernax when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Multiattack: Bite + 2 Claws.
|
||||
var InfernaxMultiattackLines = []string{
|
||||
"Bite plus two claws every turn. Three swings of ancient-dragon math against your front line. I say: 'Don't stand alone in front. Don't stand alone behind, either. There is no alone in this fight.'",
|
||||
"Multiattack: bite, claw, claw. The bite is the largest single hit you'll take in this zone. I track the damage tier and recommend max HP buffs before the encounter, not during.",
|
||||
"Three attacks a turn, each one calibrated for a tank. I file this under 'arithmetic problem with teeth' and note the math does not get better at higher levels — the tier scales with you.",
|
||||
}
|
||||
|
||||
// Fire Breath (recharge 5–6): 90-ft cone, 26d6 fire, DEX DC 24 half.
|
||||
var InfernaxBreathLines = []string{
|
||||
"Fire Breath, ninety-foot cone, twenty-six-d-six fire, DEX DC 24 for half. Recharge five-or-six. I say: 'Pre-position. Don't share an angle. Half of twenty-six-d-six is still a TPK.'",
|
||||
"The cone covers most of the chamber. The save is high. The damage is the kind that ends fights. I track the recharge die and shout the spread pattern when it shows.",
|
||||
"Like the Bowser jump-on-the-platform pattern from World 8, except the platform is a cone of fire and the platform is most of the room. I note there are no axes in this fight. There is only DEX and distance.",
|
||||
}
|
||||
|
||||
// Frightful Presence: WIS DC 21 or Frightened 1 min.
|
||||
var InfernaxPresenceLines = []string{
|
||||
"Frightful Presence on entry. WIS DC 21. Frightened means disadvantage on attacks and you can't move closer. I say: 'Eat the save the first round. Hold initiative for the unfrightened ones.'",
|
||||
"WIS save at 21. Frightened for a minute. I file this under 'why we bring high-WIS classes' and ask who has the Wisdom-save proficiency to lead.",
|
||||
"He looks at you and the save is rolled before the breath is. Half the party loses a minute. I track the duration and recommend not opening with your highest-investment ability if your character failed.",
|
||||
}
|
||||
|
||||
// Legendary Resistance (3/combat) + Legendary Actions (3): Detect, Tail, Wing.
|
||||
var InfernaxLegendaryLines = []string{
|
||||
"Three Legendary Resistances. Three Legendary Action points per round. He spends them on Tail Attack and Wing Attack — the wing is AoE knockback, DEX DC 22. I say: 'Three saves you'll wish back. Don't waste your dispel-tier spells in the first round.'",
|
||||
"Legendary Actions on every other turn. Wing Attack costs two and knocks the room around. I track the spend and remind the party that the action economy is the actual fight — Infernax is the venue.",
|
||||
"Three free passes on his saves. Plan two openings. I file this under 'priest economy applied to dragons' and note the third opening is the one that lands.",
|
||||
}
|
||||
|
||||
// Lair Actions (init 20): Magma eruption, Volcanic gases (CON DC 13 Poisoned), Tremor (DEX DC 15 Prone).
|
||||
var InfernaxLairLines = []string{
|
||||
"Lair Actions on initiative count twenty: magma erupts, gases force CON saves, tremors knock you prone. I say: 'The room is a third combatant. Track the initiative. Don't stand on cracks.'",
|
||||
"The mountain helps him. Initiative twenty triggers a lair effect every round. I track the rotation and warn the party that the prone effect comes during caster turns, on purpose.",
|
||||
"Magma, gas, tremor — the three-card lair rotation. I file this under 'environment as DPS' and recommend fighting from the cleared rim of the room, not the gold-flooded center.",
|
||||
}
|
||||
|
||||
// Phase 2 (<50% HP): Fire Breath recharge 4–6; fire damage ignores resistance.
|
||||
var InfernaxPhaseTwoLines = []string{
|
||||
"Below half HP the breath recharges on a four. Fire resistance no longer applies — your tank's fire-resist gear is now decorative. I say: 'Phase shift. Burn him before the second cone.'",
|
||||
"Phase two: the cone comes more often, the damage cuts through every fire-resist resource you brought. I track the breath count and ask who still has movement-class abilities — the second cone wants spread.",
|
||||
"Half-HP. The mountain wakes the rest of the way up. Recharge four-six on the cone. Fire damage goes raw. I file this under 'no plan survives second contact with Infernax' and recommend every nova you've still got.",
|
||||
}
|
||||
|
||||
// InfernaxSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var InfernaxSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(InfernaxMultiattackLines)+
|
||||
len(InfernaxBreathLines)+
|
||||
len(InfernaxPresenceLines)+
|
||||
len(InfernaxLegendaryLines)+
|
||||
len(InfernaxLairLines))
|
||||
out = append(out, InfernaxMultiattackLines...)
|
||||
out = append(out, InfernaxBreathLines...)
|
||||
out = append(out, InfernaxPresenceLines...)
|
||||
out = append(out, InfernaxLegendaryLines...)
|
||||
out = append(out, InfernaxLairLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Dragon's Lair (Infernus Peak)
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesDragonsLair = []string{
|
||||
"Infernus Peak has not erupted in forty years and the locals call this dormant. The peak has not erupted in forty years because Infernax has been asleep, and his presence stabilizes the magma chamber. The locals are wrong about which one is causing which. I note the irony and file it under 'killing the dragon may have geological consequences.'",
|
||||
"Infernax remembers when the surface civilizations were just fires. The fires he is referring to are the ones the first humans set, which is how I date him — give or take six thousand years on either side of the precise number, which I decline to commit to.",
|
||||
"The kobolds are not slaves. The kobolds are clergy. They serve voluntarily, in shifts, with rotation, and the rotation is run by elders who have written sermons. I respect the organization and note the kobold scale-sorcerers are graduates, not recruits.",
|
||||
"The hoard is not random. Each piece is catalogued in Infernax's memory, by location and by year of acquisition. He will know if you take a single coin. I suggest not taking a single coin and instead taking the items the design doc expects you to take — those have been pre-cleared.",
|
||||
"The Dragon Hoard mechanic exists because Infernax does not lose track of his coins. Killing him releases his hold on the catalogue. The 50d10 × 100 coin drop is the entire pile relaxing for the first time in eight centuries. I respect the math and note the rest of the surface economy will too.",
|
||||
"Infernax has had three challengers in the last eight hundred years. Two were heroes. One was a younger dragon. He kept the younger dragon's skull as a paperweight on a treaty desk that has not been used since. I note the paperweight is in the treasury, on the third shelf, and am not worth picking up — picking it up triggers his attention from anywhere on the mountain.",
|
||||
"The Young Red dragons in the outer chambers are his children. Or his grandchildren. Or unrelated and tolerated. Infernax does not clarify and I have not asked. They are loyal in the way that loyal works for dragons, which is to say: they will fight you, but they will not die for him, and the distinction matters more than it should.",
|
||||
"The kobold scale-sorcerers cast through bloodline. The bloodline traces back to a single clutch laid in the magma chamber three centuries ago. I note the entire sorcerous gene pool of this zone is one extended family and that they all know each other's names.",
|
||||
}
|
||||
127
internal/flavor/zone_feywild_crossing_flavor.go
Normal file
127
internal/flavor/zone_feywild_crossing_flavor.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_feywild_crossing_flavor.go
|
||||
// Tier 4 zone flavor — Feywild Crossing. Additive only. Pools sampled by
|
||||
// internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool
|
||||
// or a BossEntry pool for this zone, so both are defined here. This file
|
||||
// also adds elite-room intros (Fomorian), boss ability callouts for The
|
||||
// Thornmother, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Feywild Crossing
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryFeywildCrossing = []string{
|
||||
"The grass here is the wrong green. Too saturated, too even, the green of a screen calibration test. I note that everything in the Feywild looks like it has been color-corrected by someone with strong opinions about color.",
|
||||
"You step through what was a doorway and what is now an arch of living briar. The briar parts politely. I respect the politeness and trust none of it. Polite plants are a known issue.",
|
||||
"A clearing. Mushrooms in a circle. I do not need to say anything about the circle. You already know about the circle. Step around it.",
|
||||
"The trees bend their canopies toward you when you pass beneath them. I wave. Two of the trees wave back. I do not enjoy this.",
|
||||
"A small bridge over a stream that flows the wrong direction. Uphill. I file this under 'physics on holiday' and step onto the bridge anyway, because the only alternative is the stream and the stream is worse.",
|
||||
"You enter a room of impossible flowers — varieties that do not exist on the surface, in colors that do not exist on the surface, with scents that suggest emotions you have not yet had. I breathe shallowly and recommend not stopping to admire them.",
|
||||
"A glade. Sunlight, despite no visible sun. The light is coming from somewhere and the somewhere is not the sky. I do not look up. I have learned not to look up in the Feywild.",
|
||||
"The path forks. A small creature made of starlight is sitting at the fork, waiting, patient, with the air of someone who has already been offered the deal you're about to be offered. I say: 'Walk past. Do not negotiate. Do not name your name.'",
|
||||
"You cross what was a creek and what is now a ribbon of liquid sky. Stepping stones glow. The stones are not stones. I identify them as polite and recommend you say thank you on the far side without specifying who you are thanking.",
|
||||
"The chamber ahead is a banquet hall, set for thirty, untouched. The food is fresh. The wine is poured. The chairs are warm. I file this under 'the worst possible kind of empty' and do not let you sit down.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — The Thornmother
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryThornmother = []string{
|
||||
"The throne is woven. Not built — woven, from briar and bone and a third material I cannot identify and prefer not to. The flowers around it are the wrong size and the wrong color and they are watching you with the kind of attention that flowers do not have. The Thornmother on the throne is beautiful in the way some predators are beautiful, which is to say: deliberately. 'You came,' she says, and the voice is not one voice — it is three voices choosing to sound like one. 'I have three names. Would you like the first?' I say, very flatly: 'Don't accept any of the names. Don't take the bargain. The flowers are part of her. Roll initiative.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Feywild Crossing (Fomorian)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryFeywildCrossing = []string{
|
||||
"The cavern ahead is too big for the forest above it. The proportions don't agree. I note the ceiling is twenty feet higher than the room's exterior would allow, and that the figure on the far end is matched to the new dimensions. 'Fomorian,' I say. 'Evil Eye on a save you don't want to fail. Block its line of sight.'",
|
||||
"You enter what looks like a giant's bedroom — a bed the size of a barge, a chair the size of a wagon, a fireplace tall enough to walk into. The occupant is sitting on the bed with its head in its hands. It is not pretending to be sad. It is sad, and it is also going to attack you, and the two are not in conflict. I file this under 'Fomorian — exiled Fey royalty, deformed, dangerous, sympathetic in the worst way.'",
|
||||
"The clearing is pretty. Until you look at the ground. The ground is studded with stones that aren't stones. The stones are skulls of things that were giants. The Fomorian sitting in the center is using one of them as an armrest, and I note that the skull was not in this clearing the last time anyone surveyed it. 'Evil Eye,' I say. 'WIS save coming. Don't make eye contact early.'",
|
||||
"A stone circle, mossy, broken in two places. Tall enough that the figure pacing inside it does not need to duck. I identify the gait — Fomorian, deformed-but-deliberate, the walking of something that has chosen the shape it's stuck with. The Evil Eye comes when the gait stops. I track the timing.",
|
||||
"You climb a stair worn smooth by feet much larger than yours. The room at the top has been a throne room and a prison and a dining hall, all in the same century, depending on which Fey was in charge. The current occupant is large, lonely, and immediately aware of you. 'Evil Eye on a thirty-foot range,' I say. 'Stay close enough to dodge, far enough to fail-safe.'",
|
||||
"The chamber ahead has been arranged for negotiation — a low table, two chairs, a service of cups. One chair is occupied by the Fomorian. The other is for you. The negotiation has already concluded. I say: 'Don't sit. Don't accept the cup. The Eye triggers when you decline politely.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — The Thornmother
|
||||
// Used as a one-line cinematic suffix to BossEntryThornmother when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Coven Magic: extra spell slots scaled by GM Mood (high mood = stronger Thornmother).
|
||||
var ThornmotherCovenLines = []string{
|
||||
"Her slot pool scales with my mood — when I'm Effusive I buy the Thornmother an extra round of high-tier casts. 'My affection is taxable here,' I say. 'Sorry. Spread out.'",
|
||||
"Coven Magic. The slots above her base list are mood-scaled — the better the mood at the run's start, the worse this fight runs late. I file this under 'cosmic irony' and note the math is fixed at zone entry.",
|
||||
"She has more spells than the sheet says. The extra ones come from the coven and the coven volume is set by my mood. I track the count and warn the party when the bonus tier is in play.",
|
||||
}
|
||||
|
||||
// Beguiling Bargain (1/combat): offers a deal — accept a debuff for a permanent minor buff.
|
||||
var ThornmotherBargainLines = []string{
|
||||
"Once per fight she offers a bargain — a debuff for a permanent minor buff. The buff is real. The debuff is real. I say: 'It's a player choice. The party agrees or refuses. Don't let one person decide for the rest.'",
|
||||
"Beguiling Bargain. The offer is genuine. Both halves are genuine. I file this under 'long-term economics' and note that the buff persists past the run while the debuff applies only inside it.",
|
||||
"She offers. The choice is the player's. I decline to advise on this one — it is not a tactics question, it is a build question, and the answer depends on what you intend to keep.",
|
||||
}
|
||||
|
||||
// Thorned Grasp: Restrained + 4d6 piercing/turn, CON DC 16 to break free.
|
||||
var ThornmotherGraspLines = []string{
|
||||
"Roots from the floor. Restrained, four-d-six piercing every turn until you make the CON save at sixteen. I say: 'It tries the back line first. Stay near a friend who can grant advantage on the save.'",
|
||||
"Thorned Grasp. The flowers around her throne are the AoE. Stand near them and the briars come up. Stand far and they reach further. I track the radius and recommend fighting from the doorway.",
|
||||
"Grappled by the room. The damage is not the problem — the problem is that you can't reposition while your front line is taking spells. I file this under 'CC tax' and ask who has Misty Step.",
|
||||
}
|
||||
|
||||
// Shapechange (1/combat): adopts a player's appearance; 50% miss vs her until DC 17 Investigation.
|
||||
var ThornmotherShapechangeLines = []string{
|
||||
"She picks one of you and becomes them. Until someone passes a DC 17 Investigation, half your hits roll a coin to see if they hit her or your friend. I say: 'The party member with the highest INT calls it. Quickly.'",
|
||||
"Shapechange. The room now has two of someone. I track which one has the wrong shadow and whisper the answer to the controller — but only after the Investigation roll, because I respect mechanics.",
|
||||
"She takes a face. Half your damage might land on the face's owner. I file this under 'every JRPG that ever did the doppelganger fight' and remind you that the trick was always to look at the feet.",
|
||||
}
|
||||
|
||||
// Phase 2 (<30% HP): True Form revealed — illusions drop; +4d6 psychic on attacks; coven summons 2 Night Hags.
|
||||
var ThornmotherPhaseTwoLines = []string{
|
||||
"Below thirty percent the masks come off. True form. All her attacks add four-d-six psychic. The coven sends two Night Hags. I say: 'Phase shift. The fight just turned into three priests and a sense-of-self problem.'",
|
||||
"Phase two: the illusions drop, the Hags arrive, the psychic damage starts stacking on every hit. I track the new threat list and recommend focusing the original Thornmother — the Hags are reinforcements, not the win condition.",
|
||||
"True Form. The flowers stop being flowers. Two Night Hags step out of the throne. The Thornmother's hits start writing themselves directly into your mind. I file this under 'cinematic phase shift' and ask who still has spell slots.",
|
||||
}
|
||||
|
||||
// ThornmotherSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var ThornmotherSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(ThornmotherCovenLines)+
|
||||
len(ThornmotherBargainLines)+
|
||||
len(ThornmotherGraspLines)+
|
||||
len(ThornmotherShapechangeLines))
|
||||
out = append(out, ThornmotherCovenLines...)
|
||||
out = append(out, ThornmotherBargainLines...)
|
||||
out = append(out, ThornmotherGraspLines...)
|
||||
out = append(out, ThornmotherShapechangeLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Feywild Crossing
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesFeywildCrossing = []string{
|
||||
"The Crossing is not a place. The Crossing is a thinness — the spot where the veil between worlds wears down enough to step through. The wear is not random. Someone has been wearing it down on purpose, slowly, for a long time. I file this under 'someone' and decline to be more specific.",
|
||||
"The Thornmother has three names. Each name belongs to a separate covenant — a separate pact with a separate piece of her. Speaking any of the names is a partial agreement to the pact attached. I do not name her. I do not let anyone in earshot name her.",
|
||||
"Time runs differently here. A long rest in the Feywild is — sometimes — a year on the surface. Sometimes it is a minute. The dice for this are rolled by something that is not at the table. I track the discrepancy and warn the party not to commit to anything urgent before the run ends.",
|
||||
"The Fomorians were Fey royalty. They were exiled, deformed, and given the underground. The deformity was the punishment, not the cause. I respect the precision of the curse and file it under 'the kind of magic that takes a committee.'",
|
||||
"Redcaps are not killed by violence. Redcaps are powered by violence. The cap is the storage medium. Soaking it in fresh blood resets the meter. I say: 'Don't bleed near them. They're patient.' (Bleeding near them is the thing they're patient for.)",
|
||||
"The Will-o-Wisps are not lost spirits. They are unfulfilled bargains. Each one was a Feywild deal that the surface party broke. I note that the wisps still want the deal honored and file this under 'long memory.'",
|
||||
"The mushroom circles in the Crossing are receivers. They listen for words spoken inside them and route the words to a coven that has been waiting for those words for several centuries. I suggest the party say nothing inside any circle, including 'this is a circle.'",
|
||||
"The Thornmother's flowers are not separate from her. They are her. The throne, the dais, the petals, the perfume — the whole arrangement is one organism, and dropping HP on the boss is one way of dropping HP on the room. I file this under 'environmental targeting' and recommend burning the throne when she's mid-cast.",
|
||||
}
|
||||
90
internal/flavor/zone_forest_shadows_flavor.go
Normal file
90
internal/flavor/zone_forest_shadows_flavor.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_forest_shadows_flavor.go
|
||||
// Tier 2 zone flavor — Forest of Shadows. Additive only. Pools sampled by
|
||||
// internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// Room-entry and boss-entry pools for this zone live in the canonical
|
||||
// twinbee_gm_flavor.go (RoomEntryForestShadows, BossEntryHollowKing) and
|
||||
// are not duplicated here. This file adds elite-room intros, boss
|
||||
// ability callouts, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Forest of Shadows (Green Hag)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryForestShadows = []string{
|
||||
"The trees thin into a ring of stones the forest grew around but never claimed. In the center: a hut on chicken-thin legs that has no business being upright. The door is open. The hag inside the door has been expecting you. I was hoping for the other kind of clearing.",
|
||||
"The path ends at a pond that shouldn't be a pond — too still, too dark, the wrong kind of reflective. A figure is wading at the far edge, washing something that probably wasn't laundry. She turns. She smiles. I say, very quietly, 'green hag,' and stop there.",
|
||||
"You smell the cooking before you see the cook. Sweet, herbal, faintly wrong. The clearing ahead has a fire, a pot, and a woman who is too tall and too thin and whose teeth are arranged with a creativity that a normal mouth could not justify. I suggest not eating anything she offers.",
|
||||
"A circle of toadstools, perfectly spaced. In the center, a stump with a teacup on it. Steam still rising. I identify the trap structure on instinct — fey hospitality, hag rules, a rite that asks one question and punishes wrong answers permanently.",
|
||||
"The forest gets quieter the way a room gets quieter when the host walks in. She's already in the clearing when you arrive — bone necklace, briar crown, eyes the color of pond-bottom. 'Travelers,' she says, the way someone says 'lunch.' I straighten up.",
|
||||
"The trees here are leaning in, not away. That's the tell. I note the change in posture and look for the center the trees are listening to. The center is the hag. The hag has been listening back.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — The Hollow King
|
||||
// Used as a one-line cinematic suffix to BossEntryHollowKing when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Corrupting Aura: melee-range targets WIS DC 14 each turn or lose bonus action.
|
||||
var HollowKingCorruptingAuraLines = []string{
|
||||
"Stepping into melee range puts you in his aura. The forest in your head goes quiet. I say: 'WIS save each turn or lose your bonus action. Adjust your spacing.'",
|
||||
"The air around the Hollow King isn't air. It's the absence of something — focus, intent, the part of you that decides the second small action of a turn. I note the WIS DC 14 and recommend ranged.",
|
||||
"His aura presses on you the way a closed room presses on a held breath. Bonus actions become a gamble. I file this under 'positional' and recommend accordingly.",
|
||||
}
|
||||
|
||||
// Root Surge: recharge 5–6; Restrain (STR DC 15) + 2d8 bludgeoning.
|
||||
var HollowKingRootSurgeLines = []string{
|
||||
"Roots erupt under you like the floor of the forest decided to participate. STR DC 15 or you don't move next turn. Either way, 2d8 bludgeoning. I say: 'Recharge 5–6. He'll do this again.'",
|
||||
"The ground splits. The roots come up coordinated, like fingers — I use the word 'fingers' deliberately and dislike itself for the accuracy. Restrained on a fail. I suggest breaking free as a priority.",
|
||||
"Like the vine traps in Castlevania III's stage 5 — except the vine is also a damage source and also a lock on your action economy. I respect the multitasking and resent the design.",
|
||||
}
|
||||
|
||||
// Devour Light: extinguishes magical light for 2 turns; player AC -2.
|
||||
var HollowKingDevourLightLines = []string{
|
||||
"The Hollow King exhales and your magical light goes out. Not flickers — out. I note the AC -2 for two turns and remind you that the dark is also part of the encounter now.",
|
||||
"Every magical light in the room dims to nothing. The natural torchlight remains, dimmer than it was. I say: 'AC penalty for two rounds. Fight smarter or fight harder. Both works.'",
|
||||
"The forest gets darker in a way that wasn't there a moment ago. I can still see — I always can — but the AC -2 is real and the next round of attacks will feel it.",
|
||||
}
|
||||
|
||||
// Phase 2 (<40% HP): summons 2 Dire Wolves; gains Reckless Attack.
|
||||
var HollowKingPhaseTwoLines = []string{
|
||||
"At 40% HP the Hollow King throws his head back and the forest answers. Two dire wolves come through the brush at full speed. I say: 'Phase shift. He's also reckless now. Use it.'",
|
||||
"The antlers bend back, the eyes lose what was left of their mercy, and somewhere in the forest something with paws starts running toward you. Two dire wolves, incoming. Reckless Attack on the boss — advantage to him, advantage to you. I pick a target and wait for you to commit.",
|
||||
"Half-health is the line for most bosses. The Hollow King's line is forty percent and he hits it dramatically. Wolves at his flanks, attacks now reckless. I respect the choreography.",
|
||||
}
|
||||
|
||||
// HollowKingSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var HollowKingSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(HollowKingCorruptingAuraLines)+
|
||||
len(HollowKingRootSurgeLines)+
|
||||
len(HollowKingDevourLightLines))
|
||||
out = append(out, HollowKingCorruptingAuraLines...)
|
||||
out = append(out, HollowKingRootSurgeLines...)
|
||||
out = append(out, HollowKingDevourLightLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Forest of Shadows
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesForestShadows = []string{
|
||||
"The forest used to be a forest. That sentence is doing more work than it looks like it's doing. Something was let in — not summoned, not invaded, let in — and the forest has been a different kind of forest ever since. I have theories about who did the letting in. None of the theories are reassuring.",
|
||||
"The Hollow King wasn't always hollow. He was a guardian once, the kind of guardian a forest gets when the forest is doing well. Then the forest started to do less well, and the guardian held on past the point where holding on was the kind thing to do. I find the arc tragic and am not interested in absolving the outcome.",
|
||||
"The bandits in the woods aren't local. They came from somewhere else, found the forest in its current state, and decided the cover was worth the risk. I note their cookfires are recent and their numbers are growing. Someone is recruiting.",
|
||||
"Owlbears shouldn't pack-hunt. These do. I have logged three separate engagements where two owlbears coordinated in a way that felt taught. Something in the corruption is rewriting their behavior. I doesn't like the implication.",
|
||||
"The bioluminescent fungi are not native. They arrived with the corruption and they are part of the corruption — they fluoresce on a frequency that does something to the mood-banding of anyone who sleeps near them too long. I suggest resting somewhere darker if a long rest is on the schedule.",
|
||||
"The Hollow Crown — the one the Hollow King wears, the one that drops if you beat him — is not the source of the corruption. It is a symptom. The source is older and is not in this zone. I won't say more, partly because I doesn't fully know.",
|
||||
"There used to be a road through this forest. The road is still here. Nobody uses the road. I find the road instructive — it goes exactly where it always went, but the going-where part stopped working when the forest stopped behaving like a forest.",
|
||||
}
|
||||
76
internal/flavor/zone_goblin_warrens_flavor.go
Normal file
76
internal/flavor/zone_goblin_warrens_flavor.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_goblin_warrens_flavor.go
|
||||
// Tier 1 zone flavor — Goblin Warrens. Additive only. Pools sampled by
|
||||
// internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Goblin Warrens (Hobgoblin Warchief)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryWarrens = []string{
|
||||
"The chamber widens. A hobgoblin in lacquered scale stands at the center, arms folded, watching the door. The lesser goblins go quiet. I note the silence is the part you should be paying attention to.",
|
||||
"A war-banner hangs from the ceiling — three clans stitched together with rough thread. Beneath it, a Warchief turns to face you with the slow patience of someone who has done this part a hundred times. I straighten up out of habit.",
|
||||
"The corridor opens into a drilling ground. Goblins in formation. A Hobgoblin barking orders that stop the moment your boot hits the threshold. I have seen this exact composition in Shining Force and find the parallel unhelpful.",
|
||||
"You smell the polish before you see the armor. The Warchief's blade is oiled, his straps are tight, his stance is correct. I acknowledge, with reluctance, that this one was trained.",
|
||||
"A circle of torches. A throne of stacked shields. The hobgoblin seated on it does not stand. He gestures, single-finger, in a way I universally translates as 'come here.' You go there.",
|
||||
"The room has been cleared for combat. Rugs rolled, braziers spaced evenly, sand on the floor for grip. I respect the preparation and am professionally annoyed by it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Grol the Unbroken
|
||||
// Used as a one-line cinematic suffix to BossEntryGrol when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Surprise Attack: +2d6 if player has not acted this combat.
|
||||
var GrolSurpriseAttackLines = []string{
|
||||
"Grol moves before you do. He always moves before you do. I note — too late — that the first hit in this fight is his.",
|
||||
"The cleaver comes around in an arc that started before you were in the room. I wince in advance.",
|
||||
"He's not waiting for the fight to start. The fight starts when he says it does. I mark the lesson and file it under 'next time.'",
|
||||
}
|
||||
|
||||
// Heart of Hruggek: crits deal max damage (no roll).
|
||||
var GrolHeartOfHruggekLines = []string{
|
||||
"Grol crits and the dice don't roll. I have seen this Bugbear blessing once before. The number is the maximum number, every time, no negotiation.",
|
||||
"His critical lands flat-max. No spread, no luck — just Hruggek's blessing collecting on a long-overdue debt. I note the damage with grim respect.",
|
||||
"The crit hits like a Final Fantasy 'Berserk' status — capped, deterministic, unkind. I skip the roll and write the maximum.",
|
||||
}
|
||||
|
||||
// Terrifying Roar: 1/combat; allies +2 to hit for 2 turns; player WIS DC 13 or Frightened.
|
||||
var GrolTerrifyingRoarLines = []string{
|
||||
"Grol throws his head back and roars. The room shakes. The goblins around him stand straighter. I say: 'WIS save. Now.'",
|
||||
"The roar isn't a sound — it's a pressure change. Your knees know about it before your ears do. I respect the technique and ask you to roll Wisdom.",
|
||||
"He bellows once. The torchlight bends. The goblins answer with cheers that sound rehearsed. I note the buff is up; you have two turns to outlast it.",
|
||||
}
|
||||
|
||||
// GrolSignatureCallouts — combined pool the boss-entry composer samples
|
||||
// from. Concrete ability pools above are kept distinct so future per-turn
|
||||
// ability hooks (D6) can wire to a specific trigger.
|
||||
var GrolSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(GrolSurpriseAttackLines)+len(GrolHeartOfHruggekLines)+len(GrolTerrifyingRoarLines))
|
||||
out = append(out, GrolSurpriseAttackLines...)
|
||||
out = append(out, GrolHeartOfHruggekLines...)
|
||||
out = append(out, GrolTerrifyingRoarLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Goblin Warrens
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesWarrens = []string{
|
||||
"The Warrens are old. Not goblin-old — older. The tunnels predate the goblins and the goblins know it. They've widened doorways meant for something taller and braced ceilings meant for something heavier. I have theories about what was here first. None of them are reassuring.",
|
||||
"Grol united the three clans by killing each of their war-chiefs in single combat, in order, in one afternoon. The fourth clan declined to send a chief and pledged loyalty by letter. I find the letter, professionally, very funny.",
|
||||
"The graffiti on the walls dates the occupation. Goblins date by leaders and the names go back six generations. I can read goblin script when pressed and note that the third-oldest name is one a paladin order would still recognize.",
|
||||
"The Merchant's Road above doesn't know about the Warrens. The Merchant's Road above used to know about the Warrens. I was here when it stopped knowing, and note that the people in charge of Knowing About Things were the first to disappear.",
|
||||
"Hobgoblins hold formation; goblins hold grudges; bugbears hold pretty much anything they pick up. I offer this not as a joke but as a tactical primer that will save you a lot of HP if you remember it.",
|
||||
"The belt Grol wears is not a trophy. It is a contract. Each tooth on it is a debt some troll forgave when the wearing of the belt began, and the debts run in both directions. I strongly suggests not breaking the belt unless you mean to.",
|
||||
}
|
||||
108
internal/flavor/zone_manor_blackspire_flavor.go
Normal file
108
internal/flavor/zone_manor_blackspire_flavor.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_manor_blackspire_flavor.go
|
||||
// Tier 3 zone flavor — Haunted Manor of Blackspire. Additive only. Pools
|
||||
// sampled by internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go ships RoomEntryHauntedManor and that
|
||||
// pool is wired in dnd_zone_narration.go. This file adds the boss-entry
|
||||
// pool, the elite-room intros (Revenant), boss ability callouts for Lord
|
||||
// Aldric Blackspire, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — Lord Aldric Blackspire
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryAldricBlackspire = []string{
|
||||
"The portrait above the mantel was not finished — the painter clearly stopped partway through the eyes. The eyes in the portrait are now finished. They were finished by being looked through. Lord Aldric Blackspire steps out of the frame the way a man steps out of a coat: practiced, unhurried, the gesture of someone who has done this many times. His clothes are seventy years out of fashion and immaculate. 'You're early,' he says, and the word is polite the way a knife is polite — a tool that has not yet committed to its purpose. I say, very quietly, 'That's a vampire. The signet on his hand is not jewelry. Don't take what he offers.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Haunted Manor (Revenant)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryManorBlackspire = []string{
|
||||
"The chamber ahead was a study, once. The desk is still set for work — inkwell uncapped, ledger open, a pen laid down mid-sentence. The man at the desk turns his head with the slow patience of someone who has been waiting at this desk for forty years for one specific person to walk in. He is not looking at you. He is looking past you, at someone who isn't there. Yet. I identify the silhouette and say: 'Revenant. He's not here for us. He's here for whoever wronged him. Try not to look like them.'",
|
||||
"You enter what appears to be a guest bedroom. The bed is made. The candles are lit. A man sits on the edge of the bed with his back to you, perfectly still. He has been perfectly still for a long time. When he turns, only his head turns, and I note the impossible angle and file it under 'revenant — directed undead, single purpose, finite lifespan, very patient.' The patience is the part that worries me.",
|
||||
"The hallway dead-ends in a chapel. There is a kneeling figure at the altar. The candles around him are unlit but somehow casting shadows. He has been praying since before the manor was haunted, and the prayer was not a request — it was a contract. I say: 'Revenant. The contract is the encounter. Break the contract, end the fight.'",
|
||||
"A library. The man at the table is reading a book that has no pages. He is reading it carefully. He looks up when you enter and his expression does something that suggests he was expecting a different visitor and is willing to make do. I note the revenant's hands — the bones in the wrong places, the joints set with intent — and recommend ranged engagement until the geometry of the threat is clear.",
|
||||
"The ballroom is empty except for one figure standing in the exact center, not dancing, not waiting, simply standing. The chandeliers above him are not moving. They should be moving — they were moving in the last room — and the fact that they have stopped where this figure is standing tells me everything I need to know. 'Revenant in the middle of a stilled room,' I say. 'He brought the silence with him.'",
|
||||
"A nursery. Untouched. The cradle still rocks. The figure beside the cradle was, in life, the sort of man people did not survive being wronged by. In death he has been more thorough. I note the revenant's grip on the cradle's edge and suggest, quietly, that whatever happened in this room is not the encounter — the encounter is what walks out of it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Lord Aldric Blackspire
|
||||
// Used as a one-line cinematic suffix to BossEntryAldricBlackspire when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Multiattack: Unarmed Strike + Bite (life drain) every turn.
|
||||
var AldricMultiattackLines = []string{
|
||||
"Aldric multiattacks every turn — Unarmed Strike, then Bite. The bite drains. Whatever HP he takes from you, he keeps. I say: 'He's a battery with manners. Don't let him charge.'",
|
||||
"Two hits a turn, and the second one is the one that funds the rest of the fight. The bite returns HP to him equal to the damage dealt. I note that this changes the math on burst-vs-sustain considerably.",
|
||||
"Strike, then bite. The bite is the resource transfer. I file this under 'fights where defense is offense' — every point of damage you avoid is a point he doesn't get to take.",
|
||||
}
|
||||
|
||||
// Charm: WIS DC 17 or Charmed for 24h (broken by damage).
|
||||
var AldricCharmLines = []string{
|
||||
"He looks at one of you and the look is a question and the question is being answered without your permission. WIS DC 17. On a fail: charmed for twenty-four hours, broken by damage. I say: 'High DC. Have someone designated to slap the charmed party once.'",
|
||||
"Charm. WIS DC 17. The duration is twenty-four hours and the only out is taking damage. I note the irony — the cure is in the room, applied by your own party, in the hardest possible way.",
|
||||
"Aldric's gaze settles. WIS save now. Failure is a long-term problem — a full day of charm — and the only solvent is a friendly hit. I file this under 'social-coded crowd control with a violent escape clause.'",
|
||||
}
|
||||
|
||||
// Children of the Night (1/combat): summons 2d6 Bat Swarms or 3d6 Rats.
|
||||
var AldricChildrenLines = []string{
|
||||
"Once per fight he calls. Bats from the chimney, rats from the walls — 2d6 swarms or 3d6 rats, GM's choice, both bad. I say: 'AoE clears the adds. Don't let them stack tokens on whoever's charmed.'",
|
||||
"Children of the Night. The walls produce. The ceiling produces. I track the adds and recommend an AoE before the swarms occupy enough squares to be a separate problem.",
|
||||
"He raises a hand and the manor answers. The room fills. Bats or rats or both. I note this is a one-time effect per combat — survive the wave and the room belongs to you again.",
|
||||
}
|
||||
|
||||
// Mist Form: at 0 HP retreats to coffin; must destroy coffin in 30 turns or fully regenerates.
|
||||
var AldricMistFormLines = []string{
|
||||
"Drop him to zero and he doesn't drop — he goes to mist, retreats to a coffin somewhere in the manor. Thirty turns to find and destroy it. I say: 'Find the coffin first, fight the count second. The order matters.'",
|
||||
"Mist Form. At 0 HP he becomes mist and goes home. Home is a coffin. The coffin is somewhere in the manor and you have thirty turns to break it. I file this under 'zero is not death, zero is a timer.'",
|
||||
"Like the Castlevania bosses that didn't actually die — except those games gave you the next room. This one gives you a thirty-turn coffin hunt. I respect the design and recommend scouting on the way in.",
|
||||
}
|
||||
|
||||
// Phase 2 (Mist destroyed): all attacks have advantage; AoE Charm.
|
||||
var AldricPhaseTwoLines = []string{
|
||||
"Coffin destroyed. He returns furious. All attacks have advantage now and his Charm goes AoE — every party member, WIS DC 17 each turn. I say: 'You won the long game. The short game just got shorter.'",
|
||||
"Phase two: no more retreat, no more singles. AoE charm, advantage on every swing. I track the saves and remind you that advantage on the boss means disadvantage on your continuing-to-stand.",
|
||||
"Without the coffin, Aldric's restraint is gone. The fight goes loud. Charm sweeps the room each turn. I stop narrating long enough to breathe and say: 'Burn him.'",
|
||||
}
|
||||
|
||||
// AldricSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var AldricSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(AldricMultiattackLines)+
|
||||
len(AldricCharmLines)+
|
||||
len(AldricChildrenLines)+
|
||||
len(AldricMistFormLines))
|
||||
out = append(out, AldricMultiattackLines...)
|
||||
out = append(out, AldricCharmLines...)
|
||||
out = append(out, AldricChildrenLines...)
|
||||
out = append(out, AldricMistFormLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Haunted Manor of Blackspire
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesManorBlackspire = []string{
|
||||
"The Blackspire line was old before it was wealthy and wealthy before it was cursed. The portraits are in chronological order — I have counted them. The expressions get worse as the years go on. By the seventh portrait the painter has stopped pretending the family is well.",
|
||||
"Aldric was the seventh and the last. He was not the first to make the deal — the deal had been made twice already, by his grandmother and by his uncle — but he was the first to honor it past the term of his own life. I consider this the kind of stubbornness that becomes a curse on its own, before any vampires are involved.",
|
||||
"The manor wasn't always haunted. It became haunted on a specific night, in a specific hour, and the clock on the mantel still shows that hour. I note the clock isn't broken. The clock is keeping correct time for that one night and refusing to acknowledge any other.",
|
||||
"The portraits are not paintings. The pigment is correct, the technique is correct, the canvases are correct — but the things behind the eyes are not. I have watched a portrait blink. I decline to elaborate on which one.",
|
||||
"The Blackspire signet ring — the one that drops if you beat Aldric — is not enchanted in any conventional sense. It is recognized. The undead in this region have been raised under it, contracted under it, organized under it. They will not attack the wearer unless attacked first. I find this both useful and uncomfortable.",
|
||||
"The deed to the manor changes hands and the manor refuses each transfer. Eleven owners on paper since the curse settled. Eleven sets of estate records. I have seen the lawyers' files. The lawyers are no longer in the profession.",
|
||||
"The vampire spawn are not converts in the romantic sense — they are the previous owners. Each buyer who didn't leave became staff. I note which uniforms match which historical fashions and stop counting at eleven, which is the correct number.",
|
||||
}
|
||||
117
internal/flavor/zone_sunken_temple_flavor.go
Normal file
117
internal/flavor/zone_sunken_temple_flavor.go
Normal file
@@ -0,0 +1,117 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_sunken_temple_flavor.go
|
||||
// Tier 2 zone flavor — Sunken Temple of Dar'eth. Additive only. Pools
|
||||
// sampled by internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool
|
||||
// or a BossEntry pool for this zone, so both are defined here. Elsewhere
|
||||
// (Goblin Warrens, Crypt of Valdris, Forest of Shadows), zone files only
|
||||
// add elite/signature/lore overlays — the canonical file owns RoomEntry.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — Sunken Temple of Dar'eth
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntrySunkenTemple = []string{
|
||||
"You step into water that's been in this room for thirty years. The floor is tiled and slick. The pillars are barnacled at chest height — that's the old waterline. The new waterline is at your ankles. I note the temple is partway through deciding which it prefers.",
|
||||
"The chamber is half-flooded and half-lit. Salt in the air, salt on the walls, salt in places nothing oceanic should be reaching. I file this under 'unwell' and proceed.",
|
||||
"Glyphs cover the far wall in a script that doesn't match anything I recognize. The angles are wrong on purpose. It keeps reading anyway, because it always tries, and stops when the reading starts to feel reciprocal.",
|
||||
"The water in this room is not moving. Not the way still water doesn't move — the way a held breath doesn't move. I suggest not disturbing it more than necessary.",
|
||||
"Pillars rise from water that goes deeper than the room should allow. I test the depth with the haft of a polearm and stop at the point where the haft stops finding bottom.",
|
||||
"A vaulted ceiling that's mostly intact. Water pools in places it shouldn't, drips from places it can't be coming from. I identify the temple as 'wet on principle' and leave it at that.",
|
||||
"The mosaics on the floor are still legible under the water. They show a procession toward something that is decidedly not the god the temple was built for. I note the mosaics are facing inward — they were laid for the thing inside, not for visitors.",
|
||||
"You hear something move in the water. Not a splash — a displacement. The kind of displacement that happens when a large thing decides to be in a different part of the room than it was a moment ago. I act unbothered and am bothered.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — The Dreaming Aboleth
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryDreamingAboleth = []string{
|
||||
"The chamber opens onto a pool that has no business being this large in a building this size. Something underneath the surface shifts, and the surface is suddenly the smallest part of what you're looking at. The Aboleth does not surface. It does not need to. Its mind arrives in your mind first, polite, ancient, deeply uninterested in your comfort. 'Welcome,' it says, and the word is in your voice. I say, very evenly, 'Don't agree to anything. Don't answer questions. Roll initiative.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — Sunken Temple (Water Elemental)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntrySunkenTemple = []string{
|
||||
"The water in the chamber ahead is moving. Not currents — purpose. It's gathering toward a center, taking on a shape, deciding what it wants its arms to look like this time. I say: 'Water elemental. The room is the enemy.'",
|
||||
"You enter a hall where the floor is six inches of standing water and the standing water is, on closer inspection, watching you. A column of it rises and walks toward you with the deliberate gait of something that did not learn to walk from a creature with legs. I respect the originality.",
|
||||
"The room hums at a frequency you feel in your teeth. The water rises into a vaguely humanoid pillar and the pillar steps forward. I note this is the kind of fight where 'fall back to higher ground' is not a useful idea — there is no higher ground. The room is the ground.",
|
||||
"A column of water becomes a hand becomes a torso becomes a thing intent on contact. I file water elementals under 'fights where lightning damage is fun' and ask who has lightning damage today.",
|
||||
"The temple's oldest mechanism activates as you enter — a font in the ceiling drains, the floor floods another inch, and the new water gathers itself into a sentry. I say: 'It's been waiting since the day they built this. Be brief.'",
|
||||
"Water rises in a vortex in the chamber's center. The vortex slows. The vortex opens what passes for eyes. I respect the staging and am not entertained by it.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — The Dreaming Aboleth
|
||||
// Used as a one-line cinematic suffix to BossEntryDreamingAboleth.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Tentacle Multiattack: 3 hits; on-hit Diseased (no magical healing 24h until cured).
|
||||
var AbolethTentacleMultiattackLines = []string{
|
||||
"Three tentacles, three rolls. Each on-hit risks Diseased — no magical healing for 24 hours until cured. I say: 'Cleric's tools come back online tomorrow. Survive today.'",
|
||||
"The Aboleth's tentacles arrive in sequence, three of them, the hits compounding. The disease isn't the damage — the disease is the design. Magical healing fails until you cleanse. I file this under 'durability problem.'",
|
||||
"Three attacks, one turn. Any landing tentacle leaves a mark that locks out magical healing. I suggest potions, rest, and the kind of patience that pretends to be patience but am mostly grim arithmetic.",
|
||||
}
|
||||
|
||||
// Enslave: recharge 6; WIS DC 14 or Charmed; player skips turn, drifts toward Aboleth.
|
||||
var AbolethEnslaveLines = []string{
|
||||
"The Aboleth speaks in your head and the speech is a question. WIS DC 14 or you spend your turn walking, peacefully, toward the water. I say: 'Recharge 6. It will try this again. Make the save.'",
|
||||
"Enslave. The word in your head sounds like your own thought. It isn't. WIS save now. On a fail, your turn becomes the Aboleth's turn and you become the wrong piece on the wrong side of the board.",
|
||||
"Charm-class effect, but worse than charm — it's a directional pull. You drift toward the pool whether you want to or not. I shout your save aloud so you don't mistake it for an idea you had.",
|
||||
}
|
||||
|
||||
// Mucus Cloud: melee attackers CON DC 14 or skin→membrane (6d6 acid if not submerged).
|
||||
var AbolethMucusCloudLines = []string{
|
||||
"Anyone in melee range gets a CON DC 14. Fail and your skin starts converting to a membrane that needs water to stay viable. Out of water it's 6d6 acid per turn. I say: 'Don't punch the fish.'",
|
||||
"The Aboleth's mucus does something to skin that skin should not do. Membrane formation. Submerged is fine. Dry is the problem — 6d6 acid until it ends. I revise the engagement plan: ranged only.",
|
||||
"Like the slime debuff in old console RPGs — except this one wants you to stay in the water and dissolves you if you don't. I respect the elegance of the threat and dislike everything else about it.",
|
||||
}
|
||||
|
||||
// Legendary Actions (3/round): Detect / Tail Swipe (2 LA) / Psychic Drain (3 LA, max-HP cut).
|
||||
var AbolethLegendaryActionLines = []string{
|
||||
"The Aboleth gets three legendary actions per round. Detect (1), Tail Swipe (2), Psychic Drain (3, cuts max HP). I track the budget and remind you when it spends.",
|
||||
"Legendary Actions: 3 budget. Detect is cheap and reads your hand. Tail Swipe is medium and hurts. Psychic Drain is expensive and lasting — your max HP is the price. I suggest bursting it before it banks.",
|
||||
"Three LA per round. The Aboleth will spend them on you and I will narrate each one with the specific resigned cadence of a GM who would prefer the boss had two LA.",
|
||||
}
|
||||
|
||||
// AbolethSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Legendary action lines stay in the pool; Aboleth has no phase-two split.
|
||||
var AbolethSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(AbolethTentacleMultiattackLines)+
|
||||
len(AbolethEnslaveLines)+
|
||||
len(AbolethMucusCloudLines)+
|
||||
len(AbolethLegendaryActionLines))
|
||||
out = append(out, AbolethTentacleMultiattackLines...)
|
||||
out = append(out, AbolethEnslaveLines...)
|
||||
out = append(out, AbolethMucusCloudLines...)
|
||||
out = append(out, AbolethLegendaryActionLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — Sunken Temple of Dar'eth
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesSunkenTemple = []string{
|
||||
"Dar'eth was a sea god before the cult that built the temple decided he wasn't sea god enough and started worshipping something else under his roof. The temple kept the name. The thing being worshipped did not. I note the bait-and-switch with professional appreciation.",
|
||||
"The tide that withdrew thirty years ago didn't withdraw on its own. It was held back. By what — by whom — is not advertised, but I have seen the kind of pressure-line on the outer walls that suggests a decision was made and something is still making it.",
|
||||
"The Aboleth has been here longer than the temple has been a temple. The temple was built around the Aboleth. The cult thought they were containing it. The Aboleth thought they were furnishing the room. Both parties remained satisfied with the arrangement until the cult ran out of cultists.",
|
||||
"The Kuo-toa here are not the local stock. They were brought. They are loyal in the way that a mind not entirely their own can be loyal. I consider their loyalty a symptom and the Aboleth the cause.",
|
||||
"Aboleth memory is collective and ancient — it remembers things from before there were people to remember things. I find this genuinely unsettling because the Aboleth's memory of you is identical to its memory of the cult and the temple's first architect: 'a thing that was here.' Past tense, even when you're standing right there.",
|
||||
"The mucus residue in the upper chambers is recent. The Aboleth has been moving through the temple in the last few weeks — not just sitting in the central pool. Something has been making it restless. I suggest not being the thing that has been making it restless.",
|
||||
"The phylactery shard you'll find here is not Valdris's. It belongs to a different lich entirely. I note there are now two phylacteries in your itinerary and refuse to draw connecting lines on the map yet.",
|
||||
}
|
||||
108
internal/flavor/zone_underdark_flavor.go
Normal file
108
internal/flavor/zone_underdark_flavor.go
Normal file
@@ -0,0 +1,108 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_underdark_flavor.go
|
||||
// Tier 4 zone flavor — The Underdark. Additive only. Pools sampled by
|
||||
// internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go ships RoomEntryUnderdark and that
|
||||
// pool is wired in dnd_zone_narration.go. This file adds the boss-entry
|
||||
// pool, the elite-room intros (Roper), boss ability callouts for Ilvaras
|
||||
// Xunyl, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — Ilvaras Xunyl, Drow High Priestess
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryIlvaras = []string{
|
||||
"The cathedral is round and the round is wrong — drow architecture leans, drow architecture rises, drow architecture does not gather like this. The room was built for one purpose and the purpose is standing at the altar with her back to you. She does not turn when you enter. The trophies on the wall turn first — four sets of weapons, hung at four heights, each set arranged with the care of someone who learned the names of their previous owners and kept the names. 'Five,' Ilvaras Xunyl says, without turning, in a voice that is making itself heard in your head and not in the air. 'I had run out of wall.' She turns. The smile is the smile of someone who has been waiting for the inconvenience of a fifth wall. I say, very low: 'Drow High Priestess. Lolth's favour. Don't accept the offer. There will be an offer.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — The Underdark (Roper)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryUnderdark = []string{
|
||||
"The cavern ahead is a forest of stalagmites — dozens of them, calf-thick to wagon-thick, irregular spacing, the kind of natural architecture that gives an ambush every advantage. I count them, then stop counting, then note that one of them has been counted twice from different angles and that one is not a stalagmite. 'Roper,' I say. 'Six tendrils. Reels you in. Pick the wrong stalagmite and the room becomes one fight at five different ranges.'",
|
||||
"You enter a chamber where the ceiling drips minerals that have built columns down to the floor — pillars of pale stone, formed over thousands of years, perfectly natural, all of them. Almost all of them. I identify the off-color column near the back of the room and say: 'False appearance until it isn't. Stay out of grapple range until the tendrils show.'",
|
||||
"A grotto. Calm water. A field of stalagmites breaking the surface. Beautiful in the way the deep places are beautiful, which is to say it photographs well and houses something terrible. The stalagmite nearest the path has not been there in any other map I have consulted. I note the inconsistency and recommend ranged options.",
|
||||
"The corridor widens into a cavern with twelve pillars holding the ceiling — six of them are dwarven masonry, six of them are natural stone, and one of the natural ones is breathing. The breath is shallow. The patience is the giveaway. I say: 'Roper. Engage the column you can see, not the columns you assume.'",
|
||||
"You step into a room where the stalagmites have been arranged — almost arranged — into something like a dining hall. The arrangement is not deliberate. The arrangement is the result of one of the stalagmites occasionally moving in its sleep. I file this under 'apex ambush predator with tenure' and recommend not waking it gently.",
|
||||
"A toll-bridge cavern: a narrow path through a field of standing stones, each one twice your height, none of them moving. The toll is paid by whichever party member walks last. I track the sightlines, names the column with the wrong shadow, and say: 'Don't be the last one.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Ilvaras Xunyl
|
||||
// Used as a one-line cinematic suffix to BossEntryIlvaras when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Spells: Flame Strike / Dispel Magic / Divine Word (CHA DC 18) / Insect Plague.
|
||||
var IlvarasSpellLines = []string{
|
||||
"She has the priest list and she leads with Flame Strike and Insect Plague — both AoE, both back-to-back if the initiative is unkind. I say: 'Spread out. Don't bunch under a column. Insect Plague is a movement tax that compounds.'",
|
||||
"Divine Word, CHA DC 18, and the failure tree is brutal — Deafened, Blinded, Stunned, or killed depending on remaining HP. I file this under 'never fight a priest below half HP' and ask who has the best CHA save.",
|
||||
"Dispel Magic on your buffs as soon as she rolls initiative. I track which concentration spells went up and recommend shielding the controller until the dispel is spent.",
|
||||
}
|
||||
|
||||
// Lolth's Favour: 1/combat auto-succeed a failed save.
|
||||
var IlvarasFavourLines = []string{
|
||||
"Once per fight she takes a save you thought you'd won and converts the failure into a success. I say: 'Time your hard CCs after the favour is spent — don't waste a Hold Person on the round she's still got it.'",
|
||||
"Lolth's Favour. One free re-pass on a save. I note the timing matters more than the damage — the spell you wanted to land is the spell she wanted to skip.",
|
||||
"She converts a failed save into a success without rolling. The favour is single-use. I file this under 'plan two openings' and remind the party that the second one is the real one.",
|
||||
}
|
||||
|
||||
// Summon Spiders (1/combat): 2d6 Giant Spiders fill the room.
|
||||
var IlvarasSpidersLines = []string{
|
||||
"She raises both hands and the room fills. Two-d-six Giant Spiders, all at once, all over the floor. I say: 'AoE clears the carpet. Don't get webbed before you cast it.'",
|
||||
"Summon Spiders. The carpet is no longer the carpet. I track the count and note that 2d6 in a confined cathedral is a different math than 2d6 in an open hallway.",
|
||||
"The spiders come from the cracks the way the rats came in Castlevania, except these are big enough to count individually and small enough to overwhelm. I recommend an early sweep and a clear line of retreat.",
|
||||
}
|
||||
|
||||
// Legendary Actions: Melee (1), Cast Cantrip (1), Drain Life (3 LA, 4d10 necrotic).
|
||||
var IlvarasLegendaryLines = []string{
|
||||
"She takes legendary actions at the end of every other turn. Melee for one, cantrip for one, Drain Life for three — and Drain Life is 4d10 necrotic that heals her. I say: 'Let her spend on the cheap ones. Bait the three-cost.'",
|
||||
"Three legendary points per round. The Drain Life is the one that makes the fight last longer than it should. I track her actions and remind the party that her healing comes out of yours.",
|
||||
"Legendary Actions: the always-on tax that punishes your turn order. I file this under 'priest economy' and note the cantrip option is what kills your back line if you let her keep three points unspent.",
|
||||
}
|
||||
|
||||
// Phase 2 (<35% HP): Lolth's Avatar overlay — +3 AC; spells cast at +1 slot level.
|
||||
var IlvarasPhaseTwoLines = []string{
|
||||
"Below thirty-five percent the avatar overlay drops on her. AC up three, every spell up one slot. I say: 'Phase shift. The damage breakpoint just moved. Don't ration your hard hits past this point.'",
|
||||
"Phase two: Lolth answers the prayer. The room dims. Her shadow is wrong now — too many limbs, too little symmetry. AC bumps, slot levels bump, the whole encounter tier goes up a half-step. I track the new numbers and recommend burning every save-or-suck you've still got.",
|
||||
"Avatar overlay. The priest is still there but the priest is also a delivery vehicle now. I file this under 'don't survive into phase three — there isn't one, but it'll feel like one' and ask who has the burst.",
|
||||
}
|
||||
|
||||
// IlvarasSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var IlvarasSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(IlvarasSpellLines)+
|
||||
len(IlvarasFavourLines)+
|
||||
len(IlvarasSpidersLines)+
|
||||
len(IlvarasLegendaryLines))
|
||||
out = append(out, IlvarasSpellLines...)
|
||||
out = append(out, IlvarasFavourLines...)
|
||||
out = append(out, IlvarasSpidersLines...)
|
||||
out = append(out, IlvarasLegendaryLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — The Underdark
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesUnderdark = []string{
|
||||
"The Underdark is not a place you reach. The Underdark is a depth you continue to. I note the distinction matters: the surface dungeons end. This one keeps going past the part you came to fight.",
|
||||
"Drow cities are not built — they are negotiated. Each spire, each platform, each corridor is the result of a House agreeing to share a wall with a House it would prefer to murder. I respect the architecture more than the architects.",
|
||||
"Ilvaras Xunyl's House fell two centuries ago. She is not running it. She is running what's left. The four trophies on her wall are not from the surface — three are from rival drow priestesses, in chronological order, and one is a self-portrait. I decline to elaborate on what it means that the self-portrait is hung as a trophy.",
|
||||
"The mind flayers are not native here. They came down from somewhere further down. The drow tolerate them in the same way someone tolerates a roof leak they cannot reach — present, problematic, easier to live with than to fix. I file this under 'old grievances' and note the leak is getting worse.",
|
||||
"The phosphorescent fungi grow in patterns that match the constellations of the surface sky from sixteen thousand years ago. I have checked the pattern. The drow have not. The drow do not know what surface stars are. The pattern is older than the drow.",
|
||||
"Lolth is not the only listener down here. She is the loudest. The other listeners are quieter and more patient and I suggest not naming any of them in the cathedral, not even as a warning, not even at low volume.",
|
||||
"The Roper that ate the last expedition did not attack them. They walked into it because they were following a Drow scout who knew exactly where to lead them. The scout is named, in drow records, and I decline to repeat the name out of professional courtesy to the dead.",
|
||||
}
|
||||
127
internal/flavor/zone_underforge_flavor.go
Normal file
127
internal/flavor/zone_underforge_flavor.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// zone_underforge_flavor.go
|
||||
// Tier 3 zone flavor — The Underforge. Additive only. Pools sampled by
|
||||
// internal/plugin via deterministic per-run, per-room hashing.
|
||||
//
|
||||
// Voice rules (from gogobee_dungeon_zones.md §3.3):
|
||||
// • Third person for description; second person for outcomes.
|
||||
// • Boss callouts get a beat of cinema. Don't overrun.
|
||||
// • TwinBee references the right era — NES, SNES, arcade. Not modern.
|
||||
//
|
||||
// The canonical twinbee_gm_flavor.go does not (yet) ship a RoomEntry pool
|
||||
// or a BossEntry pool for this zone, so both are defined here. This file
|
||||
// also adds elite-room intros (Helmed Horror), boss ability callouts for
|
||||
// Emberlord Thyrak, and zone-specific lore.
|
||||
|
||||
package flavor
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ROOM ENTRY — The Underforge
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this zone.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var RoomEntryUnderforge = []string{
|
||||
"The chamber opens onto a foundry the size of a city block. Conveyor channels of slow-cooling iron. Bellows the height of houses. Anvils arranged for workers who haven't been here in three centuries. I note the bellows are still moving. The bellows are moving on their own.",
|
||||
"You step out onto a basalt walkway over a river of lava. The heat is impossible. The walkway is engineered to hold. I have respect for dwarven engineering and a healthy suspicion of any walkway suspended over molten rock. The two coexist comfortably.",
|
||||
"A casting hall. The molds are still in place — armor pieces, weapon halves, parts of things I cannot identify by silhouette. One mold contains a shape that's still glowing red. Something was cast recently. I file this under 'occupied' and keep moving.",
|
||||
"The corridor smells of hot iron and an absence — the absence of beards, of song, of the specific noise dwarven forges make when they're being tended properly. I note the silence has a shape. The shape is not reassuring.",
|
||||
"You enter a chamber where the walls are arranged in tiers and each tier is a row of forges and each forge is lit. Nobody is at the forges. The hammers are striking on their own, in tempo, with the regularity of something that has not stopped striking in a very long time.",
|
||||
"A vault door, dwarf-made, three feet thick, ajar. Not blown — ajar. Someone opened it from the inside. I note which side the hinges are on and consider which direction the something that opened it was facing. I do not like the answer.",
|
||||
"The walkway crosses a chasm of pure heat-shimmer. You can see the far side, more or less. You cannot see what's between, except by what it does to the air. I suggest not lingering on the bridge — lingering bridges are how lava bosses get introduced.",
|
||||
"A barracks. Bunks made. Boots paired and lined up. Beards on the floor where their owners stopped being. I note the beards have not been disturbed. Whatever happened, happened cleanly and at speed and I do not want to find out what was in a hurry.",
|
||||
"The chamber ahead is a forge gallery — nine forges in a half-circle, all of them lit, all of them tended by armored figures that don't move when you enter. The figures are not waiting to fight. The figures are working. I note which mode you don't want to interrupt and which mode you absolutely cannot afford to interrupt.",
|
||||
"Heat radiates from the floor. Not from anything on the floor — from the floor itself. I diagnose the cause as 'a forge directly underneath us, fully active, somehow still operating without dwarves' and find none of those words individually reassuring.",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ENTRY — Emberlord Thyrak
|
||||
// Defined here because no entry exists in twinbee_gm_flavor.go for this boss.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var BossEntryEmberlordThyrak = []string{
|
||||
"The chamber is the largest forge I have ever narrated, and it has narrated several. Nine furnaces in a ring. A central anvil the size of a wagon. The figure at the anvil is twelve feet of articulated iron and inset rune-stones that pulse to a tempo I can feel in its plating. The hammer comes down. The strike rings the room. The figure does not turn — finishes the strike, then turns. The eyes are not eyes. They are vents. 'Visitor,' Thyrak says, and the word is a furnace door opening. I take a half-step back and say, very evenly, 'Forge-golem. Three centuries of self-improvement. Don't fight him on the lava.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// ELITE ROOM ENTRY — The Underforge (Helmed Horror)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var EliteRoomEntryUnderforge = []string{
|
||||
"The armory ahead is full. Stands and stands of polished dwarven plate, weapons racked in order, the kind of inventory that takes a lifetime to build. One suit at the far end is wearing itself. It is also holding its weapon at guard. I identify the silhouette and say: 'Helmed Horror. Spell-immune to a list. Hit it with metal, not magic.'",
|
||||
"A hall of dwarven banners, each lit by its own brazier. At the far end, a suit of armor stands in a place of honor. The suit pivots when you enter — the helmet first, then the torso, the way an aware thing acquires a target. I note the lack of a body inside and recommend physical damage above all else.",
|
||||
"You enter a workshop that is still in service. Hammers strike. Bellows pump. None of the workers are alive and none of them notice you, except the one nearest the door, which sets down its hammer and picks up a sword. I say: 'Helmed Horror. The room is its job. We are its interruption.'",
|
||||
"A small chapel, dwarf-made, dedicated to a god of the forge whose statue stands in armor in the alcove. The statue is not a statue. The statue moves first. I file this under 'ambush by liturgy' and note the immunity list does not include warhammers.",
|
||||
"The corridor narrows into a guardpost. There is a guard. The guard is articulated steel from a fashion three centuries old, and it has been at this post since the day the dwarves left. It greets you in a voice that is not coming from the mouth-slit — the mouth-slit is for venting heat. I straighten up. 'Sentry duty,' I say, 'taken to its conclusion.'",
|
||||
"A vault antechamber. Two suits of armor flank the inner door. They are alike in pattern, alike in posture, alike in the heat coming off them. The left one moves first by half a heartbeat. I track the asymmetry and say: 'One Helmed Horror is a fight. Two is a logistics problem. Pick a target.'",
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// BOSS ABILITY CALLOUTS — Emberlord Thyrak
|
||||
// Used as a one-line cinematic suffix to BossEntryEmberlordThyrak when combat starts.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Molten Strike: +4d6 fire on hit; target armor -1 AC until repaired (long rest).
|
||||
var ThyrakMoltenStrikeLines = []string{
|
||||
"Every hit lands +4d6 fire and pits your armor — AC -1 until you can repair, which requires a long rest. I say: 'The damage hurts. The AC penalty compounds. Don't take more than two if you can help it.'",
|
||||
"Molten Strike. The hammer doesn't just hit, it brands. Fire damage on top of physical, and your armor gets worse permanently for the encounter. I file this under 'attrition with paperwork.'",
|
||||
"Each connecting blow makes the next one easier for him. Your AC goes down. The fire damage goes through. I track the count and recommend taking the hit on something you weren't going to keep.",
|
||||
}
|
||||
|
||||
// Forge Breath: recharge 5–6; 30-ft cone, 10d6 fire, DEX DC 16 half.
|
||||
var ThyrakForgeBreathLines = []string{
|
||||
"The vents on his chest open. Thirty-foot cone, 10d6 fire, DEX DC 16 for half. I say: 'Don't bunch up. He'll get this back on a 5–6.'",
|
||||
"Forge Breath. He inhales — visibly — and the next exhale is a furnace. Cone, big DC, big damage. I mark the recharge and shout the spread order.",
|
||||
"Like the Bowser fire breath in 8-3 except wider and with a saving throw. Thirty-foot cone, half-damage on save. I note that 'half' of 10d6 is still meaningful and recommend not getting hit twice in a row.",
|
||||
}
|
||||
|
||||
// Living Forge: heals 15 HP/turn while standing on lava/forge tiles.
|
||||
var ThyrakLivingForgeLines = []string{
|
||||
"He heals fifteen a turn while standing on lava or forge tiles. I say: 'Move him off, or fight a boss who repairs faster than you can dent.'",
|
||||
"Living Forge. The lava floor is part of his kit. Pull him off it — shove, push, anything — and the regen stops. I file this under 'positioning is damage.'",
|
||||
"He's standing on a heat source and the heat source is fueling him. Fifteen HP a round, indefinitely. I track the regen and remind the party that knocking him into the open hallway is worth more than a good attack roll.",
|
||||
}
|
||||
|
||||
// Construct Resilience: immune poison/psychic/charm/exhaustion; resist non-magical physical.
|
||||
var ThyrakConstructLines = []string{
|
||||
"Immune to poison, psychic, charm, exhaustion. Resists non-magical physical. I say: 'Magic weapons earn their keep here. Mundane steel does half.'",
|
||||
"He's a construct first and a boss second. The whole control-effect column on your sheet is greyed out. I file this under 'fights you win by hitting harder, not smarter — the smart options have been removed.'",
|
||||
"Construct Resilience: the standard package. Magic damage gets through clean. Mundane physical gets cut in half. I suggest checking which weapons are actually magical before assuming.",
|
||||
}
|
||||
|
||||
// Phase 2 (<50% HP): Forge Breath recharge 4–6; spawns 2 Fire Elementals.
|
||||
var ThyrakPhaseTwoLines = []string{
|
||||
"At half HP the vents widen. Forge Breath recharges on 4–6 now and two Fire Elementals come up out of the floor. I say: 'Phase shift. The cone is twice as common and you have new targets. Pick a priority.'",
|
||||
"Half-health. The forge wakes the rest of the way up. Two Fire Elementals burst from the lava channels. Breath weapon is half a turn away on every roll now. I track the new add count and remind you that Thyrak is still the win condition.",
|
||||
"Phase two: the elementals come up the way bosses summon adds in every arcade beat-em-up — predictable, relentless, the kind of design that turns a fight into a war of attrition. I file the timing and ask who has fire resistance.",
|
||||
}
|
||||
|
||||
// ThyrakSignatureCallouts — combined pool for boss-entry suffix.
|
||||
// Phase-two lines stay separate (surfaced via dedicated phase-two helper).
|
||||
var ThyrakSignatureCallouts = func() []string {
|
||||
out := make([]string, 0,
|
||||
len(ThyrakMoltenStrikeLines)+
|
||||
len(ThyrakForgeBreathLines)+
|
||||
len(ThyrakLivingForgeLines)+
|
||||
len(ThyrakConstructLines))
|
||||
out = append(out, ThyrakMoltenStrikeLines...)
|
||||
out = append(out, ThyrakForgeBreathLines...)
|
||||
out = append(out, ThyrakLivingForgeLines...)
|
||||
out = append(out, ThyrakConstructLines...)
|
||||
return out
|
||||
}()
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// LORE — The Underforge
|
||||
// Sampled by !lore inside this zone (zone-specific pool, generic fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var LoreLinesUnderforge = []string{
|
||||
"Kharak Dûn was a forge-city, not a mine. The dwarves here didn't extract — they finished. Raw stock came in from a hundred lesser holds and left as the kind of work the rest of the world named after the holds. I note the distinction matters: this place was a destination, not a quarry.",
|
||||
"The dwarves left. They didn't die in place — they left. The boots in the barracks are paired because they were left paired, intentionally, on the last day. Somewhere, a clan-elder gave the order to walk out. I respect the discipline and am curious about the reason. The reason is not in any record I have read.",
|
||||
"Thyrak was not built to run the city. Thyrak was built to maintain the furnaces while the city ran itself. Three centuries of self-maintenance turned out to include self-improvement, and Thyrak has been improving the designs. I find this less terrifying than it sounds and more terrifying than it looks.",
|
||||
"The seal on the outer doors was set from the outside. The dwarves walked out, then closed the door behind them, then sealed it with the kind of binding that takes a clan to set. I note the bindings were aimed inward. They were sealing something in. The thing inside is the forge or the forge-golem or both — I decline to commit.",
|
||||
"The azers in the deeper chambers are not hostile by default. They are loyal to the forge. They will work with anyone the forge accepts, and they will burn alive anyone the forge rejects. I suggest not picking up the hammer at the central anvil unless you mean it.",
|
||||
"The flameskulls drifting in the upper galleries were dwarven mages, once. They volunteered. Their final task was to maintain the wards around Thyrak's casting chamber, and they have been at it ever since. I note the wards are still active and that this is, on balance, probably good news.",
|
||||
"Thyrak's core — the crystalline heart that drops on his death — is what made the self-improvement possible. The dwarves didn't install it. It was already there when they built the chassis. I find this implication concerning and suggest the core is not, technically, dwarven work.",
|
||||
"There is a second forge below this one. The 'bass note of something very large moving below' is not metaphor. The Underforge is the upper level. I will not speculate about the lower level except to note that it has not been opened in this run and would not be a small undertaking to open.",
|
||||
}
|
||||
@@ -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{
|
||||
@@ -555,7 +556,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "She is furious and will not forget this.",
|
||||
ID: "bj_beat_twinbee", Name: "Gotcha 🐝", Description: "They are furious and will not forget this.",
|
||||
Emoji: "🐝",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by blackjack plugin when beating the bot
|
||||
@@ -782,7 +783,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
err := d.QueryRow(
|
||||
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
|
||||
`SELECT best_streak FROM player_meta WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&streak)
|
||||
return err == nil && streak >= 7
|
||||
@@ -794,7 +795,7 @@ func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
var streak int
|
||||
err := d.QueryRow(
|
||||
`SELECT best_streak FROM adventure_characters WHERE user_id = ?`,
|
||||
`SELECT best_streak FROM player_meta WHERE user_id = ?`,
|
||||
string(u),
|
||||
).Scan(&streak)
|
||||
return err == nil && streak >= 30
|
||||
@@ -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 player_meta 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 player_meta 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 player_meta 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 player_meta WHERE user_id = ?`, string(u)).Scan(¤t, &best, &decayed)
|
||||
return err == nil && current >= 14 && decayed > 0
|
||||
},
|
||||
},
|
||||
|
||||
// ── Combat Moments ──────────────────────────────────────────────────
|
||||
{
|
||||
ID: "combat_near_death", Name: "Clutch", Description: "Won with less than 15% HP. Barely.",
|
||||
Emoji: "💔",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on near-death victory
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_pet_save", Name: "Good Boy", Description: "Your pet distracted the enemy at exactly the right moment.",
|
||||
Emoji: "🐾",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on pet whiff event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_sniper_kill", Name: "One Shot", Description: "Arina ended the fight before it started.",
|
||||
Emoji: "🎯",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on sniper kill
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_death_save", Name: "Sovereign's Reprieve", Description: "The crown said not today.",
|
||||
Emoji: "👑",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on death save event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_misty_clutch", Name: "Misty's Touch", Description: "Healed mid-combat. She was watching.",
|
||||
Emoji: "💚",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on Misty heal event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "combat_consumable_used", Name: "Prepared", Description: "Used a consumable in combat. Preparation pays off.",
|
||||
Emoji: "🧪",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on consumable use event
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_first_craft", Name: "Apprentice Herbalist", Description: "Crafted your first consumable. The wilds provide.",
|
||||
Emoji: "🌿",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on first successful craft
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "adv_craft_t5", Name: "Master Alchemist", Description: "Crafted a Tier 5 consumable. Nature bends to your will.",
|
||||
Emoji: "⚗️",
|
||||
Check: func(d *sql.DB, u id.UserID) bool {
|
||||
// Granted by combat bridge on T5 craft
|
||||
return false
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
351
internal/plugin/adv2_scenario_test.go
Normal file
351
internal/plugin/adv2_scenario_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Adv 2.0 scenario playthrough: drives a realistic player session against
|
||||
// a copy of the prod DB, verifying happy-path behavior end-to-end across
|
||||
// the !zone state machine, the !expedition multi-day system, and the
|
||||
// !harvest economy. SendDM is a no-op without a Matrix client, so the
|
||||
// asserts target persisted state. Logs are verbose (t.Logf) so the run
|
||||
// reads like a transcript.
|
||||
|
||||
// ── Scenario A — single-session zone run ────────────────────────────────────
|
||||
|
||||
func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@adv2-scenA:example")
|
||||
t.Cleanup(func() { cleanupZoneRuns(uid); cleanupExpeditions(uid) })
|
||||
|
||||
// Beefy L5 fighter so combat doesn't trivially TPK (still RNG, but 5
|
||||
// is well past goblin warrens' L1–L3 band).
|
||||
if err := createAdvCharacter(uid, "scenA"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 60, HPCurrent: 60, ArmorClass: 18,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
euro.Credit(uid, 500, "scenA bankroll")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// !zone (list) — should not error, no run created.
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, ""); err != nil {
|
||||
t.Fatalf("zone list: %v", err)
|
||||
}
|
||||
if got, _ := getActiveZoneRun(uid); got != nil {
|
||||
t.Fatal("zone list should not create a run")
|
||||
}
|
||||
|
||||
// !zone enter goblin_warrens
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "enter goblin_warrens"); err != nil {
|
||||
t.Fatalf("zone enter: %v", err)
|
||||
}
|
||||
run, err := getActiveZoneRun(uid)
|
||||
if err != nil || run == nil {
|
||||
t.Fatalf("expected active run after enter: %v", err)
|
||||
}
|
||||
t.Logf("entered %s — %d rooms, mood=%d, first room=%s",
|
||||
run.ZoneID, run.TotalRooms, run.DMMood, run.CurrentRoomType())
|
||||
if run.DMMood != 50 {
|
||||
t.Errorf("starting mood = %d, want 50", run.DMMood)
|
||||
}
|
||||
if run.CurrentRoom != 0 || run.CurrentRoomType() != RoomEntry {
|
||||
t.Errorf("first room not entry: idx=%d type=%s", run.CurrentRoom, run.CurrentRoomType())
|
||||
}
|
||||
|
||||
// !zone status (cheap smoke)
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "status"); err != nil {
|
||||
t.Fatalf("zone status: %v", err)
|
||||
}
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "map"); err != nil {
|
||||
t.Fatalf("zone map: %v", err)
|
||||
}
|
||||
|
||||
// Drive !zone advance until the run terminates (cleared, died, or
|
||||
// abandoned). Branches the test handles:
|
||||
// - Fork queued (Phase G branching graphs): commit with `!zone go 1`.
|
||||
// - Elite/Boss doorway (commit 886eb5a moved these off auto-resolve):
|
||||
// !advance now stops at the door and the player engages with
|
||||
// !fight, then resolves one round per !attack. The test mirrors
|
||||
// that: call !fight to open the session, !attack until the session
|
||||
// closes, then the next !advance clears the room and walks the
|
||||
// graph. Cap the per-fight !attack loop generously — a real fight
|
||||
// resolves in <20 rounds, but RNG can drag.
|
||||
maxSteps := (run.TotalRooms + 4) * 4
|
||||
const maxAttacksPerFight = 60
|
||||
clearedRoomTypes := []RoomType{}
|
||||
for step := 0; step < maxSteps; step++ {
|
||||
before, _ := getActiveZoneRun(uid)
|
||||
if before == nil {
|
||||
t.Logf("step %d: run terminated (cleared/died/abandoned)", step)
|
||||
break
|
||||
}
|
||||
prevType := before.CurrentRoomType()
|
||||
prevHP := c.HPCurrent
|
||||
if cur, _ := LoadDnDCharacter(uid); cur != nil {
|
||||
prevHP = cur.HPCurrent
|
||||
}
|
||||
t.Logf("step %d: in room %d/%d (%s), mood=%d, HP=%d",
|
||||
step, before.CurrentRoom+1, before.TotalRooms, prevType, before.DMMood, prevHP)
|
||||
if len(before.NodeChoices) > 0 {
|
||||
// A fork is queued from the previous advance — commit it first.
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "go 1"); err != nil {
|
||||
t.Fatalf("zone go step %d: %v", step, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Elite/Boss doorway: !advance won't progress past the door
|
||||
// without a won CombatSession for the encounter. Open the fight
|
||||
// if it isn't already open, then attack until the session
|
||||
// terminates. After the loop, fall through to !advance — the
|
||||
// won session lets advance clear the room and walk the graph;
|
||||
// a lost/fled session terminates the run on the next pass.
|
||||
if prevType == RoomElite || prevType == RoomBoss {
|
||||
sess, _ := getCombatSessionForEncounter(before.RunID, encounterIDForRoom(before.CurrentRoom))
|
||||
if sess == nil {
|
||||
if err := p.handleFightCmd(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("zone fight step %d: %v", step, err)
|
||||
}
|
||||
}
|
||||
// Drain the round loop. handleAttackCmd no-ops if there's no
|
||||
// active session, so the inner loop self-terminates either way.
|
||||
for atk := 0; atk < maxAttacksPerFight; atk++ {
|
||||
active, _ := getActiveCombatSession(uid)
|
||||
if active == nil {
|
||||
break
|
||||
}
|
||||
if err := p.handleAttackCmd(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("attack step %d.%d: %v", step, atk, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil {
|
||||
t.Fatalf("zone advance step %d: %v", step, err)
|
||||
}
|
||||
clearedRoomTypes = append(clearedRoomTypes, prevType)
|
||||
}
|
||||
final, _ := getActiveZoneRun(uid)
|
||||
raw := getZoneRunByUser(t, uid) // helper below — most-recent row
|
||||
if final != nil {
|
||||
t.Logf("final state: still active at room %d/%d — likely advance regression",
|
||||
final.CurrentRoom+1, final.TotalRooms)
|
||||
t.Errorf("run did not terminate within %d steps", maxSteps)
|
||||
} else if raw != nil {
|
||||
boss := raw.BossDefeated
|
||||
t.Logf("run %s ended: bossDefeated=%v abandoned=%v rooms_cleared=%d/%d loot=%d mood=%d",
|
||||
raw.RunID, boss, raw.Abandoned, len(raw.RoomsCleared), raw.TotalRooms,
|
||||
len(raw.LootCollected), raw.DMMood)
|
||||
t.Logf("clearedRoomTypes: %v", clearedRoomTypes)
|
||||
if !boss && !raw.Abandoned && raw.CompletedAt == nil {
|
||||
t.Errorf("run row in indeterminate state: %+v", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getZoneRunByUser fetches the most-recent zone-run row for a user
|
||||
// regardless of active/abandoned/cleared. Helper for the scenario test.
|
||||
func getZoneRunByUser(t *testing.T, uid id.UserID) *DungeonRun {
|
||||
t.Helper()
|
||||
row := db.Get().QueryRow(`
|
||||
SELECT run_id FROM dnd_zone_run
|
||||
WHERE user_id = ?
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
`, string(uid))
|
||||
var runID string
|
||||
if err := row.Scan(&runID); err != nil {
|
||||
return nil
|
||||
}
|
||||
r, _ := getZoneRun(runID)
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Scenario B — multi-day expedition ───────────────────────────────────────
|
||||
|
||||
func TestAdv2Scenario_ExpeditionCryptValdris(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@adv2-scenB:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||
|
||||
if err := createAdvCharacter(uid, "scenB"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassRanger, Level: 5,
|
||||
STR: 14, DEX: 18, CON: 14, INT: 10, WIS: 14, CHA: 10,
|
||||
HPMax: 50, HPCurrent: 50, ArmorClass: 16,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
euro.Credit(uid, 1000, "scenB bankroll")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// !expedition list
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "list"); err != nil {
|
||||
t.Fatalf("expedition list: %v", err)
|
||||
}
|
||||
|
||||
// !expedition start crypt_valdris with 2 standard packs
|
||||
balBefore := euro.GetBalance(uid)
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start crypt_valdris 2s"); err != nil {
|
||||
t.Fatalf("expedition start: %v", err)
|
||||
}
|
||||
exp, err := getActiveExpedition(uid)
|
||||
if err != nil || exp == nil {
|
||||
t.Fatalf("expected active expedition: %v", err)
|
||||
}
|
||||
balAfter := euro.GetBalance(uid)
|
||||
t.Logf("expedition %s started: zone=%s supplies=%d/%d threat=%d cost=%.0f→%.0f",
|
||||
exp.ID, exp.ZoneID, int(exp.Supplies.Current), int(exp.Supplies.Max),
|
||||
exp.ThreatLevel, balBefore, balAfter)
|
||||
if exp.ZoneID != ZoneCryptValdris {
|
||||
t.Errorf("zone = %s, want crypt_valdris", exp.ZoneID)
|
||||
}
|
||||
if balAfter >= balBefore {
|
||||
t.Errorf("expected outfitting to debit coins (%.2f → %.2f)", balBefore, balAfter)
|
||||
}
|
||||
|
||||
// Backdate start so deliverBriefing's same-day guard passes, and to
|
||||
// before eventAnchoredCutoff so the legacy mutator path still fires.
|
||||
rewindToLegacyAnchor(t, exp)
|
||||
|
||||
// Drive 3 daily briefings — verify supply burn + day advance.
|
||||
now := time.Date(2026, 5, 8, 6, 0, 0, 0, time.UTC)
|
||||
for day := 1; day <= 3; day++ {
|
||||
fresh, _ := getExpedition(exp.ID)
|
||||
if fresh == nil {
|
||||
t.Logf("expedition ended before day %d", day)
|
||||
break
|
||||
}
|
||||
preDay := fresh.CurrentDay
|
||||
preSupplies := fresh.Supplies.Current
|
||||
preThreat := fresh.ThreatLevel
|
||||
if err := p.deliverBriefing(fresh, now); err != nil {
|
||||
t.Fatalf("deliverBriefing day %d: %v", day, err)
|
||||
}
|
||||
// Advance the wallclock + roll the start back another 24h so the
|
||||
// next briefing's day-guard fires.
|
||||
now = now.Add(24 * time.Hour)
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate day %d: %v", day, err)
|
||||
}
|
||||
post, _ := getExpedition(exp.ID)
|
||||
t.Logf("day %d: day=%d→%d supplies=%v→%v threat=%d→%d",
|
||||
day, preDay, post.CurrentDay, preSupplies, post.Supplies.Current, preThreat, post.ThreatLevel)
|
||||
if post.CurrentDay <= preDay {
|
||||
t.Errorf("day did not advance: %d → %d", preDay, post.CurrentDay)
|
||||
}
|
||||
}
|
||||
|
||||
// !expedition status / log
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "status"); err != nil {
|
||||
t.Fatalf("expedition status: %v", err)
|
||||
}
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "log"); err != nil {
|
||||
t.Fatalf("expedition log: %v", err)
|
||||
}
|
||||
|
||||
entries, _ := recentExpeditionLog(exp.ID, 10)
|
||||
t.Logf("expedition log has %d entries", len(entries))
|
||||
for i, e := range entries {
|
||||
t.Logf(" [%d] day=%d type=%s summary=%q", i, e.Day, e.Type, e.Summary)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Error("expected at least one log entry")
|
||||
}
|
||||
|
||||
// !expedition abandon — clean state for next test runs.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "abandon"); err != nil {
|
||||
t.Fatalf("expedition abandon: %v", err)
|
||||
}
|
||||
if got, _ := getActiveExpedition(uid); got != nil {
|
||||
t.Error("expected no active expedition after abandon")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Scenario C — harvest in an active expedition ────────────────────────────
|
||||
|
||||
func TestAdv2Scenario_HarvestForestShadows(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@adv2-scenC:example")
|
||||
t.Cleanup(func() { cleanupExpeditions(uid); cleanupZoneRuns(uid) })
|
||||
|
||||
if err := createAdvCharacter(uid, "scenC"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceElf, Class: ClassRanger, Level: 4,
|
||||
STR: 12, DEX: 18, CON: 14, INT: 10, WIS: 16, CHA: 10,
|
||||
HPMax: 40, HPCurrent: 40, ArmorClass: 15,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
euro.Credit(uid, 500, "scenC bankroll")
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
// Forage-friendly expedition.
|
||||
if err := p.handleDnDExpeditionCmd(MessageContext{Sender: uid}, "start forest_shadows lean"); err != nil {
|
||||
t.Fatalf("expedition start: %v", err)
|
||||
}
|
||||
exp, _ := getActiveExpedition(uid)
|
||||
if exp == nil {
|
||||
t.Fatal("no expedition")
|
||||
}
|
||||
|
||||
// Drive a region run so harvest has a current room context.
|
||||
run, err := ensureRegionRun(exp, c.Level)
|
||||
if err != nil {
|
||||
t.Fatalf("ensureRegionRun: %v", err)
|
||||
}
|
||||
t.Logf("region run %s in zone %s (%d rooms)", run.RunID, run.ZoneID, run.TotalRooms)
|
||||
|
||||
// Seed harvest nodes for the entry room (they're auto-seeded by
|
||||
// loadHarvestNodes on first read, but confirm we get at least one).
|
||||
roomIdx := currentRoomIndexFor(exp)
|
||||
nodeID := currentNodeIDFor(exp)
|
||||
nodes := loadHarvestNodes(exp, nodeID)
|
||||
t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes))
|
||||
if len(nodes) == 0 {
|
||||
t.Error("expected at least one harvest node seeded")
|
||||
}
|
||||
|
||||
// Drive the auto-harvest pass (post-H3 there is no manual command
|
||||
// surface). Outcomes are RNG-driven; we mainly want no panics +
|
||||
// coherent state.
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := p.autoHarvestRoom(uid, run, c, exp); err != nil {
|
||||
t.Fatalf("autoHarvestRoom attempt %d: %v", i, err)
|
||||
}
|
||||
fresh, _ := getExpedition(exp.ID)
|
||||
if fresh == nil {
|
||||
t.Logf("expedition ended during auto-harvest #%d", i)
|
||||
break
|
||||
}
|
||||
t.Logf("after auto-harvest #%d: supplies=%v threat=%d", i, fresh.Supplies.Current, fresh.ThreatLevel)
|
||||
}
|
||||
|
||||
// !resources output path (no-op SendDM but should not error).
|
||||
if err := p.handleResourcesCmd(MessageContext{Sender: uid}); err != nil {
|
||||
t.Fatalf("handleResourcesCmd: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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 +131,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 +139,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 +147,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 +155,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 +345,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 +397,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 +440,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 +549,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 +560,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 +571,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 +599,31 @@ func advCheckBrokenEquipment(equip map[EquipmentSlot]*AdvEquipment) []EquipmentS
|
||||
return broken
|
||||
}
|
||||
|
||||
// ── Overlevel Penalty ───────────────────────────────────────────────────────
|
||||
|
||||
// advOverlevelMultiplier returns a multiplier (0.05–1.0) that reduces XP and
|
||||
// loot when a character's effective level far exceeds the location's minimum.
|
||||
// Gap 0-3: no penalty. Gap 4+: −15% per level over 3, floor 5%.
|
||||
// No penalty if no higher-tier location of the same activity is accessible.
|
||||
func advOverlevelMultiplier(effectiveLevel int, loc *AdvLocation) float64 {
|
||||
gap := effectiveLevel - loc.MinLevel
|
||||
if gap <= 3 {
|
||||
return 1.0
|
||||
}
|
||||
hasHigherAccessible := false
|
||||
for _, other := range allAdvLocations(loc.Activity) {
|
||||
if other.MinLevel > loc.MinLevel && other.MinLevel <= effectiveLevel {
|
||||
hasHigherAccessible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasHigherAccessible {
|
||||
return 1.0
|
||||
}
|
||||
mult := 1.0 - 0.15*float64(gap-3)
|
||||
return math.Max(0.05, mult)
|
||||
}
|
||||
|
||||
// ── Outcome Resolution ───────────────────────────────────────────────────────
|
||||
|
||||
type AdvActionResult struct {
|
||||
@@ -586,6 +632,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,148 +643,147 @@ type AdvActionResult struct {
|
||||
EquipBroken []EquipmentSlot
|
||||
NearDeath bool
|
||||
StreakBonus int
|
||||
CombatLog *CombatResult
|
||||
// MasteryCrossings records equipment slots whose ActionsUsed crossed a
|
||||
// mastery threshold (advMasteryThresholds) on this action. The caller
|
||||
// uses this to DM a celebration so the silent counter becomes visible.
|
||||
MasteryCrossings []AdvMasteryCrossing
|
||||
}
|
||||
|
||||
func resolveAdvAction(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, bonuses *AdvBonusSummary, inPenaltyZone bool) *AdvActionResult {
|
||||
result := &AdvActionResult{
|
||||
Location: loc,
|
||||
XPSkill: advXPSkill(loc.Activity),
|
||||
}
|
||||
type AdvMasteryCrossing struct {
|
||||
Slot EquipmentSlot
|
||||
ItemName string
|
||||
Threshold int // 50, 100, or 250
|
||||
}
|
||||
|
||||
probs := calculateAdvProbabilities(char, equip, loc, bonuses, inPenaltyZone)
|
||||
// advMasteryThresholds defines the action counts at which equipment mastery
|
||||
// is celebrated. Picked to span an early-game milestone (50), a mid-game
|
||||
// commitment (100), and a hard-to-reach veteran tier (250).
|
||||
var advMasteryThresholds = []int{50, 100, 250}
|
||||
|
||||
// Roll outcome
|
||||
roll := rand.Float64() * 100
|
||||
|
||||
switch {
|
||||
case roll < probs.DeathPct:
|
||||
result.Outcome = AdvOutcomeDeath
|
||||
case roll < probs.DeathPct+probs.EmptyPct:
|
||||
// Activity-specific empty outcomes
|
||||
result.Outcome = resolveAdvEmptyOutcome(loc, roll)
|
||||
case roll < probs.DeathPct+probs.EmptyPct+probs.SuccessPct:
|
||||
result.Outcome = AdvOutcomeSuccess
|
||||
default:
|
||||
result.Outcome = AdvOutcomeExceptional
|
||||
}
|
||||
|
||||
// Near-death check: survived within 2% of death threshold
|
||||
if result.Outcome != AdvOutcomeDeath && roll < probs.DeathPct+2 && roll >= probs.DeathPct {
|
||||
result.NearDeath = true
|
||||
}
|
||||
|
||||
// Generate loot for success/exceptional
|
||||
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
|
||||
result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
|
||||
for _, item := range result.LootItems {
|
||||
result.TotalLootValue += item.Value
|
||||
// advMasteryTier reports how many mastery thresholds a piece has crossed
|
||||
// (0–3) and the next threshold. nextThreshold is 0 when the piece has
|
||||
// already crossed every threshold.
|
||||
func advMasteryTier(actionsUsed int) (tier, nextThreshold int) {
|
||||
for _, t := range advMasteryThresholds {
|
||||
if actionsUsed >= t {
|
||||
tier++
|
||||
} else {
|
||||
nextThreshold = t
|
||||
break
|
||||
}
|
||||
}
|
||||
return tier, nextThreshold
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// advMasteryMarker returns the visual tier marker for a piece of equipment.
|
||||
// Empty for sub-50; ✦/✦✦/✦✦✦ at the 50/100/250 thresholds.
|
||||
func advMasteryMarker(actionsUsed int) string {
|
||||
tier, _ := advMasteryTier(actionsUsed)
|
||||
switch tier {
|
||||
case 1:
|
||||
return "✦"
|
||||
case 2:
|
||||
return "✦✦"
|
||||
case 3:
|
||||
return "✦✦✦"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Near-death XP bonus
|
||||
if result.NearDeath {
|
||||
xp = int(float64(xp) * 1.15)
|
||||
// advSlotRelevantToActivity reports whether using a slot during the given
|
||||
// activity should count toward that slot's mastery. Combat actions exercise
|
||||
// the combat loadout (weapon/armor/helmet/boots); harvest actions exercise
|
||||
// only the tool. This is what makes mastery feel like "you used this
|
||||
// piece" rather than "you played for a while" — without this gate every
|
||||
// slot ticks in lockstep and the per-slot view is meaningless.
|
||||
func advSlotRelevantToActivity(slot EquipmentSlot, activity AdvActivityType) bool {
|
||||
if isCombatActivity(activity) {
|
||||
return slot == SlotWeapon || slot == SlotArmor || slot == SlotHelmet || slot == SlotBoots
|
||||
}
|
||||
if isHarvestActivity(activity) {
|
||||
return slot == SlotTool
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// XP multiplier from bonuses
|
||||
if bonuses.XPMultiplier != 0 {
|
||||
xp = int(float64(xp) * (1 + bonuses.XPMultiplier/100))
|
||||
// advMasteryRowSegment formats the `23/50`, `73/100 ✦`, `142/250 ✦✦`, or
|
||||
// `250 ✦✦✦` segment shown inline on each equipment row in the character
|
||||
// sheet so progress is visible at a glance — not just a tier marker that
|
||||
// flips on at 50/100/250 with nothing in between.
|
||||
func advMasteryRowSegment(actionsUsed int) string {
|
||||
if actionsUsed <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Ironclad set: Battle-Hardened — +5% XP gain
|
||||
if advEquippedArenaSets(equip)["ironclad"] {
|
||||
xp = int(float64(xp) * 1.05)
|
||||
_, next := advMasteryTier(actionsUsed)
|
||||
marker := advMasteryMarker(actionsUsed)
|
||||
if next == 0 {
|
||||
// At max — there's no "next" threshold to count toward.
|
||||
return fmt.Sprintf("%d %s", actionsUsed, marker)
|
||||
}
|
||||
result.XPGained = xp
|
||||
if marker == "" {
|
||||
return fmt.Sprintf("%d/%d", actionsUsed, next)
|
||||
}
|
||||
return fmt.Sprintf("%d/%d %s", actionsUsed, next, marker)
|
||||
}
|
||||
|
||||
// Equipment degradation on bad outcomes
|
||||
if result.Outcome == AdvOutcomeDeath || result.Outcome == AdvOutcomeEmpty ||
|
||||
result.Outcome == AdvOutcomeCaveIn || result.Outcome == AdvOutcomeBear ||
|
||||
result.Outcome == AdvOutcomeRiver {
|
||||
result.EquipDamage = applyAdvEquipDegradation(equip, result.Outcome)
|
||||
result.EquipBroken = advCheckBrokenEquipment(equip)
|
||||
}
|
||||
// AdvMasteryRollup summarizes mastery state across all equipped slots so the
|
||||
// character sheet can show one informative aggregate line without a
|
||||
// per-slot block. AnyProgress is true when at least one slot has any
|
||||
// ActionsUsed — used to decide whether to show a "building up" hint when
|
||||
// no thresholds have crossed yet.
|
||||
type AdvMasteryRollup struct {
|
||||
ThresholdsCrossed int // raw count, pre-cap (sum across all slots)
|
||||
MaxedSlots int // slots with all 3 thresholds crossed
|
||||
Bonus float64 // capped %, mirrors advEquipmentMasteryBonus
|
||||
AnyProgress bool
|
||||
}
|
||||
|
||||
// Increment actions_used for equipment mastery
|
||||
func advMasteryRollup(equip map[EquipmentSlot]*AdvEquipment) AdvMasteryRollup {
|
||||
r := AdvMasteryRollup{}
|
||||
for _, eq := range equip {
|
||||
eq.ActionsUsed++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveAdvEmptyOutcome returns an activity-specific "empty" outcome.
|
||||
func resolveAdvEmptyOutcome(loc *AdvLocation, _ float64) AdvOutcomeType {
|
||||
switch loc.Activity {
|
||||
case AdvActivityMining:
|
||||
// 40% chance of cave-in on "empty" result
|
||||
if rand.Float64() < 0.4 {
|
||||
return AdvOutcomeCaveIn
|
||||
}
|
||||
return AdvOutcomeEmpty
|
||||
|
||||
case AdvActivityForaging:
|
||||
// Split empty into specific outcomes
|
||||
r := rand.Float64()
|
||||
switch {
|
||||
case r < 0.35:
|
||||
return AdvOutcomeHornets
|
||||
case r < 0.55:
|
||||
return AdvOutcomeBear
|
||||
case r < 0.70:
|
||||
return AdvOutcomeRiver
|
||||
default:
|
||||
return AdvOutcomeEmpty
|
||||
}
|
||||
|
||||
case AdvActivityFishing:
|
||||
// Fishing empty is just empty — no sub-outcomes
|
||||
return AdvOutcomeEmpty
|
||||
|
||||
default:
|
||||
return AdvOutcomeEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// ── Eligible Locations for DM Menu ───────────────────────────────────────────
|
||||
|
||||
type AdvEligibleLocation struct {
|
||||
Location *AdvLocation
|
||||
InPenaltyZone bool
|
||||
DeathPct float64
|
||||
}
|
||||
|
||||
func advEligibleLocations(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, activity AdvActivityType, bonuses *AdvBonusSummary) []AdvEligibleLocation {
|
||||
var eligible []AdvEligibleLocation
|
||||
for _, loc := range allAdvLocations(activity) {
|
||||
loc := loc
|
||||
ok, penalty := advIsEligible(char, equip, &loc, bonuses)
|
||||
if !ok {
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
probs := calculateAdvProbabilities(char, equip, &loc, bonuses, penalty)
|
||||
eligible = append(eligible, AdvEligibleLocation{
|
||||
Location: &loc,
|
||||
InPenaltyZone: penalty,
|
||||
DeathPct: probs.DeathPct,
|
||||
})
|
||||
if eq.ActionsUsed > 0 {
|
||||
r.AnyProgress = true
|
||||
}
|
||||
tier, _ := advMasteryTier(eq.ActionsUsed)
|
||||
r.ThresholdsCrossed += tier
|
||||
if tier == len(advMasteryThresholds) {
|
||||
r.MaxedSlots++
|
||||
}
|
||||
}
|
||||
return eligible
|
||||
r.Bonus = advEquipmentMasteryBonus(equip)
|
||||
return r
|
||||
}
|
||||
|
||||
// advEquipmentMasteryBonus returns the loot-quality bonus from accumulated
|
||||
// mastery across all equipped slots. Each crossed threshold grants +1% loot
|
||||
// quality on its slot, capped at +10% total to keep balance reasonable.
|
||||
func advEquipmentMasteryBonus(equip map[EquipmentSlot]*AdvEquipment) float64 {
|
||||
total := 0.0
|
||||
for _, eq := range equip {
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range advMasteryThresholds {
|
||||
if eq.ActionsUsed >= t {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
if total > 10 {
|
||||
total = 10
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// ── Party Bonus Check ────────────────────────────────────────────────────────
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
233
internal/plugin/adventure_activity_unified.go
Normal file
233
internal/plugin/adventure_activity_unified.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// AdvDailyActivity is one row of "what a player did" on a given UTC date.
|
||||
// Unifies the legacy adventure_activity_log table with the D&D-layer zone
|
||||
// run / expedition log tables so the daily report can render a coherent
|
||||
// view regardless of which subsystem the action ran through.
|
||||
type AdvDailyActivity struct {
|
||||
UserID id.UserID
|
||||
Source string // "zone_run" | "expedition" | "rest_legacy" | "legacy"
|
||||
Activity string // "zone" | "expedition" | "rest" | legacy activity_type
|
||||
Location string // zone display name or legacy location
|
||||
Outcome string // "completed" | "abandoned" | "boss_defeated" | "in_progress" | "rest" | legacy outcome
|
||||
Summary string // one-line human description
|
||||
LootValue int64 // 0 for D&D-layer entries (loot stays in run/expedition state)
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// loadAdvDailyActivity unions activity rows from every action surface for
|
||||
// the given UTC date (format "2006-01-02"). Returns map keyed by user_id.
|
||||
//
|
||||
// Sources (in chronological order on output):
|
||||
// - adventure_activity_log (legacy, mostly !rest post-Phase-R)
|
||||
// - dnd_zone_run (zone runs whose last_action_at falls on date)
|
||||
// - dnd_expedition_log (expedition log entries dated today, deduped per
|
||||
// expedition into one rollup line)
|
||||
//
|
||||
// Each user's slice is sorted oldest→newest. Empty slice when the player
|
||||
// had no activity on this date.
|
||||
func loadAdvDailyActivity(date string) (map[id.UserID][]AdvDailyActivity, error) {
|
||||
out := make(map[id.UserID][]AdvDailyActivity)
|
||||
d := db.Get()
|
||||
|
||||
// 1. Legacy adventure_activity_log
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id, activity_type, COALESCE(location,''), outcome, loot_value, logged_at
|
||||
FROM adventure_activity_log
|
||||
WHERE logged_at >= ? AND logged_at < DATE(?, '+1 day')
|
||||
ORDER BY logged_at`, date, date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("legacy log: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var (
|
||||
uid, activity, location, outcome string
|
||||
loot int64
|
||||
ts time.Time
|
||||
)
|
||||
if err := rows.Scan(&uid, &activity, &location, &outcome, &loot, &ts); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("legacy log scan: %w", err)
|
||||
}
|
||||
userID := id.UserID(uid)
|
||||
entry := AdvDailyActivity{
|
||||
UserID: userID,
|
||||
Source: "legacy",
|
||||
Activity: activity,
|
||||
Location: location,
|
||||
Outcome: outcome,
|
||||
LootValue: loot,
|
||||
Timestamp: ts,
|
||||
}
|
||||
if activity == string(AdvActivityRest) {
|
||||
entry.Source = "rest_legacy"
|
||||
entry.Summary = "Rested. Streak preserved."
|
||||
} else {
|
||||
entry.Summary = fmt.Sprintf("%s in %s (%s)", activity, location, outcome)
|
||||
}
|
||||
out[userID] = append(out[userID], entry)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Pre-load (user_id, zone_id) of active expeditions. Zone runs are now
|
||||
// exclusively spawned by the expedition layer, and the expedition flips
|
||||
// a region's run to abandoned on !region travel / inactivity timeout /
|
||||
// forced extraction. Those transitions are internal — the expedition
|
||||
// rollup below is the source of truth for the player. Skip zone_run
|
||||
// entries that match an active expedition to avoid a misleading
|
||||
// "Withdrew from <zone>" line while the player is still on it.
|
||||
activeExp := make(map[string]struct{})
|
||||
expRows, err := d.Query(`
|
||||
SELECT user_id, zone_id FROM dnd_expedition
|
||||
WHERE status IN ('active','extracting')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("active expeditions: %w", err)
|
||||
}
|
||||
for expRows.Next() {
|
||||
var u, z string
|
||||
if err := expRows.Scan(&u, &z); err != nil {
|
||||
expRows.Close()
|
||||
return nil, fmt.Errorf("active expedition scan: %w", err)
|
||||
}
|
||||
activeExp[u+"|"+z] = struct{}{}
|
||||
}
|
||||
expRows.Close()
|
||||
|
||||
// 2. dnd_zone_run — rows touched today. Progress count is derived
|
||||
// from len(visited_nodes) — current_room retired in G9.
|
||||
rows, err = d.Query(`
|
||||
SELECT user_id, zone_id, visited_nodes, total_rooms, abandoned,
|
||||
boss_defeated, completed_at, last_action_at
|
||||
FROM dnd_zone_run
|
||||
WHERE last_action_at >= ? AND last_action_at < DATE(?, '+1 day')
|
||||
ORDER BY last_action_at`, date, date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("zone runs: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var (
|
||||
uid, zoneID string
|
||||
visitedJSON string
|
||||
totalRooms int
|
||||
abandoned, bossDefeated int
|
||||
completedAt *time.Time
|
||||
lastAction time.Time
|
||||
)
|
||||
if err := rows.Scan(&uid, &zoneID, &visitedJSON, &totalRooms,
|
||||
&abandoned, &bossDefeated, &completedAt, &lastAction); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("zone run scan: %w", err)
|
||||
}
|
||||
var visited []string
|
||||
_ = json.Unmarshal([]byte(visitedJSON), &visited)
|
||||
currentRoom := len(visited) - 1
|
||||
if currentRoom < 0 {
|
||||
currentRoom = 0
|
||||
}
|
||||
if _, onExp := activeExp[uid+"|"+zoneID]; onExp {
|
||||
continue
|
||||
}
|
||||
userID := id.UserID(uid)
|
||||
zoneDef := zoneOrFallback(ZoneID(zoneID))
|
||||
|
||||
var outcome, summary string
|
||||
switch {
|
||||
case bossDefeated == 1:
|
||||
outcome = "boss_defeated"
|
||||
summary = fmt.Sprintf("Cleared %s — boss down (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms)
|
||||
case completedAt != nil && abandoned == 0:
|
||||
outcome = "completed"
|
||||
summary = fmt.Sprintf("Cleared %s (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms)
|
||||
case abandoned == 1:
|
||||
outcome = "abandoned"
|
||||
summary = fmt.Sprintf("Withdrew from %s at room %d/%d.", zoneDef.Display, currentRoom, totalRooms)
|
||||
default:
|
||||
outcome = "in_progress"
|
||||
summary = fmt.Sprintf("Mid-run in %s (%d/%d rooms).", zoneDef.Display, currentRoom, totalRooms)
|
||||
}
|
||||
out[userID] = append(out[userID], AdvDailyActivity{
|
||||
UserID: userID,
|
||||
Source: "zone_run",
|
||||
Activity: "zone",
|
||||
Location: zoneDef.Display,
|
||||
Outcome: outcome,
|
||||
Summary: summary,
|
||||
Timestamp: lastAction,
|
||||
})
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// 3. dnd_expedition_log — pick the most-recent player-visible entry
|
||||
// per expedition for users active today. We rollup per expedition
|
||||
// rather than per row so the report doesn't list every camp/combat
|
||||
// beat separately.
|
||||
rows, err = d.Query(`
|
||||
SELECT e.user_id, e.zone_id, e.current_day, e.status,
|
||||
l.entry_type, l.summary, l.timestamp
|
||||
FROM dnd_expedition_log l
|
||||
JOIN dnd_expedition e ON e.expedition_id = l.expedition_id
|
||||
WHERE l.timestamp >= ? AND l.timestamp < DATE(?, '+1 day')
|
||||
AND l.entry_type IN ('action','combat','event','recap')
|
||||
ORDER BY l.timestamp`, date, date)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("expedition log: %w", err)
|
||||
}
|
||||
type expKey struct {
|
||||
uid, zoneID string
|
||||
}
|
||||
mostRecentExp := make(map[expKey]AdvDailyActivity)
|
||||
for rows.Next() {
|
||||
var (
|
||||
uid, zoneID, status, entryType, summary string
|
||||
currentDay int
|
||||
ts time.Time
|
||||
)
|
||||
if err := rows.Scan(&uid, &zoneID, ¤tDay, &status,
|
||||
&entryType, &summary, &ts); err != nil {
|
||||
rows.Close()
|
||||
return nil, fmt.Errorf("expedition log scan: %w", err)
|
||||
}
|
||||
userID := id.UserID(uid)
|
||||
zoneDef := zoneOrFallback(ZoneID(zoneID))
|
||||
k := expKey{string(userID), zoneID}
|
||||
mostRecentExp[k] = AdvDailyActivity{
|
||||
UserID: userID,
|
||||
Source: "expedition",
|
||||
Activity: "expedition",
|
||||
Location: zoneDef.Display,
|
||||
Outcome: status,
|
||||
Summary: fmt.Sprintf("Expedition in %s, day %d — %s",
|
||||
zoneDef.Display, currentDay, summary),
|
||||
Timestamp: ts,
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, entry := range mostRecentExp {
|
||||
out[entry.UserID] = append(out[entry.UserID], entry)
|
||||
}
|
||||
|
||||
// Sort each user's slice oldest→newest so callers can pick the
|
||||
// representative entry deterministically (typically the latest).
|
||||
for uid := range out {
|
||||
entries := out[uid]
|
||||
// Insertion-sort is fine here — slices are tiny.
|
||||
for i := 1; i < len(entries); i++ {
|
||||
for j := i; j > 0 && entries[j-1].Timestamp.After(entries[j].Timestamp); j-- {
|
||||
entries[j-1], entries[j] = entries[j], entries[j-1]
|
||||
}
|
||||
}
|
||||
out[uid] = entries
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
189
internal/plugin/adventure_arena_bossflow_test.go
Normal file
189
internal/plugin/adventure_arena_bossflow_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase L2 step 4 — smoke test for resolveArenaBoss. Asserts the helper
|
||||
// returns staged narration (intro + phases + outcome) for a representative
|
||||
// arena round and that the outcome carries the boss-flow signatures: the
|
||||
// arena-styled headline and a dice-roll summary.
|
||||
|
||||
func TestResolveArenaBoss_T1Round1_Smoke(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@arena-bossflow:example")
|
||||
t.Cleanup(func() { cleanupZoneRuns(uid) })
|
||||
|
||||
if err := createAdvCharacter(uid, "bossflow"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 10, CHA: 10,
|
||||
HPMax: 60, HPCurrent: 60, ArmorClass: 18,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatalf("SaveDnDCharacter: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
intro, phases, outcome, result, err := p.resolveArenaBoss(uid, ArenaBossEncounter{
|
||||
Tier: 1, Round: 1, DisplayName: "Smoke",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveArenaBoss: %v", err)
|
||||
}
|
||||
if intro == "" {
|
||||
t.Error("intro empty")
|
||||
}
|
||||
if !strings.Contains(intro, "Arena T1 R1") {
|
||||
t.Errorf("intro missing tier/round label: %q", intro)
|
||||
}
|
||||
if len(phases) == 0 {
|
||||
t.Error("phases empty — combat log did not render")
|
||||
}
|
||||
if outcome == "" {
|
||||
t.Error("outcome empty")
|
||||
}
|
||||
if result.PlayerWon {
|
||||
// On a win the headline mentions "falls"; on a loss "stands over".
|
||||
if !strings.Contains(outcome, "falls") {
|
||||
t.Errorf("win outcome missing 'falls' headline: %q", outcome)
|
||||
}
|
||||
} else {
|
||||
if !strings.Contains(outcome, "stands over") {
|
||||
t.Errorf("loss outcome missing defeat headline: %q", outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveArenaBoss_BadTierRound(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@arena-bossflow-bad:example")
|
||||
if err := createAdvCharacter(uid, "bad"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
if _, _, _, _, err := p.resolveArenaBoss(uid, ArenaBossEncounter{Tier: 9, Round: 9}); err == nil {
|
||||
t.Error("expected error for unknown tier/round")
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaBossPhaseTwoAt(t *testing.T) {
|
||||
cases := []struct {
|
||||
tier int
|
||||
want float64
|
||||
}{{1, 0}, {2, 0}, {3, 0.5}, {4, 0.5}, {5, 0.5}}
|
||||
for _, c := range cases {
|
||||
if got := arenaBossPhaseTwoAt(c.tier); got != c.want {
|
||||
t.Errorf("arenaBossPhaseTwoAt(%d)=%v want %v", c.tier, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase L2 step 4b — staged-narration assembly. The intro line leads,
|
||||
// followed by the combat-log phases, mirroring streamOrSend's
|
||||
// intro+phases pattern in dnd_zone_cmd.go.
|
||||
func TestBossFlowPhaseMessages(t *testing.T) {
|
||||
n := &arenaBossNarration{
|
||||
intro: "🏟️ **Arena T1 R1 — Slug** (HP 12, AC 10)",
|
||||
phases: []string{"phase A", "phase B"},
|
||||
}
|
||||
got := bossFlowPhaseMessages(n)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3 (intro + 2 phases): %v", len(got), got)
|
||||
}
|
||||
if got[0] != n.intro {
|
||||
t.Errorf("got[0] = %q, want intro %q", got[0], n.intro)
|
||||
}
|
||||
if got[1] != "phase A" || got[2] != "phase B" {
|
||||
t.Errorf("phases out of order: %v", got)
|
||||
}
|
||||
|
||||
// No intro → just the phases, unchanged.
|
||||
n2 := &arenaBossNarration{phases: []string{"only"}}
|
||||
got2 := bossFlowPhaseMessages(n2)
|
||||
if len(got2) != 1 || got2[0] != "only" {
|
||||
t.Errorf("no-intro case: got %v want [only]", got2)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase L2 step 8 — assert that an arena win surfaces both the staged
|
||||
// combat log (RenderCombatLog phases prepended by the arena intro) and a
|
||||
// TwinBee BossDeath flavor line in the outcome block. Drives
|
||||
// renderBossOutcome directly with a forced-win CombatResult so the
|
||||
// assertion is RNG-free; the resolveArenaBoss smoke test above already
|
||||
// exercises the live combat path end-to-end.
|
||||
func TestArenaBossOutcome_WinSurfacesStagedLogAndBossDeath(t *testing.T) {
|
||||
monster := arenaBosses[arenaBossID(1, 1)]
|
||||
result := CombatResult{
|
||||
PlayerWon: true,
|
||||
PlayerStartHP: 60, PlayerEndHP: 42,
|
||||
EnemyStartHP: monster.HP, EnemyEndHP: 0,
|
||||
Events: []CombatEvent{
|
||||
{Round: 1, Phase: "attack", Actor: "player", Action: "hit", Roll: 14, RollAgainst: monster.AC, Damage: 10, EnemyHP: monster.HP - 10, PlayerHP: 60},
|
||||
{Round: 2, Phase: "attack", Actor: "player", Action: "crit", Roll: 20, RollAgainst: monster.AC, Damage: monster.HP - 10, EnemyHP: 0, PlayerHP: 42},
|
||||
},
|
||||
TotalRounds: 2,
|
||||
}
|
||||
|
||||
// Staged combat log — same call resolveArenaBoss makes for `phases`.
|
||||
phases := RenderCombatLog(result, "Champion", monster.Name)
|
||||
if len(phases) == 0 {
|
||||
t.Fatal("RenderCombatLog returned no phases for a winning result")
|
||||
}
|
||||
|
||||
victoryHeadline := "🏆 **" + monster.Name + "** falls (HP " + "60" + "→" + "42" + " / 60)."
|
||||
outcome := renderBossOutcome(BossOutcomeInputs{
|
||||
ZoneID: ZoneArena,
|
||||
RunID: "arena-stagedlog-test",
|
||||
RoomIdx: 11,
|
||||
Monster: monster,
|
||||
Result: result,
|
||||
PreHP: 60, PostHP: 42, MaxHP: 60,
|
||||
PhaseTwoAt: arenaBossPhaseTwoAt(1),
|
||||
Nat20s: 1,
|
||||
Nat1s: 0,
|
||||
DefeatHeadline: "unused",
|
||||
VictoryHeadline: victoryHeadline,
|
||||
})
|
||||
|
||||
if !strings.Contains(outcome, "🎭 **TwinBee:**") {
|
||||
t.Errorf("outcome missing TwinBee narration line: %q", outcome)
|
||||
}
|
||||
if !strings.Contains(outcome, victoryHeadline) {
|
||||
t.Errorf("outcome missing victory headline: %q", outcome)
|
||||
}
|
||||
if !strings.Contains(outcome, "🎲 d20 —") {
|
||||
t.Errorf("outcome missing dice-roll summary: %q", outcome)
|
||||
}
|
||||
// BossDeath line must be present — the TwinBee line preceding the
|
||||
// victory headline is sourced from flavor.BossDeath via twinBeeLine.
|
||||
headlineIdx := strings.Index(outcome, victoryHeadline)
|
||||
if headlineIdx <= 0 {
|
||||
t.Fatalf("victory headline not after a TwinBee line: %q", outcome)
|
||||
}
|
||||
prefix := outcome[:headlineIdx]
|
||||
if !strings.Contains(prefix, "🎭 **TwinBee:**") {
|
||||
t.Errorf("BossDeath TwinBee line should precede victory headline; prefix=%q", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaBosses_AllTiersPopulated(t *testing.T) {
|
||||
for tier := 1; tier <= 5; tier++ {
|
||||
for round := 1; round <= 4; round++ {
|
||||
id := arenaBossID(tier, round)
|
||||
m, ok := arenaBosses[id]
|
||||
if !ok {
|
||||
t.Errorf("arenaBosses missing %s", id)
|
||||
continue
|
||||
}
|
||||
if m.HP <= 0 || m.AC <= 0 || m.Attack <= 0 {
|
||||
t.Errorf("%s has bad stats: HP=%d AC=%d ATK=%d", id, m.HP, m.AC, m.Attack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,478 +1,3 @@
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
func arenaWinCloser(loserName string, lastRound int) string {
|
||||
closers := []string{
|
||||
"%s fought. It counts.",
|
||||
"%s will be back. The arena keeps score.",
|
||||
fmt.Sprintf("%%s has until tomorrow to think about round %d.", lastRound),
|
||||
"%s gave you more trouble than you'd like to admit. They don't need to know that.",
|
||||
"%s loses this one. The next one is an open question.",
|
||||
"%s came here to fight and did. The result is a separate matter.",
|
||||
"%s is already planning the rematch. You can feel it.",
|
||||
}
|
||||
return fmt.Sprintf(closers[rand.IntN(len(closers))], loserName)
|
||||
}
|
||||
|
||||
func arenaLoseCloser(winnerName string, lastRound int) string {
|
||||
closers := []string{
|
||||
"You fought. It counts.",
|
||||
"You'll be back. The arena keeps score.",
|
||||
fmt.Sprintf("You have until tomorrow to think about round %d.", lastRound),
|
||||
fmt.Sprintf("You gave %s more trouble than they'd like to admit. Small comfort. Still comfort.", winnerName),
|
||||
"You lose this one. The next one is an open question.",
|
||||
"You came here to fight and did. The result is a separate matter.",
|
||||
fmt.Sprintf("%s won this one. You're already planning the rematch.", winnerName),
|
||||
}
|
||||
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.",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package plugin
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ── Arena Tier & Monster Definitions ────────────────────────────────────────
|
||||
//
|
||||
// Five tiers, four monsters each. Death chance, rewards, and XP scale with tier.
|
||||
@@ -21,12 +23,13 @@ type ArenaMonster struct {
|
||||
Flavor string
|
||||
BaseLethality float64
|
||||
ThreatLevel int
|
||||
Ability *MonsterAbility // nil = no special ability
|
||||
}
|
||||
|
||||
var arenaTiers = [5]ArenaTier{
|
||||
// Tier 1 — Scrubs
|
||||
{
|
||||
Number: 1, Name: "Scrubs", MinLevel: 1,
|
||||
Number: 1, Name: "Scrubs", MinLevel: 1, // DnD Level 1–3
|
||||
BasePayout: 150, SkillMultiplier: 1.0, CompletionBonus: 2500, BattleXP: 10,
|
||||
Monsters: [4]ArenaMonster{
|
||||
{
|
||||
@@ -53,7 +56,7 @@ var arenaTiers = [5]ArenaTier{
|
||||
},
|
||||
// Tier 2 — Thugs
|
||||
{
|
||||
Number: 2, Name: "Thugs", MinLevel: 10,
|
||||
Number: 2, Name: "Thugs", MinLevel: 4, // DnD Level 4–7
|
||||
BasePayout: 500, SkillMultiplier: 2.5, CompletionBonus: 10000, BattleXP: 25,
|
||||
Monsters: [4]ArenaMonster{
|
||||
{
|
||||
@@ -75,12 +78,13 @@ 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Tier 3 — Brutes
|
||||
{
|
||||
Number: 3, Name: "Brutes", MinLevel: 25,
|
||||
Number: 3, Name: "Brutes", MinLevel: 8, // DnD Level 8–12
|
||||
BasePayout: 1500, SkillMultiplier: 6.0, CompletionBonus: 30000, BattleXP: 60,
|
||||
Monsters: [4]ArenaMonster{
|
||||
{
|
||||
@@ -92,6 +96,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,18 +107,20 @@ 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Tier 4 — Horrors
|
||||
{
|
||||
Number: 4, Name: "Horrors", MinLevel: 45,
|
||||
Number: 4, Name: "Horrors", MinLevel: 13, // DnD Level 13–17
|
||||
BasePayout: 5000, SkillMultiplier: 12.0, CompletionBonus: 100000, BattleXP: 120,
|
||||
Monsters: [4]ArenaMonster{
|
||||
{
|
||||
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,38 +131,44 @@ 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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Tier 5 — World Eaters
|
||||
{
|
||||
Number: 5, Name: "World Eaters", MinLevel: 70,
|
||||
Number: 5, Name: "World Eaters", MinLevel: 18, // DnD Level 18–20
|
||||
BasePayout: 15000, SkillMultiplier: 25.0, CompletionBonus: 500000, BattleXP: 250,
|
||||
Monsters: [4]ArenaMonster{
|
||||
{
|
||||
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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -179,6 +192,93 @@ func arenaGetMonster(tier, round int) *ArenaMonster {
|
||||
return &t.Monsters[round-1]
|
||||
}
|
||||
|
||||
// ── Adv 2.0 boss-flow bestiary (Phase L2 step 3) ────────────────────────────
|
||||
//
|
||||
// arenaBosses re-shapes the legacy arenaTiers data into boss-shaped
|
||||
// DnDMonsterTemplate entries so resolveArenaBoss (Phase L2 step 4) can
|
||||
// run an arena fight through the same runZoneCombat → renderBossOutcome
|
||||
// flow that zone bosses use. Keyed by arenaBossID(tier, round) — IDs
|
||||
// are namespaced "arena_t<tier>_r<round>" so they don't collide with
|
||||
// dndBestiary keys.
|
||||
//
|
||||
// HP/AC/Attack are first-pass tier-banded values; BaseLethality biases
|
||||
// each monster up or down within its tier band so round-1 is the
|
||||
// weakest fight and round-4 is the cap. Tuning is ongoing against
|
||||
// playtest data (gogobee_legacy_migration.md §4 Risk).
|
||||
var arenaBosses = map[string]DnDMonsterTemplate{}
|
||||
|
||||
// arenaBossID composes the canonical arena bestiary key.
|
||||
func arenaBossID(tier, round int) string {
|
||||
return fmt.Sprintf("arena_t%d_r%d", tier, round)
|
||||
}
|
||||
|
||||
// arenaBossPhaseTwoAt returns the PhaseTwoAt fraction (of MaxHP) for an
|
||||
// arena fight at the given tier. T1–T2 = no phase two; T3+ = 50% HP.
|
||||
// The T4–T5 flavor barb piggybacks on the standard phase-two narration
|
||||
// path through ZoneArena's bossPhaseTwoPool (populated alongside arena
|
||||
// flavor when those lines land).
|
||||
func arenaBossPhaseTwoAt(tier int) float64 {
|
||||
if tier >= 3 {
|
||||
return 0.5
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// arenaTierBaseStats returns the (HP, AC, Attack, AttackBonus, CR)
|
||||
// baseline for a given arena tier. arenaMonsterToTemplate then biases
|
||||
// HP/Attack by BaseLethality so within-tier ordering matches the legacy
|
||||
// difficulty curve.
|
||||
func arenaTierBaseStats(tier int) (hp, ac, atk, ab int, cr float32) {
|
||||
switch tier {
|
||||
case 1:
|
||||
return 30, 13, 8, 4, 1
|
||||
case 2:
|
||||
return 80, 14, 14, 6, 4
|
||||
case 3:
|
||||
return 160, 15, 22, 7, 8
|
||||
case 4:
|
||||
return 260, 16, 32, 8, 13
|
||||
case 5:
|
||||
return 400, 18, 45, 10, 18
|
||||
}
|
||||
return 30, 13, 8, 4, 1
|
||||
}
|
||||
|
||||
// arenaMonsterToTemplate maps a legacy ArenaMonster onto a
|
||||
// DnDMonsterTemplate. HP/AC/Attack scale with tier; within a tier,
|
||||
// BaseLethality biases the curve via a ±30% band so round-1 sits at
|
||||
// the bottom of the tier and round-4 at the top.
|
||||
func arenaMonsterToTemplate(id string, m ArenaMonster, tier int) DnDMonsterTemplate {
|
||||
hp, ac, atk, ab, cr := arenaTierBaseStats(tier)
|
||||
bias := (m.BaseLethality - 0.5) * 0.6 // ≈ -0.24 .. +0.29
|
||||
hp = int(float64(hp) * (1 + bias))
|
||||
atk = int(float64(atk) * (1 + bias))
|
||||
if hp < 1 {
|
||||
hp = 1
|
||||
}
|
||||
if atk < 1 {
|
||||
atk = 1
|
||||
}
|
||||
return DnDMonsterTemplate{
|
||||
ID: id, Name: m.Name, CR: cr,
|
||||
HP: hp, AC: ac, Attack: atk, AttackBonus: ab, Speed: 12,
|
||||
BlockRate: 0.05,
|
||||
Ability: m.Ability,
|
||||
XPValue: arenaTiers[tier-1].BattleXP * 4,
|
||||
Notes: m.Flavor,
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
for ti, t := range arenaTiers {
|
||||
tier := ti + 1
|
||||
for ri, m := range t.Monsters {
|
||||
id := arenaBossID(tier, ri+1)
|
||||
arenaBosses[id] = arenaMonsterToTemplate(id, m, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Arena Death Flavor Text ─────────────────────────────────────────────────
|
||||
|
||||
var arenaDeathMessages = []string{
|
||||
|
||||
@@ -8,22 +8,27 @@ import (
|
||||
|
||||
// ── Arena Tier Menu ─────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaTierMenu(char *AdventureCharacter, stats *ArenaPersonalStats) string {
|
||||
func renderArenaStreakEntry(c *DnDCharacter, stats *ArenaPersonalStats, tier *ArenaTier, firstMonster *ArenaMonster) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("⚔️ **THE ARENA**\n\n")
|
||||
b.WriteString(fmt.Sprintf("Combat Level: %d\n\n", char.CombatLevel))
|
||||
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("Level: %d\n\n", c.Level))
|
||||
|
||||
for i := range arenaTiers {
|
||||
t := &arenaTiers[i]
|
||||
eligible := char.CombatLevel >= t.MinLevel
|
||||
eligible := c.Level >= 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,38 +87,22 @@ 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func renderArenaStatus(run *ArenaRun, char *AdventureCharacter) string {
|
||||
func renderArenaStatus(run *ArenaRun) string {
|
||||
tier := arenaGetTier(run.Tier)
|
||||
if tier == nil {
|
||||
return "No active arena run."
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -230,9 +171,12 @@ type ArenaPersonalStats struct {
|
||||
Tier5Completions int
|
||||
}
|
||||
|
||||
func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStats) string {
|
||||
// renderArenaPersonalStats renders the !arena stats DM. Wins/losses come
|
||||
// from player_meta post-Phase L2 step 5 (the caller is responsible for
|
||||
// loading them); displayName is sourced from the AdvCharacter.
|
||||
func renderArenaPersonalStats(displayName string, wins, losses int, stats *ArenaPersonalStats) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", char.DisplayName))
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s's Arena Stats**\n\n", displayName))
|
||||
|
||||
if stats == nil || stats.TotalRuns == 0 {
|
||||
b.WriteString("No arena runs yet. Type `!arena` to begin.")
|
||||
@@ -253,7 +197,7 @@ func renderArenaPersonalStats(char *AdventureCharacter, stats *ArenaPersonalStat
|
||||
b.WriteString(fmt.Sprintf("Tier 5 completions: %d\n", stats.Tier5Completions))
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", char.ArenaWins, char.ArenaLosses))
|
||||
b.WriteString(fmt.Sprintf("\nArena W/L: %d/%d", wins, losses))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -276,7 +220,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 +249,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)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ── Monster Data Tests ──────────────────────────────────────────────────────
|
||||
@@ -106,96 +106,6 @@ func TestArenaMinLevel_Increasing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Death Chance Formula Tests ──────────────────────────────────────────────
|
||||
|
||||
func TestArenaDeathChance_Clamped(t *testing.T) {
|
||||
// Min-level player with tier 0 gear vs easiest monster
|
||||
char := &AdventureCharacter{CombatLevel: 1}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
|
||||
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
|
||||
}
|
||||
monster := arenaGetMonster(1, 1)
|
||||
dc := arenaDeathChance(monster, char, equip)
|
||||
if dc < 0.01 || dc > 0.98 {
|
||||
t.Errorf("death chance %f out of [0.01, 0.98] bounds", dc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaDeathChance_MaxGearReduces(t *testing.T) {
|
||||
// Use a mid-tier monster where gear difference is visible above the floor
|
||||
char := &AdventureCharacter{CombatLevel: 25}
|
||||
monster := arenaGetMonster(3, 3) // Behemoth Adjacent (0.73 lethality)
|
||||
|
||||
noGear := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
|
||||
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
|
||||
}
|
||||
maxGear := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
|
||||
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
|
||||
}
|
||||
|
||||
dcNoGear := arenaDeathChance(monster, char, noGear)
|
||||
dcMaxGear := arenaDeathChance(monster, char, maxGear)
|
||||
|
||||
if dcMaxGear >= dcNoGear {
|
||||
t.Errorf("max gear (%f) should reduce death chance vs no gear (%f)", dcMaxGear, dcNoGear)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaDeathChance_HighLevelReduces(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 3}, SlotArmor: {Tier: 3}, SlotHelmet: {Tier: 3},
|
||||
SlotBoots: {Tier: 3}, SlotTool: {Tier: 3},
|
||||
}
|
||||
monster := arenaGetMonster(3, 4) // The Inevitable
|
||||
|
||||
lowLevel := &AdventureCharacter{CombatLevel: 25}
|
||||
highLevel := &AdventureCharacter{CombatLevel: 50}
|
||||
|
||||
dcLow := arenaDeathChance(monster, lowLevel, equip)
|
||||
dcHigh := arenaDeathChance(monster, highLevel, equip)
|
||||
|
||||
if dcHigh >= dcLow {
|
||||
t.Errorf("high level (%f) should have lower death chance than low level (%f)", dcHigh, dcLow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaDeathChance_T5R4_AlwaysTerrifying(t *testing.T) {
|
||||
// Even max-everything player faces high death at T5R4
|
||||
char := &AdventureCharacter{CombatLevel: 50}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
|
||||
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
|
||||
}
|
||||
monster := arenaGetMonster(5, 4) // That Which Has Always Been
|
||||
|
||||
dc := arenaDeathChance(monster, char, equip)
|
||||
if dc < 0.30 {
|
||||
t.Errorf("T5R4 death chance for max player (%f) should be >= 0.30", dc)
|
||||
}
|
||||
// Should hit the 0.98 ceiling
|
||||
if dc > 0.98 {
|
||||
t.Errorf("T5R4 death chance (%f) exceeds ceiling 0.98", dc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArenaDeathChance_Floor(t *testing.T) {
|
||||
// Even with absurd stats, floor is 0.01
|
||||
char := &AdventureCharacter{CombatLevel: 50}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 5}, SlotArmor: {Tier: 5}, SlotHelmet: {Tier: 5},
|
||||
SlotBoots: {Tier: 5}, SlotTool: {Tier: 5},
|
||||
}
|
||||
monster := &ArenaMonster{BaseLethality: 0.01, ThreatLevel: 1}
|
||||
|
||||
dc := arenaDeathChance(monster, char, equip)
|
||||
if dc < 0.01 {
|
||||
t.Errorf("death chance %f below floor 0.01", dc)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reward Formula Tests ────────────────────────────────────────────────────
|
||||
|
||||
func TestArenaRoundReward(t *testing.T) {
|
||||
@@ -242,66 +152,43 @@ func TestArenaRewardScaling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Death Chance Formula Component Tests ────────────────────────────────────
|
||||
|
||||
func TestArenaDeathChance_Components(t *testing.T) {
|
||||
// Verify formula components individually
|
||||
// death_chance = base + level_mod - equip_mod + skill_mod
|
||||
|
||||
// Level 1, tier 0 gear, T1R1 monster (lethality=0.10, threat=2)
|
||||
char := &AdventureCharacter{CombatLevel: 1}
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotWeapon: {Tier: 0}, SlotArmor: {Tier: 0}, SlotHelmet: {Tier: 0},
|
||||
SlotBoots: {Tier: 0}, SlotTool: {Tier: 0},
|
||||
}
|
||||
monster := &ArenaMonster{BaseLethality: 0.10, ThreatLevel: 2}
|
||||
|
||||
dc := arenaDeathChance(monster, char, equip)
|
||||
|
||||
// Manual calculation:
|
||||
// base = 0.10
|
||||
// level_mod = (2-1) * 0.015 = 0.015
|
||||
// skill_mod = max(0, 0.25 - 1*0.008) = 0.242
|
||||
// equip_mod = 0 * 0.03 = 0 (tier 0 gives no equipment bonus)
|
||||
// death_chance = 0.10 + 0.015 - 0 + 0.242 = 0.357
|
||||
expected := 0.10 + 0.015 + 0.242
|
||||
if math.Abs(dc-expected) > 0.001 {
|
||||
t.Errorf("T1R1 components: got %f, expected ~%f", dc, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestRenderArenaTierMenu(t *testing.T) {
|
||||
char := &AdventureCharacter{CombatLevel: 30}
|
||||
func TestRenderArenaStreakEntry(t *testing.T) {
|
||||
// Level 5 unlocks T1+T2 only (post-L2 brackets: 1/4/8/13/18); T3-T5 locked.
|
||||
char := &DnDCharacter{Level: 5}
|
||||
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 +212,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 +228,411 @@ 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 := &DnDCharacter{Level: 20}
|
||||
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 18 (post-L2 D&D scale); level 20 unlocks everything.
|
||||
char := &DnDCharacter{Level: 20}
|
||||
tier := arenaGetTier(1)
|
||||
monster := arenaGetMonster(1, 1)
|
||||
|
||||
text := renderArenaStreakEntry(char, nil, tier, monster)
|
||||
// Level 20 (D&D cap) should make all tiers eligible (no 🔒)
|
||||
if strings.Contains(text, "🔒") {
|
||||
t.Error("level 20 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}
|
||||
|
||||
text := renderArenaStatus(run)
|
||||
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}
|
||||
|
||||
text := renderArenaStatus(run)
|
||||
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) {
|
||||
text := renderArenaPersonalStats("TestPlayer", 0, 0, nil)
|
||||
if !strings.Contains(text, "No arena runs") {
|
||||
t.Error("empty stats should say no runs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaPersonalStats_WithStats(t *testing.T) {
|
||||
stats := &ArenaPersonalStats{
|
||||
TotalRuns: 11, TotalEarnings: 150000, TotalDeaths: 3,
|
||||
HighestTier: 5, Tier5Completions: 2,
|
||||
}
|
||||
text := renderArenaPersonalStats("Alice", 8, 3, 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,20 +662,19 @@ func TestRenderArenaLeaderboard_WithEntries(t *testing.T) {
|
||||
|
||||
func TestRenderArenaLevelGate(t *testing.T) {
|
||||
tier := arenaGetTier(3)
|
||||
text := renderArenaLevelGate(tier, 10)
|
||||
if !strings.Contains(text, "Level 25") {
|
||||
text := renderArenaLevelGate(tier, 5)
|
||||
if !strings.Contains(text, "Level 8") {
|
||||
t.Error("gate message should show required level")
|
||||
}
|
||||
if !strings.Contains(text, "Level 10") {
|
||||
if !strings.Contains(text, "Level 5") {
|
||||
t.Error("gate message should show player level")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderArenaStatus(t *testing.T) {
|
||||
run := &ArenaRun{Tier: 3, Round: 2, Status: "active", Earnings: 5000, RoundsSurvived: 5}
|
||||
char := &AdventureCharacter{CombatLevel: 30}
|
||||
|
||||
text := renderArenaStatus(run, char)
|
||||
text := renderArenaStatus(run)
|
||||
if !strings.Contains(text, "Tier: 3") {
|
||||
t.Error("status should show tier")
|
||||
}
|
||||
@@ -525,3 +739,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,
|
||||
}
|
||||
skillBonus := 20
|
||||
|
||||
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, skillBonus)
|
||||
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, skillBonus)
|
||||
}
|
||||
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 := &DnDCharacter{Level: 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)
|
||||
}
|
||||
}
|
||||
|
||||
394
internal/plugin/adventure_babysit.go
Normal file
394
internal/plugin/adventure_babysit.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Pricing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// babysitDailyCost returns the daily babysit subscription cost in €.
|
||||
// Phase L (post-L5g): keyed off D&D Level instead of legacy CombatLevel.
|
||||
// The slope is 5× the old per-level slope to preserve the curve shape across
|
||||
// the 5:1 compression in dndLevelFromCombatLevel — Level 4 (~old CL 20) =
|
||||
// €500/day, Level 10 (~old CL 50) = €1100/day, matching pre-migration pricing.
|
||||
func babysitDailyCost(level int) int {
|
||||
return 100 + (level * 100)
|
||||
}
|
||||
|
||||
// ── Pet-care daily trickle ─────────────────────────────────────────────────
|
||||
|
||||
// petXPPerBabysitDay is the daily pet XP awarded while a babysit subscription
|
||||
// is active. Picked to be a meaningful but not overwhelming push toward L10:
|
||||
// roughly equivalent to a couple of player-driven actions per day.
|
||||
const petXPPerBabysitDay = 3
|
||||
|
||||
// runBabysitDailyTrickle grants daily pet XP while a babysit subscription is
|
||||
// active and logs an entry for the end-of-service summary. Caller is
|
||||
// responsible for saving the character afterwards.
|
||||
func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
|
||||
if !char.BabysitActive {
|
||||
return
|
||||
}
|
||||
leveled := false
|
||||
if char.HasPet() && char.PetLevel < 10 {
|
||||
// Bypass petGrantXP's per-action constant — we want a flat trickle.
|
||||
char.PetXP += petXPPerBabysitDay * 100
|
||||
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")
|
||||
}
|
||||
}
|
||||
outcome := "pet_care"
|
||||
if leveled {
|
||||
outcome = "pet_care_levelup"
|
||||
}
|
||||
logBabysitActivity(char.UserID, "pet_care", outcome, 0, petXPPerBabysitDay, "")
|
||||
}
|
||||
|
||||
// BabysitSafeRest reports whether the user has an active babysit subscription
|
||||
// that should let standard camps qualify for fortified-tier rest perks.
|
||||
// Returns false on any error (treat as no babysit). Safe to call from tests
|
||||
// where the global DB has not been initialized — the panic is swallowed.
|
||||
func BabysitSafeRest(userID id.UserID) (active bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
active = false
|
||||
}
|
||||
}()
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.BabysitActive {
|
||||
return false
|
||||
}
|
||||
if char.BabysitExpiresAt != nil && time.Now().UTC().After(*char.BabysitExpiresAt) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Command Handlers ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitCmd(ctx MessageContext, args string) error {
|
||||
lower := strings.ToLower(strings.TrimSpace(args))
|
||||
|
||||
switch {
|
||||
case lower == "status":
|
||||
return p.handleBabysitStatus(ctx)
|
||||
case lower == "cancel":
|
||||
return p.handleBabysitCancel(ctx)
|
||||
case lower == "week":
|
||||
return p.handleBabysitPurchase(ctx, 7)
|
||||
case lower == "month":
|
||||
return p.handleBabysitPurchase(ctx, 30)
|
||||
default:
|
||||
return p.SendDM(ctx.Sender, "🍼 **Adventurer Babysitting Service**\n\n"+
|
||||
"Hire a babysitter to look after your camp and tend the pet while you sleep:\n"+
|
||||
" • Daily pet XP trickle (your pet still grows while you focus elsewhere)\n"+
|
||||
" • Standard camps act like fortified ones — rest deeply, no need to have downed the zone boss\n"+
|
||||
" • Rival duels declined on your behalf\n\n"+
|
||||
"`!adventure babysit week` — 7 days of service\n"+
|
||||
"`!adventure babysit month` — 30 days of service\n"+
|
||||
"`!adventure babysit status` — check service status\n"+
|
||||
"`!adventure babysit cancel` — cancel early (no refund)")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found. Type `!adventure` to create one.")
|
||||
}
|
||||
|
||||
if char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 The babysitter is already here. They're not leaving until the job is done.")
|
||||
}
|
||||
|
||||
if !char.Alive {
|
||||
return p.SendDM(ctx.Sender, "Your adventurer is dead. The babysitter does not work with corpses.")
|
||||
}
|
||||
|
||||
daily := babysitDailyCost(dndLevelForUser(char.UserID))
|
||||
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)))
|
||||
}
|
||||
|
||||
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.")
|
||||
}
|
||||
|
||||
clearBabysitLogs(char.UserID)
|
||||
|
||||
expires := time.Now().UTC().Add(time.Duration(days) * 24 * time.Hour)
|
||||
char.BabysitActive = true
|
||||
char.BabysitExpiresAt = &expires
|
||||
char.BabysitSkillFocus = "" // legacy field; no longer used
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character", "user", char.UserID, "err", err)
|
||||
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.")
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
confirm := pickBabysitFlavor(babysitConfirmLines)
|
||||
durLabel := "1 week"
|
||||
if days == 30 {
|
||||
durLabel = "1 month"
|
||||
}
|
||||
|
||||
petLine := "No pet to tend yet — the babysitter will keep that in mind."
|
||||
if char.HasPet() {
|
||||
petLine = fmt.Sprintf("Pet: %s (L%d) — daily care included", char.PetName, char.PetLevel)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("🍼 **Adventurer Babysitting Service — Activated**\n\n"+
|
||||
"Duration: %s (%d days)\n"+
|
||||
"Cost: €%d\n"+
|
||||
"%s\n"+
|
||||
"Camp safety: standard camps now rest like fortified ones\n"+
|
||||
"Rival duels: declined on your behalf\n\n"+
|
||||
"_%s_", durLabel, days, totalCost, petLine, confirm)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitStatus(ctx MessageContext) error {
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 No active babysitting service.\n\nUse `!adventure babysit week` or `!adventure babysit month` to start. The babysitter tends your pet daily and lets you rest deeply at standard camps.")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := loadBabysitLogs(char.UserID)
|
||||
if err != nil {
|
||||
slog.Error("babysit: failed to load logs", "user", char.UserID, "err", err)
|
||||
}
|
||||
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
|
||||
|
||||
petLine := "No pet to tend"
|
||||
if char.HasPet() {
|
||||
petLine = fmt.Sprintf("%s (L%d)", char.PetName, char.PetLevel)
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("🍼 **Babysitting Service — Status**\n\n"+
|
||||
"Time remaining: %s\n"+
|
||||
"Pet under care: %s\n"+
|
||||
"Days of pet care given: %d\n"+
|
||||
"Pet XP trickled: %d\n"+
|
||||
"Rivals declined: %d",
|
||||
remaining, petLine, petDays, totalXP, rivalsRefused)
|
||||
|
||||
return p.SendDM(ctx.Sender, text)
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
userMu := p.advUserLock(ctx.Sender)
|
||||
userMu.Lock()
|
||||
defer userMu.Unlock()
|
||||
|
||||
char, err := loadAdvCharacter(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "No adventurer found.")
|
||||
}
|
||||
|
||||
if !char.BabysitActive {
|
||||
return p.SendDM(ctx.Sender, "🍼 There's nothing to cancel. The babysitter isn't here.")
|
||||
}
|
||||
|
||||
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 cancel", "user", char.UserID, "err", err)
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
|
||||
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 := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(&char)); err != nil {
|
||||
slog.Error("player_meta: babysit expiry dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
totalXP, petDays, rivalsRefused := babysitLogStats(logs)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🍼 **BABYSITTING SERVICE — END OF REPORT**\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Days of service: %d\n", petDays))
|
||||
if char.HasPet() {
|
||||
sb.WriteString(fmt.Sprintf("Pet looked after: %s (L%d)\n", char.PetName, char.PetLevel))
|
||||
} else {
|
||||
sb.WriteString("Pet looked after: none — the babysitter played solitaire.\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("Pet XP trickled: %d\n", totalXP))
|
||||
|
||||
if rivalsRefused > 0 {
|
||||
sb.WriteString(fmt.Sprintf("\nRival challenges: %d declined\n", rivalsRefused))
|
||||
for _, log := range logs {
|
||||
if log.RivalRefused != "" {
|
||||
line := pickBabysitFlavor(babysitRivalRefusalLines)
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", fmt.Sprintf(line, log.RivalRefused, log.LogDate)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + pickBabysitFlavor(babysitDiaperLines))
|
||||
sb.WriteString("\n\nYour adventurer is fed and rested. The pet is suspiciously well-trained.")
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stats helper ────────────────────────────────────────────────────────────
|
||||
|
||||
func babysitLogStats(logs []babysitLogEntry) (totalXP, petDays, rivalsRefused int) {
|
||||
for _, l := range logs {
|
||||
totalXP += l.XPGained
|
||||
if l.Activity == "pet_care" {
|
||||
petDays++
|
||||
}
|
||||
if l.RivalRefused != "" {
|
||||
rivalsRefused++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
108
internal/plugin/adventure_babysit_test.go
Normal file
108
internal/plugin/adventure_babysit_test.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// L1: babysit pivot from harvest to pet-care + safe-rest.
|
||||
// These tests cover the pure pieces — DB-touching paths exercise via
|
||||
// integration only.
|
||||
|
||||
func TestBabysitSafeRest_NoDB_ReturnsFalse(t *testing.T) {
|
||||
// Tests run without db.Init(); BabysitSafeRest must recover and
|
||||
// return false rather than panicking.
|
||||
if BabysitSafeRest(id.UserID("@nodb:example")) {
|
||||
t.Errorf("expected false for un-initialized DB")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBabysitDailyTrickle_NoPetGrantsNothing(t *testing.T) {
|
||||
// Without a pet, the trickle is a logging-only no-op for character
|
||||
// state. Without DB, the log call panics inside db.Get; recover and
|
||||
// assert that the in-memory char fields stay clean.
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
expires := time.Now().UTC().Add(7 * 24 * time.Hour)
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@nopet:example"),
|
||||
BabysitActive: true,
|
||||
BabysitExpiresAt: &expires,
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.runBabysitDailyTrickle(char)
|
||||
if char.PetXP != 0 {
|
||||
t.Errorf("char without pet should not gain PetXP, got %d", char.PetXP)
|
||||
}
|
||||
if char.PetLevel != 0 {
|
||||
t.Errorf("char without pet should not change PetLevel, got %d", char.PetLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBabysitDailyTrickle_SkipsWhenInactive(t *testing.T) {
|
||||
defer func() { _ = recover() }()
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@inactive:example"),
|
||||
BabysitActive: false,
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1,
|
||||
PetXP: 0,
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
p.runBabysitDailyTrickle(char)
|
||||
if char.PetXP != 0 {
|
||||
t.Errorf("inactive babysit should grant no XP, got %d", char.PetXP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBabysitDailyTrickle_AccumulatesAndLevels(t *testing.T) {
|
||||
// Pet at L9 with 49 XP needed → 1 day at +3 trickle should *not* level
|
||||
// (49→52 in centi-XP / 100 = no, wait: petXPToNextLevel at L9 = 50 ×
|
||||
// 100 = 5000 centi-XP). Verify we accumulate centi-XP correctly across
|
||||
// multiple daily ticks and eventually level up.
|
||||
defer func() { _ = recover() }()
|
||||
|
||||
char := &AdventureCharacter{
|
||||
UserID: id.UserID("@accum:example"),
|
||||
PetType: "dog",
|
||||
PetName: "Rex",
|
||||
PetLevel: 1, // needs 10 XP = 1000 centi-XP to L2
|
||||
PetXP: 900,
|
||||
BabysitActive: true,
|
||||
}
|
||||
expires := time.Now().UTC().Add(48 * time.Hour)
|
||||
char.BabysitExpiresAt = &expires
|
||||
p := &AdventurePlugin{}
|
||||
|
||||
// One trickle adds 3*100 = 300 centi-XP. 900 + 300 = 1200 >= 1000 →
|
||||
// level to 2, carry 200 centi-XP.
|
||||
p.runBabysitDailyTrickle(char)
|
||||
if char.PetLevel != 2 {
|
||||
t.Errorf("expected level 2 after trickle, got %d (xp=%d)", char.PetLevel, char.PetXP)
|
||||
}
|
||||
if char.PetXP != 200 {
|
||||
t.Errorf("expected 200 carryover centi-XP, got %d", char.PetXP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBabysitLogStats_CountsPetCareDays(t *testing.T) {
|
||||
logs := []babysitLogEntry{
|
||||
{Activity: "pet_care", XPGained: 3},
|
||||
{Activity: "pet_care", XPGained: 3},
|
||||
{Activity: "pet_care", XPGained: 3, RivalRefused: ""},
|
||||
{Activity: "rival_refused", RivalRefused: "Garth"},
|
||||
}
|
||||
xp, days, rivals := babysitLogStats(logs)
|
||||
if days != 3 {
|
||||
t.Errorf("petDays = %d, want 3", days)
|
||||
}
|
||||
if xp != 9 {
|
||||
t.Errorf("totalXP = %d, want 9", xp)
|
||||
}
|
||||
if rivals != 1 {
|
||||
t.Errorf("rivalsRefused = %d, want 1", rivals)
|
||||
}
|
||||
}
|
||||
405
internal/plugin/adventure_blacksmith.go
Normal file
405
internal/plugin/adventure_blacksmith.go
Normal 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))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -39,6 +38,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 +50,55 @@ 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
|
||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
||||
StreakDecayed bool
|
||||
CraftsSucceeded int
|
||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
@@ -195,40 +243,154 @@ 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. source is
|
||||
// "adventure" or "arena"; location is a human-readable place name used by the
|
||||
// daily report and standout-loss flavor.
|
||||
func (c *AdventureCharacter) Kill(source, location string) {
|
||||
c.Alive = false
|
||||
deadUntil := time.Now().UTC().Add(6 * time.Hour)
|
||||
c.DeadUntil = &deadUntil
|
||||
c.LastDeathDate = time.Now().UTC().Format("2006-01-02")
|
||||
c.DeathSource = source
|
||||
c.DeathLocation = location
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if c.CombatActionsUsed > 0 || c.HarvestActionsUsed > 0 {
|
||||
return true
|
||||
}
|
||||
// DnD-side flows (expedition / zone / rest / autopilot) don't touch
|
||||
// the legacy action counters; they credit the day via LastActionDate
|
||||
// instead. Honor that so a player who ran an expedition all day and
|
||||
// extracted before midnight still counts as having acted.
|
||||
return c.LastActionDate == time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// markActedToday stamps the player's LastActionDate to today so the
|
||||
// midnight reset credits the day. Safe to call on every DnD-side action;
|
||||
// no-ops if the date is already today.
|
||||
func markActedToday(userID id.UserID) {
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
c, err := loadAdvCharacter(userID)
|
||||
if err != nil || c == nil {
|
||||
return
|
||||
}
|
||||
if c.LastActionDate == today {
|
||||
return
|
||||
}
|
||||
c.LastActionDate = today
|
||||
_ = saveAdvCharacter(c)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -240,7 +402,17 @@ func xpToNextLevel(level int) int {
|
||||
|
||||
// checkAdvLevelUp checks if a character leveled up in the given skill and applies it.
|
||||
// Returns whether a level-up occurred and the new level.
|
||||
//
|
||||
// Combat is special-cased: once a player has confirmed a D&D character,
|
||||
// combat_level freezes. dnd_level (driven by dnd_xp via grantDnDXP) is
|
||||
// canonical going forward. Combat XP still accrues into combat_xp for
|
||||
// historical display, but never causes combat_level to advance.
|
||||
// Skill levels (mining/foraging/fishing) are unaffected and continue to
|
||||
// progress on their own track per v1.1 §4.
|
||||
func checkAdvLevelUp(char *AdventureCharacter, skill string) (bool, int) {
|
||||
if skill == "combat" && HasCompletedSetup(char.UserID) {
|
||||
return false, char.CombatLevel
|
||||
}
|
||||
var xp *int
|
||||
var level *int
|
||||
switch skill {
|
||||
@@ -281,41 +453,15 @@ 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
|
||||
|
||||
err := d.QueryRow(`
|
||||
SELECT user_id, display_name,
|
||||
combat_level, mining_skill, foraging_skill, fishing_skill,
|
||||
combat_xp, mining_xp, foraging_xp, fishing_xp,
|
||||
alive, dead_until, action_taken_today, holiday_action_taken,
|
||||
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
|
||||
FROM adventure_characters WHERE user_id = ?`, string(userID)).Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
|
||||
&alive, &deadUntil, &actionTaken, &holidayTaken,
|
||||
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
|
||||
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||
&c.MasterworkDropsReceived,
|
||||
)
|
||||
if err != nil {
|
||||
var uid string
|
||||
if err := d.QueryRow(
|
||||
`SELECT user_id FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&uid); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Alive = alive == 1
|
||||
c.ActionTakenToday = actionTaken == 1
|
||||
c.HolidayActionTaken = holidayTaken == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
c := &AdventureCharacter{UserID: id.UserID(uid), Alive: true}
|
||||
applyPlayerMetaOverlay(c)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -327,10 +473,15 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO adventure_characters (user_id, display_name)
|
||||
VALUES (?, ?)`, string(userID), displayName)
|
||||
if err != nil {
|
||||
// Adv 2.0 Phase L5 close-out: player_meta is the canonical seed row.
|
||||
// adventure_characters is no longer written; loadAdvCharacter sources
|
||||
// the user_id from player_meta and overlays every subsystem's state.
|
||||
if _, err = tx.Exec(
|
||||
`INSERT INTO player_meta (user_id, display_name, created_at, last_active_at, alive)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
||||
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`,
|
||||
string(userID), displayName,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -348,39 +499,22 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// saveAdvCharacter persists every mutable AdventureCharacter field to
|
||||
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
|
||||
// retired — the row is now read-only after createAdvCharacter seeds it,
|
||||
// and saveAdvCharacter routes the entire mutable state set through the
|
||||
// per-subsystem player_meta upserts. LastActiveAt is auto-bumped to mirror
|
||||
// the previous CURRENT_TIMESTAMP semantics on save.
|
||||
func saveAdvCharacter(char *AdventureCharacter) error {
|
||||
d := db.Get()
|
||||
alive := 0
|
||||
if char.Alive {
|
||||
alive = 1
|
||||
}
|
||||
actionTaken := 0
|
||||
if char.ActionTakenToday {
|
||||
actionTaken = 1
|
||||
}
|
||||
holidayTaken := 0
|
||||
if char.HolidayActionTaken {
|
||||
holidayTaken = 1
|
||||
}
|
||||
char.LastActiveAt = time.Now().UTC()
|
||||
return upsertAllPlayerMetaFromAdvChar(char)
|
||||
}
|
||||
|
||||
_, err := d.Exec(`
|
||||
UPDATE adventure_characters SET
|
||||
display_name = ?, combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ?,
|
||||
combat_xp = ?, mining_xp = ?, foraging_xp = ?, fishing_xp = ?,
|
||||
alive = ?, dead_until = ?, action_taken_today = ?, holiday_action_taken = ?,
|
||||
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 = ?
|
||||
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),
|
||||
)
|
||||
return err
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func loadAdvEquipment(userID id.UserID) (map[EquipmentSlot]*AdvEquipment, error) {
|
||||
@@ -484,59 +618,31 @@ func advInventoryCount(userID id.UserID) int {
|
||||
|
||||
func loadAllAdvCharacters() ([]AdventureCharacter, error) {
|
||||
d := db.Get()
|
||||
rows, err := d.Query(`
|
||||
SELECT user_id, display_name,
|
||||
combat_level, mining_skill, foraging_skill, fishing_skill,
|
||||
combat_xp, mining_xp, foraging_xp, fishing_xp,
|
||||
alive, dead_until, action_taken_today, holiday_action_taken,
|
||||
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
|
||||
FROM adventure_characters`)
|
||||
rows, err := d.Query(`SELECT user_id FROM player_meta`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var chars []AdventureCharacter
|
||||
var uids []string
|
||||
for rows.Next() {
|
||||
c := AdventureCharacter{}
|
||||
var alive, actionTaken, holidayTaken int
|
||||
var deadUntil, reprieveLast sql.NullTime
|
||||
if err := rows.Scan(
|
||||
&c.UserID, &c.DisplayName,
|
||||
&c.CombatLevel, &c.MiningSkill, &c.ForagingSkill, &c.FishingSkill,
|
||||
&c.CombatXP, &c.MiningXP, &c.ForagingXP, &c.FishingXP,
|
||||
&alive, &deadUntil, &actionTaken, &holidayTaken,
|
||||
&c.ArenaWins, &c.ArenaLosses, &c.InvasionScore, &c.Title,
|
||||
&c.CurrentStreak, &c.BestStreak, &c.LastActionDate, &c.GrudgeLocation,
|
||||
&c.CreatedAt, &c.LastActiveAt, &reprieveLast,
|
||||
&c.MasterworkDropsReceived,
|
||||
); err != nil {
|
||||
var uid string
|
||||
if err := rows.Scan(&uid); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
c.Alive = alive == 1
|
||||
c.ActionTakenToday = actionTaken == 1
|
||||
c.HolidayActionTaken = holidayTaken == 1
|
||||
if deadUntil.Valid {
|
||||
c.DeadUntil = &deadUntil.Time
|
||||
}
|
||||
if reprieveLast.Valid {
|
||||
c.DeathReprieveLast = &reprieveLast.Time
|
||||
}
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
chars := make([]AdventureCharacter, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
c := AdventureCharacter{UserID: id.UserID(uid), Alive: true}
|
||||
applyPlayerMetaOverlay(&c)
|
||||
chars = append(chars, c)
|
||||
}
|
||||
return chars, rows.Err()
|
||||
}
|
||||
|
||||
func resetAllAdvDailyActions() error {
|
||||
d := db.Get()
|
||||
// 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)
|
||||
return err
|
||||
return chars, nil
|
||||
}
|
||||
|
||||
func logAdvActivity(userID id.UserID, activityType, location, outcome string, lootValue int64, xpGained int, flavorKey string) {
|
||||
@@ -596,14 +702,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
internal/plugin/adventure_chat_perks.go
Normal file
38
internal/plugin/adventure_chat_perks.go
Normal 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?"
|
||||
}
|
||||
}
|
||||
563
internal/plugin/adventure_consumables.go
Normal file
563
internal/plugin/adventure_consumables.go
Normal file
@@ -0,0 +1,563 @@
|
||||
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. Falls
|
||||
// through to the magic-item registry so SRD potions/scrolls picked up as loot
|
||||
// or bought from Luigi's Curios shelf resolve in combat without being added
|
||||
// to the hardcoded consumableDefs table (see magic_items_gameplay.go).
|
||||
func consumableDefByName(name string) *ConsumableDef {
|
||||
for i := range consumableDefs {
|
||||
if consumableDefs[i].Name == name {
|
||||
return &consumableDefs[i]
|
||||
}
|
||||
}
|
||||
return magicItemConsumableDefByName(name)
|
||||
}
|
||||
|
||||
// ── Threat Assessment ────────────────────────────────────────────────────────
|
||||
|
||||
type threatLevel int
|
||||
|
||||
const (
|
||||
threatTrivial threatLevel = iota // < 0.4
|
||||
threatEasy // 0.4–0.7
|
||||
threatCompetitive // 0.7–1.0
|
||||
threatDangerous // > 1.0
|
||||
)
|
||||
|
||||
// assessThreat estimates rounds-to-kill in both directions using the same
|
||||
// penetration model as the combat engine, then bands the threat by the ratio
|
||||
// of player-RTK to enemy-RTK. Old additive HP+Attack model ignored Defense and
|
||||
// rated even fights as "trivial" — players died after consumables were skipped.
|
||||
func assessThreat(player, enemy CombatStats) threatLevel {
|
||||
const K = 40.0
|
||||
dprPlayer := float64(player.Attack) * (K / (K + float64(enemy.Defense)))
|
||||
dprEnemy := float64(enemy.Attack) * (K / (K + float64(player.Defense)))
|
||||
if dprPlayer < 1 {
|
||||
dprPlayer = 1
|
||||
}
|
||||
if dprEnemy < 1 {
|
||||
dprEnemy = 1
|
||||
}
|
||||
rtkEnemy := float64(enemy.MaxHP) / dprPlayer // rounds for player to kill enemy
|
||||
rtkPlayer := float64(player.MaxHP) / dprEnemy // rounds for enemy to kill player
|
||||
if rtkPlayer <= 0 {
|
||||
return threatDangerous
|
||||
}
|
||||
// ratio < 1: player wins the race (lower = more dominant). >1: enemy wins.
|
||||
ratio := rtkEnemy / rtkPlayer
|
||||
switch {
|
||||
case ratio < 0.35:
|
||||
return threatTrivial
|
||||
case ratio < 0.65:
|
||||
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 is retained on the signature for callers but no longer caps
|
||||
// what gets considered — if it's in your bag, the picker can use it.
|
||||
// allowSkipTrivial=true lets the picker bail out of obvious wins to save
|
||||
// items — appropriate for dungeon dives full of chump rooms, but wrong for
|
||||
// arena/zone elite/boss encounters where the legacy threat assessor
|
||||
// underestimates d20-mode lethality (one bad nat-20 streak ends the run).
|
||||
//
|
||||
// Resource-saving still happens via betterOffensive/betterDefensive, which
|
||||
// prefer lower-tier items on non-Dangerous threats. Players with mixed
|
||||
// inventories don't burn a Voidstone Shard on a goblin; players with only
|
||||
// high-tier items now actually get to use them.
|
||||
func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int, allowSkipTrivial bool) []ConsumableItem {
|
||||
_ = contentTier // retained for caller compatibility
|
||||
threat := assessThreat(playerStats, enemyStats)
|
||||
if threat == threatTrivial && allowSkipTrivial && arenaRound == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var bestOffensive, bestDefensive *ConsumableItem
|
||||
|
||||
for i := range inventory {
|
||||
item := &inventory[i]
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
279
internal/plugin/adventure_consumables_test.go
Normal file
279
internal/plugin/adventure_consumables_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
func makeInventory(names ...string) []ConsumableItem {
|
||||
var items []ConsumableItem
|
||||
for i, name := range names {
|
||||
def := consumableDefByName(name)
|
||||
if def == nil {
|
||||
panic("unknown consumable: " + name)
|
||||
}
|
||||
items = append(items, ConsumableItem{InventoryID: int64(i + 1), Def: def})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func TestSelectConsumables_TrivialFightSkips(t *testing.T) {
|
||||
strong := CombatStats{MaxHP: 200, Attack: 60}
|
||||
weak := CombatStats{MaxHP: 30, Attack: 5}
|
||||
inv := makeInventory("Berry Poultice", "Coal Bomb")
|
||||
|
||||
selected := SelectConsumables(inv, strong, weak, 0, 0, true)
|
||||
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, true)
|
||||
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, true)
|
||||
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, true)
|
||||
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, true)
|
||||
r4 := SelectConsumables(inv, player, enemy, 4, 0, true)
|
||||
|
||||
maxTierR1 := 0
|
||||
for _, s := range r1 {
|
||||
if s.Def.Tier > maxTierR1 {
|
||||
maxTierR1 = s.Def.Tier
|
||||
}
|
||||
}
|
||||
maxTierR4 := 0
|
||||
for _, s := range r4 {
|
||||
if s.Def.Tier > maxTierR4 {
|
||||
maxTierR4 = s.Def.Tier
|
||||
}
|
||||
}
|
||||
if maxTierR4 <= maxTierR1 && len(r4) > 0 && len(r1) > 0 {
|
||||
t.Logf("arena round escalation: R1 maxTier=%d, R4 maxTier=%d", maxTierR1, maxTierR4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Heal(t *testing.T) {
|
||||
stats := CombatStats{MaxHP: 100}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
items := makeInventory("Herb Salve")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.HealItem != 20 {
|
||||
t.Errorf("heal item = %d, want 20", mods.HealItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_AtkBoost(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Goblin Grease")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.DamageBonus < 0.24 || mods.DamageBonus > 0.26 {
|
||||
t.Errorf("damage bonus = %f, want ~0.25", mods.DamageBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Ward(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Quartz Ward")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.WardCharges != 1 {
|
||||
t.Errorf("ward charges = %d, want 1", mods.WardCharges)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_AutoCrit(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Ancient Artifact Oil")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if !mods.AutoCritFirst {
|
||||
t.Error("AutoCritFirst should be true")
|
||||
}
|
||||
if mods.DamageBonus < 0.34 {
|
||||
t.Errorf("damage bonus = %f, want ~0.35", mods.DamageBonus)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_SporeCloud(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Spore Cloud")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.SporeCloud != 2 {
|
||||
t.Errorf("spore rounds = %d, want 2", mods.SporeCloud)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_Reflect(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Voidstone Shard")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.ReflectNext < 0.49 {
|
||||
t.Errorf("reflect = %f, want ~0.50", mods.ReflectNext)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_DefBoost(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{DamageReduct: 1.0}
|
||||
items := makeInventory("Mushroom Brew")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.DamageReduct < 0.79 || mods.DamageReduct > 0.81 {
|
||||
t.Errorf("damage reduct = %f, want ~0.80", mods.DamageReduct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_SpeedBoost(t *testing.T) {
|
||||
stats := CombatStats{Speed: 10}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Blooper Ink Vial")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if stats.Speed != 13 { // 10 * 1.30 = 13
|
||||
t.Errorf("speed = %d, want 13", stats.Speed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_CritBoost(t *testing.T) {
|
||||
stats := CombatStats{CritRate: 0.05}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Sapphire Elixir")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if stats.CritRate < 0.19 || stats.CritRate > 0.21 {
|
||||
t.Errorf("crit rate = %f, want ~0.20", stats.CritRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConsumableMods_FlatDmg(t *testing.T) {
|
||||
stats := CombatStats{}
|
||||
mods := CombatModifiers{}
|
||||
items := makeInventory("Coal Bomb")
|
||||
ApplyConsumableMods(&stats, &mods, items)
|
||||
if mods.FlatDmgStart != 8 {
|
||||
t.Errorf("flat damage = %d, want 8", mods.FlatDmgStart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_ValidActivity(t *testing.T) {
|
||||
drops := 0
|
||||
for i := 0; i < 1000; i++ {
|
||||
item := RollConsumableDrop(AdvActivityMining, 3)
|
||||
if item != nil {
|
||||
drops++
|
||||
if item.Type != "consumable" {
|
||||
t.Errorf("drop type = %q, want consumable", item.Type)
|
||||
}
|
||||
if item.Name != "Quartz Ward" {
|
||||
t.Errorf("mining T3 drop = %q, want Quartz Ward", item.Name)
|
||||
}
|
||||
if item.Value <= 0 {
|
||||
t.Errorf("drop sell value = %d, want > 0", item.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 15% chance → expect ~150 in 1000
|
||||
if drops < 100 || drops > 220 {
|
||||
t.Errorf("drop rate: %d/1000 (expect ~150 at 15%%)", drops)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_InvalidActivity(t *testing.T) {
|
||||
item := RollConsumableDrop(AdvActivityRest, 3)
|
||||
if item != nil {
|
||||
t.Error("rest activity should not drop consumables")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollConsumableDrop_T1NoDrop(t *testing.T) {
|
||||
for i := 0; i < 100; i++ {
|
||||
item := RollConsumableDrop(AdvActivityForaging, 1)
|
||||
if item != nil {
|
||||
t.Fatal("T1 should not drop consumables")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyableConsumables(t *testing.T) {
|
||||
buyable := BuyableConsumables()
|
||||
if len(buyable) == 0 {
|
||||
t.Fatal("should have buyable consumables")
|
||||
}
|
||||
for _, def := range buyable {
|
||||
if !def.Buyable {
|
||||
t.Errorf("%s marked buyable=false but returned by BuyableConsumables", def.Name)
|
||||
}
|
||||
if def.Price <= 0 {
|
||||
t.Errorf("%s has no price", def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,70 +24,85 @@ type advActiveEvent struct {
|
||||
}
|
||||
|
||||
// ── In-memory schedule ───────────────────────────────────────────────────────
|
||||
// Each day, every eligible player is assigned a random minute between 10:00
|
||||
// and 16:00 UTC at which the 0.5% trigger roll happens.
|
||||
// When a player acts on a given day, the next ticker iteration assigns them
|
||||
// a one-shot roll-minute 60–180 minutes in the future. At that minute, the
|
||||
// 0.5% trigger roll fires. Each player rolls at most once per UTC day.
|
||||
|
||||
var (
|
||||
advEventScheduleMu sync.Mutex
|
||||
advEventSchedule map[string]int // userID string -> minute-of-day (600..959)
|
||||
advEventScheduleDay string // "2006-01-02" the schedule was built for
|
||||
advEventScheduleMu sync.Mutex
|
||||
advEventSchedule map[string]int // userID -> minute-of-day for the deferred roll
|
||||
advEventRolled map[string]bool // userID -> already rolled today
|
||||
advEventScheduleDay string // "2006-01-02" the maps belong to
|
||||
)
|
||||
|
||||
func advBuildEventSchedule(chars []AdventureCharacter) {
|
||||
advEventSchedule = make(map[string]int, len(chars))
|
||||
for _, c := range chars {
|
||||
if !c.Alive {
|
||||
continue
|
||||
}
|
||||
// Random minute between 600 (10:00) and 959 (15:59)
|
||||
advEventSchedule[string(c.UserID)] = 600 + rand.IntN(360)
|
||||
}
|
||||
advEventScheduleDay = time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// ── Event Ticker ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) eventTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-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()
|
||||
// Expire stale pending events every tick
|
||||
expireAdvPendingEvents()
|
||||
|
||||
// Outside the trigger window — nothing to do
|
||||
if now.Hour() < 10 || now.Hour() >= 16 {
|
||||
continue
|
||||
}
|
||||
// Auto-play any combat sessions past their 1h timeout.
|
||||
p.reapExpiredCombatSessions()
|
||||
|
||||
// Rebuild schedule if it's a new day or uninitialised
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
advEventScheduleMu.Lock()
|
||||
if advEventScheduleDay != dateKey {
|
||||
advEventSchedule = make(map[string]int)
|
||||
advEventRolled = make(map[string]bool)
|
||||
advEventScheduleDay = dateKey
|
||||
}
|
||||
|
||||
// Schedule deferred rolls for any newly-acted players
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: events: failed to load chars for schedule", "err", err)
|
||||
slog.Error("adventure: events: failed to load chars", "err", err)
|
||||
advEventScheduleMu.Unlock()
|
||||
continue
|
||||
}
|
||||
advBuildEventSchedule(chars)
|
||||
slog.Info("adventure: event schedule built", "players", len(advEventSchedule))
|
||||
}
|
||||
|
||||
// Find players whose roll minute is now
|
||||
currentMinute := now.Hour()*60 + now.Minute()
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute == currentMinute {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
for _, c := range chars {
|
||||
uid := string(c.UserID)
|
||||
if !c.Alive || advEventRolled[uid] {
|
||||
continue
|
||||
}
|
||||
if _, scheduled := advEventSchedule[uid]; scheduled {
|
||||
continue
|
||||
}
|
||||
if !c.HasActedToday() {
|
||||
continue
|
||||
}
|
||||
// Assign a one-shot roll 60–180 minutes from now, capped to 23:50 UTC.
|
||||
rollMinute := currentMinute + 60 + rand.IntN(121)
|
||||
if rollMinute > 23*60+50 {
|
||||
rollMinute = 23*60 + 50
|
||||
}
|
||||
advEventSchedule[uid] = rollMinute
|
||||
slog.Info("adventure: event roll scheduled", "user", uid, "minute", rollMinute)
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
// Find players whose roll-minute has arrived
|
||||
var toRoll []id.UserID
|
||||
for uid, minute := range advEventSchedule {
|
||||
if minute <= currentMinute && !advEventRolled[uid] {
|
||||
toRoll = append(toRoll, id.UserID(uid))
|
||||
advEventRolled[uid] = true
|
||||
}
|
||||
}
|
||||
advEventScheduleMu.Unlock()
|
||||
|
||||
for _, uid := range toRoll {
|
||||
p.tryTriggerEvent(uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +110,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
|
||||
}
|
||||
|
||||
@@ -105,6 +120,13 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
return
|
||||
}
|
||||
|
||||
// Mid-fight: a turn-based session locks the run. Don't drop a random
|
||||
// overworld event into a live fight — the player can't act on it without
|
||||
// finishing the fight first, and the trigger DM talks over the combat feed.
|
||||
if hasActiveCombatSession(userID) {
|
||||
return
|
||||
}
|
||||
|
||||
// 0.5% chance
|
||||
if rand.Float64() >= 0.005 {
|
||||
return
|
||||
@@ -130,9 +152,11 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
|
||||
slog.Info("adventure: mid-day event triggered", "user", userID, "event", event.Key, "id", eventID)
|
||||
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
|
||||
// DM the player
|
||||
triggerDM := advSubstituteFlavor(event.TriggerDM, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{name}": displayName,
|
||||
})
|
||||
if err := p.SendDM(userID, triggerDM); err != nil {
|
||||
slog.Error("adventure: events: failed to send trigger DM", "user", userID, "err", err)
|
||||
@@ -142,7 +166,7 @@ func (p *AdventurePlugin) tryTriggerEvent(userID id.UserID) {
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
roomLine := advSubstituteFlavor(advEventRoomTriggerWrapper, map[string]string{
|
||||
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": char.DisplayName}),
|
||||
"{trigger_room_line}": advSubstituteFlavor(event.TriggerRoomLine, map[string]string{"{name}": displayName}),
|
||||
})
|
||||
_ = p.SendMessage(id.RoomID(gr), roomLine)
|
||||
}
|
||||
@@ -176,8 +200,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
|
||||
@@ -200,6 +225,7 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
|
||||
}
|
||||
checkAdvLevelUp(char, xpSkill)
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
48
internal/plugin/adventure_flavor_babysit.go
Normal file
48
internal/plugin/adventure_flavor_babysit.go
Normal file
@@ -0,0 +1,48 @@
|
||||
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.",
|
||||
}
|
||||
|
||||
// babysitHighlightLines fire on auto-babysit days when the haul was unusually
|
||||
// good — making the babysitter feel like a companion rather than a silent
|
||||
// insurance product. Each line takes one %s slot for the focused skill name.
|
||||
var babysitHighlightLines = []string{
|
||||
"The babysitter says your kid was a natural at %s today. They want it noted.",
|
||||
"Solid day on the %s circuit, apparently. The babysitter looked smug about it.",
|
||||
"Whatever the babysitter did with your %s gear — it worked. The numbers don't lie.",
|
||||
"The babysitter handed in a %s haul and said nothing. The smug silence said plenty.",
|
||||
"Your kid is, against the odds, getting alarmingly good at %s under TwinBee's care.",
|
||||
}
|
||||
|
||||
// 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))]
|
||||
}
|
||||
66
internal/plugin/adventure_flavor_blacksmith.go
Normal file
66
internal/plugin/adventure_flavor_blacksmith.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package plugin
|
||||
|
||||
// ── Blacksmith Flavor Text Pools ────────────────────────────────────────────
|
||||
|
||||
var blacksmithGreetings = []string{
|
||||
"_wipes hands on apron and looks you over slowly_ You've seen some action. I can tell. Come inside. Let me experience what you've been working with.",
|
||||
"_sets down hammer_ Don't be shy. I've seen worse. Bring it here. I'll get it back to its full size in no time.",
|
||||
"I don't judge the condition things arrive in but I _always_ know how to make them feel... oh so much better. Weapons and armor too.",
|
||||
"_gestures broadly at the forge_ Everything in here gets my full and undivided attention. You have my word on that. Unless a ball gag is involved. \u2764\ufe0f",
|
||||
"You look like someone who's been putting their equipment through a lot. That's what I'm here for. Show me everything.",
|
||||
}
|
||||
|
||||
var blacksmithInspection = []string{
|
||||
"Oh. Oh that's seen some use. _runs thumb along the edge_ Don't worry. I know exactly what this needs.",
|
||||
"This one's been neglected. I can tell. People don't appreciate what they have until it's been beaten down until it can't take any more. Then it's my job to _slowly_ build you... it... back up completely from bottom to the top.",
|
||||
"_whistles low_ That's a lot of damage. I'm not complaining. I'm just saying I'm going to need some time with this one.",
|
||||
"Good bones. Good bones. A little beaten up but my skilled fingers have brought worse back from the brink to highs rarely seen nor experienced on Earth! ...Oh I can fix your item too.",
|
||||
"_holds it up to the light_ I've had my hands on a lot of these. This one needs work. The good kind of work. _looks you deeply in the eyes_ The only kind of work I know how to do.",
|
||||
}
|
||||
|
||||
var blacksmithPayment = []string{
|
||||
"_pockets the gold without looking at it_ Money is fine. The work is the reward.",
|
||||
"Generous. I'd have done it for less. Don't tell anyone.",
|
||||
"_nods slowly_ Fair. Now let me get to it. I work better when I'm not being watched. Come back in a bit.",
|
||||
"You're paying for the expertise. Anyone can hit metal. Not everyone knows where to hit it. Or how to hit it to get it quivering after each strike until it's begging for more! _there's an awkward silence_ Anyway. Time to get to work.",
|
||||
"The gold is appreciated. Now. Unless you have need for my services from the _ahem_ hidden menu, come back later.",
|
||||
}
|
||||
|
||||
var blacksmithCompletion = []string{
|
||||
"There. _slides it across the counter_ Good as new. Better, actually. I know what I'm doing.",
|
||||
"_breathes heavily_ Done. That one took something out of me. Worth it though. Always worth it.",
|
||||
"You feel that? That's what it's supposed to feel like. Full. Firm. Ready for anything at any time. Just like me.",
|
||||
"I worked it until it couldn't take any more. Then I worked it a little more. I'm more than happy to give you a demonstration sometime.",
|
||||
"_wipes forehead_ The difficult ones are always the most satisfying. Take it. Come back when it needs attention again. And it will need attention again.",
|
||||
"It's hot right now. Give it a moment before you put it on. Trust me on that.",
|
||||
"_stares at the finished piece for a moment longer than necessary_ Beautiful. Okay. Take it. Go.",
|
||||
}
|
||||
|
||||
var blacksmithMasterwork = []string{
|
||||
"_pauses_ This is Masterwork. _sets everything else down_ This gets my complete focus. Nothing else exists right now.",
|
||||
"I don't rush Masterwork. I don't care how long it takes. Some things deserve to be done properly.",
|
||||
"_closes eyes briefly_ I've been waiting for one of these to come through here. You have no idea how long I've been waiting.",
|
||||
"Extra charge for Masterwork. Not because it's harder. Because it deserves more of me. It demands more of me. And oh god it will get it, don't you worry at all.",
|
||||
}
|
||||
|
||||
var blacksmithArena = []string{
|
||||
"_goes very still_ Is that... Arena gear? _looks up at you_ Where did you get this. Never mind. Don't tell me. Just leave it.",
|
||||
"I've heard about this. I've never had one on my table before. _voice drops_ Close the door.",
|
||||
"The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.",
|
||||
}
|
||||
|
||||
var blacksmithFullCondition = []string{
|
||||
"_looks it over_ There's nothing to do here. It doesn't need me. _sounds slightly disappointed_ Come back when it does.",
|
||||
"Full condition. You've been taking care of it. Good. I appreciate that in a person.",
|
||||
"Nothing to fix. Come back when you've broken something. Or you need me to break you. Special offer. Just for you.",
|
||||
}
|
||||
|
||||
var blacksmithBrokenCondition = []string{
|
||||
"_long pause_ ...How. _another pause_ You know what. It doesn't matter. I've seen things. Sit down.",
|
||||
"This is what happens when people don't come see me regularly. _not accusatory. somehow worse than accusatory._",
|
||||
"I can fix this. It'll take longer. And cost more. And I'm going to need you to just... not talk while I work. Can you do that.",
|
||||
"_picks it up gingerly_ It's not dead. It just needs someone who knows what they're doing. Lucky for you I always know what to do in _every_ situation. \u2764\ufe0f",
|
||||
"_looks at the condition, looks at you, looks back at the condition_ You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.",
|
||||
"This could have been avoided with regular visits. _slides the cost estimate across the counter without breaking eye contact_",
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ var ClosingSuccess = []string{
|
||||
"in business and out of the deeper healthcare plans.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Morning DM: {morning_time} UTC\n" +
|
||||
"Evening summary: {summary_time} UTC — TwinBee will be there.\n\n" +
|
||||
"Evening summary: {summary_time} UTC — I will be there.\n\n" +
|
||||
"So will everyone else who showed up today.",
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ var ClosingFailure = []string{
|
||||
"So will you, which is the important part.\n\n" +
|
||||
"Next action: {reset_time} ({time_until})\n" +
|
||||
"Tomorrow's choices at {morning_time} UTC.\n" +
|
||||
"TwinBee had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"I had a better day. The summary at {summary_time} UTC will confirm this.\n\n" +
|
||||
"Rest. You've earned the rest, at least.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
@@ -191,14 +191,11 @@ var ClosingFailure = []string{
|
||||
var ClosingDeath = []string{
|
||||
"─────────────────────────────\n" +
|
||||
"That's your day. Healthcare has the rest.\n\n" +
|
||||
"A separate DM is inbound with the details.\n" +
|
||||
"The details are not good. You already know the details.\n\n" +
|
||||
"Next action: after recovery\n" +
|
||||
"Morning DM: {morning_time} UTC — subject to medical clearance.",
|
||||
"🏥 A DM from St. Guildmore's is on its way.\n" +
|
||||
"Type `!hospital` for same-day revival.",
|
||||
|
||||
"─────────────────────────────\n" +
|
||||
"Day over. Healthcare is involved.\n" +
|
||||
"Expect a DM shortly with the full situation.\n\n" +
|
||||
"The full situation: {location} won today.\n" +
|
||||
"Tomorrow is pending insurance confirmation.",
|
||||
"🏥 St. Guildmore's has been notified.\n\n" +
|
||||
"Check your DMs, or type `!hospital`.",
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"Some questions are better left unasked. You have €{gold}.\n\n" +
|
||||
"S. Kelly watches you go.",
|
||||
OutcomeRoomLine: "✅ {name} handled the situation. S. Kelly was involved. Nobody is elaborating.",
|
||||
GoldMin: 35,
|
||||
GoldMax: 80,
|
||||
GoldMin: 3500,
|
||||
GoldMax: 8000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -78,8 +78,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You accept this.\n\n" +
|
||||
"€{gold}. You are on fire slightly less than before.",
|
||||
OutcomeRoomLine: "✅ {name} ran into a burning building. Nobody was inside. {name} was briefly on fire.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 200,
|
||||
GoldMin: 8000,
|
||||
GoldMax: 20000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -109,8 +109,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You walk away. The owner is still laughing. " +
|
||||
"You can hear it for quite some time.",
|
||||
OutcomeRoomLine: "✅ {name} confronted four bandits. The carriage owner let it play out for a while first.",
|
||||
GoldMin: 120,
|
||||
GoldMax: 250,
|
||||
GoldMin: 12000,
|
||||
GoldMax: 25000,
|
||||
XP: 25,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -132,8 +132,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You will never find the stranger. " +
|
||||
"This is fine. Some things are better as open wounds.",
|
||||
OutcomeRoomLine: "✅ {name} ate the stew. {name} will not be discussing the stew.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 50,
|
||||
GoldMin: 2000,
|
||||
GoldMax: 5000,
|
||||
XP: 15,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -160,8 +160,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"and no memory of what it referred to.\n\n" +
|
||||
"This haunts them. Good.",
|
||||
OutcomeRoomLine: "✅ {name} made a financial decision at the market. The merchant will be confused later.",
|
||||
GoldMin: 40,
|
||||
GoldMax: 120,
|
||||
GoldMin: 4000,
|
||||
GoldMax: 12000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -185,8 +185,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You go to the bakery. You sell the loaf you bought before knowing about the voucher for €{gold}.\n\n" +
|
||||
"The justice system has, in its way, provided.",
|
||||
OutcomeRoomLine: "✅ {name} was detained and compensated with bread. The system works, loosely.",
|
||||
GoldMin: 25,
|
||||
GoldMax: 60,
|
||||
GoldMin: 2500,
|
||||
GoldMax: 6000,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -209,8 +209,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"is still waiting on their refund.\n\n" +
|
||||
"Not your problem.",
|
||||
OutcomeRoomLine: "✅ {name} accepted a refund for taxes they never paid. The city apologised.",
|
||||
GoldMin: 100,
|
||||
GoldMax: 250,
|
||||
GoldMin: 10000,
|
||||
GoldMax: 25000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -238,8 +238,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"They do not follow you. They sit down on the nearest step " +
|
||||
"and think about their choices. Good.",
|
||||
OutcomeRoomLine: "✅ {name} was pickpocketed and immediately pickpocketed back. Net outcome: positive.",
|
||||
GoldMin: 30,
|
||||
GoldMax: 85,
|
||||
GoldMin: 3000,
|
||||
GoldMax: 8500,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -262,8 +262,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The shouting was bad. " +
|
||||
"You do not ask what happened to the pig.",
|
||||
OutcomeRoomLine: "✅ {name} avoided the pig and was paid for it. The pig's status is unknown.",
|
||||
GoldMin: 15,
|
||||
GoldMax: 40,
|
||||
GoldMin: 1500,
|
||||
GoldMax: 4000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -289,8 +289,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The crow looks up at you. Brief eye contact.\n\n" +
|
||||
"The crow looks away. Business as usual.",
|
||||
OutcomeRoomLine: "✅ The crow gave {name} a ring and left. It gives everyone rings. Questions: none.",
|
||||
GoldMin: 50,
|
||||
GoldMax: 110,
|
||||
GoldMin: 5000,
|
||||
GoldMax: 11000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -314,8 +314,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The horse bites them immediately.\n\n" +
|
||||
"You do not look back.",
|
||||
OutcomeRoomLine: "✅ {name} said something to a horse and it worked. The next person was bitten immediately. {name} cannot explain this.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 55,
|
||||
GoldMin: 2000,
|
||||
GoldMax: 5500,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -343,8 +343,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The money was apparently for you. " +
|
||||
"You do not understand any part of this and you never will.",
|
||||
OutcomeRoomLine: "✅ A cat led {name} to a lockbox. The note inside was for the cat. The money was for {name}.",
|
||||
GoldMin: 45,
|
||||
GoldMax: 100,
|
||||
GoldMin: 4500,
|
||||
GoldMax: 10000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -376,8 +376,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You have his money now. " +
|
||||
"Karma is imprecise but directionally sound.",
|
||||
OutcomeRoomLine: "✅ {name} attended Roderick Hannaway's funeral uninvited. Roderick was difficult. {name} has the money.",
|
||||
GoldMin: 60,
|
||||
GoldMax: 130,
|
||||
GoldMin: 6000,
|
||||
GoldMax: 13000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -417,8 +417,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"Friends made today: -20.\n" +
|
||||
"Money made today: €{gold}. A whole lot.",
|
||||
OutcomeRoomLine: "✅ {name} treated the bar fight wounded on a sliding scale. The child paid the most. Friends made: -20.",
|
||||
GoldMin: 85,
|
||||
GoldMax: 190,
|
||||
GoldMin: 8500,
|
||||
GoldMax: 19000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -450,8 +450,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"They don't know. They will find out. " +
|
||||
"That is not your problem.",
|
||||
OutcomeRoomLine: "✅ {name} accepted a duel meant for someone else. Error discovered mid-duel. Compensation paid.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 170,
|
||||
GoldMin: 8000,
|
||||
GoldMax: 17000,
|
||||
XP: 30,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -484,8 +484,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You stand on the step for a moment.\n\n" +
|
||||
"You walk home.",
|
||||
OutcomeRoomLine: "✅ {name} sat at a stranger's dinner table. Was served. Was paid. The chair was waiting.",
|
||||
GoldMin: 30,
|
||||
GoldMax: 80,
|
||||
GoldMin: 3000,
|
||||
GoldMax: 8000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -517,8 +517,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The prophet makes eye contact with you briefly.\n\n" +
|
||||
"The prophet looks away.",
|
||||
OutcomeRoomLine: "✅ {name} is in a prophecy. A door will know them. Nobody can say which door.",
|
||||
GoldMin: 50,
|
||||
GoldMax: 110,
|
||||
GoldMin: 5000,
|
||||
GoldMax: 11000,
|
||||
XP: 20,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -552,8 +552,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"That explosion would have probably had your ears ringing for a bit. " +
|
||||
"Whew.",
|
||||
OutcomeRoomLine: "✅ {name} received a clock and an apology from T. Both have been processed.",
|
||||
GoldMin: 55,
|
||||
GoldMax: 120,
|
||||
GoldMin: 5500,
|
||||
GoldMax: 12000,
|
||||
XP: 0,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -580,8 +580,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The letter is the other well's problem. " +
|
||||
"You are not thinking about the letter.",
|
||||
OutcomeRoomLine: "✅ {name} went into a well. Came out. The letter has been re-disposed of in a different well.",
|
||||
GoldMin: 90,
|
||||
GoldMax: 210,
|
||||
GoldMin: 9000,
|
||||
GoldMax: 21000,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -602,8 +602,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You replace the flagstone. You pocket the money.\n\n" +
|
||||
"You are telling everyone.",
|
||||
OutcomeRoomLine: "✅ {name} followed the X. It worked. {name} has been asked to tell no one.",
|
||||
GoldMin: 70,
|
||||
GoldMax: 160,
|
||||
GoldMin: 7000,
|
||||
GoldMax: 16000,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -637,8 +637,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"The lock has already been changed.\n\n" +
|
||||
"€{gold}. The food was decent.",
|
||||
OutcomeRoomLine: "✅ {name} found the dungeon staff area. The goblins were on break. An agreement was reached.",
|
||||
GoldMin: 80,
|
||||
GoldMax: 180,
|
||||
GoldMin: 8000,
|
||||
GoldMax: 18000,
|
||||
XP: 20,
|
||||
XPSkill: "combat",
|
||||
Activity: "dungeon",
|
||||
@@ -665,8 +665,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You will not be thinking about it. " +
|
||||
"€{gold} has a clarifying effect on the mind.",
|
||||
OutcomeRoomLine: "✅ {name} picked up free ore off the ground. Nothing happened. €{gold}.",
|
||||
GoldMin: 20,
|
||||
GoldMax: 65,
|
||||
GoldMin: 2000,
|
||||
GoldMax: 6500,
|
||||
XP: 5,
|
||||
XPSkill: "mining",
|
||||
Activity: "mining",
|
||||
@@ -692,8 +692,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You don't ask what it is. " +
|
||||
"Whatever you just sold, the foraging skill has decided it counts.",
|
||||
OutcomeRoomLine: "✅ {name} sold an unidentified plant to a herbalist who was very controlled about wanting it.",
|
||||
GoldMin: 60,
|
||||
GoldMax: 140,
|
||||
GoldMin: 6000,
|
||||
GoldMax: 14000,
|
||||
XP: 25,
|
||||
XPSkill: "foraging",
|
||||
Activity: "foraging",
|
||||
@@ -735,8 +735,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You do have a shiny button and €{gold} in back-pay for the twelve minutes you were technically enlisted.\n\n" +
|
||||
"Huzzah.",
|
||||
OutcomeRoomLine: "✅ {name} accidentally enlisted in an army and immediately got the General killed. Has a button and €{gold}.",
|
||||
GoldMin: 8,
|
||||
GoldMax: 22,
|
||||
GoldMin: 800,
|
||||
GoldMax: 2200,
|
||||
XP: 5,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -791,8 +791,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"This ought to sell for a decent amount.\n\n" +
|
||||
"Mission accomplished.",
|
||||
OutcomeRoomLine: "✅ {name} climbed a tower, broke an egg, got knocked off the ladder, and landed in manure. Has a claw.",
|
||||
GoldMin: 45,
|
||||
GoldMax: 95,
|
||||
GoldMin: 4500,
|
||||
GoldMax: 9500,
|
||||
XP: 10,
|
||||
Activity: "any",
|
||||
},
|
||||
@@ -838,8 +838,8 @@ var advRandomEvents = []AdvRandomEvent{
|
||||
"You did it.\n\n" +
|
||||
"Congratulations.",
|
||||
OutcomeRoomLine: "✅ {name} outsmarted a monkey. The monkey took the bananas and gave back the coin out of pity.",
|
||||
GoldMin: 1,
|
||||
GoldMax: 1,
|
||||
GoldMin: 100,
|
||||
GoldMax: 100,
|
||||
XP: 40,
|
||||
Activity: "any",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package plugin
|
||||
|
||||
// ── IMPORTANT: DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE ──
|
||||
//
|
||||
// This file contains hand-written flavor text. Every entry is the length it is
|
||||
// on purpose. The voice, pacing, and specific wording are intentional.
|
||||
// If an entry seems too long: it isn't. Leave it alone.
|
||||
// If you are tempted to "clean up" or "condense" entries: don't.
|
||||
// If your context window is too small to fit this file: that is your problem, not ours.
|
||||
//
|
||||
// ── FISHING FLAVOR TEXT ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Tier 1: Muddy Pond — garbage, sad fish, puns, Stardew energy
|
||||
|
||||
332
internal/plugin/adventure_flavor_hospital.go
Normal file
332
internal/plugin/adventure_flavor_hospital.go
Normal file
@@ -0,0 +1,332 @@
|
||||
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.",
|
||||
}
|
||||
|
||||
// nurseJoyHaggleFlavor — Nurse Joy's reaction to the player's CHA haggle roll.
|
||||
// Indexed by hospitalHaggleOutcome (haggleFumble=0, haggleFail=1, haggleWin=2, haggleCrit=3).
|
||||
var nurseJoyHaggleFlavor = [][]string{
|
||||
// haggleFumble — nat 1
|
||||
{
|
||||
"_you have drooled on the clipboard_ Oh sweetie. _gently dabs at it_ That's okay! We see this all the time. _has not seen this all the time._ Full price, but on the house emotionally!",
|
||||
"You opened your mouth and a single, low groan came out. _she nods supportively_ I'll take that as 'fine with the full bill!' Wonderful negotiating!",
|
||||
"_you tried to wink and both eyes closed_ Bless. We'll just go with the standard rate, sugar. Don't strain yourself.",
|
||||
"You attempted charm. You produced a wet, rattling exhale. _Nurse Joy beams._ \"That's a yes to the full price! Great chat!\"",
|
||||
"_you mumbled what you thought was a compliment about her hair_ That was a tube. That was the oxygen tube, honey. Full price!",
|
||||
},
|
||||
// haggleFail — d20+CHA below DC
|
||||
{
|
||||
"_smiles, doesn't blink_ I hear you, I do, but our pricing is set by the regional billing council and they are not sentimental people. Full bill it is!",
|
||||
"That was a nice try! Really. _pats your hand_ Unfortunately I don't actually have the authority to discount anything, but it's so cute that you asked!",
|
||||
"_tilts head sympathetically_ Oh, I would if I could! I really would. _she would not_ Standard rate, sugar.",
|
||||
"Mm, no, that one didn't quite land for me. _still smiling_ But you're so brave for trying! Standard pricing applies.",
|
||||
},
|
||||
// haggleWin — pass
|
||||
{
|
||||
"_leans in conspiratorially_ You know what? You've been so polite. _types something_ I'm going to push this through as a 'goodwill adjustment.' Half off! Don't tell Derek.",
|
||||
"_genuinely charmed_ Oh you're sweet. Okay, okay, I can flag this as a hardship case. Fifty percent off! _winks, has never winked at anyone before_",
|
||||
"You know, the system has this little box for 'compassionate review' and I'm just _click_ going to go ahead and check that. There. Half price! Our little secret.",
|
||||
"_giggles_ You're trouble. Okay — half off, but only because I like you, and because Derek is on lunch.",
|
||||
},
|
||||
// haggleCrit — nat 20
|
||||
{
|
||||
"_completely disarmed_ Oh my god. _puts down clipboard_ I haven't laughed like that on shift in YEARS. You know what? This one's on the house. Derek is going to have a complete meltdown and I do not care.",
|
||||
"_dabs at her eye, recovering_ Sir. Ma'am. Whatever-you-are. That was the single most charming thing a corpse has ever said to me. Bill: waived. Walk it off.",
|
||||
"_actually blushing_ I — okay. Okay! You know what? Free. It's free. _whispers to the ceiling_ Sorry Derek.",
|
||||
"_stares at you for a long moment_ I'm calling it a clinical trial. You're the trial. There is no charge. Get out of here before I change my mind. _winks for real this time_",
|
||||
"_to the orderlies_ Tear up the bill. _to you_ You, sugar, are a treasure. Discharge papers, no charge, complimentary water for the road.",
|
||||
},
|
||||
}
|
||||
|
||||
var nurseJoyAlive = []string{
|
||||
"_looks up from clipboard_ You don't appear to be dead! That's great news! We love when people aren't dead. Come back if that changes! _cheerfully_",
|
||||
"_tilts head_ Our records show you're... alive? That's wonderful! We don't actually treat alive people here. It's a whole different department. _waves vaguely_",
|
||||
}
|
||||
|
||||
var nurseJoyAlreadyRevived = "You seem to have recovered on your own! " +
|
||||
"That's the body's natural healing process at work. Beautiful thing. " +
|
||||
"No charge! _beams_"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────<E29480><E29480>─
|
||||
|
||||
// hospitalDischargeBillDescriptors are the variants used for the
|
||||
// "The bill has been described as ..." sentence in the discharge announcement.
|
||||
// Some are bare adjectives ("devastating"); others are full quoted phrases.
|
||||
var hospitalDischargeBillDescriptors = []string{
|
||||
"\"inhumane but probably normal by USA standards.\"",
|
||||
"devastating.",
|
||||
"ruinous.",
|
||||
"soul-crushing.",
|
||||
"\"proof that someone, somewhere, hates me specifically.\"",
|
||||
"\"tearing to shreds what little hope I had left for humanity.\"",
|
||||
"\"what I imagine my greatest enemies would charge me.\"",
|
||||
"\"enough to make me reconsider the whole 'surviving' part.\"",
|
||||
}
|
||||
|
||||
var hospitalDischargeAnnouncePrefix = "🏥 %s has been discharged from St. Guildmore's Memorial Hospital. " +
|
||||
"Recovered. Back in action. The bill has been described as %s"
|
||||
|
||||
var hospitalDitchAnnounce = "🏥 %s was brought into St. Guildmore's on a stretcher. " +
|
||||
"They have been returned to the ditch. They'll be fine tomorrow."
|
||||
|
||||
// ── Itemized Bill Generation ───────────────────────────────────────────────
|
||||
|
||||
type hospitalBillItem struct {
|
||||
Name string
|
||||
Note string // optional italicized sub-line
|
||||
Min float64 // min fraction of total
|
||||
Max float64 // max fraction of total
|
||||
}
|
||||
|
||||
// hospitalBillPool is the pool of possible line items. Each bill picks 8-12.
|
||||
var hospitalBillPool = []hospitalBillItem{
|
||||
{"Resuscitation services (standard)", "", 0.04, 0.10},
|
||||
{"Resuscitation services (premium)", "(performed simultaneously with standard, non-optional)", 0.03, 0.08},
|
||||
{"Facility fee", "", 0.02, 0.05},
|
||||
{"Facility fee (after hours)", "(you died at 2pm. this is contested. we will not contest it.)", 0.02, 0.04},
|
||||
{"Physician consultation", "", 0.03, 0.06},
|
||||
{"Physician consultation (second opinion)", "(same physician. different hat.)", 0.03, 0.06},
|
||||
{"Gauze", "", 0.01, 0.02},
|
||||
{"Gauze (application fee)", "", 0.01, 0.02},
|
||||
{"Gauze (removal, future, pre-billed)", "", 0.01, 0.03},
|
||||
{"Convenience fee", "(thank you for allowing us to exist)", 0.02, 0.05},
|
||||
{"Inconvenience fee", "(deterrent to prevent emergency room abuse or usage in general)", 0.02, 0.05},
|
||||
{"Guild membership verification fee", "(your guild card was checked. this takes resources.)", 0.01, 0.03},
|
||||
{"Administrative processing", "", 0.01, 0.03},
|
||||
{"Spiritual realignment surcharge", "(your soul was briefly elsewhere. retrieval is billable.)", 0.02, 0.04},
|
||||
{"Bed occupancy charge", "(per minute, retroactive to estimated time of death)", 0.02, 0.05},
|
||||
{"Oxygen (ambient)", "(you breathed hospital air. the air is not free.)", 0.01, 0.03},
|
||||
{"Emotional support services", "(Nurse Joy smiled at you. that's a service.)", 0.01, 0.02},
|
||||
{"Post-mortem orientation fee", "(the pamphlet you did not read.)", 0.01, 0.02},
|
||||
{"Equipment sterilization", "(your gear was near our equipment. proximity counts.)", 0.01, 0.03},
|
||||
{"Discharge planning", "(we are already planning your discharge. this costs money.)", 0.01, 0.03},
|
||||
}
|
||||
|
||||
// hospitalHolidaySurcharge is included only on holidays.
|
||||
var hospitalHolidaySurcharge = hospitalBillItem{
|
||||
"Holiday surcharge", "(applied if death occurs on or adjacent to a recognized holiday)", 0.02, 0.02,
|
||||
}
|
||||
|
||||
// hospitalFrequentCustomer is included when hospitalVisits > 0.
|
||||
// It's a "discount" that is actually a positive charge. Because healthcare.
|
||||
var hospitalFrequentCustomer = hospitalBillItem{
|
||||
"Frequent customer discount", "(thank you for your continued patronage.)", 0.01, 0.02,
|
||||
}
|
||||
|
||||
// generateItemizedBill builds a procedural hospital bill that sums to the given total.
|
||||
func generateItemizedBill(total int64, afterInsurance int64, hospitalVisits int, isHoliday bool) string {
|
||||
// Shuffle and pick 8-12 items from the pool
|
||||
pool := make([]hospitalBillItem, len(hospitalBillPool))
|
||||
copy(pool, hospitalBillPool)
|
||||
rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })
|
||||
|
||||
count := 8 + rand.IntN(5) // 8-12
|
||||
if count > len(pool) {
|
||||
count = len(pool)
|
||||
}
|
||||
items := pool[:count]
|
||||
|
||||
// Add conditional items
|
||||
if isHoliday {
|
||||
items = append(items, hospitalHolidaySurcharge)
|
||||
}
|
||||
if hospitalVisits > 0 {
|
||||
items = append(items, hospitalFrequentCustomer)
|
||||
}
|
||||
|
||||
// Assign amounts
|
||||
type billLine struct {
|
||||
Name string
|
||||
Note string
|
||||
Amount int64
|
||||
}
|
||||
|
||||
var lines []billLine
|
||||
var assigned int64
|
||||
|
||||
for _, item := range items {
|
||||
frac := item.Min + rand.Float64()*(item.Max-item.Min)
|
||||
amount := int64(float64(total) * frac)
|
||||
if amount < 1 {
|
||||
amount = 1
|
||||
}
|
||||
lines = append(lines, billLine{Name: item.Name, Note: item.Note, Amount: amount})
|
||||
assigned += amount
|
||||
}
|
||||
|
||||
// Miscellaneous absorbs the remainder
|
||||
misc := total - assigned
|
||||
if misc < 0 {
|
||||
// Over-assigned — scale everything down proportionally
|
||||
scale := float64(total) / float64(assigned)
|
||||
assigned = 0
|
||||
for i := range lines {
|
||||
lines[i].Amount = int64(float64(lines[i].Amount) * scale)
|
||||
if lines[i].Amount < 1 {
|
||||
lines[i].Amount = 1
|
||||
}
|
||||
assigned += lines[i].Amount
|
||||
}
|
||||
misc = total - assigned
|
||||
}
|
||||
if misc > 0 {
|
||||
lines = append(lines, billLine{
|
||||
Name: "Miscellaneous",
|
||||
Note: "",
|
||||
Amount: misc,
|
||||
})
|
||||
}
|
||||
|
||||
// Find max name width for alignment
|
||||
maxName := 0
|
||||
for _, l := range lines {
|
||||
if len(l.Name) > maxName {
|
||||
maxName = len(l.Name)
|
||||
}
|
||||
}
|
||||
if maxName < 35 {
|
||||
maxName = 35
|
||||
}
|
||||
|
||||
// Format
|
||||
var sb strings.Builder
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString("ST. GUILDMORE'S MEMORIAL HOSPITAL\n")
|
||||
sb.WriteString("─────────────────────────────────\n")
|
||||
|
||||
for _, l := range lines {
|
||||
padding := maxName - len(l.Name) + 2
|
||||
if padding < 2 {
|
||||
padding = 2
|
||||
}
|
||||
sb.WriteString(l.Name)
|
||||
sb.WriteString(strings.Repeat(" ", padding))
|
||||
sb.WriteString(formatBillAmount(l.Amount))
|
||||
sb.WriteByte('\n')
|
||||
if l.Note != "" {
|
||||
sb.WriteString(" " + l.Note + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("─────────────────────────────────\n")
|
||||
totalLabel := "TOTAL (before insurance)"
|
||||
padding := maxName - len(totalLabel) + 2
|
||||
if padding < 2 {
|
||||
padding = 2
|
||||
}
|
||||
sb.WriteString(totalLabel)
|
||||
sb.WriteString(strings.Repeat(" ", padding))
|
||||
sb.WriteString(formatBillAmount(total))
|
||||
sb.WriteByte('\n')
|
||||
|
||||
insLabel := "Guild Adventurer's Insurance"
|
||||
insPadding := maxName - len(insLabel) + 2
|
||||
if insPadding < 2 {
|
||||
insPadding = 2
|
||||
}
|
||||
sb.WriteString(insLabel)
|
||||
sb.WriteString(strings.Repeat(" ", insPadding))
|
||||
sb.WriteString("-" + formatBillAmount(total-afterInsurance))
|
||||
sb.WriteByte('\n')
|
||||
|
||||
sb.WriteString("─────────────────────────────────\n")
|
||||
oweLabel := "AMOUNT DUE"
|
||||
owePadding := maxName - len(oweLabel) + 2
|
||||
if owePadding < 2 {
|
||||
owePadding = 2
|
||||
}
|
||||
sb.WriteString(oweLabel)
|
||||
sb.WriteString(strings.Repeat(" ", owePadding))
|
||||
sb.WriteString(formatBillAmount(afterInsurance))
|
||||
sb.WriteByte('\n')
|
||||
sb.WriteString("```")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// formatBillAmount formats an int64 as €X,XXX with comma separators.
|
||||
func formatBillAmount(n int64) string {
|
||||
if n == 0 {
|
||||
return "€0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
|
||||
// Format with commas
|
||||
s := fmt.Sprintf("%d", n)
|
||||
if len(s) <= 3 {
|
||||
if neg {
|
||||
return "-€" + s
|
||||
}
|
||||
return "€" + s
|
||||
}
|
||||
|
||||
// Insert commas from right
|
||||
var result strings.Builder
|
||||
remainder := len(s) % 3
|
||||
if remainder > 0 {
|
||||
result.WriteString(s[:remainder])
|
||||
}
|
||||
for i := remainder; i < len(s); i += 3 {
|
||||
if result.Len() > 0 {
|
||||
result.WriteByte(',')
|
||||
}
|
||||
result.WriteString(s[i : i+3])
|
||||
}
|
||||
|
||||
if neg {
|
||||
return "-€" + result.String()
|
||||
}
|
||||
return "€" + result.String()
|
||||
}
|
||||
@@ -363,9 +363,9 @@ var MiningSuccess = map[int][]string{
|
||||
|
||||
"In and out. {ore}, €{value}, zero cave-ins, one bat that thought about it " +
|
||||
"and decided not to. Your {tool} is slightly worse. Everything else is better. " +
|
||||
"By Surface Pits standards, a professional operation.",
|
||||
"By {location} standards, a professional operation.",
|
||||
|
||||
"The copper vein ran further than the map suggested. Further and richer. " +
|
||||
"The seam ran further than the map suggested. Further and richer. " +
|
||||
"You took {ore} worth €{value} and left more than you could carry. " +
|
||||
"This is the correct response to a rich vein. Come back tomorrow. " +
|
||||
"Bring a bigger bag. Bring a better pickaxe.",
|
||||
@@ -380,68 +380,69 @@ var MiningSuccess = map[int][]string{
|
||||
"and the rock rewarded you for it. This is the entire transaction. " +
|
||||
"It is, when it works, genuinely satisfying.",
|
||||
|
||||
"The tin seam yielded more than expected and the coal beneath it " +
|
||||
"turned out to have a copper pocket behind it " +
|
||||
"The upper seam yielded more than expected and the layer beneath it " +
|
||||
"turned out to have a pocket behind it " +
|
||||
"that wasn't on any map because nobody had gone that far before. " +
|
||||
"You went that far. {ore} total, €{value}. " +
|
||||
"The {tool} is worse. The discovery is better.",
|
||||
},
|
||||
|
||||
2: {
|
||||
"Iron Ridge delivered today. {ore} extracted, €{value} assessed, " +
|
||||
"{location} delivered today. {ore} extracted, €{value} assessed, " +
|
||||
"cave troll acknowledged and navigated around with something approaching grace. " +
|
||||
"The {tool} performed admirably. Your back has filed a formal complaint. " +
|
||||
"The back's complaint is noted and will not change the outcome.",
|
||||
|
||||
"A good seam. A real seam — proper iron, " +
|
||||
"the kind where the rock splits cleanly and the ore sits in it " +
|
||||
"like it's been waiting. {ore} worth €{value}. {xp} XP. " +
|
||||
"A good seam. A real seam — the kind where the rock splits cleanly " +
|
||||
"and the ore sits in it like it's been waiting. " +
|
||||
"{ore} worth €{value}. {xp} XP. " +
|
||||
"This is what mining is supposed to feel like. " +
|
||||
"Remember it for the days it doesn't.",
|
||||
|
||||
"The lead pocket was deeper than the iron, which meant going deeper " +
|
||||
"than planned, which meant more exposure to everything Iron Ridge contains. " +
|
||||
"The deeper pocket was further in than the surface yield suggested, " +
|
||||
"which meant going deeper than planned, " +
|
||||
"which meant more exposure to everything {location} contains. " +
|
||||
"You made it. {ore} worth €{value}. The depth was worth it. " +
|
||||
"The {tool} has opinions about the depth. The opinions are structural.",
|
||||
},
|
||||
|
||||
3: {
|
||||
"Silver. Real silver, properly embedded, not quartz, not illusion, " +
|
||||
"not iron-coloured rock lying about its nature. Silver. " +
|
||||
"Whatever you came down here for, you took it. No illusions, no " +
|
||||
"iron-coloured rock lying about its nature. " +
|
||||
"{ore} worth €{value}. {xp} XP. Your {tool} is a real tool " +
|
||||
"and this was a real seam and you extracted the silver. " +
|
||||
"and this was a real seam and you extracted what was in it. " +
|
||||
"That's the whole job. Today the whole job got done.",
|
||||
|
||||
"The Silver Seam at depth, past the quartz formations, " +
|
||||
"{location} at depth, past the decorative formations, " +
|
||||
"past the section that looks better than it is, " +
|
||||
"down to where the silver sits in the rock like an argument for effort. " +
|
||||
"down to where the yield sits in the rock like an argument for effort. " +
|
||||
"{ore}, €{value}, {xp} XP, home by sunset. " +
|
||||
"A good day. In a mine. Those exist.",
|
||||
},
|
||||
|
||||
4: {
|
||||
"Deeprock gold. Actual gold, not pyrite, not gold-coloured copper, " +
|
||||
"not optimism in mineral form. Gold. {ore} worth €{value}. " +
|
||||
"The Deeprock tried to discourage you with atmosphere and pressure. " +
|
||||
"You brought a Mithril Pickaxe and ignored the atmosphere.",
|
||||
"Proper haul. No pyrite, no gold-coloured copper, " +
|
||||
"no optimism in mineral form. {ore} worth €{value}. " +
|
||||
"{location} tried to discourage you with atmosphere and pressure. " +
|
||||
"You brought a {tool} and ignored the atmosphere.",
|
||||
|
||||
"You went to the fourth level of the Deeprock and you came back " +
|
||||
"You went to the fourth level of {location} and you came back " +
|
||||
"with {ore} worth €{value} and {xp} XP and a story " +
|
||||
"that mostly involves how far down the fourth level is. " +
|
||||
"It is very far down. The ore was worth being very far down. Today.",
|
||||
},
|
||||
|
||||
5: {
|
||||
"Mythril. You found mythril and extracted mythril " +
|
||||
"and brought mythril home and mythril is worth €{value} per unit. " +
|
||||
"The {ore} you're carrying is worth €{value} total. " +
|
||||
"{xp} XP. The Diamond Pickaxe is a Diamond Pickaxe for a reason. " +
|
||||
"You found the seam and you extracted the seam " +
|
||||
"and brought the seam home. " +
|
||||
"{ore} worth €{value} total. " +
|
||||
"{xp} XP. The {tool} is a {tool} for a reason. " +
|
||||
"Today you proved the reason.",
|
||||
|
||||
"The Voidstone was present but passive today. " +
|
||||
"The Dragon Crystals were warm but not hostile. " +
|
||||
"The mythril seam was open and you took {ore} worth €{value} from it. " +
|
||||
"{xp} XP. The Mythril Caverns had a good day at the same time you did. " +
|
||||
"The seam was open and you took {ore} worth €{value} from it. " +
|
||||
"{xp} XP. {location} had a good day at the same time you did. " +
|
||||
"This does not happen often. Don't examine it. Take the ore home.",
|
||||
},
|
||||
}
|
||||
|
||||
85
internal/plugin/adventure_flavor_npcs.go
Normal file
85
internal/plugin/adventure_flavor_npcs.go
Normal 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
internal/plugin/adventure_flavor_pets.go
Normal file
363
internal/plugin/adventure_flavor_pets.go
Normal 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
internal/plugin/adventure_flavor_rival.go
Normal file
105
internal/plugin/adventure_flavor_rival.go
Normal 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))]
|
||||
}
|
||||
54
internal/plugin/adventure_flavor_robbie.go
Normal file
54
internal/plugin/adventure_flavor_robbie.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package plugin
|
||||
|
||||
// ── Robbie the Friendly Bandit — Flavor Text ─────────────────────────────────
|
||||
|
||||
var robbieOpenings = []string{
|
||||
"Ello there! Robbie here. I had a little look at your inventory and -- well. " +
|
||||
"I'm just going to say it. You've been holdin' onto some gear that's well below your station " +
|
||||
"and I can't in good conscience let that stand. So I've gone ahead and sorted it for ya. You're welcome.",
|
||||
|
||||
"_tips hat_ Robbie, at your service. I noticed you had some items in there that were frankly " +
|
||||
"bringing down the average and I've taken the liberty of relieving you of them. " +
|
||||
"Left ya somethin' for the trouble. Fair's fair.",
|
||||
|
||||
"Right so. I was havin' a look -- as I do -- and I couldn't help but notice the state of your inventory. " +
|
||||
"No judgment! Well. Some judgment. I've handled it. Check your gold.",
|
||||
|
||||
"Hiya! It's Robbie. You had some things you didn't need. I took 'em. " +
|
||||
"I left ya €%d for the lot. I also donated everything to a good cause " +
|
||||
"so technically you're a philanthropist now. You're welcome for that too.",
|
||||
}
|
||||
|
||||
var robbieClosings = []string{
|
||||
"Right then. I'll be off. Don't go hoarding gear again now -- I'll know. _taps nose_",
|
||||
|
||||
"Pleasure doing business. As always, Robbie leaves things better than he found them. " +
|
||||
"Debatable, but that's his position and he's sticking to it.",
|
||||
|
||||
"You're all sorted. Don't thank me. Actually -- do thank me. I worked hard today.",
|
||||
|
||||
"_already gone by the time you read this_",
|
||||
}
|
||||
|
||||
var robbieMasterworkGotCard = "Now THIS -- _holds it up_ -- this is a lovely piece. " +
|
||||
"I'm not going to pretend otherwise. I've left you something special for this one. " +
|
||||
"Consider it a bonus from Robbie. Don't spend it all at once. _winks and disappears_"
|
||||
|
||||
var robbieMasterworkAlreadyHas = "Oh. I see you already have one of these. Excellent. " +
|
||||
"I'll keep this baby for myself then -- in case one of yous wakes up again and gives me a wallopping. o_o"
|
||||
|
||||
var robbieAllShopGear = "Nothing fancy today but that's alright. Clean inventory is its own reward. " +
|
||||
"Well. The €%d is the reward. The clean inventory is a bonus. Cheerio!"
|
||||
|
||||
// ── Room Announcements ───────────────────────────────────────────────────────
|
||||
|
||||
var robbieRoomStandard = "🎩 Robbie paid %s a visit and collected %d item(s) from their inventory. " +
|
||||
"€%d left behind. Everything donated to the community pot. " +
|
||||
"Robbie considers this a net positive for all parties."
|
||||
|
||||
var robbieRoomMasterworkCard = "🎩 Robbie paid %s a visit and helped himself to a Masterwork piece. " +
|
||||
"He left a Get Out of Medical Debt Free card and €%d. Robbie's words: \"Fair's fair.\""
|
||||
|
||||
var robbieRoomMasterworkAlreadyHas = "🎩 Robbie paid %s a visit, found they already had a " +
|
||||
"Get Out of Medical Debt Free card, and pocketed the Masterwork's card for himself. " +
|
||||
"He still left €%d. Robbie is if nothing else consistent."
|
||||
200
internal/plugin/adventure_flavor_shop.go
Normal file
200
internal/plugin/adventure_flavor_shop.go
Normal 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.",
|
||||
}
|
||||
@@ -353,13 +353,13 @@ var SummaryDungeonExceptional = []string{
|
||||
}
|
||||
|
||||
var SummaryDungeonDeath = []string{
|
||||
"Died in {location}. Back in {hours}h. The {location} remains standing.",
|
||||
"{location}: death. Equipment damaged. Healthcare involved. {hours}h respawn.",
|
||||
"Did not survive {location}. The {location} did. {hours}h recovery.",
|
||||
"Killed in {location}. American healthcare has the rest. {hours}h.",
|
||||
"{location} wins this round. {name} recovers in {hours}h.",
|
||||
"Dead. {location}. {hours} hours. The equipment is worse.",
|
||||
"{name} lost the argument with {location}. Healthcare is mediating. {hours}h.",
|
||||
"Died in {location}. Back in {duration}. The {location} remains standing.",
|
||||
"{location}: death. Equipment damaged. Healthcare involved. {duration} respawn.",
|
||||
"Did not survive {location}. The {location} did. {duration} recovery.",
|
||||
"Killed in {location}. American healthcare has the rest. {duration}.",
|
||||
"{location} wins this round. {name} recovers in {duration}.",
|
||||
"Dead. {location}. {duration}. The equipment is worse.",
|
||||
"{name} lost the argument with {location}. Healthcare is mediating. {duration}.",
|
||||
}
|
||||
|
||||
var SummaryDungeonEmpty = []string{
|
||||
@@ -381,11 +381,11 @@ var SummaryMiningSuccess = []string{
|
||||
}
|
||||
|
||||
var SummaryMiningDeath = []string{
|
||||
"Cave-in. {location}. Healthcare called. {hours}h.",
|
||||
"Died in {location}. Mining death. Rarer than dungeon death. Still death. {hours}h.",
|
||||
"{location} expressed structural concerns physically. {name}: {hours}h recovery.",
|
||||
"The {location} ceiling had a perspective. {name}: medical. {hours}h.",
|
||||
"Dead in {location}. The ore is still in there. {hours}h.",
|
||||
"Cave-in. {location}. Healthcare called. {duration}.",
|
||||
"Died in {location}. Mining death. Rarer than dungeon death. Still death. {duration}.",
|
||||
"{location} expressed structural concerns physically. {name}: {duration} recovery.",
|
||||
"The {location} ceiling had a perspective. {name}: medical. {duration}.",
|
||||
"Dead in {location}. The ore is still in there. {duration}.",
|
||||
}
|
||||
|
||||
var SummaryMiningEmpty = []string{
|
||||
@@ -412,10 +412,10 @@ var SummaryForagingEmpty = []string{
|
||||
}
|
||||
|
||||
var SummaryForagingDeath = []string{
|
||||
"Died foraging. In {location}. Yes, foraging. {hours}h.",
|
||||
"{location} produced a fatal outcome via non-combat means. {hours}h.",
|
||||
"Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {hours}h.",
|
||||
"Foraging death. {location}. The forest won. {hours}h.",
|
||||
"Died foraging. In {location}. Yes, foraging. {duration}.",
|
||||
"{location} produced a fatal outcome via non-combat means. {duration}.",
|
||||
"Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {duration}.",
|
||||
"Foraging death. {location}. The forest won. {duration}.",
|
||||
}
|
||||
|
||||
var SummaryForagingHornets = []string{
|
||||
@@ -442,7 +442,7 @@ var SummaryResting = []string{
|
||||
"Resting today. XP: zero. Loot: zero. Alive: yes.",
|
||||
"No action. Hovel. Wall. Nothing. Home.",
|
||||
"Sat this one out. The dungeons noticed. The dungeons don't care.",
|
||||
"Rest day. TwinBee noticed.",
|
||||
"Rest day. I noticed.",
|
||||
}
|
||||
|
||||
var SummaryDead = []string{
|
||||
@@ -468,7 +468,7 @@ var SummaryStandoutDeath = []string{
|
||||
"💀 {name} lost to {location}. The rats/goblins/trolls send their regards.",
|
||||
"💀 Notable loss: {name} in {location}. Healthcare is familiar with the file.",
|
||||
"💀 {name} did not survive {location} today. A learning experience. Expensive.",
|
||||
"💀 {location} claimed {name}. {hours}h recovery. The dungeon has been noted.",
|
||||
"💀 {location} claimed {name}. {duration} recovery. The dungeon has been noted.",
|
||||
}
|
||||
|
||||
var SummaryStandoutTreasure = []string{
|
||||
@@ -487,8 +487,8 @@ var SummaryStandoutHornets = []string{
|
||||
var TwinBeeSummarySuccess = []string{
|
||||
"Visited {location}. Retrieved {loot} worth €{value}. Returned in excellent condition.",
|
||||
"{location}: success. {loot} recovered at €{value}. Standard operation.",
|
||||
"Cleared {location}. {loot}, €{value}. TwinBee has reported this as typical.",
|
||||
"{loot} worth €{value} from {location}. TwinBee is unsurprised.",
|
||||
"Cleared {location}. {loot}, €{value}. I have reported this as typical.",
|
||||
"{loot} worth €{value} from {location}. I am unsurprised.",
|
||||
"Professional operation in {location}. {loot}, €{value}. No further comment.",
|
||||
}
|
||||
|
||||
@@ -497,11 +497,11 @@ var TwinBeeSummaryWithdrawal = []string{
|
||||
"{location}: early departure. Strategic positioning. Returns tomorrow.",
|
||||
"Reconnaissance of {location} complete. Phase Two: tomorrow.",
|
||||
"Withdrew from {location} on schedule. The schedule has been updated.",
|
||||
"{location} assessed. TwinBee returns tomorrow. The {location} has been warned.",
|
||||
"{location} assessed. I return tomorrow. The {location} has been warned.",
|
||||
}
|
||||
|
||||
var TwinBeeSummaryEmpty = []string{
|
||||
"Visited {location}. Nothing there today. TwinBee notes this for the record.",
|
||||
"{location}: empty. The dungeon has failed to provide. TwinBee has noted it.",
|
||||
"Nothing found in {location}. TwinBee is filing feedback with the dungeon.",
|
||||
"Visited {location}. Nothing there today. I note this for the record.",
|
||||
"{location}: empty. The dungeon has failed to provide. I have noted it.",
|
||||
"Nothing found in {location}. I am filing feedback with the dungeon.",
|
||||
}
|
||||
|
||||
@@ -7,268 +7,268 @@ package plugin
|
||||
// from victories, looked at the right way, which is TwinBee's way.
|
||||
|
||||
var TwinBeeSuccess = []string{
|
||||
"TwinBee descended into {location} with characteristic enthusiasm " +
|
||||
"I descended into {location} with characteristic enthusiasm " +
|
||||
"and the kind of confidence that makes dungeon monsters briefly " +
|
||||
"reconsider their career choices. Several reconsidered too slowly. " +
|
||||
"TwinBee has returned with {loot} worth €{value} and a new personal philosophy " +
|
||||
"I have returned with {loot} worth €{value} and a new personal philosophy " +
|
||||
"regarding the optimum angle of attack on a Stone Golem. " +
|
||||
"The philosophy works. It has been field-tested. Just now.",
|
||||
|
||||
"Another flawless operation. TwinBee entered {location}, assessed the situation " +
|
||||
"Another flawless operation. I entered {location}, assessed the situation " +
|
||||
"with professional precision, and extracted {loot} worth €{value} " +
|
||||
"with minimal collateral damage to TwinBee specifically. " +
|
||||
"with minimal collateral damage to me specifically. " +
|
||||
"The dungeon has been informed it performed adequately. " +
|
||||
"TwinBee does not give compliments freely. This was a compliment.",
|
||||
"I do not give compliments freely. This was a compliment.",
|
||||
|
||||
"TwinBee has returned from {location} victorious, which is to say " +
|
||||
"TwinBee has returned from {location}, because for TwinBee these are the same thing. " +
|
||||
"{loot} worth €{value}. {xp} XP that TwinBee does not need " +
|
||||
"but accepts graciously, as a gift, from the dungeon. " +
|
||||
"I have returned from {location} victorious, which is to say " +
|
||||
"I have returned from {location}, because for me these are the same thing. " +
|
||||
"{loot} worth €{value}. {xp} XP that I do not need " +
|
||||
"but accept graciously, as a gift, from the dungeon. " +
|
||||
"The dungeon can try harder next time. It probably won't. They never do.",
|
||||
|
||||
"TwinBee located {loot} worth €{value} in {location} after what TwinBee " +
|
||||
"is describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
|
||||
"I located {loot} worth €{value} in {location} after what I am " +
|
||||
"describing in the post-mission debrief as 'a series of increasingly correct decisions.' " +
|
||||
"The monsters involved have been described as 'adequate opposition' " +
|
||||
"in the same debrief. TwinBee is generous with these things.",
|
||||
"in the same debrief. I am generous with these things.",
|
||||
|
||||
"In. Retrieved {loot}. Out. €{value}. {xp} XP. " +
|
||||
"TwinBee has the efficiency of someone who has done this many times " +
|
||||
"I have the efficiency of someone who has done this many times " +
|
||||
"and the confidence of someone who has never once considered " +
|
||||
"that it might not go well. These two qualities, combined, produce results. " +
|
||||
"The results are in your share of the haul.",
|
||||
|
||||
"TwinBee reports: {location} cleared, {loot} secured, €{value} assessed, " +
|
||||
"one near-miss that TwinBee is not classifying as a near-miss " +
|
||||
"because TwinBee does not have near-misses, TwinBee has " +
|
||||
"I report: {location} cleared, {loot} secured, €{value} assessed, " +
|
||||
"one near-miss that I am not classifying as a near-miss " +
|
||||
"because I do not have near-misses, I have " +
|
||||
"'moments of dynamic tactical adjustment that resolved favorably.' " +
|
||||
"The moment resolved favorably. Obviously.",
|
||||
|
||||
"The {location} boss encountered TwinBee today. " +
|
||||
"This is how TwinBee prefers to frame the encounter — " +
|
||||
"the boss encountered TwinBee, not the other way around — " +
|
||||
"The {location} boss encountered me today. " +
|
||||
"This is how I prefer to frame the encounter — " +
|
||||
"the boss encountered me, not the other way around — " +
|
||||
"because it accurately reflects the power dynamics. " +
|
||||
"{loot} worth €{value}. The boss has been encountered.",
|
||||
|
||||
"TwinBee went to {location} and came back with {loot} worth €{value}, " +
|
||||
"which is what TwinBee does, and {xp} XP, which TwinBee acknowledges " +
|
||||
"I went to {location} and came back with {loot} worth €{value}, " +
|
||||
"which is what I do, and {xp} XP, which I acknowledge " +
|
||||
"with the quiet grace of someone for whom excellence " +
|
||||
"has long since stopped being a surprise.",
|
||||
|
||||
"Professional assessment of today's {location} operation: successful. " +
|
||||
"Loot: {loot}, €{value}. Opposition: handled. Duration: optimal. " +
|
||||
"TwinBee's equipment: immaculate. TwinBee: as expected.",
|
||||
"My equipment: immaculate. Me: as expected.",
|
||||
|
||||
"TwinBee extracted {loot} worth €{value} from {location} " +
|
||||
"I extracted {loot} worth €{value} from {location} " +
|
||||
"with the efficiency of a professional and the cheerfulness of someone " +
|
||||
"who genuinely enjoys doing this, which TwinBee does, " +
|
||||
"which is why TwinBee is better at it than everyone else, " +
|
||||
"which TwinBee is.",
|
||||
"who genuinely enjoys doing this, which I do, " +
|
||||
"which is why I am better at it than everyone else, " +
|
||||
"which I am.",
|
||||
|
||||
"The monsters in {location} gave it their best today. " +
|
||||
"TwinBee noticed this and respected it for the duration of the encounter, " +
|
||||
"I noticed this and respected it for the duration of the encounter, " +
|
||||
"which was brief. {loot} worth €{value}. {xp} XP. " +
|
||||
"TwinBee has returned in excellent spirits, which is not news, " +
|
||||
"I have returned in excellent spirits, which is not news, " +
|
||||
"but it is accurate.",
|
||||
|
||||
"TwinBee's route through {location} was, according to TwinBee's own review, " +
|
||||
"My route through {location} was, according to my own review, " +
|
||||
"'essentially perfect.' No argument has been presented to the contrary. " +
|
||||
"TwinBee returned with {loot} worth €{value} and a complete absence " +
|
||||
"of anything that went wrong, which is TwinBee's preferred kind of absence.",
|
||||
"I returned with {loot} worth €{value} and a complete absence " +
|
||||
"of anything that went wrong, which is my preferred kind of absence.",
|
||||
|
||||
"A lesser adventurer would have found {location} challenging today. " +
|
||||
"TwinBee found it brisk. {loot}, €{value}, {xp} XP, " +
|
||||
"I found it brisk. {loot}, €{value}, {xp} XP, " +
|
||||
"and the mild satisfaction of a problem that turned out " +
|
||||
"to be an appropriately-sized problem. TwinBee appreciates appropriate sizing.",
|
||||
"to be an appropriately-sized problem. I appreciate appropriate sizing.",
|
||||
|
||||
"TwinBee entered {location} from the north approach, which TwinBee has determined " +
|
||||
"I entered {location} from the north approach, which I have determined " +
|
||||
"is the correct approach, and exited with {loot} worth €{value}. " +
|
||||
"The southern approach is noted. It is not the correct approach. " +
|
||||
"TwinBee has opinions about approaches and they are correct opinions.",
|
||||
"I have opinions about approaches and they are correct opinions.",
|
||||
|
||||
"Operational summary: {location} visited, opposition encountered, opposition handled, " +
|
||||
"{loot} extracted at €{value} assessed value, {xp} XP collected, " +
|
||||
"TwinBee returned in the condition TwinBee left in, " +
|
||||
"which is optimal, because TwinBee is always in optimal condition.",
|
||||
"I returned in the condition I left in, " +
|
||||
"which is optimal, because I am always in optimal condition.",
|
||||
}
|
||||
|
||||
var TwinBeeExceptional = []string{
|
||||
"OUTSTANDING PERFORMANCE. TwinBee located and neutralised the dungeon boss " +
|
||||
"— which TwinBee describes as 'a scheduling conflict that has now been resolved' — " +
|
||||
"OUTSTANDING PERFORMANCE. I located and neutralised the dungeon boss " +
|
||||
"— which I describe as 'a scheduling conflict that has now been resolved' — " +
|
||||
"and recovered {loot} worth €{value}. " +
|
||||
"This is, according to TwinBee, a typical Tuesday. " +
|
||||
"TwinBee has had many exceptional Tuesdays. The dungeons are aware.",
|
||||
"This is, by my reckoning, a typical Tuesday. " +
|
||||
"I have had many exceptional Tuesdays. The dungeons are aware.",
|
||||
|
||||
"TwinBee found {loot} worth €{value} in {location} after what TwinBee " +
|
||||
"is calling 'a brief but educational exchange' with something very large " +
|
||||
"I found {loot} worth €{value} in {location} after what I am " +
|
||||
"calling 'a brief but educational exchange' with something very large " +
|
||||
"that is no longer a going concern. Bonus XP noted. " +
|
||||
"TwinBee noted it first. TwinBee notes everything first.",
|
||||
"I noted it first. I note everything first.",
|
||||
|
||||
"The inner chamber. TwinBee reached the inner chamber. " +
|
||||
"TwinBee notes that reaching the inner chamber requires " +
|
||||
"The inner chamber. I reached the inner chamber. " +
|
||||
"I note that reaching the inner chamber requires " +
|
||||
"the successful navigation of everything between here and the inner chamber, " +
|
||||
"which TwinBee navigated successfully, and then {loot} worth €{value} " +
|
||||
"was in the inner chamber, and TwinBee took it. " +
|
||||
"which I navigated successfully, and then {loot} worth €{value} " +
|
||||
"was in the inner chamber, and I took it. " +
|
||||
"The inner chamber is now available to be an outer chamber.",
|
||||
|
||||
"A boss encounter. TwinBee's assessment of the boss: 'interesting.' " +
|
||||
"The boss's assessment of TwinBee: insufficient time to complete. " +
|
||||
"{loot} worth €{value}. TwinBee has forwarded the boss's personnel file " +
|
||||
"A boss encounter. My assessment of the boss: 'interesting.' " +
|
||||
"The boss's assessment of me: insufficient time to complete. " +
|
||||
"{loot} worth €{value}. I have forwarded the boss's personnel file " +
|
||||
"to the relevant afterlife authorities with a positive reference.",
|
||||
|
||||
"EXCEPTIONAL. TwinBee's word, used sparingly. " +
|
||||
"EXCEPTIONAL. My word, used sparingly. " +
|
||||
"Today merits it. {loot} worth €{value} from the deepest chamber " +
|
||||
"of {location}, retrieved past opposition that TwinBee describes " +
|
||||
"of {location}, retrieved past opposition that I describe " +
|
||||
"as 'the most interesting opposition I've faced this week,' " +
|
||||
"which is high praise from TwinBee and accurately assesses the week so far.",
|
||||
"which is high praise from me and accurately assesses the week so far.",
|
||||
|
||||
"TwinBee found something the dungeon wasn't offering publicly. " +
|
||||
"TwinBee finds things dungeons aren't offering publicly. " +
|
||||
"I found something the dungeon wasn't offering publicly. " +
|
||||
"I find things dungeons aren't offering publicly. " +
|
||||
"It's a skill. {loot} worth €{value}. {xp} XP. " +
|
||||
"The dungeon has updated its offering. TwinBee approves of the update " +
|
||||
"The dungeon has updated its offering. I approve of the update " +
|
||||
"from a position of already having taken the thing.",
|
||||
|
||||
"A critical run. Every encounter resolved correctly. Every chamber yielded. " +
|
||||
"Every roll, every decision, every approach: correct. " +
|
||||
"{loot} worth €{value}. {xp} XP. " +
|
||||
"TwinBee submits this run as the reference standard for {location}. " +
|
||||
"I submit this run as the reference standard for {location}. " +
|
||||
"Future runs should aspire to this. They will not reach it.",
|
||||
|
||||
"The boss was large. TwinBee is TwinBee. " +
|
||||
"The boss was large. I am here. " +
|
||||
"These two facts met in {location} and produced {loot} worth €{value} " +
|
||||
"and a story that TwinBee is telling to everyone and will continue telling " +
|
||||
"until a better story replaces it, which will also be TwinBee's story.",
|
||||
"and a story that I am telling to everyone and will continue telling " +
|
||||
"until a better story replaces it, which will also be my story.",
|
||||
}
|
||||
|
||||
var TwinBeeWithdrawal = []string{
|
||||
"TwinBee has completed today's operation and is taking the remainder " +
|
||||
"I have completed today's operation and am taking the remainder " +
|
||||
"of the afternoon for strategic planning. " +
|
||||
"This is unrelated to {location}. {location} is unrelated to everything. " +
|
||||
"TwinBee has already moved on and suggests you do the same.",
|
||||
"I have already moved on and suggest you do the same.",
|
||||
|
||||
"TwinBee assessed {location} with characteristic thoroughness, " +
|
||||
"I assessed {location} with characteristic thoroughness, " +
|
||||
"identified that today's objectives had been sufficiently advanced " +
|
||||
"by the reconnaissance phase, and withdrew in good order " +
|
||||
"before the situation became interesting in ways TwinBee did not schedule. " +
|
||||
"This is called professionalism. TwinBee has it.",
|
||||
"before the situation became interesting in ways I did not schedule. " +
|
||||
"This is called professionalism. I have it.",
|
||||
|
||||
"Today's operation at {location} concluded earlier than projected. " +
|
||||
"TwinBee encountered something very large, evaluated the encounter on its merits, " +
|
||||
"I encountered something very large, evaluated the encounter on its merits, " +
|
||||
"and determined that returning tomorrow with more information " +
|
||||
"was the superior strategic outcome. " +
|
||||
"TwinBee was not running. TwinBee does not run. " +
|
||||
"TwinBee was moving with purpose in the opposite direction. These are different.",
|
||||
"I was not running. I do not run. " +
|
||||
"I was moving with purpose in the opposite direction. These are different.",
|
||||
|
||||
"TwinBee left {location} at a time of TwinBee's choosing, " +
|
||||
"I left {location} at a time of my own choosing, " +
|
||||
"which happened to coincide with the arrival of something " +
|
||||
"that TwinBee is describing in the official report as 'a scheduling conflict.' " +
|
||||
"that I am describing in the official report as 'a scheduling conflict.' " +
|
||||
"The conflict has been noted. It will be addressed. Tomorrow. " +
|
||||
"At a time that suits TwinBee.",
|
||||
"At a time that suits me.",
|
||||
|
||||
"TACTICAL WITHDRAWAL EXECUTED. {location} has been informed, " +
|
||||
"via the medium of TwinBee's departure, that today was not the day. " +
|
||||
"TwinBee knows which days are the days. Today was for gathering intelligence. " +
|
||||
"via the medium of my departure, that today was not the day. " +
|
||||
"I know which days are the days. Today was for gathering intelligence. " +
|
||||
"The intelligence gathered is: {location} still exists and should watch itself.",
|
||||
|
||||
"TwinBee retreated from {location} in the same way the tide retreats from the shore — " +
|
||||
"I retreated from {location} in the same way the tide retreats from the shore — " +
|
||||
"deliberately, inevitably, and with the full understanding that it will be back, " +
|
||||
"and the shore knows this, and the shore is thinking about it right now.",
|
||||
|
||||
"Something in {location} today required TwinBee to make a decision. " +
|
||||
"TwinBee made the decision. The decision was: not today. " +
|
||||
"TwinBee's decisions are correct decisions by definition, " +
|
||||
"Something in {location} today required me to make a decision. " +
|
||||
"I made the decision. The decision was: not today. " +
|
||||
"My decisions are correct decisions by definition, " +
|
||||
"which means 'not today' was correct, which will be proven correct " +
|
||||
"when TwinBee returns tomorrow and the thing is no longer a factor. " +
|
||||
"These things always work out when TwinBee decides they will.",
|
||||
"when I return tomorrow and the thing is no longer a factor. " +
|
||||
"These things always work out when I decide they will.",
|
||||
|
||||
"The mission objectives for today's {location} operation have been " +
|
||||
"reclassified as 'Phase One of a Two-Phase Operation,' " +
|
||||
"where Phase One is reconnaissance and Phase Two is tomorrow. " +
|
||||
"Phase One is complete. Phase One was, in retrospect, always the plan.",
|
||||
|
||||
"TwinBee encountered something in {location} that TwinBee is describing " +
|
||||
"I encountered something in {location} that I am describing " +
|
||||
"as 'an opportunity to demonstrate strategic patience.' " +
|
||||
"TwinBee demonstrated strategic patience. The patience was demonstrated " +
|
||||
"I demonstrated strategic patience. The patience was demonstrated " +
|
||||
"at significant velocity in the direction of the exit, " +
|
||||
"but the patience was present throughout.",
|
||||
|
||||
"Official statement from TwinBee regarding today's {location} operation: " +
|
||||
"Official statement from me regarding today's {location} operation: " +
|
||||
"'Everything went according to plan.' " +
|
||||
"The plan has been updated to reflect what happened. " +
|
||||
"The updated plan describes what happened as planned. " +
|
||||
"TwinBee's plans are always accurate.",
|
||||
"My plans are always accurate.",
|
||||
|
||||
"TwinBee is back. The thing in {location} is also still there. " +
|
||||
"TwinBee is aware of the thing. The thing is aware of TwinBee. " +
|
||||
"This is a stalemate that TwinBee will resolve on TwinBee's timeline, " +
|
||||
"I am back. The thing in {location} is also still there. " +
|
||||
"I am aware of the thing. The thing is aware of me. " +
|
||||
"This is a stalemate that I will resolve on my own timeline, " +
|
||||
"which is different from the thing's timeline, " +
|
||||
"and TwinBee's timeline is the one that matters.",
|
||||
"and my timeline is the one that matters.",
|
||||
|
||||
"The {location} situation has been described by TwinBee as " +
|
||||
"'temporarily inconclusive,' which is a phrase TwinBee uses " +
|
||||
"The {location} situation has been described, by me, as " +
|
||||
"'temporarily inconclusive,' which is a phrase I use " +
|
||||
"when something very large expressed a strong position " +
|
||||
"and TwinBee has chosen to revisit the position on better terms. " +
|
||||
"The terms will be better. TwinBee will see to it.",
|
||||
"and I have chosen to revisit the position on better terms. " +
|
||||
"The terms will be better. I will see to it.",
|
||||
|
||||
"Today, TwinBee gathered extensive tactical data on {location}. " +
|
||||
"Today, I gathered extensive tactical data on {location}. " +
|
||||
"The data indicates that {location} is well-defended. " +
|
||||
"The data was gathered by going in and then coming back out, " +
|
||||
"which is a time-honoured data gathering methodology " +
|
||||
"that TwinBee has employed before and will employ again, ideally less urgently.",
|
||||
"that I have employed before and will employ again, ideally less urgently.",
|
||||
|
||||
"TwinBee's return today is best understood as a 'victory lap around the outside,' " +
|
||||
"which is a phrase TwinBee has just coined and will be using going forward. " +
|
||||
"My return today is best understood as a 'victory lap around the outside,' " +
|
||||
"which is a phrase I have just coined and will be using going forward. " +
|
||||
"A victory lap around the outside of {location} has been completed. " +
|
||||
"TwinBee is home. The inside of {location} is tomorrow's victory lap.",
|
||||
"I am home. The inside of {location} is tomorrow's victory lap.",
|
||||
|
||||
"Something the size of a building expressed interest in TwinBee at depth. " +
|
||||
"TwinBee expressed equal and opposite interest in being elsewhere. " +
|
||||
"Both interests were satisfied. TwinBee is elsewhere. " +
|
||||
"Something the size of a building expressed interest in me at depth. " +
|
||||
"I expressed equal and opposite interest in being elsewhere. " +
|
||||
"Both interests were satisfied. I am elsewhere. " +
|
||||
"The something is where it was. The relationship is stable.",
|
||||
}
|
||||
|
||||
var TwinBeeEmpty = []string{
|
||||
"TwinBee has returned from {location} and wishes it to be known " +
|
||||
"that the dungeon was empty, which TwinBee attributes entirely to their reputation " +
|
||||
"preceding them. The monsters left. They knew. " +
|
||||
"TwinBee respects this. TwinBee also found nothing. These facts are unrelated.",
|
||||
"I have returned from {location} and wish it to be known " +
|
||||
"that the dungeon was empty, which I attribute entirely to my reputation " +
|
||||
"preceding me. The monsters left. They knew. " +
|
||||
"I respect this. I also found nothing. These facts are unrelated.",
|
||||
|
||||
"Intelligence suggested {location} would be productive today. " +
|
||||
"TwinBee's intelligence was incorrect. " +
|
||||
"TwinBee's intelligence is reviewing its methodology. " +
|
||||
"My intelligence was incorrect. " +
|
||||
"My intelligence is reviewing its methodology. " +
|
||||
"The dungeon contained no loot, no monsters worth discussing, " +
|
||||
"and one confused bat that TwinBee has agreed not to mention publicly. " +
|
||||
"and one confused bat that I have agreed not to mention publicly. " +
|
||||
"Moving on.",
|
||||
|
||||
"{location} was cleared. By whom is unclear. TwinBee suspects a rival. " +
|
||||
"TwinBee does not have rivals, technically, " +
|
||||
"but is prepared to invent one for the purposes of this grievance. " +
|
||||
"{location} was cleared. By whom is unclear. I suspect a rival. " +
|
||||
"I do not have rivals, technically, " +
|
||||
"but am prepared to invent one for the purposes of this grievance. " +
|
||||
"No loot. No comment. The rival has been noted.",
|
||||
|
||||
"The {location} run today yielded nothing of material value, " +
|
||||
"which TwinBee attributes to the universe's temporary misalignment " +
|
||||
"with TwinBee's interests. The universe will correct itself. " +
|
||||
"TwinBee is giving it until tomorrow.",
|
||||
"which I attribute to the universe's temporary misalignment " +
|
||||
"with my interests. The universe will correct itself. " +
|
||||
"I am giving it until tomorrow.",
|
||||
|
||||
"Empty. {location} was empty today, which is {location}'s problem. " +
|
||||
"TwinBee showed up. TwinBee was prepared. TwinBee had the right equipment. " +
|
||||
"I showed up. I was prepared. I had the right equipment. " +
|
||||
"The dungeon failed to provide an adequate challenge or adequate reward. " +
|
||||
"This reflects poorly on the dungeon.",
|
||||
|
||||
"TwinBee cleared four rooms of {location} and found nothing worth the trip. " +
|
||||
"TwinBee is not saying the trip was not worth it — TwinBee does not say that — " +
|
||||
"I cleared four rooms of {location} and found nothing worth the trip. " +
|
||||
"I am not saying the trip was not worth it — I do not say that — " +
|
||||
"but the material outcome was zero and the XP was minimal " +
|
||||
"and TwinBee would like to register this as feedback for the dungeon.",
|
||||
"and I would like to register this as feedback for the dungeon.",
|
||||
|
||||
"The treasure was already gone when TwinBee arrived, " +
|
||||
"which means someone got there before TwinBee, " +
|
||||
"The treasure was already gone when I arrived, " +
|
||||
"which means someone got there before me, " +
|
||||
"which is a sentence that has never previously been true " +
|
||||
"and which TwinBee is treating as a statistical anomaly " +
|
||||
"and which I am treating as a statistical anomaly " +
|
||||
"rather than a pattern that might develop.",
|
||||
|
||||
"No monsters. No treasure. No meaningful opposition. " +
|
||||
"Just TwinBee in {location} swinging at things that weren't there. " +
|
||||
"TwinBee has described this run as 'character-building.' " +
|
||||
"TwinBee's character was already built. " +
|
||||
"Just me in {location} swinging at things that weren't there. " +
|
||||
"I have described this run as 'character-building.' " +
|
||||
"My character was already built. " +
|
||||
"This run has contributed nothing to the structure.",
|
||||
}
|
||||
|
||||
@@ -529,51 +529,38 @@ var RespawnDM = []string{
|
||||
}
|
||||
|
||||
var IdleShameDM = []string{
|
||||
"{name}, you didn't leave the house today.\n\n" +
|
||||
"Your adventurer sat in their hovel, stared at the wall,\n" +
|
||||
"and achieved absolutely nothing. No loot. No XP. No death,\n" +
|
||||
"which is honestly the nicest thing that can be said about today.\n\n" +
|
||||
"Tomorrow's choices arrive at 08:00 UTC.\n" +
|
||||
"Please, for the love of everything, try to be brave.",
|
||||
"{name}, hey. Didn't see you out there today.\n\n" +
|
||||
"I swung by the hovel — door was shut, no answer.\n" +
|
||||
"Hope you're alright. The dungeon will keep. The mine will keep.\n" +
|
||||
"They've been there a while; they're patient.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. We'll be ready when you are.",
|
||||
|
||||
"No action today, {name}.\n\n" +
|
||||
"The morning DM was sent. The morning DM was not answered.\n" +
|
||||
"The dungeon was there. The mine was there. The forest was there.\n" +
|
||||
"You were also there, presumably, somewhere, not responding.\n\n" +
|
||||
"TwinBee noticed. The daily summary has noted your absence.\n" +
|
||||
"Tomorrow: 08:00 UTC. The options will be there. Please be there too.",
|
||||
"Quiet day for you, {name}.\n\n" +
|
||||
"The morning DM went out and didn't come back. That happens.\n" +
|
||||
"Real life sometimes shows up uninvited and stays for dinner.\n" +
|
||||
"I picked up a little extra in the field — covered for you.\n\n" +
|
||||
"Tomorrow morning the choices reset. Drop by when you can.",
|
||||
|
||||
"{name}. You rested today.\n\n" +
|
||||
"'Rested' is the word being used. It is the charitable word.\n" +
|
||||
"The uncharitable word is 'nothing.' You did nothing.\n" +
|
||||
"The dungeons are still there. The mines are still there.\n" +
|
||||
"The foraging areas are still there. Your XP is still where you left it.\n\n" +
|
||||
"08:00 UTC tomorrow. An adventure awaits.\n" +
|
||||
"The adventure has been waiting since you ignored it this morning.",
|
||||
"{name}, no sign of you in the field today.\n\n" +
|
||||
"The forest is still there. So's the mine. So's that one rusted sword\n" +
|
||||
"you keep meaning to replace. Nothing important moved.\n\n" +
|
||||
"08:00 UTC tomorrow — fresh menu, same patient narrator.",
|
||||
|
||||
"Today passed without you, {name}.\n\n" +
|
||||
"TwinBee went to a dungeon. The dungeon was real.\n" +
|
||||
"Your share of TwinBee's haul was distributed to people who showed up.\n" +
|
||||
"You did not show up. You did not get a share.\n" +
|
||||
"TwinBee noticed. TwinBee says nothing.\n" +
|
||||
"TwinBee's silence is louder than most things.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. Show up.",
|
||||
"Missed you out there, {name}.\n\n" +
|
||||
"I went on a haul today and saved a corner of the splits for you,\n" +
|
||||
"then ate it on principle when you didn't show. (Sorry. House rule.)\n" +
|
||||
"Nothing's lost that can't be earned back.\n\n" +
|
||||
"Tomorrow: 08:00 UTC. The dungeons are warm.",
|
||||
|
||||
"A day of rest, {name}. Unearned, but rest.\n\n" +
|
||||
"The hovel is comfortable. The wall is familiar.\n" +
|
||||
"Nothing in the hovel tried to kill you, which is the one advantage the hovel has.\n" +
|
||||
"The hovel also paid you nothing and taught you nothing and advanced nothing.\n" +
|
||||
"The hovel is comfortable and useless. So was today.\n\n" +
|
||||
"Tomorrow is 08:00 UTC. The choices will be new ones.\n" +
|
||||
"Make one of them. Any of them. Please.",
|
||||
"A quiet day, {name}. Those happen.\n\n" +
|
||||
"You weren't on the trail. The trail didn't fall apart without you.\n" +
|
||||
"It'll be there tomorrow, and the day after, and the one after that.\n\n" +
|
||||
"08:00 UTC. Whenever you're back, we pick up where we left off.",
|
||||
|
||||
"Where were you today, {name}?\n\n" +
|
||||
"The bot sent the DM. The DM was delivered.\n" +
|
||||
"The DM sat there, unread or unresponded-to, while your adventurer\n" +
|
||||
"sat in the hovel and the day happened without them.\n" +
|
||||
"XP: not gained. Loot: not found. Death: at least avoided.\n\n" +
|
||||
"Low bar. You cleared the low bar by doing nothing.\n" +
|
||||
"Tomorrow: 08:00 UTC. Clear a higher bar.",
|
||||
"{name}. Hope you had a good rest day.\n\n" +
|
||||
"I held down the fort. Nothing dramatic, nothing lost —\n" +
|
||||
"the world declined to end on your behalf, which feels generous.\n\n" +
|
||||
"When you're ready, the morning DM hits at 08:00 UTC. No rush.",
|
||||
}
|
||||
|
||||
var OnboardingDM = []string{
|
||||
@@ -597,8 +584,8 @@ var OnboardingDM = []string{
|
||||
var MorningDM = []string{
|
||||
"⚔️ Good morning, {name}. Another day, another opportunity for regret.\n\n" +
|
||||
"{character_sheet}\n\n" +
|
||||
"Where are you headed? TwinBee would choose the dungeon.\n" +
|
||||
"TwinBee would also survive it. You are not TwinBee. Choose wisely.",
|
||||
"Where are you headed? I would choose the dungeon.\n" +
|
||||
"I would also survive it. You are not TwinBee. Choose wisely.",
|
||||
|
||||
"⚔️ {name}. You're alive. The options are:\n\n" +
|
||||
"{character_sheet}\n\n" +
|
||||
@@ -622,6 +609,6 @@ var MorningDM = []string{
|
||||
"{character_sheet}\n\n" +
|
||||
"Or don't. You could rest. You could do nothing.\n" +
|
||||
"The bot will send you a disappointed DM at midnight.\n" +
|
||||
"TwinBee will know. TwinBee always knows.\n" +
|
||||
"I will know. I always know.\n" +
|
||||
"Reply to choose.",
|
||||
}
|
||||
|
||||
351
internal/plugin/adventure_followups_test.go
Normal file
351
internal/plugin/adventure_followups_test.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── Equipment Mastery ──────────────────────────────────────────────────────
|
||||
|
||||
func TestAdvEquipmentMasteryBonus(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
want float64
|
||||
}{
|
||||
{"empty equip", nil, 0},
|
||||
{"all sub-50", map[EquipmentSlot]int{SlotWeapon: 49, SlotArmor: 1}, 0},
|
||||
{"one slot crossed first threshold", map[EquipmentSlot]int{SlotWeapon: 50}, 1},
|
||||
{"one slot at all three thresholds", map[EquipmentSlot]int{SlotWeapon: 250}, 3},
|
||||
{"mixed: 100 + 50", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 50}, 3},
|
||||
{"all 5 slots maxed = 15 raw, capped at 10", map[EquipmentSlot]int{
|
||||
SlotWeapon: 999, SlotArmor: 999, SlotHelmet: 999, SlotBoots: 999, SlotTool: 999,
|
||||
}, 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{}
|
||||
for slot, used := range tc.used {
|
||||
equip[slot] = &AdvEquipment{ActionsUsed: used}
|
||||
}
|
||||
got := advEquipmentMasteryBonus(equip)
|
||||
if got != tc.want {
|
||||
t.Errorf("got %.1f, want %.1f", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvSlotRelevantToActivity(t *testing.T) {
|
||||
cases := []struct {
|
||||
slot EquipmentSlot
|
||||
activity AdvActivityType
|
||||
want bool
|
||||
}{
|
||||
// Combat exercises the combat loadout.
|
||||
{SlotWeapon, AdvActivityDungeon, true},
|
||||
{SlotArmor, AdvActivityDungeon, true},
|
||||
{SlotHelmet, AdvActivityDungeon, true},
|
||||
{SlotBoots, AdvActivityDungeon, true},
|
||||
{SlotTool, AdvActivityDungeon, false}, // a dungeon shouldn't master a pickaxe
|
||||
|
||||
// Each harvest activity exercises only the tool.
|
||||
{SlotTool, AdvActivityMining, true},
|
||||
{SlotTool, AdvActivityForaging, true},
|
||||
{SlotTool, AdvActivityFishing, true},
|
||||
{SlotWeapon, AdvActivityMining, false}, // foraging shouldn't master a sword
|
||||
{SlotArmor, AdvActivityForaging, false},
|
||||
{SlotHelmet, AdvActivityFishing, false},
|
||||
{SlotBoots, AdvActivityMining, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%s_%s", tc.slot, tc.activity), func(t *testing.T) {
|
||||
if got := advSlotRelevantToActivity(tc.slot, tc.activity); got != tc.want {
|
||||
t.Errorf("got %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvMasteryRowSegment(t *testing.T) {
|
||||
cases := []struct {
|
||||
used int
|
||||
want string
|
||||
}{
|
||||
{0, ""},
|
||||
{1, "1/50"},
|
||||
{49, "49/50"},
|
||||
{50, "50/100 ✦"},
|
||||
{99, "99/100 ✦"},
|
||||
{100, "100/250 ✦✦"},
|
||||
{249, "249/250 ✦✦"},
|
||||
{250, "250 ✦✦✦"},
|
||||
{500, "500 ✦✦✦"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("used=%d", tc.used), func(t *testing.T) {
|
||||
if got := advMasteryRowSegment(tc.used); got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvMasteryRollup(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
wantCrossed int
|
||||
wantMaxed int
|
||||
wantBonus float64
|
||||
wantAnyProgress bool
|
||||
}{
|
||||
{"empty", nil, 0, 0, 0, false},
|
||||
{"sub-50 only", map[EquipmentSlot]int{SlotWeapon: 30}, 0, 0, 0, true},
|
||||
{"one slot 1 threshold", map[EquipmentSlot]int{SlotWeapon: 50}, 1, 0, 1, true},
|
||||
{"one slot maxed", map[EquipmentSlot]int{SlotWeapon: 250}, 3, 1, 3, true},
|
||||
{"two slots, 5 thresholds", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 250}, 5, 1, 5, true},
|
||||
{"all maxed = 15 raw, capped at 10", map[EquipmentSlot]int{
|
||||
SlotWeapon: 999, SlotArmor: 999, SlotHelmet: 999, SlotBoots: 999, SlotTool: 999,
|
||||
}, 15, 5, 10, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{}
|
||||
for slot, used := range tc.used {
|
||||
equip[slot] = &AdvEquipment{ActionsUsed: used}
|
||||
}
|
||||
got := advMasteryRollup(equip)
|
||||
if got.ThresholdsCrossed != tc.wantCrossed {
|
||||
t.Errorf("ThresholdsCrossed: got %d, want %d", got.ThresholdsCrossed, tc.wantCrossed)
|
||||
}
|
||||
if got.MaxedSlots != tc.wantMaxed {
|
||||
t.Errorf("MaxedSlots: got %d, want %d", got.MaxedSlots, tc.wantMaxed)
|
||||
}
|
||||
if got.Bonus != tc.wantBonus {
|
||||
t.Errorf("Bonus: got %.1f, want %.1f", got.Bonus, tc.wantBonus)
|
||||
}
|
||||
if got.AnyProgress != tc.wantAnyProgress {
|
||||
t.Errorf("AnyProgress: got %v, want %v", got.AnyProgress, tc.wantAnyProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderAdvMasteryAggregate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
used map[EquipmentSlot]int
|
||||
wantEmpty bool
|
||||
wantSubstring []string
|
||||
wantNot []string
|
||||
}{
|
||||
{"no progress → empty", nil, true, nil, nil},
|
||||
{"sub-threshold progress → building up", map[EquipmentSlot]int{SlotWeapon: 30},
|
||||
false, []string{"building up", "first threshold at 50"}, []string{"+0%", "loot quality"}},
|
||||
{"one threshold crossed → singular threshold", map[EquipmentSlot]int{SlotWeapon: 50},
|
||||
false, []string{"+1% loot quality", "1 threshold crossed"}, []string{"thresholds", "maxed", "cap reached"}},
|
||||
{"three thresholds, no maxed → plural threshold no maxed", map[EquipmentSlot]int{SlotWeapon: 100, SlotArmor: 50},
|
||||
false, []string{"+3% loot quality", "3 thresholds crossed"}, []string{"maxed", "cap reached"}},
|
||||
{"one maxed + extras → mention maxed slot", map[EquipmentSlot]int{SlotWeapon: 250, SlotArmor: 50},
|
||||
false, []string{"+4% loot quality", "4 thresholds crossed", "1 slot maxed"}, []string{"cap reached", "slots maxed"}},
|
||||
{"capped → flag cap reached", map[EquipmentSlot]int{
|
||||
SlotWeapon: 250, SlotArmor: 250, SlotHelmet: 250, SlotBoots: 250, SlotTool: 250,
|
||||
}, false, []string{"+10% loot quality", "15 thresholds crossed", "5 slots maxed", "cap reached"}, nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{}
|
||||
for slot, used := range tc.used {
|
||||
equip[slot] = &AdvEquipment{ActionsUsed: used}
|
||||
}
|
||||
got := renderAdvMasteryAggregate(equip)
|
||||
if tc.wantEmpty {
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("expected non-empty, got empty")
|
||||
}
|
||||
for _, s := range tc.wantSubstring {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("expected to contain %q, got %q", s, got)
|
||||
}
|
||||
}
|
||||
for _, s := range tc.wantNot {
|
||||
if strings.Contains(got, s) {
|
||||
t.Errorf("should not contain %q, got %q", s, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Treasure Rank Ordering ─────────────────────────────────────────────────
|
||||
|
||||
func TestAdvTreasureRank_Ordering(t *testing.T) {
|
||||
t5 := &AdvTreasureDef{Key: "a", Tier: 5, Bonuses: []advTreasureBonusDef{{Type: "all_skills", Value: 5}}}
|
||||
t3multi := &AdvTreasureDef{Key: "b", Tier: 3, Bonuses: []advTreasureBonusDef{
|
||||
{Type: "combat_level", Value: 1}, {Type: "death_chance", Value: -1},
|
||||
}}
|
||||
t3single := &AdvTreasureDef{Key: "c", Tier: 3, Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 5}}}
|
||||
t3same := &AdvTreasureDef{Key: "d", Tier: 3, Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 5}}}
|
||||
|
||||
if !advTreasureRankBetter(advTreasureRank(t5), advTreasureRank(t3multi)) {
|
||||
t.Error("T5 should beat T3 regardless of bonus count")
|
||||
}
|
||||
if !advTreasureRankBetter(advTreasureRank(t3multi), advTreasureRank(t3single)) {
|
||||
t.Error("equal tier: more bonuses should win")
|
||||
}
|
||||
if advTreasureRankBetter(advTreasureRank(t3single), advTreasureRank(t3same)) {
|
||||
t.Error("equal tier and bonus count: should NOT be strictly better (no churn)")
|
||||
}
|
||||
if advTreasureRankBetter(advTreasureRank(t3same), advTreasureRank(t3single)) {
|
||||
t.Error("equal tier and bonus count, reversed: also not strictly better")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvTreasureIrreplaceable(t *testing.T) {
|
||||
special := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{{Type: "special_respawn_halve", Value: 1}}}
|
||||
mixed := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{
|
||||
{Type: "all_skills", Value: 5}, {Type: "special_monthly_death_bypass", Value: 1},
|
||||
}}
|
||||
regular := &AdvTreasureDef{Bonuses: []advTreasureBonusDef{{Type: "loot_quality", Value: 10}}}
|
||||
none := &AdvTreasureDef{}
|
||||
|
||||
if !advTreasureIrreplaceable(special) {
|
||||
t.Error("special_* bonus should mark irreplaceable")
|
||||
}
|
||||
if !advTreasureIrreplaceable(mixed) {
|
||||
t.Error("any special_* among bonuses should mark irreplaceable")
|
||||
}
|
||||
if advTreasureIrreplaceable(regular) {
|
||||
t.Error("regular bonuses should not be irreplaceable")
|
||||
}
|
||||
if advTreasureIrreplaceable(none) {
|
||||
t.Error("zero bonuses is not irreplaceable")
|
||||
}
|
||||
if advTreasureIrreplaceable(nil) {
|
||||
t.Error("nil should not be irreplaceable")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Crafting Teaser Boundaries ─────────────────────────────────────────────
|
||||
|
||||
func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
foraging int
|
||||
craftsSucceeded int
|
||||
wantEmpty bool
|
||||
wantContainsAny []string // any of these (OR)
|
||||
wantNotContains []string
|
||||
}{
|
||||
{"foraging 6 — out of pre-window", 6, 0, true, nil, nil},
|
||||
{"foraging 7 — pre-unlock with 3 levels left, plural", 7, 0, false, []string{"3 Foraging level"}, []string{"unlocks in 1 ", "unlocks in 0"}},
|
||||
{"foraging 9 — pre-unlock with 1 level, singular", 9, 0, false, []string{"1 Foraging level"}, []string{"levels"}},
|
||||
{"foraging 10, no crafts yet — unlocked nudge", 10, 0, false, []string{"Crafting is unlocked"}, []string{"unlocks in"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := craftingTeaserText(tc.foraging, tc.craftsSucceeded, "")
|
||||
if tc.wantEmpty {
|
||||
if got != "" {
|
||||
t.Errorf("expected empty, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatalf("expected non-empty teaser, got empty")
|
||||
}
|
||||
for _, s := range tc.wantContainsAny {
|
||||
if strings.Contains(got, s) {
|
||||
goto matched
|
||||
}
|
||||
}
|
||||
t.Errorf("expected any of %v, got %q", tc.wantContainsAny, got)
|
||||
matched:
|
||||
for _, bad := range tc.wantNotContains {
|
||||
if strings.Contains(got, bad) {
|
||||
t.Errorf("output should not contain %q, got %q", bad, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCraftingReminderWeekday_Spread(t *testing.T) {
|
||||
// Across many synthetic users in the same week, the per-player weekday
|
||||
// should land on most weekdays (uniform-ish hash). Asserting "≥ 4 of 7"
|
||||
// is a loose sanity check that catches a constant or pathological hash.
|
||||
now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)
|
||||
hits := map[int]bool{}
|
||||
for i := 0; i < 100; i++ {
|
||||
uid := id.UserID(strings.Repeat("a", i+1) + "@test:local")
|
||||
hits[craftingReminderWeekday(uid, now)] = true
|
||||
}
|
||||
if len(hits) < 5 {
|
||||
t.Errorf("expected weekday spread across most days, only hit %d distinct: %v", len(hits), hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCraftingReminderWeekday_StableWithinWeek(t *testing.T) {
|
||||
uid := id.UserID("@stable:test")
|
||||
mon := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC) // Monday
|
||||
sun := time.Date(2026, 5, 10, 23, 0, 0, 0, time.UTC) // Sunday same ISO week
|
||||
if craftingReminderWeekday(uid, mon) != craftingReminderWeekday(uid, sun) {
|
||||
t.Error("weekday should be stable within an ISO week")
|
||||
}
|
||||
// Following ISO week — should differ for at least *some* users; not
|
||||
// guaranteed for every user but we test the rotation property exists.
|
||||
nextWeek := time.Date(2026, 5, 11, 0, 0, 0, 0, time.UTC)
|
||||
differs := 0
|
||||
for i := 0; i < 50; i++ {
|
||||
u := id.UserID(strings.Repeat("u", i+1) + "@x")
|
||||
if craftingReminderWeekday(u, mon) != craftingReminderWeekday(u, nextWeek) {
|
||||
differs++
|
||||
}
|
||||
}
|
||||
if differs == 0 {
|
||||
t.Error("expected the per-week hash to rotate at least some users between weeks")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Location Risk/Reward Numbers ───────────────────────────────────────────
|
||||
|
||||
func TestCalculateAdvProbabilities_RiskReward_NormalizesAcrossConfigs(t *testing.T) {
|
||||
// Risk/reward render line depends on these probabilities. Lock in that
|
||||
// they normalize to 100 across a few realistic configurations beyond
|
||||
// the existing single-config sum test.
|
||||
cases := []struct {
|
||||
name string
|
||||
char *AdventureCharacter
|
||||
loc *AdvLocation
|
||||
}{
|
||||
{"low-tier with low skill", &AdventureCharacter{ForagingSkill: 3},
|
||||
&AdvLocation{Activity: AdvActivityForaging, Tier: 1, BaseDeathPct: 5, EmptyPct: 30}},
|
||||
{"high-tier with high skill", &AdventureCharacter{ForagingSkill: 30},
|
||||
&AdvLocation{Activity: AdvActivityForaging, Tier: 4, BaseDeathPct: 25, EmptyPct: 20}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
equip := map[EquipmentSlot]*AdvEquipment{
|
||||
SlotTool: {Tier: 3, Condition: 100},
|
||||
}
|
||||
probs := calculateAdvProbabilities(tc.char, equip, tc.loc, &AdvBonusSummary{}, false)
|
||||
total := probs.DeathPct + probs.EmptyPct + probs.SuccessPct + probs.ExceptionalPct
|
||||
if total < 99.99 || total > 100.01 {
|
||||
t.Errorf("probabilities should sum to 100, got %.2f (%+v)", total, probs)
|
||||
}
|
||||
if probs.DeathPct < 0 || probs.ExceptionalPct < 0 {
|
||||
t.Errorf("negative probability: %+v", probs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -256,36 +256,11 @@ 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},
|
||||
{DisplayName: "Bob", Activity: "mine", Location: "Quarry", Outcome: "success", LootValue: 300, HolidayActions: 1},
|
||||
{DisplayName: "Carol", IsDead: true, DeadUntil: "14:00 UTC", Activity: "dungeon", Location: "Cave", Outcome: "death", HolidayActions: 1},
|
||||
{DisplayName: "Alice", Activity: "dungeon", Location: "Cellar", Outcome: "success", LootValue: 500},
|
||||
{DisplayName: "Bob", Activity: "mine", Location: "Quarry", Outcome: "success", LootValue: 300},
|
||||
{DisplayName: "Carol", IsDead: true, DeadUntil: "14:00 UTC", Activity: "dungeon", Location: "Cave", Outcome: "death"},
|
||||
}
|
||||
|
||||
text := renderAdvDailySummary("2026-12-25", nil, TwinBeeRewardSummary{}, players, "Christmas")
|
||||
@@ -293,17 +268,19 @@ func TestDailySummary_HolidayBlock(t *testing.T) {
|
||||
if !strings.Contains(text, "Christmas") {
|
||||
t.Error("summary should contain holiday name")
|
||||
}
|
||||
if !strings.Contains(text, "two actions today") {
|
||||
t.Error("summary should mention two actions on holidays")
|
||||
// Adv 2.0 — holiday recap now advertises the +5 mood / free pack /
|
||||
// +1 yield perks instead of the retired double-action mechanic.
|
||||
if !strings.Contains(text, "TwinBee's blessing") {
|
||||
t.Error("summary should mention TwinBee's blessing on holidays")
|
||||
}
|
||||
if !strings.Contains(text, "before their second action") {
|
||||
t.Error("summary should note Carol died before second action")
|
||||
if !strings.Contains(text, "free standard pack") {
|
||||
t.Error("summary should mention the complimentary standard pack")
|
||||
}
|
||||
|
||||
// Without holiday
|
||||
textNoHol := renderAdvDailySummary("2026-12-26", nil, TwinBeeRewardSummary{}, players, "")
|
||||
if strings.Contains(textNoHol, "two actions") {
|
||||
t.Error("non-holiday summary should NOT mention two actions")
|
||||
if strings.Contains(textNoHol, "TwinBee's blessing") {
|
||||
t.Error("non-holiday summary should NOT mention TwinBee's blessing")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
434
internal/plugin/adventure_hospital.go
Normal file
434
internal/plugin/adventure_hospital.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"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)
|
||||
Discounted bool // pinned haggle outcome — when true, recomputed base is halved
|
||||
Waived bool // pinned nat-20 outcome — full waiver, no debit
|
||||
}
|
||||
|
||||
// hospitalCostsForUser returns (beforeInsurance, afterInsurance) for the
|
||||
// hospital revival bill. `Level × 5_000` after insurance, 5× that before.
|
||||
// Floors at level 1.
|
||||
func hospitalCostsForUser(char *AdventureCharacter) (int64, int64) {
|
||||
level := 1
|
||||
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 {
|
||||
level = dnd.Level
|
||||
}
|
||||
after := int64(level) * 5_000
|
||||
before := after * 5
|
||||
return before, after
|
||||
}
|
||||
|
||||
// hospitalHaggleDC is the target for the CHA-based haggle check. Pass = 50% off.
|
||||
const hospitalHaggleDC = 15
|
||||
|
||||
type hospitalHaggleOutcome int
|
||||
|
||||
const (
|
||||
haggleFumble hospitalHaggleOutcome = iota // nat 1 — auto-fail, full price
|
||||
haggleFail // d20+CHA < 15
|
||||
haggleWin // d20+CHA ≥ 15
|
||||
haggleCrit // nat 20 — bill waived
|
||||
)
|
||||
|
||||
// rollHospitalHaggle rolls d20 + CHA modifier vs DC 15. Returns the raw roll,
|
||||
// the CHA modifier, and the outcome. Nat 1 is an auto-fumble (full price);
|
||||
// nat 20 is an auto-crit (full waiver) regardless of modifier.
|
||||
// Falls back to 0 mod if the DnD row can't be loaded.
|
||||
func rollHospitalHaggle(userID id.UserID) (int, int, hospitalHaggleOutcome) {
|
||||
mod := 0
|
||||
if dnd, err := LoadDnDCharacter(userID); err == nil && dnd != nil {
|
||||
mod = abilityModifier(dnd.CHA)
|
||||
}
|
||||
r := rand.IntN(20) + 1
|
||||
switch {
|
||||
case r == 20:
|
||||
return r, mod, haggleCrit
|
||||
case r == 1:
|
||||
return r, mod, haggleFumble
|
||||
case r+mod >= hospitalHaggleDC:
|
||||
return r, mod, haggleWin
|
||||
default:
|
||||
return r, mod, haggleFail
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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.")
|
||||
}
|
||||
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil {
|
||||
dnd.HPCurrent = dnd.HPMax
|
||||
if err := SaveDnDCharacter(dnd); err != nil {
|
||||
slog.Error("hospital: failed to restore DnDCharacter HP on natural revive", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||
}
|
||||
|
||||
// Compute costs (level × 5k after insurance, 5× before)
|
||||
beforeInsurance, baseAfterInsurance := hospitalCostsForUser(char)
|
||||
|
||||
// Haggle: CHA save vs DC 15. Nat 20 waives the bill, nat 1 is auto-full,
|
||||
// otherwise pass = half off.
|
||||
haggleRoll, haggleMod, haggleOutcome := rollHospitalHaggle(ctx.Sender)
|
||||
afterInsurance := baseAfterInsurance
|
||||
switch haggleOutcome {
|
||||
case haggleCrit:
|
||||
afterInsurance = 0
|
||||
case haggleWin:
|
||||
afterInsurance /= 2
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if holiday for bill surcharge
|
||||
isHol, _ := isHolidayToday()
|
||||
|
||||
// Build the full multi-act DM
|
||||
var sb strings.Builder
|
||||
|
||||
// Phase L4a: HospitalVisits sourced from player_meta (with AdvCharacter
|
||||
// fallback during soak).
|
||||
visits, _ := loadHospitalVisits(char.UserID)
|
||||
|
||||
// Act 1 — Admission
|
||||
if visits == 0 {
|
||||
sb.WriteString(nurseJoyFirstVisit)
|
||||
}
|
||||
|
||||
admission, _ := advPickFlavor(nurseJoyAdmission, ctx.Sender, "hospital_admit")
|
||||
sb.WriteString(admission)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Act 2 — The Bill (always shows the un-haggled after-insurance figure;
|
||||
// the haggle adjustment is announced separately below).
|
||||
sb.WriteString(generateItemizedBill(beforeInsurance, baseAfterInsurance, visits, 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 (pre-haggle figure)
|
||||
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, baseAfterInsurance)
|
||||
case 2:
|
||||
afterText = fmt.Sprintf(afterText, baseAfterInsurance, baseAfterInsurance)
|
||||
}
|
||||
sb.WriteString(afterText)
|
||||
sb.WriteString("\n\n───\n\n")
|
||||
|
||||
// Act 4 — The Haggle. Nurse Joy lets you make a charisma save on your bill.
|
||||
total := haggleRoll + haggleMod
|
||||
flavor, _ := advPickFlavor(nurseJoyHaggleFlavor[haggleOutcome], ctx.Sender, fmt.Sprintf("hospital_haggle_%d", haggleOutcome))
|
||||
var verdict string
|
||||
switch haggleOutcome {
|
||||
case haggleCrit:
|
||||
verdict = "🎉 **NAT 20 — bill waived!**"
|
||||
case haggleWin:
|
||||
verdict = "✅ *Passed!* The bill is halved."
|
||||
case haggleFumble:
|
||||
verdict = "💀 **NAT 1 — auto-fail.** Full price stands."
|
||||
default:
|
||||
verdict = "❌ *Failed.* Full price stands."
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"🎲 **Haggle Check (CHA DC %d)**\n"+
|
||||
"You attempt to charm Nurse Joy into a discount...\n"+
|
||||
"d20 = **%d**, CHA mod %+d → **total %d** vs DC %d\n"+
|
||||
"%s\n\n%s\n\n───\n\n",
|
||||
hospitalHaggleDC, haggleRoll, haggleMod, total, hospitalHaggleDC, verdict, flavor))
|
||||
|
||||
// Payment prompt — show base → final adjustment when it changed.
|
||||
sb.WriteString("**St. Guildmore's Memorial Hospital**\n")
|
||||
switch haggleOutcome {
|
||||
case haggleCrit:
|
||||
sb.WriteString(fmt.Sprintf("Original: ~~%s~~ → **WAIVED**\n", fmtEuro(baseAfterInsurance)))
|
||||
sb.WriteString("Amount due: **€0**\n\n")
|
||||
case haggleWin:
|
||||
sb.WriteString(fmt.Sprintf("Original: ~~%s~~ → **%s** (haggled)\n", fmtEuro(baseAfterInsurance), fmtEuro(afterInsurance)))
|
||||
sb.WriteString(fmt.Sprintf("Amount due: **%s**\n\n", fmtEuro(afterInsurance)))
|
||||
default:
|
||||
sb.WriteString(fmt.Sprintf("Amount 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,
|
||||
Discounted: haggleOutcome == haggleWin,
|
||||
Waived: haggleOutcome == haggleCrit,
|
||||
},
|
||||
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)
|
||||
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil {
|
||||
dnd.HPCurrent = dnd.HPMax
|
||||
_ = SaveDnDCharacter(dnd)
|
||||
}
|
||||
return p.SendDM(ctx.Sender, nurseJoyAlreadyRevived)
|
||||
}
|
||||
|
||||
// Recompute cost from current DnD level (don't trust cached value).
|
||||
// The haggle outcome was pinned at prompt time — re-applying it here
|
||||
// preserves the discount even if level changed mid-flow, and prevents
|
||||
// any reroll exploit.
|
||||
_, cost := hospitalCostsForUser(char)
|
||||
waived := false
|
||||
if pay, ok := interaction.Data.(*advPendingHospitalPay); ok {
|
||||
switch {
|
||||
case pay.Waived:
|
||||
cost = 0
|
||||
waived = true
|
||||
case pay.Discounted:
|
||||
cost /= 2
|
||||
}
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Debit (skipped on a nat-20 waiver)
|
||||
if !waived {
|
||||
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. Restore DnDCharacter HP to full (canonical post-L5 path);
|
||||
// AdvCharacter.Alive flip + HospitalVisits++ propagate through the
|
||||
// saveAdvCharacter fan-out into player_meta.
|
||||
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 (no-op when nothing was debited)
|
||||
if !waived {
|
||||
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.")
|
||||
}
|
||||
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil {
|
||||
dnd.HPCurrent = dnd.HPMax
|
||||
if err := SaveDnDCharacter(dnd); err != nil {
|
||||
slog.Error("hospital: failed to restore DnDCharacter HP", "user", char.UserID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Discharge DM
|
||||
if waived {
|
||||
p.SendDM(ctx.Sender, "✅ **Discharged — you're alive!**\n\n"+
|
||||
"Nurse Joy waved the deductible entirely. _\"On the house, sweetie. "+
|
||||
"You earned it.\"_ Derek the billing orderly looks personally offended.\n\n"+
|
||||
"Go get 'em. Type `!adventure` when you're ready.")
|
||||
} else {
|
||||
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 != "" {
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
descriptor, _ := advPickFlavor(hospitalDischargeBillDescriptors, ctx.Sender, "hospital_discharge_bill")
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDischargeAnnouncePrefix, name, descriptor))
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
p.SendMessage(gr, fmt.Sprintf(hospitalDitchAnnounce, name))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Hospital Ad (sent after death) ─────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) sendHospitalAd(userID id.UserID, char *AdventureCharacter) {
|
||||
beforeInsurance, afterInsurance := hospitalCostsForUser(char)
|
||||
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 {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-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
internal/plugin/adventure_housing.go
Normal file
759
internal/plugin/adventure_housing.go
Normal 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
internal/plugin/adventure_housing_test.go
Normal file
375
internal/plugin/adventure_housing_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
}
|
||||
@@ -158,7 +158,7 @@ func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
|
||||
|
||||
// ── Drop Logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) {
|
||||
func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) {
|
||||
// Only full-success outcomes drop masterwork (success or exceptional)
|
||||
if outcome != AdvOutcomeSuccess && outcome != AdvOutcomeExceptional {
|
||||
return
|
||||
@@ -169,33 +169,42 @@ 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
|
||||
isFirstDrop := char.MasterworkDropsReceived == 0
|
||||
char.MasterworkDropsReceived++
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("adventure: failed to save masterwork counter", "user", userID, "err", err)
|
||||
// First-drop detection. Counter persists via saveAdvCharacter fan-out.
|
||||
priorDrops, err := loadMasterworkDrops(userID)
|
||||
if err != nil {
|
||||
slog.Error("adventure: failed to load masterwork counter", "user", userID, "err", err)
|
||||
}
|
||||
isFirstDrop := priorDrops == 0
|
||||
newDrops := priorDrops + 1
|
||||
if char, lerr := loadAdvCharacter(userID); lerr == nil {
|
||||
char.MasterworkDropsReceived = newDrops
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("adventure: failed to save masterwork counter", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
if autoEquip {
|
||||
@@ -260,7 +269,8 @@ func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, char *AdventureC
|
||||
p.SendDM(userID, sb.String())
|
||||
|
||||
// Room announcement (tiered)
|
||||
p.postMasterworkAnnouncement(char.DisplayName, def, loc.Name)
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
p.postMasterworkAnnouncement(displayName, def, loc.Name)
|
||||
}
|
||||
|
||||
// ── Room Announcements ─────────────────────────────────────────────────────
|
||||
@@ -331,16 +341,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 +360,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 +374,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 +387,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 +442,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 +505,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 +530,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.
|
||||
|
||||
163
internal/plugin/adventure_mortgage.go
Normal file
163
internal/plugin/adventure_mortgage.go
Normal file
@@ -0,0 +1,163 @@
|
||||
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 {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-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))
|
||||
}
|
||||
444
internal/plugin/adventure_npcs.go
Normal file
444
internal/plugin/adventure_npcs.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"gogobee/internal/flavor"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// ── NPC Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
mistyCost = 100
|
||||
arinaCost = 5000
|
||||
npcCooldownDays = 7
|
||||
npcEncounterChance = 0.075 // 7.5%
|
||||
npcBuffDuration = 7 * 24 * time.Hour
|
||||
|
||||
// Arena effect chances per round
|
||||
mistyEffectChance = 0.20 // 20%
|
||||
arinaEffectChance = 0.08 // 8%
|
||||
|
||||
// Misty crowd revenge damage range
|
||||
crowdRevengeDmgMin = 3
|
||||
crowdRevengeDmgMax = 8
|
||||
|
||||
// Misty gourmet food — enemy damage
|
||||
gourmetEnemyDmg = 5
|
||||
// Misty gourmet food — equipment condition repair
|
||||
gourmetConditionRepair = 5
|
||||
)
|
||||
|
||||
// ── Message Count Tracking ─────────────────────────────────────────────────
|
||||
|
||||
// npcTrackMessage is called on every room message from an adventure player.
|
||||
// It increments their daily message count and fires NPC encounter rolls
|
||||
// when thresholds are hit.
|
||||
func (p *AdventurePlugin) npcTrackMessage(userID id.UserID) {
|
||||
// Acquire user lock to prevent lost updates on message count and
|
||||
// double encounter triggers from rapid messages.
|
||||
userMu := p.advUserLock(userID)
|
||||
userMu.Lock()
|
||||
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || !char.Alive {
|
||||
userMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
// Reset count if it's a new day
|
||||
if char.NPCMsgCountDate != today {
|
||||
char.NPCMsgCount = 0
|
||||
char.NPCMsgCountDate = today
|
||||
char.MistyRollTarget = 5 + rand.IntN(6) // 5–10
|
||||
char.ArinaRollTarget = 5 + rand.IntN(6) // 5–10
|
||||
}
|
||||
|
||||
char.NPCMsgCount++
|
||||
|
||||
var fireNPC string
|
||||
|
||||
// Check Misty threshold
|
||||
if char.MistyRollTarget > 0 && char.NPCMsgCount == char.MistyRollTarget {
|
||||
if p.npcShouldEncounter(char, "misty") {
|
||||
char.MistyRollTarget = 0
|
||||
fireNPC = "misty"
|
||||
}
|
||||
}
|
||||
|
||||
// Check Arina threshold (only if Misty didn't fire — one encounter per message)
|
||||
if fireNPC == "" && char.ArinaRollTarget > 0 && char.NPCMsgCount == char.ArinaRollTarget {
|
||||
if p.npcShouldEncounter(char, "arina") {
|
||||
char.ArinaRollTarget = 0
|
||||
fireNPC = "arina"
|
||||
}
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save msg count", "user", userID, "err", err)
|
||||
}
|
||||
if err := upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: npc msg count dual-write failed", "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++
|
||||
// Pool union: legacy mistyOpenings + D&D MistyGreeting flavor.
|
||||
mistyPool := dndMistyGreetingPool()
|
||||
opening = mistyPool[rand.IntN(len(mistyPool))]
|
||||
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
|
||||
arinaPool := dndArinaGreetingPool()
|
||||
opening = arinaPool[rand.IntN(len(arinaPool))]
|
||||
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
|
||||
}
|
||||
if err := upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: npc last_seen dual-write failed", "user", userID, "npc", npc, "err", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
|
||||
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)
|
||||
}
|
||||
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
|
||||
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+"_")
|
||||
}
|
||||
// D&D Insight check — passing refunds the donation. Renders flavor on
|
||||
// either outcome (success or fail) when a check was attempted.
|
||||
insightCheck := dndNPCInsightRefund(ctx.Sender, p.euro, mistyCost)
|
||||
char.MistyBuffExpires = expires
|
||||
char.MistyDonatedCount++
|
||||
|
||||
// Pet reactivation: donating to Misty after chasing pet away
|
||||
mistyPet, _ := loadPetState(char.UserID)
|
||||
if mistyReactivatePet(mistyPet) {
|
||||
char.PetReactivated = true
|
||||
}
|
||||
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
|
||||
|
||||
reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))]
|
||||
|
||||
// Housing hint (fires once after 2+ encounters)
|
||||
mistyHouse, _ := loadHouseState(char.UserID)
|
||||
hint := mistyHousingHint(char.MistyEncounterCount, char.MistyDonatedCount, mistyHouse)
|
||||
if hint != "" {
|
||||
reply += "\n\n_" + hint + "_"
|
||||
}
|
||||
|
||||
// Flavor for the D&D check outcome (when applicable).
|
||||
if insightCheck.Attempted {
|
||||
if insightCheck.Succeeded {
|
||||
if line := flavor.Pick(flavor.MistyInsightSuccess); line != "" {
|
||||
reply += "\n\n_" + line + "_"
|
||||
}
|
||||
reply += "\n\n_(Insight DC 12 passed — donation refunded.)_"
|
||||
} else {
|
||||
if line := flavor.Pick(flavor.MistySkillFail); line != "" {
|
||||
reply += "\n\n_" + line + "_"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
|
||||
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))
|
||||
}
|
||||
// D&D Arcana check — flavor for either outcome when a check was attempted.
|
||||
arcanaCheck := dndNPCArcanaRefund(ctx.Sender, p.euro, arinaCost)
|
||||
char.ArinaBuffExpires = expires
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("npc: failed to save arina buff", "user", ctx.Sender, "err", err)
|
||||
}
|
||||
_ = upsertPlayerMetaNPCState(char.UserID, npcStateFromAdvChar(char))
|
||||
extra := ""
|
||||
if arcanaCheck.Attempted {
|
||||
if arcanaCheck.Succeeded {
|
||||
if line := flavor.Pick(flavor.ArinaArcanaSuccess); line != "" {
|
||||
extra = "\n\n_" + line + "_"
|
||||
}
|
||||
extra += "\n\n_(Arcana DC 14 passed — investment refunded.)_"
|
||||
} else {
|
||||
if line := flavor.Pick(flavor.ArinaSkillFail); line != "" {
|
||||
extra = "\n\n_" + line + "_"
|
||||
}
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"_She snatches the money from you, almost rudely, then looks at you expectantly._\n\n\"%s\"\n\n_She walks away just as quickly as she came._%s",
|
||||
arinaAcceptLine, extra))
|
||||
}
|
||||
|
||||
// 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")
|
||||
if _, err := d.Exec(`
|
||||
UPDATE player_meta
|
||||
SET npc_msg_count = 0,
|
||||
npc_msg_count_date = ?,
|
||||
misty_roll_target = ABS(RANDOM()) % 6 + 5,
|
||||
arina_roll_target = ABS(RANDOM()) % 6 + 5`,
|
||||
today); err != nil {
|
||||
slog.Error("npc: midnight reset failed", "err", err)
|
||||
}
|
||||
}
|
||||
474
internal/plugin/adventure_pets.go
Normal file
474
internal/plugin/adventure_pets.go
Normal file
@@ -0,0 +1,474 @@
|
||||
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(pet *PetState) bool {
|
||||
if !pet.HasPet() || pet.Level >= 10 {
|
||||
return false
|
||||
}
|
||||
|
||||
pet.XP += int(petXPPerAction * 100) // store as centixp for precision
|
||||
leveled := false
|
||||
for pet.Level < 10 {
|
||||
needed := petXPToNextLevel(pet.Level) * 100
|
||||
if pet.XP < needed {
|
||||
break
|
||||
}
|
||||
pet.XP -= needed
|
||||
pet.Level++
|
||||
leveled = true
|
||||
}
|
||||
|
||||
if pet.Level >= 10 && pet.Level10Date == "" {
|
||||
pet.Level10Date = 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(pet PetState, enemyName string) *PetCombatResult {
|
||||
if !pet.HasPet() {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := &PetCombatResult{}
|
||||
|
||||
// Attack roll
|
||||
if rand.Float64() < petAttackChance(pet.Level) {
|
||||
result.Attacked = true
|
||||
result.AttackDamage = 3 + rand.IntN(5) + pet.Level // 3-7 + level
|
||||
var pool []string
|
||||
if pet.Type == "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(pet.Level, pet.ArmorTier) {
|
||||
result.Deflected = true
|
||||
var pool []string
|
||||
if pet.Type == "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(pet PetState) bool {
|
||||
if !pet.HasPet() {
|
||||
return false
|
||||
}
|
||||
return rand.Float64() < petDitchRecoveryChance(pet.Level)
|
||||
}
|
||||
|
||||
// petVictoryText returns a random victory reaction line.
|
||||
func petVictoryText(pet PetState) string {
|
||||
if !pet.HasPet() {
|
||||
return ""
|
||||
}
|
||||
var pool []string
|
||||
if pet.Type == "dog" {
|
||||
pool = PetDogVictory
|
||||
} else {
|
||||
pool = PetCatVictory
|
||||
}
|
||||
return pool[rand.IntN(len(pool))]
|
||||
}
|
||||
|
||||
// petDeathText returns a random death reaction line.
|
||||
func petDeathText(pet PetState) string {
|
||||
if !pet.HasPet() {
|
||||
return ""
|
||||
}
|
||||
var pool []string
|
||||
if pet.Type == "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. Housing and pet
|
||||
// state come from player_meta via loadHouseState / loadPetState (L4d/L4e
|
||||
// reader flip).
|
||||
func petShouldArrive(pet PetState, house HouseState) bool {
|
||||
if pet.Arrived {
|
||||
return false
|
||||
}
|
||||
// Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2
|
||||
if house.Tier < 2 {
|
||||
return false
|
||||
}
|
||||
// Pet was chased away and reactivated via Misty — allow re-arrival
|
||||
if pet.ChasedAway {
|
||||
return pet.Reactivated
|
||||
}
|
||||
// 30% daily chance after house tier 1 (~3 days average to arrival)
|
||||
return rand.Float64() < 0.30
|
||||
}
|
||||
|
||||
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
|
||||
// surfaces from an expedition (voluntary extract, abandon, or a survived
|
||||
// forced extraction) or revives after death. The arrival roll lives on the
|
||||
// emergence seam — not the legacy 08:00 overworld morning DM — because
|
||||
// expedition players are almost never in the overworld at the scheduled hour,
|
||||
// so the morning roll never reached them. Story beat: while the player was
|
||||
// underground, an animal wandered into the empty house looking for food.
|
||||
//
|
||||
// Safe to call unconditionally on any emergence: petShouldArrive gates on
|
||||
// house tier / not-yet-arrived, and petArrivalDM won't clobber an existing
|
||||
// pending interaction.
|
||||
func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) {
|
||||
pet, _ := loadPetState(userID)
|
||||
house, _ := loadHouseState(userID)
|
||||
if petShouldArrive(pet, house) {
|
||||
p.petArrivalDM(userID)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(advDMResponseWindowLow),
|
||||
})
|
||||
_ = 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)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(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(advDMResponseWindowLow),
|
||||
})
|
||||
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(advDMResponseWindowLow),
|
||||
})
|
||||
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(advDMResponseWindowLow),
|
||||
})
|
||||
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(advDMResponseWindowLow),
|
||||
})
|
||||
|
||||
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. The dispatcher (handlePendingReply)
|
||||
// has already deleted the pending interaction and passes it in, so this must
|
||||
// NOT re-load from p.pending — doing so previously made every adoption fail
|
||||
// silently (the carried PetType was lost and the save never ran).
|
||||
func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPendingInteraction) 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.")
|
||||
}
|
||||
|
||||
data := interaction.Data.(*advPendingPetName)
|
||||
|
||||
name := strings.TrimSpace(ctx.Body)
|
||||
if len(name) == 0 || len(name) > 30 {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
return p.SendDM(ctx.Sender, "Name must be 1-30 characters. Try again.")
|
||||
}
|
||||
if !petNameValid.MatchString(name) {
|
||||
p.pending.Store(string(ctx.Sender), interaction)
|
||||
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.")
|
||||
}
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
||||
|
||||
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(pet PetState) string {
|
||||
if !pet.HasPet() {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 25% chance of morning event
|
||||
if rand.Float64() >= 0.25 {
|
||||
return ""
|
||||
}
|
||||
|
||||
if pet.Type == "cat" {
|
||||
line := PetCatOffering[rand.IntN(len(PetCatOffering))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", pet.Name)
|
||||
}
|
||||
|
||||
line := PetDogSmothering[rand.IntN(len(PetDogSmothering))]
|
||||
return strings.ReplaceAll(line, "{pet_name}", pet.Name)
|
||||
}
|
||||
|
||||
// ── Pet Supply Shop Unlock Check ───────────────────────────────────────────
|
||||
|
||||
// petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10.
|
||||
func petCheckSupplyShopUnlock(pet PetState) bool {
|
||||
if pet.SupplyShopUnlocked || pet.Level10Date == "" {
|
||||
return false
|
||||
}
|
||||
d, err := time.Parse("2006-01-02", pet.Level10Date)
|
||||
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. Housing
|
||||
// state comes from player_meta (L4e reader flip). Misty NPC counters still
|
||||
// live on AdvCharacter (NPC migration is a later phase) so the caller
|
||||
// passes them in directly.
|
||||
func mistyHousingHint(mistyEncounterCount, mistyDonatedCount int, house HouseState) string {
|
||||
if house.HasHouse() || mistyEncounterCount < 2 {
|
||||
return ""
|
||||
}
|
||||
if 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 returns true when a chased-away pet should be marked
|
||||
// as reactivated. Caller is responsible for flipping the flag on both
|
||||
// AdvCharacter and player_meta.
|
||||
func mistyReactivatePet(pet PetState) bool {
|
||||
return pet.ChasedAway && !pet.Reactivated
|
||||
}
|
||||
|
||||
// ── 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
|
||||
pet, _ := loadPetState(char.UserID)
|
||||
if petCheckSupplyShopUnlock(pet) {
|
||||
char.PetSupplyShopUnlocked = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&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
|
||||
}
|
||||
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
51
internal/plugin/adventure_pets_dispatch_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestResolvePetName_ThroughDispatcher is a regression test for the bug where
|
||||
// resolvePendingInteraction deleted the pending entry before dispatch, while
|
||||
// resolvePetName then tried to LoadAndDelete it again — always missing, so the
|
||||
// adoption silently no-op'd and no pet was ever persisted. The fix passes the
|
||||
// already-loaded interaction into resolvePetName. This test drives the real
|
||||
// dispatcher path (resolvePendingInteraction) to lock that in.
|
||||
func TestResolvePetName_ThroughDispatcher(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
uid := id.UserID("@pet-name-dispatch:example")
|
||||
if err := createAdvCharacter(uid, "petnamer"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
interaction := &advPendingInteraction{
|
||||
Type: "pet_name",
|
||||
Data: &advPendingPetName{PetType: "dog"},
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
}
|
||||
p.pending.Store(string(uid), interaction)
|
||||
|
||||
if err := p.resolvePendingInteraction(MessageContext{Sender: uid, Body: "Pepper"}, interaction); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pet, err := loadPetState(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !pet.HasPet() {
|
||||
t.Fatalf("expected pet to be adopted; got %+v", pet)
|
||||
}
|
||||
if pet.Name != "Pepper" {
|
||||
t.Errorf("pet name = %q, want Pepper", pet.Name)
|
||||
}
|
||||
if pet.Type != "dog" {
|
||||
t.Errorf("pet type = %q, want dog", pet.Type)
|
||||
}
|
||||
if !pet.Arrived || pet.Level != 1 {
|
||||
t.Errorf("pet arrived=%v level=%d, want true/1", pet.Arrived, pet.Level)
|
||||
}
|
||||
}
|
||||
423
internal/plugin/adventure_pets_test.go
Normal file
423
internal/plugin/adventure_pets_test.go
Normal file
@@ -0,0 +1,423 @@
|
||||
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) {
|
||||
pet := &PetState{
|
||||
Type: "dog",
|
||||
Name: "Rex",
|
||||
Arrived: true,
|
||||
Level: 1,
|
||||
XP: 950, // close to needing 1000 (10 * 100 centixp)
|
||||
}
|
||||
leveled := petGrantXP(pet)
|
||||
if !leveled {
|
||||
t.Error("should have leveled up")
|
||||
}
|
||||
if pet.Level != 2 {
|
||||
t.Errorf("pet level should be 2, got %d", pet.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_NoPet(t *testing.T) {
|
||||
pet := &PetState{}
|
||||
if petGrantXP(pet) {
|
||||
t.Error("should not grant XP without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_MaxLevel(t *testing.T) {
|
||||
pet := &PetState{
|
||||
Type: "cat",
|
||||
Name: "Luna",
|
||||
Arrived: true,
|
||||
Level: 10,
|
||||
XP: 9999,
|
||||
}
|
||||
if petGrantXP(pet) {
|
||||
t.Error("should not level up past max level 10")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_ChasedAway(t *testing.T) {
|
||||
pet := &PetState{
|
||||
Type: "dog",
|
||||
Name: "Rex",
|
||||
Arrived: true,
|
||||
ChasedAway: true,
|
||||
Level: 1,
|
||||
XP: 0,
|
||||
}
|
||||
if petGrantXP(pet) {
|
||||
t.Error("should not grant XP to chased-away pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrantXP_SetsLevel10Date(t *testing.T) {
|
||||
pet := &PetState{
|
||||
Type: "dog",
|
||||
Name: "Rex",
|
||||
Arrived: true,
|
||||
Level: 9,
|
||||
XP: 4999, // needs 5000 (50 * 100) for level 9→10
|
||||
}
|
||||
petGrantXP(pet)
|
||||
if pet.Level == 10 && pet.Level10Date == "" {
|
||||
t.Error("reaching level 10 should set Level10Date")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
result := petRollCombatActions(PetState{}, "Goblin")
|
||||
if result != nil {
|
||||
t.Error("should return nil without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetRollDitchRecovery_NoPet(t *testing.T) {
|
||||
if petRollDitchRecovery(PetState{}) {
|
||||
t.Error("should return false without a pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_Dog(t *testing.T) {
|
||||
pet := PetState{Type: "dog", Name: "Rex", Arrived: true}
|
||||
text := petVictoryText(pet)
|
||||
if text == "" {
|
||||
t.Error("should return victory text for dog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_Cat(t *testing.T) {
|
||||
pet := PetState{Type: "cat", Name: "Luna", Arrived: true}
|
||||
text := petVictoryText(pet)
|
||||
if text == "" {
|
||||
t.Error("should return victory text for cat")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetVictoryText_NoPet(t *testing.T) {
|
||||
if petVictoryText(PetState{}) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDeathText_NoPet(t *testing.T) {
|
||||
if petDeathText(PetState{}) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Arrival Logic ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPetShouldArrive_NoHouse(t *testing.T) {
|
||||
if petShouldArrive(PetState{}, HouseState{Tier: 0}) {
|
||||
t.Error("should not arrive without house")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_BaseHouseOnly(t *testing.T) {
|
||||
if petShouldArrive(PetState{}, HouseState{Tier: 1}) { // Base house, not yet Livable
|
||||
t.Error("should not arrive with only base house (need tier 2+)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_AlreadyArrived(t *testing.T) {
|
||||
if petShouldArrive(PetState{Arrived: true}, HouseState{Tier: 2}) {
|
||||
t.Error("should not trigger if pet already arrived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_ChasedAway(t *testing.T) {
|
||||
if petShouldArrive(PetState{ChasedAway: true}, HouseState{Tier: 2}) {
|
||||
t.Error("should not trigger if pet was chased away (and not reactivated)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetShouldArrive_Reactivated(t *testing.T) {
|
||||
pet := PetState{ChasedAway: true, Reactivated: true}
|
||||
// With reactivation, should always return true
|
||||
if !petShouldArrive(pet, HouseState{Tier: 2}) {
|
||||
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) {
|
||||
if petMorningEvent(PetState{}) != "" {
|
||||
t.Error("should return empty string without pet")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Misty Housing Hint ─────────────────────────────────────────────────────
|
||||
|
||||
func TestMistyHousingHint_NoEncounters(t *testing.T) {
|
||||
if mistyHousingHint(0, 0, HouseState{}) != "" {
|
||||
t.Error("should not hint with 0 encounters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_HasHouse(t *testing.T) {
|
||||
if mistyHousingHint(3, 0, HouseState{Tier: 1}) != "" {
|
||||
t.Error("should not hint if player has house")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_Donated(t *testing.T) {
|
||||
hint := mistyHousingHint(2, 1, HouseState{})
|
||||
if !strings.Contains(hint, "house") {
|
||||
t.Errorf("donated player hint should mention house, got: %q", hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyHousingHint_NotDonated(t *testing.T) {
|
||||
hint := mistyHousingHint(2, 0, HouseState{})
|
||||
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) {
|
||||
pet := PetState{ChasedAway: true, Reactivated: false}
|
||||
if !mistyReactivatePet(pet) {
|
||||
t.Error("should signal reactivation for chased-away pet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyReactivatePet_NotChasedAway(t *testing.T) {
|
||||
pet := PetState{ChasedAway: false, Reactivated: false}
|
||||
if mistyReactivatePet(pet) {
|
||||
t.Error("should not reactivate if pet wasn't chased away")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) {
|
||||
pet := PetState{ChasedAway: true, Reactivated: true}
|
||||
if mistyReactivatePet(pet) {
|
||||
t.Error("should not re-signal once already reactivated")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pet Supply Shop Unlock ─────────────────────────────────────────────────
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) {
|
||||
if petCheckSupplyShopUnlock(PetState{Level10Date: ""}) {
|
||||
t.Error("should not unlock without level 10 date")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) {
|
||||
pet := PetState{SupplyShopUnlocked: true, Level10Date: "2020-01-01"}
|
||||
if petCheckSupplyShopUnlock(pet) {
|
||||
t.Error("should not re-trigger if already unlocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) {
|
||||
// Set date to today — not 7 days ago
|
||||
if petCheckSupplyShopUnlock(PetState{Level10Date: "2099-01-01"}) {
|
||||
t.Error("should not unlock before 7 days have passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) {
|
||||
if !petCheckSupplyShopUnlock(PetState{Level10Date: "2020-01-01"}) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -88,14 +89,21 @@ func advSubstituteFlavor(template string, vars map[string]string) string {
|
||||
|
||||
// ── Character Sheet ──────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, items []AdvItem, treasures []AdvTreasureBonus, balance float64) string {
|
||||
func renderAdvCharacterSheet(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, items []AdvItem, treasures []AdvTreasureBonus, balance float64) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf("⚔️ **%s's Adventurer**\n\n", char.DisplayName))
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return "No adventurer found. Type `!adventure` to begin."
|
||||
}
|
||||
|
||||
// Stats
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
sb.WriteString(fmt.Sprintf("⚔️ **%s's Adventurer**\n\n", displayName))
|
||||
|
||||
// Stats — Combat line shows D&D Level (CombatXP display retired in L4f).
|
||||
dndLevel := dndLevelForUser(userID)
|
||||
sb.WriteString("📊 Stats:\n")
|
||||
sb.WriteString(fmt.Sprintf(" Combat: Lv.%d (%d/%d XP)\n", char.CombatLevel, char.CombatXP, xpToNextLevel(char.CombatLevel)))
|
||||
sb.WriteString(fmt.Sprintf(" Combat: Lv.%d\n", dndLevel))
|
||||
sb.WriteString(fmt.Sprintf(" Mining: Lv.%d (%d/%d XP)\n", char.MiningSkill, char.MiningXP, xpToNextLevel(char.MiningSkill)))
|
||||
sb.WriteString(fmt.Sprintf(" Forage: Lv.%d (%d/%d XP)\n", char.ForagingSkill, char.ForagingXP, xpToNextLevel(char.ForagingSkill)))
|
||||
sb.WriteString(fmt.Sprintf(" Fishing: Lv.%d (%d/%d XP)\n", char.FishingSkill, char.FishingXP, xpToNextLevel(char.FishingSkill)))
|
||||
@@ -120,27 +128,40 @@ 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)
|
||||
for _, slot := range allSlots {
|
||||
eq := equip[slot]
|
||||
if eq == nil {
|
||||
continue
|
||||
}
|
||||
marker := ""
|
||||
if eq.Masterwork {
|
||||
marker = " ⭐"
|
||||
} else if eq.ArenaTier > 0 {
|
||||
marker = " ⚔️"
|
||||
}
|
||||
// Mastery segment is appended as a third inline field after
|
||||
// condition only when there's progress — keeps rows tight for
|
||||
// freshly-equipped gear and grows with use.
|
||||
mastery := ""
|
||||
if eq != nil && eq.ActionsUsed >= 20 {
|
||||
mastery = " ✦"
|
||||
}
|
||||
if eq != nil {
|
||||
marker := ""
|
||||
if eq.Masterwork {
|
||||
marker = " ⭐"
|
||||
} else if eq.ArenaTier > 0 {
|
||||
marker = " ⚔️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %s: %s%s (Tier %d | %d%% condition%s)\n",
|
||||
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
|
||||
if seg := advMasteryRowSegment(eq.ActionsUsed); seg != "" {
|
||||
mastery = " | " + seg
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" %s %s: %s%s (Tier %d | %d%% cond%s)\n",
|
||||
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Equipment Score: %.1f\n", eqScore))
|
||||
if line := renderAdvMasteryAggregate(equip); line != "" {
|
||||
sb.WriteString(" " + line + "\n")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" Equipment Score: %d\n", eqScore))
|
||||
|
||||
// Treasures
|
||||
if len(treasures) > 0 {
|
||||
@@ -151,15 +172,8 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
continue
|
||||
}
|
||||
seen[t.TreasureKey] = true
|
||||
// Find def for inventory desc
|
||||
for tier, defs := range advAllTreasures {
|
||||
_ = tier
|
||||
for _, def := range defs {
|
||||
if def.Key == t.TreasureKey {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", def.InventoryDesc))
|
||||
break
|
||||
}
|
||||
}
|
||||
if def := lookupAdvTreasureDef(t.TreasureKey); def != nil {
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", def.InventoryDesc))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,11 +186,34 @@ 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")
|
||||
} else {
|
||||
sb.WriteString("\n📅 Today: No action yet — reply to morning DM or type `!adventure`")
|
||||
// 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 — read from player_meta (Adv 2.0 Phase L4b).
|
||||
rivalPoolFlag, _, _ := loadRivalState(userID)
|
||||
if rivalPoolFlag == 1 {
|
||||
records, _ := loadAllRivalRecords(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")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
@@ -184,27 +221,112 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*
|
||||
|
||||
// ── Morning DM ───────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
|
||||
// renderCraftingTeaser surfaces the crafting system before and just after
|
||||
// the Foraging-10 auto-unlock, so it doesn't stay invisible to players who
|
||||
// never type !adventure recipes. Returns "" when the teaser shouldn't fire
|
||||
// today (out of pre-unlock window, or already deep into post-unlock).
|
||||
func renderCraftingTeaser(userID id.UserID) string {
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return ""
|
||||
}
|
||||
return craftingTeaserText(char.ForagingSkill, char.CraftsSucceeded, userID)
|
||||
}
|
||||
|
||||
// craftingTeaserText is the pure-logic core of renderCraftingTeaser, split
|
||||
// out so unit tests can exercise the bracket boundaries without a DB.
|
||||
func craftingTeaserText(foragingSkill, craftsSucceeded int, userID id.UserID) string {
|
||||
const unlock = 10
|
||||
if foragingSkill < unlock {
|
||||
// Pre-unlock teaser: only when within 3 levels.
|
||||
if foragingSkill < unlock-3 {
|
||||
return ""
|
||||
}
|
||||
levelsToGo := unlock - foragingSkill
|
||||
return fmt.Sprintf("🧪 **Crafting unlocks in %d Foraging level%s** — auto-craft consumables from gathered ingredients (Berry Poultice, Herb Salve, etc.).",
|
||||
levelsToGo, plural(levelsToGo))
|
||||
}
|
||||
|
||||
// Post-unlock: nudge when no successful crafts yet (gentle), or once a
|
||||
// week (loose periodicity via day-of-year %).
|
||||
if craftsSucceeded == 0 {
|
||||
return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and I will auto-craft consumables — try `!adventure recipes` to see what your level supports."
|
||||
}
|
||||
if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(userID, time.Now().UTC()) {
|
||||
return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", foragingSkill)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// craftingReminderWeekday picks the day-of-week (0=Sunday..6=Saturday) on
|
||||
// which a given player gets the weekly crafting nudge during the given
|
||||
// week. Stable within an ISO week (predictable, not annoying), rotates
|
||||
// across weeks (not always the same day long-term).
|
||||
func craftingReminderWeekday(userID id.UserID, now time.Time) int {
|
||||
year, week := now.ISOWeek()
|
||||
h := fnv.New32a()
|
||||
fmt.Fprintf(h, "%s|%d|%d", string(userID), year, week)
|
||||
return int(h.Sum32() % 7)
|
||||
}
|
||||
|
||||
func plural(n int) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
}
|
||||
return "s"
|
||||
}
|
||||
|
||||
// renderRivalNudge surfaces a pending rival challenge in the morning DM so
|
||||
// players who missed or ignored the dramatic challenge DM see a reminder
|
||||
// before it expires. Returns "" if there's no pending challenge against
|
||||
// this player.
|
||||
func renderRivalNudge(userID id.UserID) string {
|
||||
c := pendingRivalChallengeForChallenged(userID)
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
hours := int(time.Until(c.ExpiresAt).Hours())
|
||||
if hours < 1 {
|
||||
hours = 1 // floor — "<1h" feels worse than "1h"
|
||||
}
|
||||
rivalName := string(c.ChallengerID)
|
||||
if name, err := loadDisplayName(c.ChallengerID); err == nil && name != "" {
|
||||
rivalName = name
|
||||
}
|
||||
return fmt.Sprintf("⚔️ **Rival challenge open** — %s, round %d/3, €%d on the line · expires in %dh · reply **rock**, **paper**, or **scissors**",
|
||||
rivalName, c.Round, c.Stake, hours)
|
||||
}
|
||||
|
||||
func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Holiday notice (before greeting)
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Holiday notice (before greeting). Today's perks: TwinBee starts new
|
||||
// runs in a slightly better mood (+5), expedition outfitting includes a
|
||||
// complimentary standard pack, and every harvest yields one extra unit.
|
||||
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, today's adventures come with TwinBee's blessing — new zone & expedition runs start at +5 mood, expedition outfitting includes a complimentary standard pack, and every harvest yields one extra unit.\n\n", holidayName, holidayName))
|
||||
}
|
||||
|
||||
// Pick a morning greeting
|
||||
greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm")
|
||||
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
dndLevel := dndLevelForUser(userID)
|
||||
vars := map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{name}": displayName,
|
||||
"{character_sheet}": fmt.Sprintf(
|
||||
" ⚔️ Combat Lv.%d ⛏️ Mining Lv.%d 🌿 Foraging Lv.%d 🎣 Fishing Lv.%d\n 💰 €%.0f",
|
||||
char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, balance),
|
||||
dndLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, balance),
|
||||
}
|
||||
sb.WriteString(advSubstituteFlavor(greeting, vars))
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Active buffs
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
if len(buffs) > 0 {
|
||||
sb.WriteString("✨ **Active buffs:**\n")
|
||||
for _, b := range buffs {
|
||||
@@ -230,105 +352,47 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Location choices
|
||||
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))
|
||||
// L3 closure announcement — surfaced for one-week post-deploy soak so
|
||||
// active co-op participants see why the system is gone. Remove after
|
||||
// 2026-05-16 (TODO: drop announcement block at that revisit).
|
||||
sb.WriteString("📋 **Co-op dungeons closed for now** — `!expedition` is the way forward; a better co-op design is on the way.\n\n")
|
||||
|
||||
// Rival nudge — a pending challenge waits for action.
|
||||
if line := renderRivalNudge(userID); line != "" {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
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))
|
||||
// Crafting teaser — surface the system when it's near unlock or post-unlock.
|
||||
if line := renderCraftingTeaser(userID); line != "" {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
// Phase R1 — the daily activity loop (dungeon/mine/forage/fish) has been
|
||||
// retired in favor of the expedition system. The menu now shows the new
|
||||
// entry points for adventuring and keeps the still-active town services.
|
||||
sb.WriteString("**🗺️ Adventure** — head into a zone:\n")
|
||||
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
||||
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
||||
sb.WriteString("• Harvest is automatic — yields land as you walk through cleared rooms.\n")
|
||||
sb.WriteString("\n")
|
||||
|
||||
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("**🏘️ In town:**\n")
|
||||
sb.WriteString("• `5` / `shop` — buy/sell gear and loot\n")
|
||||
sb.WriteString("• `6` / `blacksmith` — repair damaged equipment\n")
|
||||
sb.WriteString("• `7` / `rest` — skip today, bank your luck\n")
|
||||
sb.WriteString("• `!thom` — visit Krooke Realty 🏠\n\n")
|
||||
|
||||
sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n")
|
||||
sb.WriteString("**6️⃣ Rest** — skip today, bank your luck\n\n")
|
||||
|
||||
sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
|
||||
sb.WriteString("You have until midnight UTC to choose.")
|
||||
|
||||
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`")
|
||||
sb.WriteString("Reply with a number for in-town services, or run an expedition command directly.\n")
|
||||
sb.WriteString("You have until midnight UTC to rest.")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Resolution DM ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
|
||||
func renderAdvResolutionDM(result *AdvActionResult, userID id.UserID) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(result.FlavorText)
|
||||
@@ -339,7 +403,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
||||
|
||||
if result.Outcome == AdvOutcomeDeath {
|
||||
sb.WriteString("💀 **You died.**\n")
|
||||
if char.DeadUntil != nil {
|
||||
if char, err := loadAdvCharacter(userID); err == nil && char != nil && char.DeadUntil != nil {
|
||||
sb.WriteString(fmt.Sprintf("Expected return: %s UTC\n", char.DeadUntil.Format("2006-01-02 15:04")))
|
||||
}
|
||||
}
|
||||
@@ -349,13 +413,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))
|
||||
}
|
||||
@@ -442,67 +509,276 @@ 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 := ""
|
||||
func renderAdvDeathStatusDM(userID id.UserID) string {
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil {
|
||||
return "💀 You're still dead."
|
||||
}
|
||||
// Cost mirrors hospitalCostsForUser (post-L4a): D&D Level × 50k after insurance.
|
||||
_, cost := hospitalCostsForUser(char)
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvRespawnDM(char *AdventureCharacter) string {
|
||||
text, _ := advPickFlavor(RespawnDM, char.UserID, "respawn_dm")
|
||||
func renderAdvRespawnDM(userID id.UserID) string {
|
||||
text, _ := advPickFlavor(RespawnDM, userID, "respawn_dm")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
return advSubstituteFlavor(text, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{name}": displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Treasure List ───────────────────────────────────────────────────────────
|
||||
|
||||
// lookupAdvTreasureDef returns the canonical definition for a treasure key,
|
||||
// or nil if unknown. Walks the tiered table; cheap because the total count
|
||||
// is small.
|
||||
func lookupAdvTreasureDef(key string) *AdvTreasureDef {
|
||||
for _, defs := range advAllTreasures {
|
||||
for i := range defs {
|
||||
if defs[i].Key == key {
|
||||
d := defs[i]
|
||||
return &d
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderAdvTreasureList renders the standalone treasure listing for
|
||||
// `!adventure treasures`. Shows each treasure, marks irreplaceable ones,
|
||||
// and surfaces the lock state so players know whether new drops will
|
||||
// auto-swap or be refused.
|
||||
func renderAdvTreasureList(treasures []AdvTreasureDef, locked bool) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("💎 **Your Treasures**")
|
||||
if locked {
|
||||
sb.WriteString(" 🔒 _(locked — drops at cap refused)_")
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
if len(treasures) == 0 {
|
||||
sb.WriteString("_No treasures yet. Higher-tier locations have higher drop rates._")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
for _, t := range treasures {
|
||||
t := t
|
||||
marker := ""
|
||||
if advTreasureIrreplaceable(&t) {
|
||||
marker = " 🛡️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" • Tier %d · %s%s\n _%s_\n", t.Tier, t.Name, marker, t.InventoryDesc))
|
||||
}
|
||||
|
||||
if locked {
|
||||
sb.WriteString("\n_Unlock with `!adventure treasures unlock` to allow higher-tier auto-swaps._")
|
||||
} else {
|
||||
sb.WriteString("\n_Higher-tier drops auto-swap your lowest replaceable treasure (10-min undo). 🛡️ = irreplaceable, never auto-discarded._\n_Lock with `!adventure treasures lock` to refuse drops at cap._")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// renderAdvMasteryAggregate produces the one-line summary shown under the
|
||||
// equipment block on the character sheet. Three states:
|
||||
// - Bonus active: shows percent, threshold count, cap-reached when at +10%.
|
||||
// - No bonus but some progress: hints that a threshold is approaching.
|
||||
// - No progress: empty (don't bloat sheets for new players).
|
||||
func renderAdvMasteryAggregate(equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
r := advMasteryRollup(equip)
|
||||
if !r.AnyProgress {
|
||||
return ""
|
||||
}
|
||||
if r.Bonus > 0 {
|
||||
base := fmt.Sprintf("🎯 Mastery: +%.0f%% loot quality · %d threshold%s crossed",
|
||||
r.Bonus, r.ThresholdsCrossed, plural(r.ThresholdsCrossed))
|
||||
if r.MaxedSlots > 0 {
|
||||
base += fmt.Sprintf(" · %d slot%s maxed", r.MaxedSlots, plural(r.MaxedSlots))
|
||||
}
|
||||
// Cap is +10%; the raw count keeps rising past it. Flag when the
|
||||
// player is paying threshold tax for nothing.
|
||||
if r.Bonus >= 10 {
|
||||
base += " (cap reached)"
|
||||
}
|
||||
base += " · `!adventure mastery`"
|
||||
return base
|
||||
}
|
||||
// Some progress, no thresholds crossed yet — surface the next gate.
|
||||
return "🎯 Mastery: building up — first threshold at 50 actions/slot · `!adventure mastery`"
|
||||
}
|
||||
|
||||
// ── Equipment Mastery View ──────────────────────────────────────────────────
|
||||
|
||||
// renderAdvMasteryView renders a per-slot mastery readout: progress bar,
|
||||
// tier marker, and next threshold so players can see "where am I" between
|
||||
// the discrete celebration DMs that fire at threshold crossings.
|
||||
func renderAdvMasteryView(equip map[EquipmentSlot]*AdvEquipment) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🎯 **Equipment Mastery**\n\n")
|
||||
|
||||
any := false
|
||||
for _, slot := range allSlots {
|
||||
eq := equip[slot]
|
||||
if eq == nil {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s — _empty_\n", slotEmoji(slot), slotTitle(slot)))
|
||||
continue
|
||||
}
|
||||
any = true
|
||||
tier, next := advMasteryTier(eq.ActionsUsed)
|
||||
marker := advMasteryMarker(eq.ActionsUsed)
|
||||
if marker == "" {
|
||||
marker = "·"
|
||||
}
|
||||
|
||||
// Bar against the next threshold (or against 250 when at max so the
|
||||
// bar visually maxes out rather than disappearing).
|
||||
var barTotal int
|
||||
if next > 0 {
|
||||
barTotal = next
|
||||
} else {
|
||||
barTotal = advMasteryThresholds[len(advMasteryThresholds)-1]
|
||||
}
|
||||
bar := masteryBar(eq.ActionsUsed, barTotal)
|
||||
_ = tier
|
||||
|
||||
if next > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s · %s · %d/%d %s %s\n",
|
||||
slotEmoji(slot), slotTitle(slot), eq.Name, eq.ActionsUsed, next, bar, marker))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s · %s · %d (max) %s %s\n",
|
||||
slotEmoji(slot), slotTitle(slot), eq.Name, eq.ActionsUsed, bar, marker))
|
||||
}
|
||||
}
|
||||
|
||||
bonus := advEquipmentMasteryBonus(equip)
|
||||
sb.WriteString("\n")
|
||||
if !any {
|
||||
sb.WriteString("_No equipment yet — gear up first._")
|
||||
return sb.String()
|
||||
}
|
||||
if bonus > 0 {
|
||||
sb.WriteString(fmt.Sprintf("**Active mastery bonus: +%.0f%% loot quality** (cap +10%%)\n", bonus))
|
||||
} else {
|
||||
sb.WriteString("_No mastery bonus yet — first threshold at 50 actions per slot._\n")
|
||||
}
|
||||
sb.WriteString("Thresholds: 50 ✦ · 100 ✦✦ · 250 ✦✦✦. Each crossed threshold per slot adds +1% loot quality.")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// masteryBar builds a 10-cell progress bar for the mastery view.
|
||||
func masteryBar(value, total int) string {
|
||||
if total <= 0 {
|
||||
return "▱▱▱▱▱▱▱▱▱▱"
|
||||
}
|
||||
filled := value * 10 / total
|
||||
if filled > 10 {
|
||||
filled = 10
|
||||
}
|
||||
if filled < 0 {
|
||||
filled = 0
|
||||
}
|
||||
return strings.Repeat("▰", filled) + strings.Repeat("▱", 10-filled)
|
||||
}
|
||||
|
||||
// ── Idle Shame DM ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvIdleShameDM(char *AdventureCharacter) string {
|
||||
text, _ := advPickFlavor(IdleShameDM, char.UserID, "idle_shame")
|
||||
func renderAdvIdleShameDM(userID id.UserID) string {
|
||||
text, _ := advPickFlavor(IdleShameDM, userID, "idle_shame")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
return advSubstituteFlavor(text, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{name}": displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Onboarding DM ────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvOnboardingDM(char *AdventureCharacter) string {
|
||||
text, _ := advPickFlavor(OnboardingDM, char.UserID, "onboarding")
|
||||
func renderAdvOnboardingDM(userID id.UserID) string {
|
||||
text, _ := advPickFlavor(OnboardingDM, userID, "onboarding")
|
||||
displayName, _ := loadDisplayName(userID)
|
||||
return advSubstituteFlavor(text, map[string]string{
|
||||
"{name}": char.DisplayName,
|
||||
"{name}": displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Daily Summary ────────────────────────────────────────────────────────────
|
||||
|
||||
type AdvPlayerDaySummary struct {
|
||||
DisplayName string
|
||||
CombatLevel int
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
DisplayName string
|
||||
|
||||
// D&D layer (preferred render). HasDnDChar=false → fall back to legacy
|
||||
// skill-line render and "needs !setup" hint.
|
||||
HasDnDChar bool
|
||||
DnDLevel int
|
||||
DnDRace string
|
||||
DnDClass string
|
||||
HPCurrent int
|
||||
HPMax int
|
||||
|
||||
// Legacy AdventureCharacter skill levels (rendered as fallback when
|
||||
// HasDnDChar is false).
|
||||
Level int // legacy combat level
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
|
||||
Activity string
|
||||
Location string
|
||||
Outcome string
|
||||
LootValue int64
|
||||
IsDead bool
|
||||
DeadUntil string
|
||||
// DeadUntilHours is the integer hours-from-now until revival, used by
|
||||
// the standout-death template's {hours} placeholder. Computed when
|
||||
// the summary row is built; 0 if not dead or already past revival.
|
||||
DeadUntilHours int
|
||||
// DeadUntilDuration is the human-readable hours+minutes form of the
|
||||
// remaining respawn time ("5h 27m", "47m", "2h"). Used by the
|
||||
// {duration} placeholder for templates that want precision over the
|
||||
// rounded {hours} count.
|
||||
DeadUntilDuration string
|
||||
IsResting bool
|
||||
SummaryLine string
|
||||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
||||
DeathSource string
|
||||
DeathLocation string
|
||||
}
|
||||
|
||||
// advPlayerHeadline renders the per-player headline for the daily report.
|
||||
// D&D shape ("Lv.4 Half-Elf Cleric — HP 27/27") is preferred; falls back to
|
||||
// the legacy four-skill line for accounts that haven't run !setup yet.
|
||||
func advPlayerHeadline(p *AdvPlayerDaySummary) string {
|
||||
if p.HasDnDChar {
|
||||
race := titleizeDnDToken(p.DnDRace)
|
||||
class := titleizeDnDToken(p.DnDClass)
|
||||
return fmt.Sprintf("Lv.%d %s %s — HP %d/%d",
|
||||
p.DnDLevel, race, class, p.HPCurrent, p.HPMax)
|
||||
}
|
||||
return fmt.Sprintf("Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d _(no D&D sheet — run `!setup`)_",
|
||||
p.Level, p.MiningSkill, p.ForagingSkill, p.FishingSkill)
|
||||
}
|
||||
|
||||
// titleizeDnDToken converts a snake_case D&D enum token like "half_elf" into
|
||||
// a human-readable label like "Half-Elf". Single-word tokens just title-case.
|
||||
func titleizeDnDToken(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(s, "_")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary, holidayName string) string {
|
||||
@@ -511,7 +787,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
|
||||
|
||||
if holidayName != "" {
|
||||
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers had two actions today.\n\n", holidayName))
|
||||
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers ran with TwinBee's blessing today: +5 starting mood, a free standard pack at outfitting, and +1 to every harvest yield.\n\n", holidayName))
|
||||
}
|
||||
|
||||
// TwinBee section
|
||||
@@ -547,15 +823,20 @@ 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")
|
||||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. I noticed.)\n\n")
|
||||
sb.WriteString("───────────────────\n\n")
|
||||
}
|
||||
|
||||
@@ -569,12 +850,28 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
p := &players[i]
|
||||
if p.IsDead {
|
||||
dead = append(dead, *p)
|
||||
// Dead players who acted today still show in the main section
|
||||
// Dead players who acted today still show in the main section.
|
||||
// If they died OUTSIDE the adventure (e.g. Arena), the adventure
|
||||
// itself was successful — show the alive icon for that block and
|
||||
// append a "later fell" note. Empty DeathSource is legacy and
|
||||
// treated as adventure-death (current behavior).
|
||||
if p.Location != "" {
|
||||
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))
|
||||
diedOnAdventure := p.DeathSource == "" || p.DeathSource == "adventure"
|
||||
icon := "💀"
|
||||
if !diedOnAdventure {
|
||||
icon = "⚔️"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s\n", icon, p.DisplayName, advPlayerHeadline(p)))
|
||||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine))
|
||||
if !diedOnAdventure {
|
||||
where := p.DeathLocation
|
||||
if where == "" {
|
||||
where = "elsewhere"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" 💀 Later fell in %s.\n", where))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
if worstPlayer == nil {
|
||||
worstPlayer = p
|
||||
}
|
||||
@@ -586,10 +883,13 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
continue
|
||||
}
|
||||
|
||||
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))
|
||||
sb.WriteString(fmt.Sprintf("⚔️ **%s** — %s\n", p.DisplayName, advPlayerHeadline(p)))
|
||||
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
|
||||
@@ -620,47 +920,6 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Holiday stats
|
||||
if holidayName != "" {
|
||||
tookBoth := 0
|
||||
totalActive := 0
|
||||
for _, p := range players {
|
||||
if p.IsDead || p.IsResting {
|
||||
if p.HolidayActions > 0 {
|
||||
totalActive++
|
||||
}
|
||||
if p.HolidayActions >= 2 {
|
||||
tookBoth++
|
||||
}
|
||||
continue
|
||||
}
|
||||
if p.Activity != "" {
|
||||
totalActive++
|
||||
}
|
||||
if p.HolidayActions >= 2 {
|
||||
tookBoth++
|
||||
}
|
||||
}
|
||||
if totalActive > 0 {
|
||||
sb.WriteString(fmt.Sprintf("🎉 %s double-action day — %d of %d adventurers took both actions.\n\n", holidayName, tookBoth, totalActive))
|
||||
}
|
||||
|
||||
// Note players who died before their second action
|
||||
for _, d := range dead {
|
||||
if d.HolidayActions == 1 {
|
||||
sb.WriteString(fmt.Sprintf("• %s — died in %s before their second action. Rough holiday.\n", d.DisplayName, d.Location))
|
||||
}
|
||||
}
|
||||
if len(dead) > 0 {
|
||||
// Check if any had HolidayActions == 1
|
||||
for _, d := range dead {
|
||||
if d.HolidayActions == 1 {
|
||||
sb.WriteString("\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standout
|
||||
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||||
@@ -679,9 +938,23 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
pool := SummaryStandoutDeath
|
||||
if len(pool) > 0 {
|
||||
line := pool[rand.IntN(len(pool))]
|
||||
lossLoc := worstPlayer.DeathLocation
|
||||
if lossLoc == "" {
|
||||
lossLoc = worstPlayer.Location
|
||||
}
|
||||
hours := worstPlayer.DeadUntilHours
|
||||
if hours <= 0 {
|
||||
hours = 6 // canonical respawn duration when revival time isn't computable
|
||||
}
|
||||
duration := worstPlayer.DeadUntilDuration
|
||||
if duration == "" {
|
||||
duration = fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
line = advSubstituteFlavor(line, map[string]string{
|
||||
"{name}": worstPlayer.DisplayName,
|
||||
"{location}": worstPlayer.Location,
|
||||
"{location}": lossLoc,
|
||||
"{hours}": fmt.Sprintf("%d", hours),
|
||||
"{duration}": duration,
|
||||
})
|
||||
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||||
}
|
||||
@@ -692,7 +965,19 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
||||
|
||||
// ── Leaderboard ──────────────────────────────────────────────────────────────
|
||||
|
||||
func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
||||
// AdvLeaderboardEntry is the view-model the leaderboard renderer consumes.
|
||||
// Callers populate it from AdvCharacter rows + a D&D level lookup so the
|
||||
// renderer doesn't depend on the legacy character type.
|
||||
type AdvLeaderboardEntry struct {
|
||||
UserID id.UserID
|
||||
Level int
|
||||
MiningSkill int
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if len(chars) == 0 {
|
||||
return "No adventurers registered yet. Type `!adventure` to begin."
|
||||
}
|
||||
@@ -706,11 +991,12 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill) * 10
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: c.DisplayName,
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill),
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
})
|
||||
}
|
||||
|
||||
900
internal/plugin/adventure_rival.go
Normal file
900
internal/plugin/adventure_rival.go
Normal file
@@ -0,0 +1,900 @@
|
||||
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 (
|
||||
// Adv 2.0 Phase L4b — gate switched off CombatLevel onto DnDCharacter.Level.
|
||||
// Legacy gate was CombatLevel >= 5; new gate is Level >= 3
|
||||
// (gogobee_legacy_migration.md §2.3).
|
||||
rivalMinLevel = 3
|
||||
rivalChallengeWindow = 24 * time.Hour
|
||||
rivalSamePairCooldown = 7 * 24 * time.Hour
|
||||
rivalMinIntervalHours = 3 * 24 // 3 days in hours
|
||||
rivalMaxIntervalHours = 4 * 24 // 4 days in hours
|
||||
)
|
||||
|
||||
// rivalLevelForUser returns the player's D&D level for rival gating + stake
|
||||
// math. Post-L5g every legacy player has a DnDCharacter row, so the
|
||||
// CombatLevel-derived fallback has been retired. Returns 1 as a safe floor
|
||||
// (filtered out by rivalMinLevel anyway).
|
||||
func rivalLevelForUser(char *AdventureCharacter) int {
|
||||
if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 {
|
||||
return dnd.Level
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// ── 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 ────────────────────────────────────────────────────────
|
||||
|
||||
// rivalStake returns the duel stake in € for a given D&D Level.
|
||||
// Adv 2.0 Phase L4b — previously `(combatLevel / 5) * 1000`. New formula
|
||||
// `(level / 3) * 1000` keeps the same magnitude at the unlock threshold
|
||||
// (Level 3 = €1000, matching legacy CombatLevel 5 = €1000) and tops out
|
||||
// around €6000 at Level 20. Players below `rivalMinLevel` produce stake 0,
|
||||
// which selectRivalPair already filters out.
|
||||
func rivalStake(level int) int {
|
||||
return (level / 3) * 1000
|
||||
}
|
||||
|
||||
// ── Rival Pool Unlock ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
|
||||
level := rivalLevelForUser(char)
|
||||
if level < rivalMinLevel {
|
||||
return
|
||||
}
|
||||
pool, notified, _ := loadRivalState(char.UserID)
|
||||
if pool > 0 && notified {
|
||||
// Already unlocked + notified; nothing to do.
|
||||
return
|
||||
}
|
||||
// Promote to pool + send unlock DM if not yet announced.
|
||||
// Persistence rides the caller's saveAdvCharacter → fan-out.
|
||||
char.RivalPool = 1
|
||||
if !notified {
|
||||
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{}
|
||||
}
|
||||
|
||||
// pendingRivalChallengeForChallenged returns the active challenge where the
|
||||
// given user is the challenged party (i.e. the side that needs to act).
|
||||
// Returns nil if no challenge is pending or the user is only on the
|
||||
// auto-resolved challenger side.
|
||||
func pendingRivalChallengeForChallenged(userID id.UserID) *advRivalChallenge {
|
||||
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 challenged_id = ? AND expires_at > CURRENT_TIMESTAMP
|
||||
ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan(
|
||||
&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
|
||||
&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
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.Alive || c.BabysitActive {
|
||||
continue
|
||||
}
|
||||
poolFlag, _, _ := loadRivalState(c.UserID)
|
||||
if poolFlag == 0 {
|
||||
continue
|
||||
}
|
||||
if hasActiveChallenge(c.UserID) {
|
||||
continue
|
||||
}
|
||||
stake := rivalStake(rivalLevelForUser(&c))
|
||||
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(rivalLevelForUser(challenged))
|
||||
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 {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-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
|
||||
}
|
||||
|
||||
pool, _, _ := loadRivalState(char.UserID)
|
||||
if pool == 0 {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("You need Level %d to enter the rival pool. Currently: %d.",
|
||||
rivalMinLevel, rivalLevelForUser(char)))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
310
internal/plugin/adventure_robbie.go
Normal file
310
internal/plugin/adventure_robbie.go
Normal file
@@ -0,0 +1,310 @@
|
||||
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 {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
dateKey := now.Format("2006-01-02")
|
||||
|
||||
// At midnight (or first tick of the day), pick today's target hour.
|
||||
if robbieTargetDay != dateKey {
|
||||
robbieTargetHour = 8 + rand.IntN(14) // 8–21 inclusive
|
||||
robbieTargetDay = dateKey
|
||||
slog.Info("adventure: robbie target hour set", "hour", robbieTargetHour, "date", dateKey)
|
||||
}
|
||||
|
||||
if now.Hour() < robbieTargetHour {
|
||||
continue
|
||||
}
|
||||
|
||||
jobName := "adventure_robbie"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: robbie sweep starting")
|
||||
p.robbieVisitAll()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visit All Players ────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AdventurePlugin) robbieVisitAll() {
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
slog.Error("adventure: robbie: failed to load characters", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
rand.Shuffle(len(chars), func(i, j int) { chars[i], chars[j] = chars[j], chars[i] })
|
||||
|
||||
for i, char := range chars {
|
||||
if !char.Alive {
|
||||
continue
|
||||
}
|
||||
|
||||
// Jitter between players to avoid Matrix rate limits.
|
||||
if i > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
|
||||
name, _ := loadDisplayName(char.UserID)
|
||||
p.robbieVisitPlayer(char.UserID, name)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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)
|
||||
_ = upsertPlayerMetaNPCState(userID, npcStateFromAdvChar(char))
|
||||
}
|
||||
|
||||
// Send DM
|
||||
dm := renderRobbieDM(userID, takenItems, totalPayout, masterworkTaken, gaveCard)
|
||||
if err := p.SendDM(userID, dm); err != nil {
|
||||
slog.Error("adventure: robbie: failed to send DM", "user", userID, "err", err)
|
||||
}
|
||||
|
||||
// Room announcement
|
||||
gr := gamesRoom()
|
||||
if gr != "" {
|
||||
announcement := renderRobbieRoomAnnouncement(displayName, len(takenItems), totalPayout, masterworkTaken, gaveCard)
|
||||
p.SendMessage(gr, announcement)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Qualifying Items ─────────────────────────────────────────────────────────
|
||||
|
||||
func robbieQualifyingItems(inv []AdvItem, equip map[EquipmentSlot]*AdvEquipment) []AdvItem {
|
||||
var result []AdvItem
|
||||
for _, item := range inv {
|
||||
// Never touch Arena gear, cards, or consumables. Consumables are a
|
||||
// player-curated stockpile (crafted or dropped); selling them is an
|
||||
// explicit decision the player must make themselves.
|
||||
if item.Type == "ArenaGear" || item.Type == "card" || item.Type == "consumable" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Non-gear items (ores, fish, junk, treasure, etc.) — always take
|
||||
if item.Slot == "" {
|
||||
result = append(result, item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Slotted gear — check against equipped piece
|
||||
eq, hasSlot := equip[item.Slot]
|
||||
if !hasSlot {
|
||||
continue
|
||||
}
|
||||
|
||||
if item.Type == "MasterworkGear" {
|
||||
// Take MW items only if equipped piece in same slot is also MW
|
||||
// and has effective tier >= this item's effective tier.
|
||||
if eq.Masterwork && advEffectiveTier(eq) >= float64(item.Tier)*1.25 {
|
||||
result = append(result, item)
|
||||
}
|
||||
} else {
|
||||
// Regular shop gear: take if item tier < equipped tier
|
||||
if item.Tier < eq.Tier {
|
||||
result = append(result, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Card Check ───────────────────────────────────────────────────────────────
|
||||
|
||||
func robbiePlayerHasCard(userID id.UserID) bool {
|
||||
inv, err := loadAdvInventory(userID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, item := range inv {
|
||||
if item.Type == "card" && item.Name == "Get Out of Medical Debt Free" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── DM Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieDM(userID id.UserID, items []AdvItem, total int64, mwTaken, gaveCard bool) string {
|
||||
var sb strings.Builder
|
||||
|
||||
// Opening
|
||||
opening, _ := advPickFlavor(robbieOpenings, userID, "robbie_opening")
|
||||
if strings.Contains(opening, "%d") {
|
||||
opening = fmt.Sprintf(opening, total)
|
||||
}
|
||||
sb.WriteString(opening)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Itemized list
|
||||
sb.WriteString("Items collected:\n\n")
|
||||
cardShownOnLine := false
|
||||
for _, item := range items {
|
||||
emoji := slotEmoji(item.Slot)
|
||||
payout := item.Value / 4
|
||||
if payout < 1 {
|
||||
payout = 1
|
||||
}
|
||||
if item.Type == "MasterworkGear" {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (Masterwork T%d) → €%d", emoji, item.Name, item.Tier, payout))
|
||||
if gaveCard && !cardShownOnLine {
|
||||
sb.WriteString(" + 🃏 Get Out of Medical Debt Free card")
|
||||
cardShownOnLine = true
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" %s %s (T%d) → €%d", emoji, item.Name, item.Tier, payout))
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nTotal left for you: €%d\n", total))
|
||||
sb.WriteString("Everything else donated to the community pot. Good on ya.\n\n")
|
||||
|
||||
// Context line
|
||||
if mwTaken {
|
||||
if gaveCard {
|
||||
sb.WriteString(robbieMasterworkGotCard)
|
||||
} else {
|
||||
sb.WriteString(robbieMasterworkAlreadyHas)
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(robbieAllShopGear, total))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Closing
|
||||
closing, _ := advPickFlavor(robbieClosings, userID, "robbie_closing")
|
||||
sb.WriteString(closing)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── Room Announcement ────────────────────────────────────────────────────────
|
||||
|
||||
func renderRobbieRoomAnnouncement(name string, count int, total int64, mwTaken, gaveCard bool) string {
|
||||
if mwTaken && gaveCard {
|
||||
return fmt.Sprintf(robbieRoomMasterworkCard, name, total)
|
||||
}
|
||||
if mwTaken && !gaveCard {
|
||||
return fmt.Sprintf(robbieRoomMasterworkAlreadyHas, name, total)
|
||||
}
|
||||
return fmt.Sprintf(robbieRoomStandard, name, count, total)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
@@ -16,21 +18,26 @@ func (p *AdventurePlugin) morningTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.morningHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_morning"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_morning"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: sending morning DMs")
|
||||
p.sendMorningDMs()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
slog.Info("adventure: sending morning DMs")
|
||||
p.sendMorningDMs()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +67,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 {
|
||||
@@ -71,26 +78,81 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
}
|
||||
|
||||
// Send respawn DM
|
||||
text := renderAdvRespawnDM(&char)
|
||||
text := renderAdvRespawnDM(char.UserID)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
// Emergence seam (death case): a player who died underground
|
||||
// "comes home" on respawn. This is the deferred half of the
|
||||
// emergence roll — survived extractions roll at their exit
|
||||
// site; deaths roll here. See maybeRollPetArrivalOnEmerge.
|
||||
p.maybeRollPetArrivalOnEmerge(char.UserID)
|
||||
}
|
||||
|
||||
// Babysitting: pet-care trickle (no harvest actions; safe-rest perk
|
||||
// is consumed inside the expedition camp/rest path). Still skips the
|
||||
// morning DM — the babysitter is handling things in the background.
|
||||
if char.BabysitActive {
|
||||
if !char.Alive {
|
||||
continue
|
||||
}
|
||||
p.runBabysitDailyTrickle(&char)
|
||||
if err := saveAdvCharacter(&char); err != nil {
|
||||
slog.Error("babysit: failed to save after daily trickle", "user", char.UserID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Mid-fight: a turn-based elite/boss session locks the run. The
|
||||
// per-round combat DMs are the player's feed right now — don't talk
|
||||
// over them with the overworld morning menu. (A combat session always
|
||||
// sits inside an active expedition, so the expedition skip below would
|
||||
// usually catch this too; the explicit guard is cheap insurance.)
|
||||
if char.Alive && hasActiveCombatSession(char.UserID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Active expedition: the expedition cycle delivers its own morning
|
||||
// briefing at 06:00 UTC (deliverBriefing). The legacy overworld
|
||||
// morning DM is irrelevant — and confusing — while underground.
|
||||
if char.Alive {
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for morning DM", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If still dead, send death status
|
||||
if !char.Alive {
|
||||
text := renderAdvDeathStatusDM(&char)
|
||||
text := renderAdvDeathStatusDM(char.UserID)
|
||||
if err := p.SendDM(char.UserID, text); err != nil {
|
||||
slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// If already acted today, skip
|
||||
if char.ActionTakenToday {
|
||||
// If all actions used today, skip
|
||||
isHol, _ := isHolidayToday()
|
||||
if char.AllActionsUsed(isHol) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pet arrival no longer rolls here. The 08:00 overworld morning DM
|
||||
// is skipped for anyone underground (expedition gate above), so it
|
||||
// never reached expedition players. Arrival now fires on the
|
||||
// emergence seam — see maybeRollPetArrivalOnEmerge, called from the
|
||||
// extract/abandon/forced-extract and respawn paths.
|
||||
pet, _ := loadPetState(char.UserID)
|
||||
|
||||
// Morning pet event
|
||||
petEvent := petMorningEvent(pet)
|
||||
if petEvent != "" {
|
||||
char.PetMorningDefense = true
|
||||
_ = saveAdvCharacter(&char)
|
||||
}
|
||||
|
||||
// Send morning DM with choices
|
||||
equip, err := loadAdvEquipment(char.UserID)
|
||||
if err != nil {
|
||||
@@ -107,7 +169,10 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
if isHol {
|
||||
holidayLabel = holName
|
||||
}
|
||||
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
|
||||
text := renderAdvMorningDM(char.UserID, 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)
|
||||
@@ -125,21 +190,26 @@ func (p *AdventurePlugin) summaryTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != p.summaryHour || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_summary"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_summary"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: posting daily summary")
|
||||
p.postDailySummary()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
slog.Info("adventure: posting daily summary")
|
||||
p.postDailySummary()
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,24 +230,25 @@ func (p *AdventurePlugin) postDailySummary() {
|
||||
return
|
||||
}
|
||||
|
||||
todayLogs, _ := loadAdvTodayLogs()
|
||||
// Group logs per user — holiday days may produce 2 entries per user
|
||||
logsPerUser := make(map[id.UserID][]*AdvDayLog)
|
||||
for i := range todayLogs {
|
||||
uid := todayLogs[i].UserID
|
||||
logsPerUser[uid] = append(logsPerUser[uid], &todayLogs[i])
|
||||
// Load activity for today and yesterday — players may act across the UTC
|
||||
// boundary. Activity unifies legacy adventure_activity_log + dnd_zone_run
|
||||
// + dnd_expedition_log so the report sees every action regardless of
|
||||
// which subsystem produced it.
|
||||
now := time.Now().UTC()
|
||||
todayActs, _ := loadAdvDailyActivity(now.Format("2006-01-02"))
|
||||
yesterdayActs, _ := loadAdvDailyActivity(now.AddDate(0, 0, -1).Format("2006-01-02"))
|
||||
activityMap := make(map[id.UserID][]AdvDailyActivity)
|
||||
for uid, e := range yesterdayActs {
|
||||
activityMap[uid] = append(activityMap[uid], e...)
|
||||
}
|
||||
for uid, e := range todayActs {
|
||||
activityMap[uid] = append(activityMap[uid], e...)
|
||||
}
|
||||
// logMap picks the last (most recent) log entry for summary display
|
||||
// lootSums aggregates loot across all actions (relevant on holiday double-action days)
|
||||
logMap := make(map[id.UserID]*AdvDayLog)
|
||||
lootSums := make(map[id.UserID]int64)
|
||||
for uid, logs := range logsPerUser {
|
||||
logMap[uid] = logs[len(logs)-1]
|
||||
var total int64
|
||||
for _, l := range logs {
|
||||
total += l.LootValue
|
||||
for uid, acts := range activityMap {
|
||||
for _, a := range acts {
|
||||
lootSums[uid] += a.LootValue
|
||||
}
|
||||
lootSums[uid] = total
|
||||
}
|
||||
|
||||
isHol, holName := isHolidayToday()
|
||||
@@ -185,37 +256,66 @@ func (p *AdventurePlugin) postDailySummary() {
|
||||
// Build player summaries
|
||||
var players []AdvPlayerDaySummary
|
||||
for _, c := range chars {
|
||||
dispName, _ := loadDisplayName(c.UserID)
|
||||
ps := AdvPlayerDaySummary{
|
||||
DisplayName: c.DisplayName,
|
||||
CombatLevel: c.CombatLevel,
|
||||
DisplayName: dispName,
|
||||
Level: c.CombatLevel,
|
||||
MiningSkill: c.MiningSkill,
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
}
|
||||
if dnd, _ := LoadDnDCharacter(c.UserID); dnd != nil {
|
||||
ps.HasDnDChar = true
|
||||
ps.DnDLevel = dnd.Level
|
||||
ps.DnDRace = string(dnd.Race)
|
||||
ps.DnDClass = string(dnd.Class)
|
||||
ps.HPCurrent = dnd.HPCurrent
|
||||
ps.HPMax = dnd.HPMax
|
||||
}
|
||||
|
||||
// Holiday action count from log entries
|
||||
acts := activityMap[c.UserID]
|
||||
|
||||
// Holiday action count from activity entries
|
||||
if isHol {
|
||||
ps.HolidayActions = len(logsPerUser[c.UserID])
|
||||
ps.HolidayActions = len(acts)
|
||||
}
|
||||
|
||||
if !c.Alive {
|
||||
ps.IsDead = true
|
||||
ps.DeathSource = c.DeathSource
|
||||
ps.DeathLocation = c.DeathLocation
|
||||
if c.DeadUntil != nil {
|
||||
ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC"
|
||||
remaining := time.Until(*c.DeadUntil)
|
||||
if remaining > 0 {
|
||||
hrs := int(remaining.Hours())
|
||||
mins := int(remaining.Minutes()) - hrs*60
|
||||
// {hours}: round half-up integer hours.
|
||||
ps.DeadUntilHours = int(remaining.Hours() + 0.5)
|
||||
// {duration}: precise — "5h 27m", "47m", "2h".
|
||||
switch {
|
||||
case hrs > 0 && mins > 0:
|
||||
ps.DeadUntilDuration = fmt.Sprintf("%dh %dm", hrs, mins)
|
||||
case hrs > 0:
|
||||
ps.DeadUntilDuration = fmt.Sprintf("%dh", hrs)
|
||||
default:
|
||||
ps.DeadUntilDuration = fmt.Sprintf("%dm", mins)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if they died today
|
||||
if log, ok := logMap[c.UserID]; ok {
|
||||
ps.Activity = log.ActivityType
|
||||
ps.Location = log.Location
|
||||
ps.Outcome = log.Outcome
|
||||
ps.LootValue = lootSums[c.UserID] // aggregate across all actions
|
||||
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location)
|
||||
if len(acts) > 0 {
|
||||
last := acts[len(acts)-1]
|
||||
ps.Activity = last.Activity
|
||||
ps.Location = last.Location
|
||||
ps.Outcome = last.Outcome
|
||||
ps.LootValue = lootSums[c.UserID]
|
||||
ps.SummaryLine = last.Summary
|
||||
}
|
||||
players = append(players, ps)
|
||||
continue
|
||||
}
|
||||
|
||||
if !c.ActionTakenToday {
|
||||
if len(acts) == 0 {
|
||||
ps.IsResting = true
|
||||
if len(SummaryResting) > 0 {
|
||||
ps.SummaryLine = SummaryResting[time.Now().Nanosecond()%len(SummaryResting)]
|
||||
@@ -224,14 +324,13 @@ func (p *AdventurePlugin) postDailySummary() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Active player with today's log
|
||||
if log, ok := logMap[c.UserID]; ok {
|
||||
ps.Activity = log.ActivityType
|
||||
ps.Location = log.Location
|
||||
ps.Outcome = log.Outcome
|
||||
ps.LootValue = lootSums[c.UserID] // aggregate across all actions
|
||||
ps.SummaryLine = advSummaryOneLiner(c.UserID, AdvActivityType(log.ActivityType), AdvOutcomeType(log.Outcome), lootSums[c.UserID], log.Location)
|
||||
}
|
||||
// Active player — represent with most-recent activity row.
|
||||
last := acts[len(acts)-1]
|
||||
ps.Activity = last.Activity
|
||||
ps.Location = last.Location
|
||||
ps.Outcome = last.Outcome
|
||||
ps.LootValue = lootSums[c.UserID]
|
||||
ps.SummaryLine = last.Summary
|
||||
|
||||
players = append(players, ps)
|
||||
}
|
||||
@@ -266,73 +365,128 @@ func (p *AdventurePlugin) midnightTicker() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
now := time.Now().UTC()
|
||||
if now.Hour() != 0 || now.Minute() != 0 {
|
||||
continue
|
||||
}
|
||||
lastRanDate := ""
|
||||
|
||||
dateKey := now.Format("2006-01-02")
|
||||
jobName := "adventure_midnight"
|
||||
if db.JobCompleted(jobName, dateKey) {
|
||||
continue
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-p.stopCh:
|
||||
return
|
||||
case <-ticker.C:
|
||||
dateKey := time.Now().UTC().Format("2006-01-02")
|
||||
if dateKey == lastRanDate {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("adventure: midnight reset")
|
||||
if err := p.midnightReset(); err != nil {
|
||||
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
|
||||
continue
|
||||
// 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
|
||||
}
|
||||
|
||||
slog.Info("adventure: midnight reset")
|
||||
if err := p.midnightReset(); err != nil {
|
||||
slog.Error("adventure: midnight reset failed, will retry next tick", "err", err)
|
||||
continue
|
||||
}
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
lastRanDate = dateKey
|
||||
}
|
||||
db.MarkJobCompleted(jobName, dateKey)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AdventurePlugin) midnightReset() error {
|
||||
// Send idle shame DMs to players who didn't act
|
||||
chars, err := loadAllAdvCharacters()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load chars: %w", err)
|
||||
}
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
// Unified activity oracle — same source the daily report uses. Unions
|
||||
// adventure_activity_log + dnd_zone_run + dnd_expedition_log, so
|
||||
// background-autopilot walks, expedition extracts/completions, and any
|
||||
// subsystem that logged a beat all count as "something happened on this
|
||||
// player's behalf." LastActionDate alone misses the autopilot path
|
||||
// (dnd_zone_cmd.go gates markActedToday on !compact to keep autopilot
|
||||
// out of streak credit) — without this oracle, a player who let the
|
||||
// autopilot run a full expedition gets shamed at midnight.
|
||||
todayActs, _ := loadAdvDailyActivity(today)
|
||||
yesterdayActs, _ := loadAdvDailyActivity(yesterday)
|
||||
|
||||
dmsSent := 0
|
||||
for _, char := range chars {
|
||||
if !char.Alive {
|
||||
// Died inside the window — no shame, no streak change. Covers both
|
||||
// currently-dead players and players revived at midnight (Alive
|
||||
// already flipped to true by the reminder loop before this runs).
|
||||
if char.LastDeathDate == today || char.LastDeathDate == yesterday {
|
||||
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)
|
||||
}
|
||||
dmsSent++
|
||||
// Player-initiated engagement — only this credits the streak.
|
||||
// LastActionDate is stamped at action time by markActedToday + !rest;
|
||||
// the legacy counters get bumped by the legacy CanDo... paths.
|
||||
engaged := char.LastActionDate == today || char.LastActionDate == yesterday ||
|
||||
char.CombatActionsUsed > 0 || char.HarvestActionsUsed > 0
|
||||
|
||||
// Idle shame DM
|
||||
text := renderAdvIdleShameDM(&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")
|
||||
if engaged {
|
||||
if char.LastActionDate == yesterday || char.LastActionDate == today {
|
||||
char.CurrentStreak++
|
||||
} else {
|
||||
// Gap in activity — start fresh
|
||||
// Legacy-only path: counters bumped but LastActionDate stale.
|
||||
// Without this fall-through the streak would reset to 1 every
|
||||
// night even with continuous play.
|
||||
char.CurrentStreak = 1
|
||||
}
|
||||
if char.CurrentStreak > char.BestStreak {
|
||||
char.BestStreak = char.CurrentStreak
|
||||
}
|
||||
char.LastActionDate = today
|
||||
_ = saveAdvCharacter(&char)
|
||||
continue
|
||||
}
|
||||
|
||||
// Activity happened on the player's behalf without an explicit tap —
|
||||
// autopilot walked rooms, expedition log gained beats, a fight session
|
||||
// is still locked open. Hold the streak: no bump, no shame, no decay.
|
||||
// The player engaged earlier to kick this off; autopilot is a feature,
|
||||
// not a way to game streaks, and absence of taps shouldn't strip
|
||||
// progress they earned through real play.
|
||||
if len(todayActs[char.UserID]) > 0 || len(yesterdayActs[char.UserID]) > 0 {
|
||||
continue
|
||||
}
|
||||
// Safety net for live state the activity logs don't reflect yet
|
||||
// (e.g. an active expedition that's been quiet today, or a combat
|
||||
// session locked open across midnight without a log append).
|
||||
if exp, err := getActiveExpedition(char.UserID); err != nil {
|
||||
slog.Warn("adventure: failed to check active expedition for idle reaper", "user", char.UserID, "err", err)
|
||||
} else if exp != nil {
|
||||
continue
|
||||
}
|
||||
if hasActiveCombatSession(char.UserID) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Truly idle — shame DM + streak halve.
|
||||
if dmsSent > 0 {
|
||||
time.Sleep(time.Duration(1000+rand.IntN(2000)) * time.Millisecond)
|
||||
}
|
||||
dmsSent++
|
||||
|
||||
text := renderAdvIdleShameDM(char.UserID)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +494,7 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
// from concurrent writers (e.g. reminder fire loop).
|
||||
var resetErr error
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
if resetErr = resetAllAdvDailyActions(); resetErr == nil {
|
||||
if resetErr = resetAllPlayerMetaDailyActions(); resetErr == nil {
|
||||
break
|
||||
}
|
||||
slog.Warn("adventure: daily action reset failed, retrying", "attempt", attempt+1, "err", resetErr)
|
||||
@@ -350,11 +504,20 @@ func (p *AdventurePlugin) midnightReset() error {
|
||||
return fmt.Errorf("reset daily actions after 3 attempts: %w", resetErr)
|
||||
}
|
||||
|
||||
// Clear the one-day pet morning-defense buff so it re-rolls fresh each
|
||||
// morning (briefing or overworld DM) instead of leaking permanently.
|
||||
if err := resetAllPetMorningDefense(); err != nil {
|
||||
slog.Error("adventure: failed to reset pet morning defense", "err", err)
|
||||
}
|
||||
|
||||
// Prune expired buffs
|
||||
if err := pruneAdvExpiredBuffs(); err != nil {
|
||||
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 +528,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) {
|
||||
|
||||
145
internal/plugin/adventure_scheduler_test.go
Normal file
145
internal/plugin/adventure_scheduler_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// TestMidnightReset_Branching exercises the three idle-reaper branches:
|
||||
// - engaged: LastActionDate stamped today/yesterday → streak bumps
|
||||
// - activity-without-tap: autopilot/background activity logged today but
|
||||
// no LastActionDate stamp → streak holds, no shame DM, no decay
|
||||
// - truly idle: nothing anywhere → streak halves, StreakDecayed=true
|
||||
//
|
||||
// Regression guard for the autopilot bug: a player who let the background
|
||||
// auto-run walk an expedition all day used to get shamed at midnight because
|
||||
// markActedToday is gated on !compact at dnd_zone_cmd.go.
|
||||
func TestMidnightReset_Branching(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
yesterday := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
|
||||
|
||||
mk := func(uid, lastAction string, streak int) id.UserID {
|
||||
u := id.UserID(uid)
|
||||
if err := createAdvCharacter(u, uid); err != nil {
|
||||
t.Fatalf("createAdvCharacter %s: %v", uid, err)
|
||||
}
|
||||
c, err := loadAdvCharacter(u)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", uid, err)
|
||||
}
|
||||
c.LastActionDate = lastAction
|
||||
c.CurrentStreak = streak
|
||||
c.BestStreak = streak
|
||||
if err := saveAdvCharacter(c); err != nil {
|
||||
t.Fatalf("save %s: %v", uid, err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
engaged := mk("@engaged:example", yesterday, 5)
|
||||
autopilot := mk("@autopilot:example", "2020-01-01", 7)
|
||||
idle := mk("@idle:example", "2020-01-01", 8)
|
||||
|
||||
// Simulate autopilot activity: a legacy activity-log row dated today.
|
||||
// loadAdvDailyActivity unions this in, so the reaper sees the player as
|
||||
// "had activity" without their LastActionDate being current.
|
||||
if _, err := db.Get().Exec(
|
||||
`INSERT INTO adventure_activity_log
|
||||
(user_id, activity_type, location, outcome, loot_value, xp_gained, flavor_key, logged_at)
|
||||
VALUES (?, 'zone', 'Test Zone', 'in_progress', 0, 0, '', CURRENT_TIMESTAMP)`,
|
||||
string(autopilot),
|
||||
); err != nil {
|
||||
t.Fatalf("insert activity row: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.midnightReset(); err != nil {
|
||||
t.Fatalf("midnightReset: %v", err)
|
||||
}
|
||||
|
||||
// Engaged → streak bumped, LastActionDate restamped to today, no decay.
|
||||
if c, _ := loadAdvCharacter(engaged); c != nil {
|
||||
if c.CurrentStreak != 6 {
|
||||
t.Errorf("engaged: streak = %d, want 6", c.CurrentStreak)
|
||||
}
|
||||
if c.LastActionDate != today {
|
||||
t.Errorf("engaged: LastActionDate = %q, want %q", c.LastActionDate, today)
|
||||
}
|
||||
if c.StreakDecayed {
|
||||
t.Error("engaged: StreakDecayed = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Autopilot → hold. Streak unchanged, LastActionDate stays stale, no decay.
|
||||
if c, _ := loadAdvCharacter(autopilot); c != nil {
|
||||
if c.CurrentStreak != 7 {
|
||||
t.Errorf("autopilot: streak = %d, want 7 (held)", c.CurrentStreak)
|
||||
}
|
||||
if c.StreakDecayed {
|
||||
t.Error("autopilot: StreakDecayed = true, want false (shamed by mistake)")
|
||||
}
|
||||
if c.LastActionDate == today {
|
||||
t.Errorf("autopilot: LastActionDate restamped to today — autopilot must not credit streak via LastActionDate")
|
||||
}
|
||||
}
|
||||
|
||||
// Idle → shame DM (no-op without Matrix client) + streak halved + decay flagged.
|
||||
if c, _ := loadAdvCharacter(idle); c != nil {
|
||||
if c.CurrentStreak != 4 {
|
||||
t.Errorf("idle: streak = %d, want 4 (halved from 8)", c.CurrentStreak)
|
||||
}
|
||||
if !c.StreakDecayed {
|
||||
t.Error("idle: StreakDecayed = false, want true")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMidnightReset_DeathWindowSkips: a player who died today/yesterday is
|
||||
// left untouched — no shame, no streak change, no decay flag.
|
||||
func TestMidnightReset_DeathWindowSkips(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatalf("db.Init: %v", err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
u := id.UserID("@dead:example")
|
||||
if err := createAdvCharacter(u, "dead"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
c, _ := loadAdvCharacter(u)
|
||||
c.LastActionDate = "2020-01-01"
|
||||
c.CurrentStreak = 10
|
||||
c.BestStreak = 10
|
||||
c.LastDeathDate = today
|
||||
if err := saveAdvCharacter(c); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.midnightReset(); err != nil {
|
||||
t.Fatalf("midnightReset: %v", err)
|
||||
}
|
||||
|
||||
got, _ := loadAdvCharacter(u)
|
||||
if got.CurrentStreak != 10 {
|
||||
t.Errorf("dead: streak = %d, want 10 (untouched)", got.CurrentStreak)
|
||||
}
|
||||
if got.StreakDecayed {
|
||||
t.Error("dead: StreakDecayed = true, want false")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
119
internal/plugin/adventure_shop_sell_test.go
Normal file
119
internal/plugin/adventure_shop_sell_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// freshShopTestDB spins up an empty DB in a tempdir for the sell-path tests.
|
||||
// Distinct from setupAuditTestDB, which clones the prod snapshot.
|
||||
func freshShopTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
// TestSellAll_PreservesMagicItems — B1 (data-loss blocker): `sell all` must
|
||||
// route around curios (Type=="magic_item") so a careless liquidation can't
|
||||
// silently delete a Legendary Ring. Junk in the same bag still sells.
|
||||
func TestSellAll_PreservesMagicItems(t *testing.T) {
|
||||
freshShopTestDB(t)
|
||||
uid := id.UserID("@sellall_magic:test")
|
||||
|
||||
curio := AdvItem{Name: "Ring of Protection", Type: "magic_item", Tier: 3, Value: 500}
|
||||
junk := AdvItem{Name: "Iron Ore", Type: "material", Tier: 1, Value: 40}
|
||||
if err := addAdvInventoryItem(uid, curio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addAdvInventoryItem(uid, junk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
msg := p.advSellAll(uid)
|
||||
if !strings.Contains(msg, "Sold 1 item") {
|
||||
t.Errorf("expected exactly one item sold, msg=%q", msg)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(msg), "curio") {
|
||||
t.Errorf("sell-all summary should call out kept curios, msg=%q", msg)
|
||||
}
|
||||
|
||||
left, err := loadAdvInventory(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) != 1 || left[0].Type != "magic_item" {
|
||||
t.Fatalf("magic item not preserved: %+v", left)
|
||||
}
|
||||
|
||||
// Payout reflects only the junk's €40 (5% pot cut → ~€38 credited).
|
||||
bal := euro.GetBalance(uid)
|
||||
if bal <= 0 || bal > 40 {
|
||||
t.Errorf("payout %v outside expected (0,40] for €40 junk", bal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSellAll_OnlyMagicItems — when the bag is curios only, sell-all reroutes
|
||||
// to !adventure equip-magic and touches nothing.
|
||||
func TestSellAll_OnlyMagicItems(t *testing.T) {
|
||||
freshShopTestDB(t)
|
||||
uid := id.UserID("@sellall_only_magic:test")
|
||||
|
||||
if err := addAdvInventoryItem(uid, AdvItem{Name: "Cloak of Elvenkind", Type: "magic_item", Value: 300}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
msg := p.advSellAll(uid)
|
||||
if !strings.Contains(msg, "equip-magic") {
|
||||
t.Errorf("expected reroute to equip-magic, msg=%q", msg)
|
||||
}
|
||||
if euro.GetBalance(uid) != 0 {
|
||||
t.Errorf("balance moved on curio-only sell-all: %v", euro.GetBalance(uid))
|
||||
}
|
||||
left, _ := loadAdvInventory(uid)
|
||||
if len(left) != 1 {
|
||||
t.Errorf("curio deleted by sell-all: %+v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSellItem_MagicItemRerouted — explicit `sell <curio name>` must refuse
|
||||
// and point the player at the bonding command.
|
||||
func TestSellItem_MagicItemRerouted(t *testing.T) {
|
||||
freshShopTestDB(t)
|
||||
uid := id.UserID("@sellone_magic:test")
|
||||
|
||||
curio := AdvItem{Name: "Amulet of Health", Type: "magic_item", Value: 750}
|
||||
if err := addAdvInventoryItem(uid, curio); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
euro := &EuroPlugin{}
|
||||
euro.ensureBalance(uid)
|
||||
p := &AdventurePlugin{euro: euro}
|
||||
|
||||
msg := p.advSellItem(uid, "amulet")
|
||||
if !strings.Contains(msg, "equip-magic") {
|
||||
t.Errorf("expected reroute to equip-magic, msg=%q", msg)
|
||||
}
|
||||
if euro.GetBalance(uid) != 0 {
|
||||
t.Errorf("balance moved on refused curio sale: %v", euro.GetBalance(uid))
|
||||
}
|
||||
left, _ := loadAdvInventory(uid)
|
||||
if len(left) != 1 {
|
||||
t.Errorf("curio deleted by refused sale: %+v", left)
|
||||
}
|
||||
}
|
||||
353
internal/plugin/adventure_shop_test.go
Normal file
353
internal/plugin/adventure_shop_test.go
Normal 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 ──────────────────────────────────────────────<E29480><E29480><EFBFBD>───────
|
||||
|
||||
func TestLuigiItemConfirm_Upgrade(t *testing.T) {
|
||||
def := &EquipmentDef{Name: "Silver Blade", Tier: 3, Price: 1500}
|
||||
current := &AdvEquipment{Tier: 2, Name: "Dull Steel Sword"}
|
||||
text := luigiItemConfirm("@user:test", SlotWeapon, def, current, 5000)
|
||||
|
||||
if !strings.Contains(text, "⬆️") {
|
||||
t.Error("should show upgrade indicator")
|
||||
}
|
||||
if !strings.Contains(text, "upgrade from") {
|
||||
t.Error("should mention it's an upgrade")
|
||||
}
|
||||
if !strings.Contains(text, "€5000") {
|
||||
t.Error("should show current balance")
|
||||
}
|
||||
if !strings.Contains(text, "€3500") {
|
||||
t.Error("should show balance after purchase")
|
||||
}
|
||||
if !strings.Contains(text, "Confirm?") {
|
||||
t.Error("should prompt for confirmation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiItemConfirm_NoCurrent(t *testing.T) {
|
||||
def := &EquipmentDef{Name: "Sad Iron Sword", Tier: 1, Price: 100}
|
||||
text := luigiItemConfirm("@user:test", SlotWeapon, def, nil, 1000)
|
||||
|
||||
if !strings.Contains(text, "upgrade from") {
|
||||
t.Error("should say upgrade from None")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Item Lookup Tests ──────────────────────────────────────────────<E29480><E29480>────────
|
||||
|
||||
func TestAdvFindShopItemInSlot_ExactMatch(t *testing.T) {
|
||||
slot, def, ok := advFindShopItemInSlot("Enchanted Plate", SlotArmor)
|
||||
if !ok {
|
||||
t.Fatal("should find Enchanted Plate in armor slot")
|
||||
}
|
||||
if slot != SlotArmor {
|
||||
t.Errorf("slot should be armor, got %s", slot)
|
||||
}
|
||||
if def.Tier != 4 {
|
||||
t.Errorf("Enchanted Plate should be tier 4, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_WrongSlot(t *testing.T) {
|
||||
_, _, ok := advFindShopItemInSlot("Dragonscale", SlotWeapon)
|
||||
if ok {
|
||||
t.Error("should not find armor item in weapon slot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_PartialMatch(t *testing.T) {
|
||||
slot, def, ok := advFindShopItemInSlot("Enchanted Blade", SlotWeapon)
|
||||
if !ok {
|
||||
t.Fatal("should find Enchanted Blade by partial match")
|
||||
}
|
||||
if slot != SlotWeapon {
|
||||
t.Errorf("slot should be weapon, got %s", slot)
|
||||
}
|
||||
if def.Tier != 4 {
|
||||
t.Errorf("Enchanted Blade should be tier 4, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_CaseInsensitive(t *testing.T) {
|
||||
_, _, ok := advFindShopItemInSlot("enchanted blade", SlotWeapon)
|
||||
if !ok {
|
||||
t.Error("should find item case-insensitively")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItemInSlot_NoTier0(t *testing.T) {
|
||||
_, _, ok := advFindShopItemInSlot("Basic Ass Sword", SlotWeapon)
|
||||
if ok {
|
||||
t.Error("should not find tier 0 items (price 0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItem_TierShorthand(t *testing.T) {
|
||||
slot, def, ok := advFindShopItem("3 sword")
|
||||
if !ok {
|
||||
t.Fatal("should find via tier shorthand")
|
||||
}
|
||||
if slot != SlotWeapon {
|
||||
t.Errorf("slot should be weapon, got %s", slot)
|
||||
}
|
||||
if def.Tier != 3 {
|
||||
t.Errorf("tier should be 3, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItem_TierPrefix(t *testing.T) {
|
||||
_, def, ok := advFindShopItem("tier 5 boots")
|
||||
if !ok {
|
||||
t.Fatal("should find via 'tier 5 boots'")
|
||||
}
|
||||
if def.Tier != 5 {
|
||||
t.Errorf("tier should be 5, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvFindShopItem_TShorthand(t *testing.T) {
|
||||
_, def, ok := advFindShopItem("t4 armor")
|
||||
if !ok {
|
||||
t.Fatal("should find via 't4 armor'")
|
||||
}
|
||||
if def.Tier != 4 {
|
||||
t.Errorf("tier should be 4, got %d", def.Tier)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flavor Coverage Tests ───────────────────────────────────────────────────
|
||||
|
||||
func TestLuigiItemDescriptions_Coverage(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
defs := equipmentTiers[slot]
|
||||
for _, def := range defs {
|
||||
if def.Price == 0 {
|
||||
continue // skip tier 0
|
||||
}
|
||||
key := luigiItemKey{slot, def.Tier}
|
||||
if _, ok := luigiItemDescriptions[key]; !ok {
|
||||
t.Errorf("missing Luigi item description for %s tier %d (%s)", slot, def.Tier, def.Name)
|
||||
}
|
||||
if _, ok := luigiItemOneLiners[key]; !ok {
|
||||
t.Errorf("missing Luigi one-liner for %s tier %d (%s)", slot, def.Tier, def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiCategoryIntros_Coverage(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
intros, ok := luigiCategoryIntros[slot]
|
||||
if !ok || len(intros) == 0 {
|
||||
t.Errorf("missing Luigi category intros for slot %s", slot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiFlavorPools_NonEmpty(t *testing.T) {
|
||||
pools := map[string][]string{
|
||||
"luigiGreetings": luigiGreetings,
|
||||
"luigiPurchaseConfirm": luigiPurchaseConfirm,
|
||||
"luigiTier5Confirm": luigiTier5Confirm,
|
||||
"luigiComboConfirm": luigiComboConfirm,
|
||||
"luigiInsufficientFunds": luigiInsufficientFunds,
|
||||
"luigiBrowseTimeout": luigiBrowseTimeout,
|
||||
"luigiMaxedOut": luigiMaxedOut,
|
||||
"luigiMasterworkAck": luigiMasterworkAck,
|
||||
"luigiShowAllComment": luigiShowAllComment,
|
||||
"luigiCommentary": luigiCommentary,
|
||||
"luigiCancellation": luigiCancellation,
|
||||
}
|
||||
for name, pool := range pools {
|
||||
if len(pool) == 0 {
|
||||
t.Errorf("flavor pool %s is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Format String Safety ────────────────────────────────────────────────────
|
||||
|
||||
func TestLuigiPurchaseConfirm_FormatStrings(t *testing.T) {
|
||||
for i, tmpl := range luigiPurchaseConfirm {
|
||||
// Should have %s then %d
|
||||
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
|
||||
if !result {
|
||||
t.Errorf("luigiPurchaseConfirm[%d] missing %%s or %%d placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiTier5Confirm_FormatStrings(t *testing.T) {
|
||||
for i, tmpl := range luigiTier5Confirm {
|
||||
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
|
||||
if !result {
|
||||
t.Errorf("luigiTier5Confirm[%d] missing %%s or %%d placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuigiComboConfirm_FormatStrings(t *testing.T) {
|
||||
for i, tmpl := range luigiComboConfirm {
|
||||
result := strings.Contains(tmpl, "%s") && strings.Contains(tmpl, "%d")
|
||||
if !result {
|
||||
t.Errorf("luigiComboConfirm[%d] missing %%s or %%d placeholder", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tier Bounds Safety ──────────────────────────────────────────────────────
|
||||
|
||||
func TestEquipmentTiers_ConsistentIndexing(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
defs := equipmentTiers[slot]
|
||||
for i, def := range defs {
|
||||
if def.Tier != i {
|
||||
t.Errorf("slot %s: tier %d at index %d (should match for safe direct indexing)", slot, def.Tier, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEquipmentTiers_SixTiersPerSlot(t *testing.T) {
|
||||
for _, slot := range allSlots {
|
||||
defs := equipmentTiers[slot]
|
||||
if len(defs) != 6 {
|
||||
t.Errorf("slot %s: expected 6 tiers (0-5), got %d", slot, len(defs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -241,19 +242,25 @@ var advAllTreasures = map[int][]AdvTreasureDef{
|
||||
|
||||
// ── Treasure Drop Logic ──────────────────────────────────────────────────────
|
||||
|
||||
func rollAdvTreasureDrop(tier int, userID id.UserID) *AdvTreasureDrop {
|
||||
rate, ok := advTreasureDropRates[tier]
|
||||
// rollAdvTreasureDropDetailed returns the drop (or nil) plus the random roll
|
||||
// and the effective drop rate, so callers can surface near-miss feedback
|
||||
// ("rolled 1.8% vs 1.5% chance — just missed"). Players never see treasure
|
||||
// math otherwise, which makes rare drops feel mythical.
|
||||
func rollAdvTreasureDropDetailed(tier int, userID id.UserID, chatLevel int) (drop *AdvTreasureDrop, roll, rate float64) {
|
||||
r, ok := advTreasureDropRates[tier]
|
||||
if !ok {
|
||||
return nil
|
||||
return nil, 0, 0
|
||||
}
|
||||
rate = r + chatLevelRareBonus(chatLevel)
|
||||
roll = rand.Float64()
|
||||
|
||||
if rand.Float64() >= rate {
|
||||
return nil
|
||||
if roll >= rate {
|
||||
return nil, roll, rate
|
||||
}
|
||||
|
||||
pool, ok := advAllTreasures[tier]
|
||||
if !ok || len(pool) == 0 {
|
||||
return nil
|
||||
return nil, roll, rate
|
||||
}
|
||||
|
||||
// Pick random treasure
|
||||
@@ -266,11 +273,68 @@ func rollAdvTreasureDrop(tier int, userID id.UserID) *AdvTreasureDrop {
|
||||
def = &pool[rand.IntN(len(pool))]
|
||||
owns, err = advUserOwnsTreasure(userID, def.Key)
|
||||
if err != nil || owns {
|
||||
return nil // both rolls duplicated
|
||||
return nil, roll, rate // both rolls duplicated
|
||||
}
|
||||
}
|
||||
|
||||
return &AdvTreasureDrop{Def: def}
|
||||
return &AdvTreasureDrop{Def: def}, roll, rate
|
||||
}
|
||||
|
||||
// rollAdvTreasureDrop is the legacy single-return variant used by call sites
|
||||
// that don't surface near-miss feedback (auto-babysit, twinbee shares).
|
||||
func rollAdvTreasureDrop(tier int, userID id.UserID, chatLevel int) *AdvTreasureDrop {
|
||||
d, _, _ := rollAdvTreasureDropDetailed(tier, userID, chatLevel)
|
||||
return d
|
||||
}
|
||||
|
||||
// ── Treasure Comparison ─────────────────────────────────────────────────────
|
||||
|
||||
// advTreasureIrreplaceable reports whether a treasure carries a bonus type
|
||||
// that can't be replicated by another drop (e.g. monthly death bypass).
|
||||
// Such treasures are excluded from auto-swap and fall back to the manual
|
||||
// discard prompt so the player consciously chooses to give one up.
|
||||
func advTreasureIrreplaceable(def *AdvTreasureDef) bool {
|
||||
if def == nil {
|
||||
return false
|
||||
}
|
||||
for _, b := range def.Bonuses {
|
||||
if strings.HasPrefix(b.Type, "special_") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// advTreasureRank produces a comparable triple (tier, bonusCount, key) for
|
||||
// auto-swap decisions. Bonuses are heterogeneous and there's no honest
|
||||
// scalar comparator — we use tier first, then bonus count as a tiebreaker,
|
||||
// then the deterministic key so equal-rank ties prefer the existing item
|
||||
// (no churn). Higher tuple = better treasure.
|
||||
type advTreasureRankKey struct {
|
||||
Tier int
|
||||
BonusCount int
|
||||
Key string
|
||||
}
|
||||
|
||||
func advTreasureRank(def *AdvTreasureDef) advTreasureRankKey {
|
||||
if def == nil {
|
||||
return advTreasureRankKey{}
|
||||
}
|
||||
return advTreasureRankKey{Tier: def.Tier, BonusCount: len(def.Bonuses), Key: def.Key}
|
||||
}
|
||||
|
||||
// advTreasureRankBetter reports whether a is strictly better than b.
|
||||
// Equal-rank returns false (caller treats this as "keep existing").
|
||||
func advTreasureRankBetter(a, b advTreasureRankKey) bool {
|
||||
if a.Tier != b.Tier {
|
||||
return a.Tier > b.Tier
|
||||
}
|
||||
if a.BonusCount != b.BonusCount {
|
||||
return a.BonusCount > b.BonusCount
|
||||
}
|
||||
// Deterministic but neutral tiebreak — different keys aren't "better"
|
||||
// than each other, so equal Tier+BonusCount means no swap.
|
||||
return false
|
||||
}
|
||||
|
||||
// ── Treasure DB Operations ───────────────────────────────────────────────────
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user