mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Adv 2.0 D&D polish: GM→DM rename, streamed zone combat, end-to-end scenario test
GM→DM rename across docs and code (GMNarrationType→DMNarrationType, GMState→DMState, narration constants, comments) so the system reads as "Dungeon Master" everywhere. Player-visible "GM mood" wording stays where it appears in flavor. Streamed zone/expedition combat: zone advance now stages patrol → patrol play-by-play → patrol resolution → room intro → room play-by-play → final outcome through sendZoneCombatMessages with 2–3s pacing (arena keeps its 5–8s window). Combat narrative lines pick up a compact d20-vs-AC roll annotation for hit/crit/miss/block events. Combat outcome polish: dndHPSnapshot lets narration show sheet HP rather than legacy combat-engine HP, and markAdventureDead clears the zombie state where hp_current was 0 but the legacy alive flag stayed true after a D&D-layer KO. Adv 2.0 announcement (ADVENTURE_2.0_ANNOUNCEMENT.md), README rewrite covering the new layer, and adv2_scenario_test.go — a full zone-run + expedition + harvest playthrough against a copy of the prod DB asserting persisted state end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
252
ADVENTURE_2.0_ANNOUNCEMENT.md
Normal file
252
ADVENTURE_2.0_ANNOUNCEMENT.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Adventure 2.0 — D&D Layer
|
||||
|
||||
**Status:** shipping to prod from branch `adv-2.0`.
|
||||
**Compatibility:** fully additive. Every existing character, treasure, balance,
|
||||
streak, and inventory row is preserved. Players who don't opt in keep the
|
||||
classic adventure experience unchanged.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Adventure 2.0 grafts a full D&D-style character layer onto the existing
|
||||
adventure system, then builds two new endgame loops on top of it: **single-
|
||||
session dungeon zones** and **multi-day expeditions**. It introduces ten
|
||||
hand-tuned zones across five tiers, a real-time threat clock, harvest nodes
|
||||
per room, multi-region travel, an extract/resume cycle, and TwinBee — a
|
||||
zone-narrating mood-driven dungeon master who reacts to what you do.
|
||||
|
||||
If you've been running `!adventure`, nothing breaks. If you run `!setup`, the
|
||||
whole game opens up.
|
||||
|
||||
---
|
||||
|
||||
## What's new
|
||||
|
||||
### 1. A D&D character, on top of your existing one
|
||||
|
||||
Run `!setup` once. You pick a **race** and **class**, your existing
|
||||
`combat_level` becomes your D&D level, and the system inherits your
|
||||
treasures and equipment as attunements. From that point on every fight,
|
||||
forage, and arena run is resolved through D&D mechanics — STR/DEX/CON/INT/WIS/CHA,
|
||||
HP, AC, ability checks, saving throws, the lot.
|
||||
|
||||
- **5 classes:** Fighter, Mage, Cleric, Ranger, Rogue
|
||||
- **6 races** with distinct passives
|
||||
- **HP / AC / ability scores** with proper modifiers
|
||||
- **Resource pools** per class (stamina, spell slots, channel divinity, etc.)
|
||||
- `!sheet` shows everything: stats, bonuses, equipped gear, attunements
|
||||
|
||||
Players who haven't run `!setup` see the legacy adventure UI as before.
|
||||
|
||||
### 2. Subclasses & level-up features (L3 → L15)
|
||||
|
||||
At L3 you specialize — every class has 3 subclasses, each with its own
|
||||
passives, abilities, and signature moves through L15.
|
||||
|
||||
| Class | Subclasses |
|
||||
|---|---|
|
||||
| Fighter | Champion, Battle Master, Berserker |
|
||||
| Mage | Evocation, Abjuration, Necromancy, Arcane Trickster |
|
||||
| Cleric | Life, War, Trickery |
|
||||
| Ranger | Hunter, Beast Master, Gloom Stalker |
|
||||
| Rogue | Thief, Assassin, Arcane Trickster |
|
||||
|
||||
Subclass features unlock at L5, L7, L10, L15 — extra attacks, spell slot
|
||||
upgrades, signature spells, beast companions, sneak-attack scaling, divine
|
||||
strike, etc. Use `!subclass` to inspect and pick.
|
||||
|
||||
### 3. Spells & spellbooks
|
||||
|
||||
Mages, Clerics, and Rangers cast for real now. Spell slots are tracked,
|
||||
combat consumes them, and Mages have a **spellbook cap** that grows with
|
||||
level — pick which spells to learn at level-up via `!learn`. Spells fire
|
||||
automatically through the combat AI when the situation favors them.
|
||||
|
||||
### 4. Forward-simulating combat engine
|
||||
|
||||
Combat is no longer a one-shot dice roll. The engine plays out the encounter
|
||||
turn by turn, threading consumables, monster abilities, status effects,
|
||||
class resources, crits and fumbles, and renders a real combat narrative.
|
||||
Every fight in arena, encounters, expeditions, and zones runs through it.
|
||||
|
||||
### 5. Dungeon zones (single-session)
|
||||
|
||||
`!zone` — pick a zone, run it, come out with loot.
|
||||
|
||||
- **10 zones, 5 tiers**, level bands L1 through L20
|
||||
- T1: Goblin Warrens, Crypt of Valdris
|
||||
- T2: Forest Shadows, Sunken Temple
|
||||
- T3: Haunted Manor, Underforge
|
||||
- T4: Underdark Outpost, Feywild Crossing
|
||||
- T5: Dragon's Lair, Abyss Portal
|
||||
- **Procedural rooms:** entry → exploration → trap → elite → boss, with the
|
||||
middle padded out per zone
|
||||
- **Per-zone bestiary, traps, atmosphere, and flavor pools**
|
||||
- **Boss phase-two narration** when a boss crosses 50% HP
|
||||
- **TwinBee** — your in-zone DM. Has a 0–100 mood score that shifts on
|
||||
crits, deaths, taunts, compliments, zone completions. Mood gates the
|
||||
flavor of every aside, gloat, and recap you read.
|
||||
- `!zone map` — ASCII layout `E──?──T──★──☠` with your current marker
|
||||
- `!zone lore` — TwinBee shares zone history
|
||||
- `!zone taunt` / `!zone compliment` — directly poke or flatter your DM
|
||||
|
||||
### 6. Multi-day expeditions
|
||||
|
||||
`!expedition` — the long form. Pack supplies, head out, manage your day-by-day
|
||||
survival, come home with the haul (or not).
|
||||
|
||||
- **Outfitting:** buy supply packs at start; standard or deluxe; cost scales
|
||||
with zone tier
|
||||
- **Real-time day cycle:** 06:00 morning briefing, 21:00 evening recap
|
||||
(configurable per environment)
|
||||
- **Supply burn:** rations tick down; harsh zones burn faster; running out
|
||||
triggers a forced extraction
|
||||
- **Threat Clock** (0–100): every action you take is noticed. As threat
|
||||
rises you cross bands — Quiet → Stirring → Alert → Hostile → Siege —
|
||||
each band changes monster density, patrol odds, and night events
|
||||
- **Patrol encounters** at Alert+: fights can interrupt you while traversing
|
||||
cleared rooms
|
||||
- **Wandering monsters** at night
|
||||
- **Siege Mode** at threat 80+: fortified camp branches, special warnings
|
||||
- **Camp:** `!camp rough` (free, no protection) vs `!camp standard`
|
||||
(consumes supplies, grants overnight rest)
|
||||
- **Milestones** (§13): First Night, Deep Dive, Long Haul, etc.
|
||||
- **Expedition log:** every event recorded; `!expedition log` shows the trail
|
||||
|
||||
### 7. Multi-region zones
|
||||
|
||||
Higher-tier zones are **multi-region**. The Underdark, Feywild Crossing,
|
||||
Dragon's Lair, and Abyss Portal each have multiple connected regions.
|
||||
|
||||
- `!region` — see where you are, where you can travel, what you've explored
|
||||
- **Inter-region travel** consumes supplies and ticks threat
|
||||
- **Base camp** is a persistent waypoint per expedition — anchor your
|
||||
return, retreat to safety, regroup
|
||||
- `!map` (E6a) — full multi-region map renderer with visited markers
|
||||
|
||||
### 8. Per-zone temporal events
|
||||
|
||||
Every T2+ zone has a **signature time-based effect**:
|
||||
|
||||
- **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
|
||||
|
||||
These are enumerated and active at all expedition tiers, layered on top
|
||||
of the threat clock.
|
||||
|
||||
### 9. Extract & resume
|
||||
|
||||
`!extract` — bail safely from an expedition. You keep what you've found,
|
||||
the expedition row goes to `extracting` status, and you can `!resume`
|
||||
later to pick up from your base camp on the same expedition. Multi-region
|
||||
extract→resume preserves your current region and visited set.
|
||||
|
||||
### 10. Harvest nodes per room
|
||||
|
||||
Foraging, mining, and (in coastal zones) fishing now happen **inside** zones.
|
||||
|
||||
- Every room gets a seeded set of **harvest nodes** — finite, room-scoped
|
||||
- Harvest actions roll against zone-specific DCs with class bonuses
|
||||
(Ranger forage/fish, Fighter mine, etc.)
|
||||
- **Class-specific yield bonuses** — Rangers find rare game; Fighters
|
||||
break tougher ore; Mages divine arcane components
|
||||
- **Zone-condition effects** — Tidal Rising blocks fishing; Heat blocks
|
||||
mining; nightly reset re-seeds nodes; etc.
|
||||
- **Per-zone resource tables** — every zone has its own loot pool
|
||||
|
||||
### 11. Fishing zones
|
||||
|
||||
Coastal/aquatic zones (Sunken Temple, Forest Shadows streams, Feywild
|
||||
Crossing) support `!harvest fish` with their own resource pools, fishing
|
||||
DCs, and **Ranger rare-catch upgrades**.
|
||||
|
||||
### 12. Zone loot drops
|
||||
|
||||
- **Boss kills** drop from a per-zone loot table
|
||||
- **Elite kills** drop from a tier-scoped table
|
||||
- Loot persists into your inventory and can be sold, equipped, or
|
||||
attuned via the existing flow
|
||||
|
||||
### 13. Economy integration
|
||||
|
||||
All Adv 2.0 systems use real coins. Outfitting expeditions debits at start;
|
||||
extracted loot credits at completion; harvest yields stack into your
|
||||
existing inventory; arena multipliers and streak XP scale through D&D
|
||||
combat. No parallel currency.
|
||||
|
||||
### 14. TwinBee narration coverage
|
||||
|
||||
Every zone × every narration type (room entry, combat start, combat end,
|
||||
crits, fumbles, player death, zone complete, mood asides, lore, boss
|
||||
entry, elite entry, taunt/compliment responses) has a non-empty pool.
|
||||
A coverage test (`TestNarrationCoverage_AllZonesAllTypes`) locks this in
|
||||
so future zone additions can't ship with empty pools.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| `!zone` / `!zone list` | Show available zones |
|
||||
| `!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 TwinBee |
|
||||
| `!expedition list` | Zones available at your level |
|
||||
| `!expedition start <zone> [Ns] [Md]` | Outfit and begin |
|
||||
| `!expedition status` / `!expedition log` | Daily briefing + history |
|
||||
| `!camp rough` / `!camp standard` | Make camp |
|
||||
| `!region` | Multi-region inspection + travel |
|
||||
| `!map` | Full multi-region map |
|
||||
| `!harvest forage` / `!harvest mine` / `!harvest fish` | Work the room |
|
||||
| `!resources` | Inventory of harvested materials |
|
||||
| `!threat` | Current threat clock |
|
||||
| `!extract` | Bail safely from an expedition |
|
||||
| `!resume` | Pick up an extracted expedition |
|
||||
|
||||
---
|
||||
|
||||
## Compatibility & rollout
|
||||
|
||||
- **Additive migrations only.** No columns dropped, no tables renamed. Every
|
||||
pre-2.0 column is preserved with original semantics.
|
||||
- **Opt-in.** A `pending_setup` flag keeps non-opted players on the legacy
|
||||
flow. Output formats only switch after `!setup` completes.
|
||||
- **Treasures → attunements.** Existing treasure rows are interpreted as
|
||||
attunements rather than re-issued.
|
||||
- **`combat_level` → D&D level.** Same column, new lens.
|
||||
- **Equipment slots** carry forward 1:1.
|
||||
|
||||
If you've been playing pre-2.0, your save is intact. Run `!setup` when you
|
||||
want the new layer. Don't run it and nothing about your day changes.
|
||||
|
||||
---
|
||||
|
||||
## Developer notes
|
||||
|
||||
- **Branch:** `adv-2.0`, 104 commits ahead of `main`
|
||||
- **Test coverage:** full `internal/plugin` suite green, including a new
|
||||
scenario playthrough (`adv2_scenario_test.go`) that drives end-to-end
|
||||
flows against a copy of the prod database
|
||||
- **Phases shipped on this branch:**
|
||||
- Phases 1–9 — character system, abilities, combat engine, spells (SP1–SP4)
|
||||
- Phase 10 — subclasses (SUB1–SUB3d, all classes through L15)
|
||||
- Phase 11 — dungeon zones D1–D11 (10 zones + TwinBee + map + coverage test)
|
||||
- Phase 12 — expeditions E1–E6 (supplies, threat clock, day cycle, camp,
|
||||
multi-region, extract/resume, milestones, flavor)
|
||||
- Phase R — resource/combat integration (R1–R7, including post-completion
|
||||
audit hardening)
|
||||
- **Deferred (pre-existing systems required):**
|
||||
- Lemmy/Matrix activity → mood signal hooks (waiting on activity aggregation)
|
||||
- Pete bot zone announcements (Phase 16 deliverable)
|
||||
151
README.md
151
README.md
@@ -30,7 +30,7 @@ Written in Go using [mautrix-go](https://github.com/mautrix/go) for encryption a
|
||||
- **E2EE that actually works** - mautrix-go with goolm (pure Go). Crypto state lives in SQLite so device keys survive restarts. Cross-signing bootstraps on first run — the bot self-verifies its own device.
|
||||
- **No CGo, no system deps** - builds to a single static binary. Cross-compile to whatever you want.
|
||||
- **50 plugins** with dependency injection and ordered registration
|
||||
- **Games & economy** - Euro virtual currency, Hangman (collaborative, threaded, tiered scoring, multilingual clue mode via DreamDict, difficulty tier display), Blackjack (1-4 players, auto-play timeout), UNO (solo vs bot or 2–4 player multiplayer via DMs, with optional No Mercy mode), Texas Hold'em (2-9 players, CFR-trained NPC bot, DM-based gameplay with Ollama coaching tips, 1-hour idle auto-close with 45-min warning), Wordle (daily cooperative, DreamDict-powered, 5-20 letter words, guess persistence across restarts, midnight expiry announcements, video game themed bonus words with category hints, dupe prevention across last 500 puzzles, earnings tracked in stats), Adventure (daily idle RPG via DMs — dungeon, mine, forage, fish, shop, or rest with TwinBee NPC distributing level-scaled rewards, mid-day random events, tier shorthand buying, holiday double actions, hospital revival system, Robbie the Friendly Bandit automated inventory cleaner), Arena (5-tier combat gauntlet with 20 unique monsters, risk-reward cashout system, death lockout, leaderboard), 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
|
||||
- **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.
|
||||
- **Passive tracking** - XP, stats, streaks, achievements, markov corpus, keyword alerts, all running silently
|
||||
- **Scheduled posts** via [robfig/cron](https://github.com/robfig/cron) - Palavra do Dia (Portuguese WOTD with en/fr translations and etymology), holidays, game releases, birthdays, anime/movie releases, concert digests, esteemed members
|
||||
@@ -473,44 +473,150 @@ Daily cooperative Wordle — one puzzle per day, the community works together wi
|
||||
|
||||
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, fish, forage, visit the shop, blacksmith, or rest. Outcomes resolve with flavor text and loot is credited to your euro balance. An evening summary posts to the games room. TwinBee is a permanent NPC adventurer who distributes rewards to active players.
|
||||
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` (or `!adv`) command. All gameplay happens in DMs — reply to the bot's morning prompt with your choice. DM replies are only interpreted as adventure choices for 15 minutes after a menu is sent, so other DM-based games (UNO, Hold'em) won't conflict.
|
||||
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` / `!adv` | 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 run your daily action (trains your weakest gathering skill) |
|
||||
| `!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`–`7`) or activity name (`dungeon`, `mine`, `fish`, `forage`, `shop`, `blacksmith`, `rest`). You can specify a location: `1 Soggy Cellar`, `mine 3`, etc.
|
||||
#### Skill tracks
|
||||
|
||||
#### Activities
|
||||
|
||||
Four activity types across 5 tiers of locations. Higher tiers require higher character level and equipment.
|
||||
|
||||
- **Dungeon** — combat XP, chance of death, best loot potential
|
||||
- **Mining** — mining XP, cave-in risk, ore and gem drops
|
||||
- **Fishing** — fishing XP, hazard risk, catch drops
|
||||
- **Foraging** — foraging XP, wildlife hazards, herb and reagent drops
|
||||
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
|
||||
|
||||
@@ -519,10 +625,9 @@ Four activity types across 5 tiers of locations. Higher tiers require higher cha
|
||||
- **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 resets your streak. Dead players' streaks are frozen — you won't lose your streak to involuntary downtime.
|
||||
- **Grudge** — dying at a location marks it as your grudge. Returning there grants +10% success and +25% XP. Clears on success.
|
||||
- **Party bonus** — if two players independently visit the same location on the same day, both get +10% loot value.
|
||||
- **Death** — locked out for 6 hours. Natural respawn happens automatically when the timer expires. Use `!hospital` for immediate revival at a cost. Death's Reprieve (surviving a lethal roll) sets all equipment to 1 condition instead of destroying it. Dead players' streaks are preserved with a grace period — if you die and can't act on revival day, your streak won't reset.
|
||||
- **Holidays** — on recognized holidays (~20/year across religious and cultural calendars), you get a second daily action. Hebrew and Islamic calendar support for floating holidays.
|
||||
- **TwinBee NPC** — takes a daily action (location tier capped by best player's combined level), distributes loot share to active players scaled quadratically by level, and occasionally gifts random buffs.
|
||||
- **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.
|
||||
@@ -544,11 +649,11 @@ Max auto-crafts per combat scales with level: 1 at Foraging 10, 2 at 20, 3 at 30
|
||||
|
||||
#### Blacksmith & Repair
|
||||
|
||||
Equipment accumulates damage on bad outcomes and breaks at 0 condition. The blacksmith repairs gear for a per-point fee that scales with tier (base rates T0→T5: €1, €2, €5, €12, €30, €80; masterwork and arena gear use the next tier up). The cost has a mild convexity (`baseRate × damage × (1 + damage/200)`), so repairing earlier is slightly cheaper per point than letting gear sit at low condition — but not punitively so. `!adventure blacksmith` previews quotes; `!adventure repair all` or `!adventure repair <slot>` commits. Visiting the blacksmith counts as your daily action.
|
||||
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 run your daily action for you. Daily cost is `€100 + combatLevel × €20`. TwinBee trains your weakest gathering skill (mining/fishing/foraging) so the service doubles as catch-up for neglected tracks. Subscribe by the week or month, or check/cancel anytime: `!adventure babysit week|month|status|cancel`.
|
||||
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
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ type DungeonRun struct {
|
||||
RoomsCleared []int `json:"rooms_cleared"`
|
||||
BossDefeated bool `json:"boss_defeated"`
|
||||
LootCollected []string `json:"loot_collected"` // item IDs
|
||||
GMMood int `json:"gm_mood"`
|
||||
DMMood int `json:"gm_mood"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
Abandoned bool `json:"abandoned"`
|
||||
@@ -648,27 +648,27 @@ type TrapEntry struct {
|
||||
TierMin int `json:"tier_min"`
|
||||
}
|
||||
|
||||
type GMState struct {
|
||||
type DMState struct {
|
||||
Mood int `json:"mood"` // 0–100
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
}
|
||||
|
||||
// GMNarrationType classifies what kind of line TwinBee is generating.
|
||||
type GMNarrationType string
|
||||
// DMNarrationType classifies what kind of line TwinBee is generating.
|
||||
type DMNarrationType string
|
||||
|
||||
const (
|
||||
GMRoomEntry GMNarrationType = "room_entry"
|
||||
GMCombatStart GMNarrationType = "combat_start"
|
||||
GMCombatEnd GMNarrationType = "combat_end"
|
||||
GMNat20 GMNarrationType = "nat_20"
|
||||
GMNat1 GMNarrationType = "nat_1"
|
||||
GMBossEntry GMNarrationType = "boss_entry"
|
||||
GMBossDeath GMNarrationType = "boss_death"
|
||||
GMPlayerDeath GMNarrationType = "player_death"
|
||||
GMZoneComplete GMNarrationType = "zone_complete"
|
||||
GMTrapDetected GMNarrationType = "trap_detected"
|
||||
GMTrapTripped GMNarrationType = "trap_tripped"
|
||||
GMLore GMNarrationType = "lore"
|
||||
DMRoomEntry DMNarrationType = "room_entry"
|
||||
DMCombatStart DMNarrationType = "combat_start"
|
||||
DMCombatEnd DMNarrationType = "combat_end"
|
||||
DMNat20 DMNarrationType = "nat_20"
|
||||
DMNat1 DMNarrationType = "nat_1"
|
||||
DMBossEntry DMNarrationType = "boss_entry"
|
||||
DMBossDeath DMNarrationType = "boss_death"
|
||||
DMPlayerDeath DMNarrationType = "player_death"
|
||||
DMZoneComplete DMNarrationType = "zone_complete"
|
||||
DMTrapDetected DMNarrationType = "trap_detected"
|
||||
DMTrapTripped DMNarrationType = "trap_tripped"
|
||||
DMLore DMNarrationType = "lore"
|
||||
)
|
||||
```
|
||||
|
||||
@@ -712,7 +712,7 @@ const (
|
||||
- [ ] Corresponding flavor files
|
||||
|
||||
### Phase D6 — GM Polish
|
||||
- [ ] Full `GMNarrationType` flavor text pass (all types, all zones)
|
||||
- [ ] Full `DMNarrationType` flavor text pass (all types, all zones)
|
||||
- [ ] TwinBee `!taunt` / `!compliment` interaction lines
|
||||
- [ ] ASCII `!map` renderer
|
||||
- [ ] GM Mood community trigger hooks (Lemmy/Matrix activity integration)
|
||||
|
||||
@@ -361,7 +361,7 @@ type Expedition struct {
|
||||
Log []ExpeditionEntry `json:"log"`
|
||||
LootCollected []string `json:"loot_collected"`
|
||||
XPEarned int `json:"xp_earned"`
|
||||
GMMood int `json:"gm_mood"`
|
||||
DMMood int `json:"gm_mood"`
|
||||
LastActivity time.Time `json:"last_activity"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// DO NOT REWRITE, SUMMARIZE, OR SHORTEN ANY ENTRIES IN THIS FILE
|
||||
// twinbee_gm_flavor.go
|
||||
// TwinBee GM Dialogue — All narration lines for the GogoBee dungeon system.
|
||||
// Organized by GMNarrationType. Each slice is randomly sampled at runtime.
|
||||
// Organized by DMNarrationType. Each slice is randomly sampled at runtime.
|
||||
// Add new entries freely. Never remove or alter existing entries.
|
||||
|
||||
package flavor
|
||||
|
||||
315
internal/plugin/adv2_scenario_test.go
Normal file
315
internal/plugin/adv2_scenario_test.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"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). Cap iterations as a safety net in case advance becomes
|
||||
// a no-op due to a regression.
|
||||
maxSteps := run.TotalRooms + 4
|
||||
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 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.
|
||||
if _, err := dbExecExpeditionBackdate(exp.ID, 24*time.Hour); err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
|
||||
// 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"); 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)
|
||||
nodes := loadHarvestNodes(exp, roomIdx)
|
||||
t.Logf("room %d has %d harvest nodes", roomIdx, len(nodes))
|
||||
if len(nodes) == 0 {
|
||||
t.Error("expected at least one harvest node seeded")
|
||||
}
|
||||
|
||||
// Try each harvest action a couple of times and watch for crashes.
|
||||
// Outcomes are RNG-driven; we mainly want no panics + coherent state.
|
||||
rng := rand.New(rand.NewPCG(7, 13))
|
||||
_ = rng
|
||||
for _, action := range []HarvestAction{HarvestForage, HarvestMine} {
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := p.handleHarvestCmd(MessageContext{Sender: uid}, action); err != nil {
|
||||
t.Fatalf("handleHarvestCmd(%s) attempt %d: %v", action, i, err)
|
||||
}
|
||||
fresh, _ := getExpedition(exp.ID)
|
||||
if fresh == nil {
|
||||
t.Logf("expedition ended during harvest %s #%d", action, i)
|
||||
break
|
||||
}
|
||||
t.Logf("after %s #%d: supplies=%v threat=%d", action, 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)
|
||||
}
|
||||
}
|
||||
@@ -192,14 +192,14 @@ func (p *AdventurePlugin) Init() error {
|
||||
if err := lockCoopCombatActions(); err != nil {
|
||||
slog.Error("adventure: startup coop combat lock failed", "err", err)
|
||||
}
|
||||
// Phase R1 — archive any active dnd_zone_run rows that aren't linked to
|
||||
// an active expedition. Idempotent: re-running it does nothing once the
|
||||
// orphan set is empty.
|
||||
if n, err := archiveOrphanZoneRuns(); err != nil {
|
||||
slog.Error("adventure: R1 orphan zone-run archive failed", "err", err)
|
||||
} else if n > 0 {
|
||||
slog.Info("adventure: R1 archived orphan zone runs", "count", n)
|
||||
}
|
||||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||||
// also kills legitimate `!zone enter` single-session runs every time
|
||||
// the bot restarts. That migration's job was done on its first
|
||||
// execution; leaving it on Init was eating live runs. The function is
|
||||
// preserved for one-off invocation if a future schema change ever
|
||||
// needs it again.
|
||||
// Revive any characters whose DeadUntil has expired
|
||||
p.catchUpRespawns(chars)
|
||||
|
||||
|
||||
@@ -258,9 +258,9 @@ func TestFixedHolidays_NoDuplicateMonthDay(t *testing.T) {
|
||||
|
||||
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")
|
||||
@@ -268,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -328,12 +328,27 @@ func (p *AdventurePlugin) prependCraftNarrative(userID id.UserID, messages []str
|
||||
// Runs in a goroutine so it doesn't block the message handler.
|
||||
// Returns a channel that is closed when all messages have been sent.
|
||||
func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||||
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 5, 4) // 5–8s
|
||||
}
|
||||
|
||||
// sendZoneCombatMessages is the zone/expedition variant — same staging as
|
||||
// arena, but tighter pacing (2–3s) so dungeon advances feel snappier.
|
||||
func (p *AdventurePlugin) sendZoneCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
|
||||
return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 2, 2) // 2–3s
|
||||
}
|
||||
|
||||
// sendCombatMessagesWithDelay is the shared streamer. base/jitter define
|
||||
// the per-message delay window in seconds: delay = base + rand.IntN(jitter).
|
||||
func (p *AdventurePlugin) sendCombatMessagesWithDelay(userID id.UserID, phaseMessages []string, finalMessage string, base, jitter int) <-chan struct{} {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for _, msg := range phaseMessages {
|
||||
_ = p.SendDM(userID, msg)
|
||||
delay := 5 + rand.IntN(4) // 5-8 seconds
|
||||
delay := base
|
||||
if jitter > 0 {
|
||||
delay += rand.IntN(jitter)
|
||||
}
|
||||
time.Sleep(time.Duration(delay) * time.Second)
|
||||
}
|
||||
_ = p.SendDM(userID, finalMessage)
|
||||
|
||||
@@ -103,6 +103,9 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat
|
||||
for _, e := range pg.Events {
|
||||
line := renderEvent(e, playerName, enemyName, result, picker)
|
||||
if line != "" {
|
||||
if roll := rollAnnotation(e); roll != "" {
|
||||
line = line + " " + roll
|
||||
}
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
}
|
||||
@@ -125,6 +128,26 @@ func clampHP(hp int) int {
|
||||
return hp
|
||||
}
|
||||
|
||||
// rollAnnotation returns a compact d20 tag for events that came from the
|
||||
// d20-vs-AC resolution path. Empty for ability/heal/consumable events
|
||||
// where Roll is unset. Format: "_(🎲 14 vs AC 13)_" — italicized so the
|
||||
// dice fact reads as a sidebar to the narrative line.
|
||||
func rollAnnotation(e CombatEvent) string {
|
||||
if e.Roll <= 0 {
|
||||
return ""
|
||||
}
|
||||
switch e.Action {
|
||||
case "hit", "crit", "miss", "block":
|
||||
// d20 contests — annotate with roll vs target AC.
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
if e.RollAgainst > 0 {
|
||||
return fmt.Sprintf("_(🎲 %d vs AC %d)_", e.Roll, e.RollAgainst)
|
||||
}
|
||||
return fmt.Sprintf("_(🎲 %d)_", e.Roll)
|
||||
}
|
||||
|
||||
func phaseHeader(name string) string {
|
||||
switch name {
|
||||
case "Opening", "opening":
|
||||
|
||||
@@ -57,7 +57,7 @@ type DnDClassInfo struct {
|
||||
}
|
||||
|
||||
var dndRaces = []DnDRaceInfo{
|
||||
{RaceHuman, "Human", [6]int{0, 0, 0, 0, 0, 0}, "Versatile (floating +1 bonus deferred to Phase 2)"},
|
||||
{RaceHuman, "Human", [6]int{0, 0, 0, 0, 0, 0}, "Versatile (floating +1 bonus not yet implemented)"},
|
||||
{RaceElf, "Elf", [6]int{0, 2, -1, 1, 1, 0}, "Darkvision; immune to sleep effects"},
|
||||
{RaceDwarf, "Dwarf", [6]int{1, -1, 2, 0, 1, -1}, "Poison resistance; bonus vs. underground enemies"},
|
||||
{RaceHalfling, "Halfling", [6]int{0, 2, 0, 0, 1, 0}, "Lucky: once per combat, reroll a natural 1"},
|
||||
|
||||
@@ -145,10 +145,12 @@ func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) erro
|
||||
}
|
||||
}
|
||||
|
||||
// Reaction spells deferred.
|
||||
// Reaction spells deferred — combat is one-shot SimulateCombat, no
|
||||
// per-turn windows where a reaction could trigger. Will land alongside
|
||||
// turn-based combat when that arrives.
|
||||
if spell.Effect == EffectReaction {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"%s is a reaction spell — those land in Phase 11 alongside turn-based boss combat.", spell.Name))
|
||||
"%s is a reaction spell — those aren't usable yet (combat resolves in one beat, no reaction window). Pick a different spell.", spell.Name))
|
||||
}
|
||||
|
||||
// Known + prepared check.
|
||||
|
||||
@@ -487,3 +487,36 @@ func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) {
|
||||
slog.Error("dnd: persist hp after combat", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dndHPSnapshot returns the user's D&D-scale (current, max) HP. Caller
|
||||
// captures pre-combat values via this helper, runs combat (which calls
|
||||
// persistDnDHPAfterCombat internally), then re-snapshots for the post
|
||||
// values. Used so combat outcome narration shows sheet HP rather than
|
||||
// the legacy combat-engine HP scale.
|
||||
func dndHPSnapshot(userID id.UserID) (cur, max int) {
|
||||
c, err := LoadDnDCharacter(userID)
|
||||
if err != nil || c == nil {
|
||||
return 0, 0
|
||||
}
|
||||
return c.HPCurrent, c.HPMax
|
||||
}
|
||||
|
||||
// markAdventureDead flips the legacy adventure_characters.alive flag and
|
||||
// starts the 6h respawn timer for a player who went down in a D&D-layer
|
||||
// combat. Without this, hp_current persists as 0 but the legacy alive
|
||||
// flag stays true — the "zombie" state where !hospital says "you're
|
||||
// alive!" while the sheet shows 0/33. Idempotent: bails if already dead.
|
||||
//
|
||||
// source is "zone" / "expedition" / "patrol"; location is human-readable
|
||||
// (e.g. "Forest of Shadows") and surfaces in the daily report and
|
||||
// standout-loss flavor.
|
||||
func markAdventureDead(userID id.UserID, source, location string) {
|
||||
char, err := loadAdvCharacter(userID)
|
||||
if err != nil || char == nil || !char.Alive {
|
||||
return
|
||||
}
|
||||
char.Kill(source, location)
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("dnd: kill on combat loss", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ type Expedition struct {
|
||||
RegionState map[string]any
|
||||
XPEarned int
|
||||
CoinsEarned int
|
||||
GMMood int
|
||||
DMMood int
|
||||
LastBriefingAt *time.Time
|
||||
LastRecapAt *time.Time
|
||||
LastActivity time.Time
|
||||
@@ -143,6 +143,10 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
if currentRegion != "" {
|
||||
regionState[regionStateVisitedKey] = []string{currentRegion}
|
||||
}
|
||||
startMood := 50
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
startMood = 55
|
||||
}
|
||||
exp := &Expedition{
|
||||
ID: newExpeditionID(),
|
||||
UserID: string(userID),
|
||||
@@ -155,7 +159,7 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
Supplies: supplies,
|
||||
ThreatEvents: []ThreatEvent{},
|
||||
RegionState: regionState,
|
||||
GMMood: 50,
|
||||
DMMood: startMood,
|
||||
LastActivity: now,
|
||||
}
|
||||
supJSON, _ := json.Marshal(supplies)
|
||||
@@ -167,9 +171,9 @@ func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies Exp
|
||||
(expedition_id, user_id, zone_id, run_id, status,
|
||||
start_date, current_day, current_region, supplies_json,
|
||||
threat_events, region_state, gm_mood, last_activity)
|
||||
VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, 50, ?)`,
|
||||
VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, ?, ?)`,
|
||||
exp.ID, exp.UserID, string(zoneID), nullableString(runID),
|
||||
now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), now,
|
||||
now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), startMood, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert expedition: %w", err)
|
||||
}
|
||||
@@ -235,7 +239,7 @@ func scanExpedition(row scanner) (*Expedition, error) {
|
||||
&e.StartDate, &e.CurrentDay, &e.CurrentRegion, &bossI,
|
||||
&suppliesJSON, &campJSON, &e.ThreatLevel, &siegeI,
|
||||
&threatJSON, &e.TemporalStack, ®ionJSON,
|
||||
&e.XPEarned, &e.CoinsEarned, &e.GMMood,
|
||||
&e.XPEarned, &e.CoinsEarned, &e.DMMood,
|
||||
&lastBriefingRaw, &lastRecapRaw, &e.LastActivity, &completedRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -182,7 +182,13 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
}
|
||||
|
||||
zone, _ := getZone(zoneID)
|
||||
supplies := makeSupplies(zone.Tier, purchase)
|
||||
// Holiday perk: a complimentary standard pack is added to the supplies
|
||||
// snapshot without inflating the coin cost.
|
||||
suppliesPurchase := purchase
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
suppliesPurchase.StandardPacks++
|
||||
}
|
||||
supplies := makeSupplies(zone.Tier, suppliesPurchase)
|
||||
|
||||
// Debit coins; bail on debit failure (race / cap).
|
||||
if !p.euro.Debit(ctx.Sender, cost, "expedition outfitting: "+string(zoneID)) {
|
||||
|
||||
@@ -118,10 +118,12 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
_ = SaveDnDCharacter(dndChar)
|
||||
}
|
||||
|
||||
preCombatHP, _ := dndHPSnapshot(userID)
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
var b strings.Builder
|
||||
@@ -147,6 +149,7 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
markAdventureDead(userID, "expedition", zone.Display)
|
||||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
@@ -161,8 +164,8 @@ func (p *AdventurePlugin) runHarvestInterrupt(
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).",
|
||||
monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).",
|
||||
monster.Name, preCombatHP, postHP, maxHP))
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
@@ -332,56 +335,84 @@ func rollPatrolChance(threatLevel int) float64 {
|
||||
// tryPatrolEncounter is called from zoneCmdAdvance *before* the next room
|
||||
// resolves. If the player's active expedition is at Alert+, we roll for a
|
||||
// patrol; on hit, we run a single combat against a non-elite roster entry.
|
||||
// Returns narration (empty if no patrol), endedRun (player KO), and any
|
||||
// error to surface.
|
||||
//
|
||||
// Returns staged messages so the patrol fight gets the same 2–3s pacing
|
||||
// as room/boss combat — see resolveCombatRoom for the contract. When no
|
||||
// patrol fires, intro/phases/outcome are all empty and ended is false.
|
||||
func (p *AdventurePlugin) tryPatrolEncounter(
|
||||
userID id.UserID,
|
||||
run *DungeonRun,
|
||||
zone ZoneDefinition,
|
||||
) (string, bool, error) {
|
||||
exp, err := getActiveExpedition(userID)
|
||||
if err != nil || exp == nil {
|
||||
return "", false, nil
|
||||
) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
exp, gerr := getActiveExpedition(userID)
|
||||
if gerr != nil || exp == nil {
|
||||
return
|
||||
}
|
||||
if exp.RunID != run.RunID {
|
||||
return "", false, nil
|
||||
return
|
||||
}
|
||||
chance := rollPatrolChance(exp.ThreatLevel)
|
||||
if chance <= 0 || rand.Float64() > chance {
|
||||
return "", false, nil
|
||||
return
|
||||
}
|
||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, false)
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
return
|
||||
}
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
return
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(flavor.Pick(flavor.PatrolEncounter))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(fmt.Sprintf("🛡 **Patrol — %s** (HP %d, AC %d)\n",
|
||||
// Intro: patrol-encounter flavor + creature stat block.
|
||||
var ib strings.Builder
|
||||
ib.WriteString(flavor.Pick(flavor.PatrolEncounter))
|
||||
ib.WriteString("\n")
|
||||
ib.WriteString(fmt.Sprintf("🛡 **Patrol — %s** (HP %d, AC %d)",
|
||||
monster.Name, monster.HP, monster.AC))
|
||||
intro = ib.String()
|
||||
|
||||
// Phases: forward-simulating engine play-by-play.
|
||||
playerName := "You"
|
||||
if char, _ := loadAdvCharacter(userID); char != nil && char.DisplayName != "" {
|
||||
playerName = char.DisplayName
|
||||
}
|
||||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||||
|
||||
var ob strings.Builder
|
||||
if !result.PlayerWon {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
_ = retireAllRegionRuns(exp)
|
||||
markAdventureDead(userID, "patrol", zone.Display)
|
||||
if line := flavor.Pick(flavor.PlayerDeath); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💀 The patrol takes you down. Run ended."))
|
||||
return b.String(), true, nil
|
||||
ob.WriteString("💀 The patrol takes you down. Run ended.")
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
outcome = ob.String()
|
||||
ended = true
|
||||
return
|
||||
}
|
||||
_ = recordZoneKill(exp, monster.ID)
|
||||
b.WriteString(fmt.Sprintf("✅ Patrol dispatched (HP %d→%d).",
|
||||
result.PlayerStartHP, result.PlayerEndHP))
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
ob.WriteString(fmt.Sprintf("✅ Patrol dispatched (HP %d→%d / %d).",
|
||||
preHP, postHP, maxHP))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
return b.String(), false, nil
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(drop)
|
||||
}
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -339,16 +339,24 @@ func currentRoomCleared(run *DungeonRun) bool {
|
||||
// handleHarvestCmd is the shared dispatcher for !forage/!mine/!scavenge/
|
||||
// !essence/!commune. Resolves a single attempt and DMs the outcome.
|
||||
func (p *AdventurePlugin) handleHarvestCmd(ctx MessageContext, action HarvestAction) error {
|
||||
char, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil || char == nil {
|
||||
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
|
||||
}
|
||||
exp, err := getActiveExpedition(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't load expedition state.")
|
||||
}
|
||||
if exp == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not on an expedition. Start one with `!expedition start <zone>`.")
|
||||
}
|
||||
char, err := LoadDnDCharacter(ctx.Sender)
|
||||
if err != nil || char == nil {
|
||||
return p.SendDM(ctx.Sender, "No D&D character found. Run `!setup` first.")
|
||||
// No expedition: fall through to single-session harvest if the
|
||||
// player has an active !zone enter run. Single-session harvest
|
||||
// lives in dnd_zone_harvest.go and stores nodes per-run rather
|
||||
// than per-region.
|
||||
run, _ := getActiveZoneRun(ctx.Sender)
|
||||
if run == nil {
|
||||
return p.SendDM(ctx.Sender, "You're not in a zone or expedition. Use `!zone enter <id>` or `!expedition start <zone>`.")
|
||||
}
|
||||
return p.handleStandaloneHarvest(ctx, char, action, run)
|
||||
}
|
||||
if action == HarvestFish && !isFishingZone(exp.ZoneID) {
|
||||
return p.SendDM(ctx.Sender, "There's no water to fish in here. `!fish` works only in Forest of Shadows, Sunken Temple, Underdark, or Feywild Crossing.")
|
||||
@@ -582,6 +590,9 @@ func grantHarvestYield(userID id.UserID, res ZoneResource, qty int) error {
|
||||
if qty <= 0 {
|
||||
return nil
|
||||
}
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
qty++
|
||||
}
|
||||
tier := zoneTierFromID(res.ZoneID)
|
||||
for i := 0; i < qty; i++ {
|
||||
item := AdvItem{
|
||||
|
||||
@@ -553,7 +553,7 @@ func TestDragonsLair_AwarenessPulseFiresEveryThreeDays(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := getExpedition(exp.ID)
|
||||
// +10 awareness pulse + ~3 daily threat drift = 13 (mod GMMood 50 = neutral).
|
||||
// +10 awareness pulse + ~3 daily threat drift = 13 (mod DMMood 50 = neutral).
|
||||
if got.ThreatLevel < prevThreat+10 {
|
||||
t.Errorf("threat = %d, want ≥ %d (awareness pulse +10)", got.ThreatLevel, prevThreat+10)
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ func TestStartExpedition_RoundTrip(t *testing.T) {
|
||||
if got.Supplies.Current != 10 || got.Supplies.DailyBurn != 1 {
|
||||
t.Errorf("supplies round-trip wrong: %+v", got.Supplies)
|
||||
}
|
||||
if got.GMMood != 50 {
|
||||
t.Errorf("gm_mood = %d", got.GMMood)
|
||||
if got.DMMood != 50 {
|
||||
t.Errorf("gm_mood = %d", got.DMMood)
|
||||
}
|
||||
if got.Camp != nil {
|
||||
t.Errorf("expected no camp at start")
|
||||
|
||||
@@ -81,18 +81,18 @@ func threatBandInfo(b ThreatBand) ThreatBandInfo {
|
||||
return ThreatBandInfo{Band: ThreatBandQuiet, Label: "Quiet", SupplyMult: 1}
|
||||
}
|
||||
|
||||
// dailyThreatDrift is the +3/day base (§8.1) plus the GMMood-driven mod
|
||||
// dailyThreatDrift is the +3/day base (§8.1) plus the DMMood-driven mod
|
||||
// (Wrathful +5, Elated -3). Mood bands map: effusive (≥80) → Elated,
|
||||
// hostile (<20) → Wrathful, the middle three are neutral.
|
||||
func dailyThreatDrift(gmMood int) (int, string) {
|
||||
func dailyThreatDrift(dmMood int) (int, string) {
|
||||
base := 3
|
||||
mod := 0
|
||||
tag := ""
|
||||
switch {
|
||||
case gmMood >= 80:
|
||||
case dmMood >= 80:
|
||||
mod = -3
|
||||
tag = "elated"
|
||||
case gmMood < 20:
|
||||
case dmMood < 20:
|
||||
mod = 5
|
||||
tag = "wrathful"
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func applyDailyThreatDrift(e *Expedition) (int, string, error) {
|
||||
if e.BossDefeated {
|
||||
return 0, "", nil
|
||||
}
|
||||
delta, reason := dailyThreatDrift(e.GMMood)
|
||||
delta, reason := dailyThreatDrift(e.DMMood)
|
||||
if delta == 0 {
|
||||
return 0, reason, nil
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ func renderSetupComplete(c *DnDCharacter) string {
|
||||
" HP %d/%d AC %d\n"+
|
||||
" STR %d DEX %d CON %d INT %d WIS %d CHA %d\n\n"+
|
||||
"_%s_\n\n"+
|
||||
"Use `!sheet` anytime to review. Combat, abilities, and rest mechanics arrive in the next phases.",
|
||||
"Use `!sheet` anytime to review. `!zone list` to head out, or `!expedition list` for a longer run.",
|
||||
c.Level, ri.Display, ci.Display,
|
||||
c.HPCurrent, c.HPMax, c.ArmorClass,
|
||||
c.STR, c.DEX, c.CON, c.INT, c.WIS, c.CHA,
|
||||
|
||||
@@ -174,6 +174,5 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[Equipmen
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString("\n_Combat, abilities, and rest mechanics arrive in upcoming phases._")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -150,7 +150,6 @@ func (p *AdventurePlugin) applySubclassChoice(
|
||||
b.WriteString(fmt.Sprintf("_%d euros spent. Cooldown: %dd before next change._\n\n",
|
||||
dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour))))
|
||||
}
|
||||
b.WriteString("_" + info.Flavor + "_\n\n")
|
||||
b.WriteString("_(Mechanical L5/7/10/15 subclass abilities land in upcoming phases.)_")
|
||||
b.WriteString("_" + info.Flavor + "_")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// D1a (this file) ships zone *definitions* only — names, tiers, level
|
||||
// ranges, enemy rosters by bestiary ID, boss stat block, and loot stubs.
|
||||
// State machine (DungeonRun), commands (!zone enter/advance/etc), and
|
||||
// TwinBee GM mood land in subsequent D1 sub-phases (D1b–D1d).
|
||||
// TwinBee DM mood land in subsequent D1 sub-phases (D1b–D1d).
|
||||
//
|
||||
// Tier 1 ships first (Goblin Warrens, Crypt of Valdris). Tiers 2–5 are
|
||||
// added in D2/D3/D4/D5. Loot table item IDs reference equipment/treasure
|
||||
|
||||
@@ -197,11 +197,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms))
|
||||
b.WriteString("_" + zone.Hook + "_\n\n")
|
||||
if line := twinBeeLine(zone.ID, GMRoomEntry, run.RunID, run.CurrentRoom); line != "" {
|
||||
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if aside := moodAsideLine(run.GMMood, run.RunID, run.CurrentRoom); aside != "" {
|
||||
if aside := moodAsideLine(run.DMMood, run.RunID, run.CurrentRoom); aside != "" {
|
||||
b.WriteString(aside)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
@@ -225,18 +225,18 @@ func (p *AdventurePlugin) zoneCmdStatus(ctx MessageContext) error {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("**%s** — room %d/%d (%s)\n",
|
||||
zone.Display, run.CurrentRoom+1, run.TotalRooms, prettyRoomType(run.CurrentRoomType())))
|
||||
b.WriteString(fmt.Sprintf("Cleared: %d Loot: %d GM mood: %d/100 (%s)\n",
|
||||
len(run.RoomsCleared), len(run.LootCollected), run.GMMood, gmMoodLabel(run.GMMood)))
|
||||
b.WriteString(fmt.Sprintf("Cleared: %d Loot: %d DM mood: %d/100 (%s)\n",
|
||||
len(run.RoomsCleared), len(run.LootCollected), run.DMMood, dmMoodLabel(run.DMMood)))
|
||||
b.WriteString(fmt.Sprintf("Started: %s Last action: %s",
|
||||
run.StartedAt.Format("2006-01-02 15:04"),
|
||||
run.LastActionAt.Format("2006-01-02 15:04")))
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
// gmMoodLabel returns a human-friendly label for the mood gauge per
|
||||
// dmMoodLabel returns a human-friendly label for the mood gauge per
|
||||
// design doc §3.2 mood bands (≥80 effusive, 60–79 friendly, 40–59 neutral,
|
||||
// 20–39 grumpy, <20 hostile).
|
||||
func gmMoodLabel(mood int) string {
|
||||
func dmMoodLabel(mood int) string {
|
||||
switch {
|
||||
case mood >= 80:
|
||||
return "effusive"
|
||||
@@ -361,22 +361,44 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
// §4.1 Patrol Encounter: at Threat-Clock Alert+, patrols may move
|
||||
// through cleared rooms. Roll on `!advance` *before* the next room's
|
||||
// own resolution. Player KO ends the run.
|
||||
patrolNarr, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone)
|
||||
//
|
||||
// Patrol-then-room is chained into a single 2–3s-paced stream so the
|
||||
// player reads patrol → patrol play-by-play → patrol resolution →
|
||||
// room intro → room play-by-play → final, in one continuous beat.
|
||||
patrolIntro, patrolPhases, patrolOutcome, patrolEnded, perr := p.tryPatrolEncounter(ctx.Sender, run, zone)
|
||||
if perr != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't resolve patrol: "+perr.Error())
|
||||
}
|
||||
var preStream []string
|
||||
if patrolPhases != nil {
|
||||
if patrolIntro != "" {
|
||||
preStream = append(preStream, patrolIntro)
|
||||
}
|
||||
preStream = append(preStream, patrolPhases...)
|
||||
}
|
||||
if patrolEnded {
|
||||
return p.SendDM(ctx.Sender, patrolNarr)
|
||||
// Patrol dropped the player; run is over.
|
||||
return p.streamFlow(ctx.Sender, preStream, patrolOutcome)
|
||||
}
|
||||
// Patrol survived (or didn't fire). If it fired, patrolOutcome becomes
|
||||
// a streamed midpoint between the patrol fight and the room resolver.
|
||||
if patrolPhases != nil && patrolOutcome != "" {
|
||||
preStream = append(preStream, patrolOutcome)
|
||||
}
|
||||
|
||||
// Resolve the current room *before* clearing it, so combat results
|
||||
// can decide whether the player advances or the run ends.
|
||||
resolution, ended, err := p.resolveRoom(ctx.Sender, run, zone)
|
||||
// can decide whether the player advances or the run ends. Combat
|
||||
// rooms return a non-nil phases slice — those get streamed with
|
||||
// 2–3s delays for suspense; non-combat rooms collapse into a single
|
||||
// SendDM as before.
|
||||
intro, phases, outcome, ended, err := p.resolveRoom(ctx.Sender, run, zone)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't resolve room: "+err.Error())
|
||||
}
|
||||
if ended {
|
||||
return p.SendDM(ctx.Sender, resolution)
|
||||
// Death (combat or otherwise). Stream phases if present, then the
|
||||
// death narration as the final message.
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, outcome)
|
||||
}
|
||||
|
||||
next, err := markRoomCleared(run.RunID)
|
||||
@@ -386,37 +408,28 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
if next == "" {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventZoneComplete)
|
||||
var b strings.Builder
|
||||
if patrolNarr != "" {
|
||||
b.WriteString(patrolNarr)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if resolution != "" {
|
||||
b.WriteString(resolution)
|
||||
if outcome != "" {
|
||||
b.WriteString(outcome)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("🏆 **Cleared %s.** Run complete.\n\n", zone.Display))
|
||||
if line := twinBeeLine(zone.ID, GMZoneComplete, run.RunID, prevIdx); line != "" {
|
||||
if line := twinBeeLine(zone.ID, DMZoneComplete, run.RunID, prevIdx); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
// Drop the zone loot table on boss kill.
|
||||
if granted := p.rollZoneLoot(ctx.Sender, run, zone); len(granted) > 0 {
|
||||
b.WriteString("**Loot:**\n")
|
||||
for _, id := range granted {
|
||||
b.WriteString("• " + id + "\n")
|
||||
}
|
||||
}
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
|
||||
}
|
||||
|
||||
nextIdx := run.CurrentRoom + 1
|
||||
var b strings.Builder
|
||||
if patrolNarr != "" {
|
||||
b.WriteString(patrolNarr)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if resolution != "" {
|
||||
b.WriteString(resolution)
|
||||
if outcome != "" {
|
||||
b.WriteString(outcome)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✓ Cleared room %d (%s).\n\n", prevIdx+1, prettyRoomType(prev)))
|
||||
@@ -426,16 +439,16 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
} else {
|
||||
if line := twinBeeLine(zone.ID, GMRoomEntry, run.RunID, nextIdx); line != "" {
|
||||
if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, nextIdx); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
// Reload mood — combat-event nat20/nat1 deltas have been persisted via
|
||||
// scanMoodEventsFromCombat but not reflected on the in-memory run.
|
||||
freshMood := run.GMMood
|
||||
freshMood := run.DMMood
|
||||
if fresh, _ := getZoneRun(run.RunID); fresh != nil {
|
||||
freshMood = fresh.GMMood
|
||||
freshMood = fresh.DMMood
|
||||
}
|
||||
if aside := moodAsideLine(freshMood, run.RunID, nextIdx); aside != "" {
|
||||
b.WriteString(aside)
|
||||
@@ -443,20 +456,58 @@ func (p *AdventurePlugin) zoneCmdAdvance(ctx MessageContext) error {
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("**Room %d/%d — %s.** ", nextIdx+1, run.TotalRooms, prettyRoomType(next)))
|
||||
b.WriteString("`!zone advance` to continue.")
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
return p.streamOrSend(ctx.Sender, preStream, intro, phases, b.String())
|
||||
}
|
||||
|
||||
// resolveRoom dispatches to the per-room-type resolver. Returns the
|
||||
// resolution narration, an `ended` flag (true when the run ended due to
|
||||
// player death — caller should send the narration and stop), and any
|
||||
// error encountered. Entry rooms are pure flavor and resolve trivially.
|
||||
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (string, bool, error) {
|
||||
// streamOrSend dispatches a staged room resolution. When the room produced
|
||||
// combat phases (or there was a paced patrol pre-stream), the messages get
|
||||
// streamed with 2–3s delays. Otherwise everything collapses into a single
|
||||
// SendDM. preStream is non-empty when a patrol fight ran ahead of the
|
||||
// current room and its play-by-play needs to lead the dispatch.
|
||||
func (p *AdventurePlugin) streamOrSend(userID id.UserID, preStream []string, intro string, phases []string, final string) error {
|
||||
if phases == nil && len(preStream) == 0 {
|
||||
var b strings.Builder
|
||||
if intro != "" {
|
||||
b.WriteString(intro)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(final)
|
||||
return p.SendDM(userID, b.String())
|
||||
}
|
||||
msgs := append([]string{}, preStream...)
|
||||
if intro != "" {
|
||||
msgs = append(msgs, intro)
|
||||
}
|
||||
if phases != nil {
|
||||
msgs = append(msgs, phases...)
|
||||
}
|
||||
return p.streamFlow(userID, msgs, final)
|
||||
}
|
||||
|
||||
// streamFlow ships a list of phase messages followed by a final message,
|
||||
// with the zone-combat 2–3s delay pacing. Single entry point for both the
|
||||
// patrol-and-die path and the patrol-leading-into-room path.
|
||||
func (p *AdventurePlugin) streamFlow(userID id.UserID, phaseMessages []string, finalMessage string) error {
|
||||
if len(phaseMessages) == 0 {
|
||||
return p.SendDM(userID, finalMessage)
|
||||
}
|
||||
p.sendZoneCombatMessages(userID, phaseMessages, finalMessage)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRoom dispatches to the per-room-type resolver. Returns staged
|
||||
// messages (intro, phases, outcome) so combat rooms can be paced with
|
||||
// inter-phase delays — see resolveCombatRoom for the contract. For
|
||||
// non-combat rooms (entry, trap), phases is nil and outcome carries the
|
||||
// resolution narration.
|
||||
func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
switch run.CurrentRoomType() {
|
||||
case RoomEntry:
|
||||
return "", false, nil
|
||||
return
|
||||
case RoomTrap:
|
||||
_, narration := p.resolveTrapRoom(userID, run, zone)
|
||||
return narration, false, nil
|
||||
outcome = narration
|
||||
return
|
||||
case RoomExploration:
|
||||
return p.resolveCombatRoom(userID, run, zone, false)
|
||||
case RoomElite:
|
||||
@@ -464,135 +515,197 @@ func (p *AdventurePlugin) resolveRoom(userID id.UserID, run *DungeonRun, zone Zo
|
||||
case RoomBoss:
|
||||
return p.resolveBossRoom(userID, run, zone)
|
||||
}
|
||||
return "", false, nil
|
||||
return
|
||||
}
|
||||
|
||||
// resolveCombatRoom spawns one roster enemy (elite filter optional),
|
||||
// runs combat, persists side effects, fires nat-1/nat-20 mood deltas,
|
||||
// and renders the narration block. Returns ended=true on player loss.
|
||||
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite bool) (string, bool, error) {
|
||||
// and renders the staged narration. Returns:
|
||||
// intro — pre-combat block (TwinBee combat-start + monster stat block)
|
||||
// phases — RenderCombatLog output, streamed with delays by the caller
|
||||
// outcome — post-combat block: nat20/nat1 flavor, kill line, loot, d20 summary
|
||||
// ended — true when the player went down (caller skips next-room teaser)
|
||||
//
|
||||
// Phases will be nil only on a "no roster" skip — caller treats that as a
|
||||
// non-paced fallthrough.
|
||||
func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition, elite bool) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
monster, ok := pickZoneEnemy(zone, run.RunID, run.CurrentRoom, elite)
|
||||
if !ok {
|
||||
return fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite]), false, nil
|
||||
outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite])
|
||||
return
|
||||
}
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
// Capture D&D-scale HP before combat so the outcome line can show
|
||||
// sheet HP rather than the engine's legacy-scale numbers.
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
return
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
var b strings.Builder
|
||||
// Intro: pre-combat narration + creature stat block. This lands first,
|
||||
// before the 5–8s phase pacing kicks in.
|
||||
var ib strings.Builder
|
||||
if elite {
|
||||
if line := eliteRoomEntryLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
ib.WriteString(line)
|
||||
ib.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, GMCombatStart, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
// Surface the most-impactful crit/fumble as TwinBee narration. A run
|
||||
// with both gets the nat-20 line — players already see the fumble in
|
||||
// the combat log, but the gloat lands harder than the pity.
|
||||
if nat20s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
} else if nat1s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, DMCombatStart, run.RunID, run.CurrentRoom); line != "" {
|
||||
ib.WriteString(line)
|
||||
ib.WriteString("\n")
|
||||
}
|
||||
if elite {
|
||||
b.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||
ib.WriteString(fmt.Sprintf("⚔️ **Elite encounter — %s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||
ib.WriteString(fmt.Sprintf("⚔️ **%s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC))
|
||||
}
|
||||
intro = ib.String()
|
||||
|
||||
// Phases: forward-simulating engine play-by-play. Use the player's
|
||||
// display name when available so narrative lines read naturally.
|
||||
playerName := "You"
|
||||
if char, _ := loadAdvCharacter(userID); char != nil && char.DisplayName != "" {
|
||||
playerName = char.DisplayName
|
||||
}
|
||||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||||
|
||||
// Outcome: post-combat block. Nat20/nat1 narration goes here so it
|
||||
// lands *after* the play-by-play, where it has dramatic context.
|
||||
var ob strings.Builder
|
||||
if nat20s > 0 {
|
||||
if line := twinBeeLine(zone.ID, DMNat20, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
} else if nat1s > 0 {
|
||||
if line := twinBeeLine(zone.ID, DMNat1, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if !result.PlayerWon {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
if line := twinBeeLine(zone.ID, GMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
|
||||
return b.String(), true, nil
|
||||
ob.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
outcome = ob.String()
|
||||
ended = true
|
||||
return
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, GMCombatEnd, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
if line := twinBeeLine(zone.ID, DMCombatEnd, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
ob.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||
recordZoneKillForUser(userID, monster.ID)
|
||||
applyRoomCombatThreatForUser(userID, elite)
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, false); drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(drop)
|
||||
}
|
||||
return b.String(), false, nil
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
|
||||
// resolveBossRoom runs the zone-boss bestiary entry through the same
|
||||
// combat path as room combat. Win → caller drops zone loot.
|
||||
func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (string, bool, error) {
|
||||
//
|
||||
// Returns staged messages — see resolveCombatRoom for the contract.
|
||||
func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zone ZoneDefinition) (intro string, phases []string, outcome string, ended bool, err error) {
|
||||
monster, ok := dndBestiary[zone.Boss.BestiaryID]
|
||||
if !ok {
|
||||
return fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name), false, nil
|
||||
outcome = fmt.Sprintf("_(Boss %s not in bestiary — skipping.)_", zone.Boss.Name)
|
||||
return
|
||||
}
|
||||
result, err := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
preHP, _ := dndHPSnapshot(userID)
|
||||
result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier))
|
||||
if rerr != nil {
|
||||
err = rerr
|
||||
return
|
||||
}
|
||||
postHP, maxHP := dndHPSnapshot(userID)
|
||||
nat20s, nat1s := scanMoodEventsFromCombat(run.RunID, result)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)\n", monster.Name, monster.HP, monster.AC))
|
||||
intro = fmt.Sprintf("👑 **Boss — %s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC)
|
||||
|
||||
playerName := "You"
|
||||
if char, _ := loadAdvCharacter(userID); char != nil && char.DisplayName != "" {
|
||||
playerName = char.DisplayName
|
||||
}
|
||||
phases = RenderCombatLog(result, playerName, monster.Name)
|
||||
|
||||
var ob strings.Builder
|
||||
if nat20s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat20, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
if line := twinBeeLine(zone.ID, DMNat20, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
} else if nat1s > 0 {
|
||||
if line := twinBeeLine(zone.ID, GMNat1, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
if line := twinBeeLine(zone.ID, DMNat1, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if phaseTwoCrossedInEvents(result.Events, result.EnemyStartHP, zone.Boss.PhaseTwoAt) {
|
||||
if line := bossPhaseTwoLine(zone.ID, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
}
|
||||
if !result.PlayerWon {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath)
|
||||
_ = abandonZoneRun(userID)
|
||||
if line := twinBeeLine(zone.ID, GMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
markAdventureDead(userID, "zone", zone.Display)
|
||||
if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name))
|
||||
return b.String(), true, nil
|
||||
ob.WriteString(fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
outcome = ob.String()
|
||||
ended = true
|
||||
return
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, GMBossDeath, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
if line := twinBeeLine(zone.ID, DMBossDeath, run.RunID, run.CurrentRoom); line != "" {
|
||||
ob.WriteString(line)
|
||||
ob.WriteString("\n")
|
||||
}
|
||||
ob.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP))
|
||||
if rollLine := dndRollSummaryLine(result); rollLine != "" {
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(rollLine)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("🏆 **%s** falls (HP %d→%d).", monster.Name, result.PlayerStartHP, result.PlayerEndHP))
|
||||
recordZoneKillForUser(userID, monster.ID)
|
||||
// §8.1: zone boss defeat drops expedition threat by 20. Silent
|
||||
// no-op for standalone zone runs (no active expedition).
|
||||
if exp, err := getActiveExpedition(userID); err == nil && exp != nil {
|
||||
if exp, eerr := getActiveExpedition(userID); eerr == nil && exp != nil {
|
||||
_ = applyBossDefeatThreat(exp.ID)
|
||||
}
|
||||
if drop := p.dropZoneLoot(userID, zone.ID, monster, true); drop != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(drop)
|
||||
ob.WriteString("\n")
|
||||
ob.WriteString(drop)
|
||||
}
|
||||
return b.String(), false, nil
|
||||
outcome = ob.String()
|
||||
return
|
||||
}
|
||||
|
||||
// ── taunt / compliment ──────────────────────────────────────────────────────
|
||||
@@ -610,7 +723,7 @@ func (p *AdventurePlugin) zoneCmdCompliment(ctx MessageContext) error {
|
||||
// from the prewritten pool, and report the new mood band.
|
||||
func (p *AdventurePlugin) zoneMoodInteraction(
|
||||
ctx MessageContext,
|
||||
ev GMMoodEvent,
|
||||
ev DMMoodEvent,
|
||||
render func(runID string, roomIdx int) string,
|
||||
icon string,
|
||||
) error {
|
||||
@@ -637,8 +750,8 @@ func (p *AdventurePlugin) zoneMoodInteraction(
|
||||
sign = "−"
|
||||
delta = -delta
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("%s GM mood: %d/100 (%s) _(%s%d)_",
|
||||
icon, newMood, gmMoodLabel(newMood), sign, delta))
|
||||
b.WriteString(fmt.Sprintf("%s DM mood: %d/100 (%s) _(%s%d)_",
|
||||
icon, newMood, dmMoodLabel(newMood), sign, delta))
|
||||
return p.SendDM(ctx.Sender, b.String())
|
||||
}
|
||||
|
||||
@@ -659,7 +772,7 @@ func (p *AdventurePlugin) zoneCmdLore(ctx MessageContext) error {
|
||||
return p.SendDM(ctx.Sender, "No active zone run. Use `!zone enter <id>`.")
|
||||
}
|
||||
zone := zoneOrFallback(run.ZoneID)
|
||||
line := twinBeeLine(zone.ID, GMLore, run.RunID, run.CurrentRoom)
|
||||
line := twinBeeLine(zone.ID, DMLore, run.RunID, run.CurrentRoom)
|
||||
if line == "" {
|
||||
return p.SendDM(ctx.Sender, "TwinBee has nothing to say about this place.")
|
||||
}
|
||||
|
||||
@@ -239,8 +239,8 @@ func TestGMMoodLabel_Bands(t *testing.T) {
|
||||
0: "hostile",
|
||||
}
|
||||
for mood, want := range cases {
|
||||
if got := gmMoodLabel(mood); got != want {
|
||||
t.Errorf("gmMoodLabel(%d) = %s, want %s", mood, got, want)
|
||||
if got := dmMoodLabel(mood); got != want {
|
||||
t.Errorf("dmMoodLabel(%d) = %s, want %s", mood, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,7 +260,7 @@ func TestZoneCmd_TauntAppliesMoodPenalty(t *testing.T) {
|
||||
if err != nil || before == nil {
|
||||
t.Fatalf("active run after enter: %v", err)
|
||||
}
|
||||
startMood := before.GMMood
|
||||
startMood := before.DMMood
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "taunt"); err != nil {
|
||||
t.Fatalf("taunt: %v", err)
|
||||
}
|
||||
@@ -269,9 +269,9 @@ func TestZoneCmd_TauntAppliesMoodPenalty(t *testing.T) {
|
||||
t.Fatalf("active run after taunt: %v", err)
|
||||
}
|
||||
want := clampMood(startMood + MoodEventDelta(MoodEventTaunt))
|
||||
if after.GMMood != want {
|
||||
if after.DMMood != want {
|
||||
t.Errorf("mood after taunt = %d, want %d (start %d, delta %d)",
|
||||
after.GMMood, want, startMood, MoodEventDelta(MoodEventTaunt))
|
||||
after.DMMood, want, startMood, MoodEventDelta(MoodEventTaunt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ func TestZoneCmd_ComplimentAppliesMoodBonus(t *testing.T) {
|
||||
if err != nil || before == nil {
|
||||
t.Fatalf("active run after enter: %v", err)
|
||||
}
|
||||
startMood := before.GMMood
|
||||
startMood := before.DMMood
|
||||
if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "compliment"); err != nil {
|
||||
t.Fatalf("compliment: %v", err)
|
||||
}
|
||||
@@ -297,9 +297,9 @@ func TestZoneCmd_ComplimentAppliesMoodBonus(t *testing.T) {
|
||||
t.Fatalf("active run after compliment: %v", err)
|
||||
}
|
||||
want := clampMood(startMood + MoodEventDelta(MoodEventCompliment))
|
||||
if after.GMMood != want {
|
||||
if after.DMMood != want {
|
||||
t.Errorf("mood after compliment = %d, want %d (start %d, delta %d)",
|
||||
after.GMMood, want, startMood, MoodEventDelta(MoodEventCompliment))
|
||||
after.DMMood, want, startMood, MoodEventDelta(MoodEventCompliment))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,8 +351,8 @@ func TestZoneCmd_LoreWithActiveRunNoSideEffects(t *testing.T) {
|
||||
if err != nil || after == nil {
|
||||
t.Fatalf("active run after lore: %v", err)
|
||||
}
|
||||
if after.GMMood != before.GMMood {
|
||||
t.Errorf("lore should not move mood: before %d, after %d", before.GMMood, after.GMMood)
|
||||
if after.DMMood != before.DMMood {
|
||||
t.Errorf("lore should not move mood: before %d, after %d", before.DMMood, after.DMMood)
|
||||
}
|
||||
if after.CurrentRoom != before.CurrentRoom {
|
||||
t.Errorf("lore should not advance room: before %d, after %d", before.CurrentRoom, after.CurrentRoom)
|
||||
@@ -393,14 +393,14 @@ func TestRenderZoneMap_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNarrationCoverage_AllZonesAllTypes — D6 polish guarantee. Locks in
|
||||
// that every GMNarrationType resolves to a non-empty pool for every
|
||||
// registered zone. Adding a new zone or new GMNarrationType without
|
||||
// that every DMNarrationType resolves to a non-empty pool for every
|
||||
// registered zone. Adding a new zone or new DMNarrationType without
|
||||
// wiring its pool will fail this test.
|
||||
func TestNarrationCoverage_AllZonesAllTypes(t *testing.T) {
|
||||
types := []GMNarrationType{
|
||||
GMRoomEntry, GMCombatStart, GMCombatEnd, GMNat20, GMNat1,
|
||||
GMBossEntry, GMBossDeath, GMPlayerDeath, GMZoneComplete,
|
||||
GMTrapDetected, GMTrapTripped, GMLore,
|
||||
types := []DMNarrationType{
|
||||
DMRoomEntry, DMCombatStart, DMCombatEnd, DMNat20, DMNat1,
|
||||
DMBossEntry, DMBossDeath, DMPlayerDeath, DMZoneComplete,
|
||||
DMTrapDetected, DMTrapTripped, DMLore,
|
||||
}
|
||||
for _, z := range allZones() {
|
||||
for _, k := range types {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
@@ -17,7 +18,7 @@ import (
|
||||
// into a fight" — spawning enemies from the zone roster, running them
|
||||
// through the existing combat engine with the player's full D&D layer,
|
||||
// awarding XP per kill and zone loot on boss defeat, and ending the run
|
||||
// (mood penalty + GMPlayerDeath narration) if the player goes down.
|
||||
// (mood penalty + DMPlayerDeath narration) if the player goes down.
|
||||
//
|
||||
// Per-room behavior:
|
||||
// Entry → no combat (already narrated on enter)
|
||||
@@ -178,6 +179,15 @@ func (p *AdventurePlugin) runZoneCombat(
|
||||
|
||||
result := SimulateCombat(player, enemy, dungeonCombatPhases)
|
||||
|
||||
// Misty condition repair (post-combat, same 20% chance as arena/encounter
|
||||
// paths in combat_bridge.go). Mirrors the buff's intent — gourmet food
|
||||
// keeps her gear in shape on long expeditions, not just in the arena.
|
||||
if char.MistyBuffExpires != nil && time.Now().UTC().Before(*char.MistyBuffExpires) {
|
||||
if rand.Float64() < 0.20 {
|
||||
npcRepairMostDamaged(userID, equip, 5)
|
||||
}
|
||||
}
|
||||
|
||||
p.grantCombatAchievements(userID, result)
|
||||
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
|
||||
if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil {
|
||||
@@ -286,11 +296,15 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect(
|
||||
detect SkillCheckResult,
|
||||
) (int, string) {
|
||||
var b strings.Builder
|
||||
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if detect.Success {
|
||||
// Detected: the TwinBee "you spotted it" line only fires here, so a
|
||||
// failed detection no longer contradicts the next line. The mismatch
|
||||
// where "Perception roll pays off" preceded a tripped trap was
|
||||
// caused by this line firing unconditionally.
|
||||
if line := twinBeeLine(zone.ID, DMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(trapSpottedHeader(trap, detect))
|
||||
return 0, b.String()
|
||||
}
|
||||
@@ -308,10 +322,10 @@ func (p *AdventurePlugin) applyTrapEffectWithDetect(
|
||||
slog.Error("zone: trap HP persist", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
// Trip flavor lives in trap.Trigger (mechanism-specific) which the
|
||||
// damage header now surfaces. The generic DMTrapTripped pool is no
|
||||
// longer pulled here — it mixed dart/ceiling/pit/glyph lines that
|
||||
// frequently mismatched the actual trap.
|
||||
b.WriteString(trapDamageHeader(trap, dmg, dndChar.HPCurrent, dndChar.HPMax))
|
||||
return dmg, b.String()
|
||||
}
|
||||
@@ -336,15 +350,12 @@ func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRu
|
||||
if err := SaveDnDCharacter(dndChar); err != nil {
|
||||
slog.Error("zone: trap HP persist (legacy)", "user", userID, "err", err)
|
||||
}
|
||||
// Legacy path always trips — no detection check. Drop the TwinBee pool
|
||||
// picks here for the same reason as applyTrapEffectWithDetect: the
|
||||
// detected line shouldn't fire when nothing was detected, and the
|
||||
// generic tripped pool routinely mismatches the actual trap. Plain
|
||||
// damage line carries the result.
|
||||
var b strings.Builder
|
||||
if line := twinBeeLine(zone.ID, GMTrapDetected, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if line := twinBeeLine(zone.ID, GMTrapTripped, run.RunID, run.CurrentRoom); line != "" {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("💢 The trap takes **%d HP** (%d/%d remaining).", dmg, dndChar.HPCurrent, dndChar.HPMax))
|
||||
return dmg, b.String()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gogobee/internal/flavor"
|
||||
)
|
||||
|
||||
// Phase 11 D1d — TwinBee narration + GM mood triggers. Implements
|
||||
// Phase 11 D1d — TwinBee narration + DM mood triggers. Implements
|
||||
// `gogobee_dungeon_zones.md` §3 (TwinBee GM System) on top of the D1b
|
||||
// state machine.
|
||||
//
|
||||
@@ -23,22 +23,22 @@ import (
|
||||
// surfaces as the human label in `!zone status` and gates downstream
|
||||
// generosity (loot rolls, hint drops) once those land.
|
||||
|
||||
// GMNarrationType — see design doc §7.
|
||||
type GMNarrationType string
|
||||
// DMNarrationType — see design doc §7.
|
||||
type DMNarrationType string
|
||||
|
||||
const (
|
||||
GMRoomEntry GMNarrationType = "room_entry"
|
||||
GMCombatStart GMNarrationType = "combat_start"
|
||||
GMCombatEnd GMNarrationType = "combat_end"
|
||||
GMNat20 GMNarrationType = "nat_20"
|
||||
GMNat1 GMNarrationType = "nat_1"
|
||||
GMBossEntry GMNarrationType = "boss_entry"
|
||||
GMBossDeath GMNarrationType = "boss_death"
|
||||
GMPlayerDeath GMNarrationType = "player_death"
|
||||
GMZoneComplete GMNarrationType = "zone_complete"
|
||||
GMTrapDetected GMNarrationType = "trap_detected"
|
||||
GMTrapTripped GMNarrationType = "trap_tripped"
|
||||
GMLore GMNarrationType = "lore"
|
||||
DMRoomEntry DMNarrationType = "room_entry"
|
||||
DMCombatStart DMNarrationType = "combat_start"
|
||||
DMCombatEnd DMNarrationType = "combat_end"
|
||||
DMNat20 DMNarrationType = "nat_20"
|
||||
DMNat1 DMNarrationType = "nat_1"
|
||||
DMBossEntry DMNarrationType = "boss_entry"
|
||||
DMBossDeath DMNarrationType = "boss_death"
|
||||
DMPlayerDeath DMNarrationType = "player_death"
|
||||
DMZoneComplete DMNarrationType = "zone_complete"
|
||||
DMTrapDetected DMNarrationType = "trap_detected"
|
||||
DMTrapTripped DMNarrationType = "trap_tripped"
|
||||
DMLore DMNarrationType = "lore"
|
||||
)
|
||||
|
||||
// MoodBand — five-state band derived from the 0–100 score.
|
||||
@@ -127,31 +127,31 @@ func bossEntryPool(zoneID ZoneID) []string {
|
||||
// pickPool routes a narration type to the right []string pool from
|
||||
// internal/flavor. Returns nil for kinds that have no pool yet (caller
|
||||
// is expected to skip rendering or fall back to a static line).
|
||||
func pickPool(zoneID ZoneID, kind GMNarrationType) []string {
|
||||
func pickPool(zoneID ZoneID, kind DMNarrationType) []string {
|
||||
switch kind {
|
||||
case GMRoomEntry:
|
||||
case DMRoomEntry:
|
||||
return zoneRoomEntryPool(zoneID)
|
||||
case GMBossEntry:
|
||||
case DMBossEntry:
|
||||
return bossEntryPool(zoneID)
|
||||
case GMCombatStart:
|
||||
case DMCombatStart:
|
||||
return flavor.CombatStart
|
||||
case GMCombatEnd:
|
||||
case DMCombatEnd:
|
||||
return flavor.CombatVictory
|
||||
case GMNat20:
|
||||
case DMNat20:
|
||||
return flavor.Nat20
|
||||
case GMNat1:
|
||||
case DMNat1:
|
||||
return flavor.Nat1
|
||||
case GMBossDeath:
|
||||
case DMBossDeath:
|
||||
return flavor.BossDeath
|
||||
case GMPlayerDeath:
|
||||
case DMPlayerDeath:
|
||||
return flavor.PlayerDeath
|
||||
case GMZoneComplete:
|
||||
case DMZoneComplete:
|
||||
return flavor.ZoneComplete
|
||||
case GMTrapDetected:
|
||||
case DMTrapDetected:
|
||||
return flavor.TrapDetected
|
||||
case GMTrapTripped:
|
||||
case DMTrapTripped:
|
||||
return flavor.TrapTriggered
|
||||
case GMLore:
|
||||
case DMLore:
|
||||
return zoneLorePool(zoneID)
|
||||
}
|
||||
return nil
|
||||
@@ -302,7 +302,7 @@ func eliteRoomEntryPool(zoneID ZoneID) []string {
|
||||
// has a signature pool, a single ability callout on the line below.
|
||||
// Falls back to the bare boss-entry line when no signature pool exists.
|
||||
func composeBossEntry(zoneID ZoneID, runID string, roomIdx int) string {
|
||||
entry := twinBeeLine(zoneID, GMBossEntry, runID, roomIdx)
|
||||
entry := twinBeeLine(zoneID, DMBossEntry, runID, roomIdx)
|
||||
pool := bossSignaturePool(zoneID)
|
||||
if entry == "" || len(pool) == 0 {
|
||||
return entry
|
||||
@@ -352,7 +352,7 @@ func pickLineDeterministic(lines []string, runID string, salt int) string {
|
||||
//
|
||||
// roomIdx is folded into the deterministic selector so the same room
|
||||
// in the same run always renders the same prose (idempotent reads).
|
||||
func twinBeeLine(zoneID ZoneID, kind GMNarrationType, runID string, roomIdx int) string {
|
||||
func twinBeeLine(zoneID ZoneID, kind DMNarrationType, runID string, roomIdx int) string {
|
||||
pool := pickPool(zoneID, kind)
|
||||
line := pickLineDeterministic(pool, runID, roomIdx)
|
||||
if line == "" {
|
||||
@@ -414,22 +414,22 @@ func moodAsideLine(mood int, runID string, roomIdx int) string {
|
||||
|
||||
// ── Mood event table & application ───────────────────────────────────────────
|
||||
|
||||
// GMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.
|
||||
type GMMoodEvent string
|
||||
// DMMoodEvent enumerates the mood-modifier triggers from design doc §3.2.
|
||||
type DMMoodEvent string
|
||||
|
||||
const (
|
||||
MoodEventZoneComplete GMMoodEvent = "zone_complete"
|
||||
MoodEventPlayerDeath GMMoodEvent = "player_death"
|
||||
MoodEventNat20 GMMoodEvent = "nat_20"
|
||||
MoodEventNat1 GMMoodEvent = "nat_1"
|
||||
MoodEventCommunityMilestone GMMoodEvent = "community_milestone"
|
||||
MoodEventDowntime GMMoodEvent = "downtime"
|
||||
MoodEventTaunt GMMoodEvent = "taunt"
|
||||
MoodEventCompliment GMMoodEvent = "compliment"
|
||||
MoodEventZoneComplete DMMoodEvent = "zone_complete"
|
||||
MoodEventPlayerDeath DMMoodEvent = "player_death"
|
||||
MoodEventNat20 DMMoodEvent = "nat_20"
|
||||
MoodEventNat1 DMMoodEvent = "nat_1"
|
||||
MoodEventCommunityMilestone DMMoodEvent = "community_milestone"
|
||||
MoodEventDowntime DMMoodEvent = "downtime"
|
||||
MoodEventTaunt DMMoodEvent = "taunt"
|
||||
MoodEventCompliment DMMoodEvent = "compliment"
|
||||
)
|
||||
|
||||
// moodEventDelta — design doc §3.2 mood-modifier table.
|
||||
var moodEventDelta = map[GMMoodEvent]int{
|
||||
var moodEventDelta = map[DMMoodEvent]int{
|
||||
MoodEventZoneComplete: +10,
|
||||
MoodEventPlayerDeath: -5,
|
||||
MoodEventNat20: +3,
|
||||
@@ -441,11 +441,11 @@ var moodEventDelta = map[GMMoodEvent]int{
|
||||
}
|
||||
|
||||
// MoodEventDelta exposes the static delta for an event (test use).
|
||||
func MoodEventDelta(ev GMMoodEvent) int { return moodEventDelta[ev] }
|
||||
func MoodEventDelta(ev DMMoodEvent) int { return moodEventDelta[ev] }
|
||||
|
||||
// applyMoodEvent updates the run's mood by the event's delta. Returns
|
||||
// the new mood score after clamping.
|
||||
func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) {
|
||||
func applyMoodEvent(runID string, ev DMMoodEvent) (int, error) {
|
||||
delta, ok := moodEventDelta[ev]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown mood event: %s", ev)
|
||||
@@ -457,7 +457,7 @@ func applyMoodEvent(runID string, ev GMMoodEvent) (int, error) {
|
||||
if err != nil || r == nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.GMMood, nil
|
||||
return r.DMMood, nil
|
||||
}
|
||||
|
||||
// passiveDecayMood drifts the mood toward 50 at ±2/hour, scaled by
|
||||
@@ -509,8 +509,8 @@ func applyMoodDecayIfStale(r *DungeonRun) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
newMood := passiveDecayMood(r.GMMood, r.LastActionAt, time.Now().UTC())
|
||||
if newMood == r.GMMood {
|
||||
newMood := passiveDecayMood(r.DMMood, r.LastActionAt, time.Now().UTC())
|
||||
if newMood == r.DMMood {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Get().Exec(`
|
||||
@@ -518,6 +518,6 @@ func applyMoodDecayIfStale(r *DungeonRun) error {
|
||||
newMood, r.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
r.GMMood = newMood
|
||||
r.DMMood = newMood
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestMoodBand_Boundaries(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMoodEventDelta_DesignDocTable(t *testing.T) {
|
||||
want := map[GMMoodEvent]int{
|
||||
want := map[DMMoodEvent]int{
|
||||
MoodEventZoneComplete: +10,
|
||||
MoodEventPlayerDeath: -5,
|
||||
MoodEventNat20: +3,
|
||||
@@ -75,8 +75,8 @@ func TestPassiveDecayMood_DriftsTowardFifty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) {
|
||||
a := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 2)
|
||||
b := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 2)
|
||||
a := twinBeeLine(ZoneGoblinWarrens, DMRoomEntry, "abc123", 2)
|
||||
b := twinBeeLine(ZoneGoblinWarrens, DMRoomEntry, "abc123", 2)
|
||||
if a != b {
|
||||
t.Errorf("non-deterministic:\n a=%q\n b=%q", a, b)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) {
|
||||
// Different room index should typically yield a different line
|
||||
// (chance of accidental collision is 1/N; with the Goblin+Generic
|
||||
// pool that's tiny but theoretically possible — only assert prefix).
|
||||
c := twinBeeLine(ZoneGoblinWarrens, GMRoomEntry, "abc123", 3)
|
||||
c := twinBeeLine(ZoneGoblinWarrens, DMRoomEntry, "abc123", 3)
|
||||
if !strings.HasPrefix(c, "🎭 **TwinBee:**") {
|
||||
t.Errorf("missing prefix on alt salt: %q", c)
|
||||
}
|
||||
@@ -94,18 +94,18 @@ func TestTwinBeeLine_DeterministicAcrossCalls(t *testing.T) {
|
||||
|
||||
func TestTwinBeeLine_BossEntryUsesNamedPool(t *testing.T) {
|
||||
// Grol's pool is a single hand-crafted line.
|
||||
got := twinBeeLine(ZoneGoblinWarrens, GMBossEntry, "any", 0)
|
||||
got := twinBeeLine(ZoneGoblinWarrens, DMBossEntry, "any", 0)
|
||||
if !strings.Contains(got, "Grol") {
|
||||
t.Errorf("Goblin Warrens boss entry should mention Grol; got %q", got)
|
||||
}
|
||||
got = twinBeeLine(ZoneCryptValdris, GMBossEntry, "any", 0)
|
||||
got = twinBeeLine(ZoneCryptValdris, DMBossEntry, "any", 0)
|
||||
if !strings.Contains(got, "Valdris") {
|
||||
t.Errorf("Crypt boss entry should mention Valdris; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwinBeeLine_UnknownKindReturnsEmpty(t *testing.T) {
|
||||
if got := twinBeeLine(ZoneGoblinWarrens, GMNarrationType("not_a_real_kind"), "x", 0); got != "" {
|
||||
if got := twinBeeLine(ZoneGoblinWarrens, DMNarrationType("not_a_real_kind"), "x", 0); got != "" {
|
||||
t.Errorf("expected empty for unknown kind, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -120,8 +120,8 @@ func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
if run.GMMood != 50 {
|
||||
t.Fatalf("starting mood = %d, want 50", run.GMMood)
|
||||
if run.DMMood != 50 {
|
||||
t.Fatalf("starting mood = %d, want 50", run.DMMood)
|
||||
}
|
||||
|
||||
// +10 → 60
|
||||
@@ -144,8 +144,8 @@ func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventDowntime)
|
||||
}
|
||||
r, _ := getZoneRun(run.RunID)
|
||||
if r.GMMood != 0 {
|
||||
t.Errorf("after extreme negative stack: mood = %d, want 0 (clamped)", r.GMMood)
|
||||
if r.DMMood != 0 {
|
||||
t.Errorf("after extreme negative stack: mood = %d, want 0 (clamped)", r.DMMood)
|
||||
}
|
||||
|
||||
// Clamp upward
|
||||
@@ -153,13 +153,13 @@ func TestApplyMoodEvent_PersistsClampedDelta(t *testing.T) {
|
||||
_, _ = applyMoodEvent(run.RunID, MoodEventCommunityMilestone)
|
||||
}
|
||||
r, _ = getZoneRun(run.RunID)
|
||||
if r.GMMood != 100 {
|
||||
t.Errorf("after extreme positive stack: mood = %d, want 100 (clamped)", r.GMMood)
|
||||
if r.DMMood != 100 {
|
||||
t.Errorf("after extreme positive stack: mood = %d, want 100 (clamped)", r.DMMood)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMoodEvent_UnknownEventErrors(t *testing.T) {
|
||||
if _, err := applyMoodEvent("fake-run", GMMoodEvent("ghosts")); err == nil {
|
||||
if _, err := applyMoodEvent("fake-run", DMMoodEvent("ghosts")); err == nil {
|
||||
t.Error("expected error on unknown mood event")
|
||||
}
|
||||
}
|
||||
@@ -363,8 +363,8 @@ func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) {
|
||||
if !final.BossDefeated {
|
||||
t.Error("boss should be defeated after final markRoomCleared")
|
||||
}
|
||||
if final.GMMood != 60 {
|
||||
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.GMMood)
|
||||
if final.DMMood != 60 {
|
||||
t.Errorf("post-completion mood = %d, want 60 (50 + zone_complete +10)", final.DMMood)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ type DungeonRun struct {
|
||||
BossDefeated bool
|
||||
Abandoned bool
|
||||
LootCollected []string
|
||||
GMMood int
|
||||
DMMood int
|
||||
StartedAt time.Time
|
||||
LastActionAt time.Time
|
||||
CompletedAt *time.Time
|
||||
@@ -178,6 +178,10 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
seq := generateRoomSequence(zone, rng)
|
||||
seqJSON, _ := json.Marshal(seq)
|
||||
|
||||
startMood := 50
|
||||
if isHol, _ := isHolidayToday(); isHol {
|
||||
startMood = 55
|
||||
}
|
||||
run := &DungeonRun{
|
||||
RunID: newRunID(),
|
||||
UserID: string(userID),
|
||||
@@ -186,7 +190,7 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
TotalRooms: len(seq),
|
||||
RoomSeq: seq,
|
||||
RoomsCleared: []int{},
|
||||
GMMood: 50,
|
||||
DMMood: startMood,
|
||||
StartedAt: time.Now().UTC(),
|
||||
LastActionAt: time.Now().UTC(),
|
||||
}
|
||||
@@ -194,8 +198,8 @@ func startZoneRun(userID id.UserID, zoneID ZoneID, dndLevel int, rng *rand.Rand)
|
||||
INSERT INTO dnd_zone_run
|
||||
(run_id, user_id, zone_id, current_room, total_rooms,
|
||||
room_seq_json, rooms_cleared, gm_mood)
|
||||
VALUES (?, ?, ?, 0, ?, ?, '[]', 50)`,
|
||||
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON),
|
||||
VALUES (?, ?, ?, 0, ?, ?, '[]', ?)`,
|
||||
run.RunID, run.UserID, string(zoneID), run.TotalRooms, string(seqJSON), startMood,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert zone run: %w", err)
|
||||
}
|
||||
@@ -271,7 +275,7 @@ func scanZoneRun(row scanner) (*DungeonRun, error) {
|
||||
if err := row.Scan(
|
||||
&r.RunID, &r.UserID, &zoneID, &r.CurrentRoom, &r.TotalRooms,
|
||||
&seqJSON, &clearedJSON, &bossDefeatedI, &abandonedI,
|
||||
&lootJSON, &r.GMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
|
||||
&lootJSON, &r.DMMood, &r.StartedAt, &r.LastActionAt, &completedAtRaw,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -83,8 +83,8 @@ func TestStartZoneRun_HappyPath(t *testing.T) {
|
||||
if run.CurrentRoom != 0 {
|
||||
t.Errorf("current room = %d", run.CurrentRoom)
|
||||
}
|
||||
if run.GMMood != 50 {
|
||||
t.Errorf("gm mood = %d", run.GMMood)
|
||||
if run.DMMood != 50 {
|
||||
t.Errorf("gm mood = %d", run.DMMood)
|
||||
}
|
||||
if !run.IsActive() {
|
||||
t.Error("expected run active")
|
||||
@@ -201,15 +201,15 @@ func TestAdjustGMMoodClampsBounds(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := getZoneRun(run.RunID)
|
||||
if r.GMMood != 100 {
|
||||
t.Errorf("upper clamp: mood = %d", r.GMMood)
|
||||
if r.DMMood != 100 {
|
||||
t.Errorf("upper clamp: mood = %d", r.DMMood)
|
||||
}
|
||||
if err := adjustGMMood(run.RunID, -250); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ = getZoneRun(run.RunID)
|
||||
if r.GMMood != 0 {
|
||||
t.Errorf("lower clamp: mood = %d", r.GMMood)
|
||||
if r.DMMood != 0 {
|
||||
t.Errorf("lower clamp: mood = %d", r.DMMood)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -285,8 +285,11 @@ func trapDamageHeader(tr zoneTrapDef, dmg int, hpCur, hpMax int) string {
|
||||
return fmt.Sprintf("🚫 **%s** — %s. Spells fizzle and enchanted gear sleeps until you leave the room.", tr.Display, tr.Trigger)
|
||||
}
|
||||
dice := fmt.Sprintf("%dd%d", tr.DamageDiceN, tr.DamageDiceD)
|
||||
return fmt.Sprintf("💢 **%s** (%s %s) — **%d HP** (%d/%d remaining).",
|
||||
tr.Display, dice, tr.DamageType, dmg, hpCur, hpMax)
|
||||
// Lead with the trap's own mechanism-specific Trigger so the narrative
|
||||
// matches the trap kind (e.g. a Collapsing Ceiling no longer reads as
|
||||
// a dart in the air). Trigger is required on every catalog entry.
|
||||
return fmt.Sprintf("💢 **%s** — %s. (%s %s) — **%d HP** (%d/%d remaining).",
|
||||
tr.Display, tr.Trigger, dice, tr.DamageType, dmg, hpCur, hpMax)
|
||||
}
|
||||
|
||||
// trapSpottedHeader — narration for a successful detection roll. No
|
||||
|
||||
Reference in New Issue
Block a user