diff --git a/CHANGES_24H.md b/CHANGES_24H.md new file mode 100644 index 0000000..62fa2e3 --- /dev/null +++ b/CHANGES_24H.md @@ -0,0 +1,116 @@ +# 24-Hour Change Summary — 2026-05-08 → 2026-05-09 + +139 commits since midnight 2026-05-09 plus an active uncommitted set. Grouped by theme below; commit hashes in parentheses for traceability. + +--- + +## 1. Phase L — Legacy `AdventureCharacter` retirement (final wave) + +The multi-day plan to retire `AdvCharacter`/`CombatLevel`/`combat_engine` reached its close-out. After today, `dnd_character` + `player_meta` are the canonical stores; the legacy table is cold and gated behind `GOGOBEE_LEGACY_PURGE`. + +- **L2 — Arena boss flow** (`62eed7a` → `3b4dfa4`): extracted `renderBossOutcome`, added `ZoneArena` synthetic ID, built boss-shaped arena bestiary, wired `ARENA_BOSS_FLOW`, migrated arena counters/render/tier-gates to `DnDCharacter`+`player_meta`, then **ripped the legacy arena combat path** — boss flow is now the only path. +- **L3** (`d300008`): co-op dungeons deleted; in-flight runs auto-refunded on startup. +- **L4a–L4f** (`730f165` → `c719b30`): per-feature dual-write + reader flips off `AdvCharacter` — hospital, rival, masterwork, pet, housing, render/twinbee. DisplayName migration scaffold (`5000c3d`, `cc6fb7d`) preceded the L5 work. +- **L5a–L5h** (`79e5d19` → `1c7939c`): final teardown sub-phases — skills, babysit, NPC counters, streak/lifecycle, death state, misc fields, mass DnDCharacter backfill, then `saveAdvCharacter` fan-outs to `player_meta`. Table goes cold. +- **L5 close-out** (`36e14c6`, `c0f03a4`, `5d98e56`, `9d6e8f6`, `c8849fd`, `35fa2b8`): §7.4 doc reconciled, last 3 `CombatLevel` readers retired, loader rewired with bootstrap, redundant dual-writes dropped, `!expedition list` forked off `zoneCmdList`, and the **daily report migrated to a D&D-native renderer** with unified activity source. + +Memory state: `project_phase_L_progress.md` now reads "deployed to prod; legacy table cold; operator can flip GOGOBEE_LEGACY_PURGE=1 anytime." + +## 2. Phase G — Branching zone graphs (greenfield rebuild) + +End-to-end shipped today. Old linear room-sequence model retired in favor of explicit graph nodes per zone. + +- **G1–G4** (`a413c92`): schema, graph types, legacy compiler, run-state dual-write. +- **G5** (`2d249d7`): navigation surface gated behind `GOGOBEE_BRANCHING_ZONES=1`. +- **G6** (`893d3da`): dependent surfaces re-keyed onto graph nodes. +- **G7** (`90ff766`, `b3d3db9`): Crypt of Valdris POC graph; empty columns collapse in `!zone map`. +- **G8a–G8i** (`9312ef5` → `304ad27`): all 9 zones authored with **deliberately varied map shapes** (per playtester feedback that uniform shapes felt rote) — Goblin Warrens diamond, Forest Shadows asymmetric fork, Sunken Temple sequential, Manor Blackspire stacked 3-way, Underforge late triple fork, Feywild woven, Dragon's Lair converging+capstone, Abyss Portal three sequential, Underdark convergent triangle. +- **G9a–G9c** (`103cf30`, `a5c0a83`, `7ec78f3`): feature flag removed, `current_room`/`room_seq_json` no longer persisted, legacy columns dropped behind a purge gate. + +Memory: `project_phase_G_progress.md` — G1–G8 shipped; G9 column purge / merge to main complete this session. + +## 3. Hospital flavor + economy + +- `2454f5f`: revival cost cut to **5k/level** + new CHA-based haggle roll (`!hospital`). +- `374d5ea`, `b8e8f99`, `97e4a50`: discharge-bill descriptor variety (USA-standards quip, prep fix, "a lot" swap). +- Merge: `feat/hospital-bill-flavor` → main (`3446103`). + +## 4. Misc admin / UX + +- `8e8d378`: Space inviter now DMs admin to confirm invites for Space-less local users; merged via `86c6736`. +- `3cf83e3`: `!eurogrant` admin command for granting EUR balance. + +## 5. Combat — Sudden Death + HP precision + +- `de02907`: **Sudden Death phase** added to all combat. Tiebreak now uses absolute HP (was % of max — fixed an edge case where a wounded high-HP fighter would lose to an undamaged low-HP enemy). +- `c5217e9`: wounded-carry HP display fixed (the "100/100 instead of 100/123" bug); auto-crit narration cleaned. +- `9ce82f7`: **round half-up on HP wound persistence** to fix the 101→100 round-trip drift through the dndChar HP scale. This was the patch that exposed the deeper dual-scale problem fixed by today's session work below. + +--- + +## 6. Today's session (uncommitted as of writing) + +### 6a. Combat HP unification — kill the dual scale + +Drift bug `9ce82f7` only papered over: combat ran on `50 + CombatLevel*2 + equip + arena_sets + housing` (~123 for an L8 char) and bridged to `dnd_character.hp_max` (~78) via percentage scaling each fight. Now: + +- New `CombatStats.HPBonus int` field captures the equipment / arena-set / housing HP buff as an **absolute number** (`legacyTotal − (50 + CombatLevel*2)`) — DerivePlayerStats keeps writing it for tests, but production combat ignores its `MaxHP`. +- `applyDnDPlayerLayer` now reseats `stats.MaxHP = c.HPMax + stats.HPBonus`. Single canonical HP scale; gear/arena/housing buffs preserved as a flat add-on. +- `applyDnDHPScaling` is a **direct integer copy**: wounded entry = `c.HPCurrent + HPBonus` (no percent math, no `dndWoundFloor`). +- `persistDnDHPAfterCombat` is a **direct integer copy**: clamps `endHP` to `[0, c.HPMax]`. Gear cushion is fight-only, doesn't carry over. +- Penalty zone HP scaling moved from before to after `applyDnDPlayerLayer` so the penalty applies on the unified scale. +- Dropped the unused `legacyStartHP` parameter from `persistDnDHPAfterCombat`. +- Stale "legacy HP / legacy combat-engine" comments swept; `dndWoundFloor` constant deleted. + +Files: `combat_engine.go`, `combat_stats.go`, `combat_bridge.go`, `dnd_combat.go`, `dnd_zone_combat.go` + tests (`dnd_plugs_test.go`, `dnd_combat_test.go`, `dnd_audit_fixes_test.go`, `dnd_proddb_integration_test.go`). + +Memory: `project_unify_combat_hp.md` flipped to **shipped**. + +### 6b. Stats card cleanup + +`!stats` (super-stats EX+α) now drops two stale references: + +- The **"Combat Lv.X (XP)"** column — legacy `combat_level` from `player_meta` is no longer rendered (the survivor in `adventure_render.go` reads from `dndLevel` and is correct under the unified model). +- The **"🤖 Lost to TwinBee: N times"** line — entire `bot_defeats` block dropped. + +Files: `stats.go`. + +### 6c. Zone combat consumables wiring + +Bug: `runZoneCombat` (zone elite/boss + arena + expedition path) never loaded, selected, or applied consumables — players' healing potions / wards / damage tonics sat in inventory while they died at 21/56 → 0/56 to a Wight. + +- Wired `loadConsumableInventory` → `SelectConsumables` → `ApplyConsumableMods` → `injectConsumableEvents` → `removeAdvInventoryItem` into `runZoneCombat`, mirroring `runDungeonCombat`'s shape. +- Added `allowSkipTrivial bool` parameter to `SelectConsumables`. Zone/arena pass `false` (those are never chump fights, and the legacy threat assessor underestimates d20 lethality — one nat-20 streak ends the run). Dungeon path keeps `true` so trivial rooms still skip. + +Files: `dnd_zone_combat.go`, `combat_bridge.go`, `adventure_consumables.go` (+test). + +### 6d. TwinBee voice — surgical third-person trim + +User flagged TwinBee flavor as overusing third-person self-reference (`"TwinBee X. TwinBee Y. TwinBee Z."` density across the same entry). Sweep across **14 flavor files**, surgical rule: trim only entries with 3+ "TwinBee" mentions or genuine subject-stack tics; leave 1–2 mentions alone; never switch to first-person "I"; preserve all jokes/references/length. + +| File | Entries trimmed | +| --- | --- | +| `twinbee_gm_flavor.go` | 7 | +| `twinbee_expedition_flavor.go` | 17 | +| `twinbee_resource_flavor.go` | 5 | +| 6 of 11 `zone_*flavor.go` files | 1 each | +| `zone_dragons_lair`, `zone_underdark`, `zone_forest_shadows`, `zone_goblin_warrens`, +1 | 0 (already sparse) | + +~35 entries touched out of ~600+. Memory: new `feedback_twinbee_voice.md` documents the rule for future authoring; the `// DO NOT REWRITE` headers on flavor files now treated as soft (override required for sweeps, but standing for incidental edits). + +--- + +## Headline numbers + +- **139 commits** since midnight 2026-05-09 (most landed before this session). +- **Two long-running phases closed** today: Phase L (legacy adv-character retirement) and Phase G (branching zone graphs). +- **One new refactor shipped this session**: combat HP unification — round-trip drift gone, single canonical HP scale. +- **Two production bugs fixed this session**: stats card stale columns; zone combat consumables not firing. +- **One creative QA pass**: ~35 TwinBee flavor entries trimmed across 14 files. + +## Next on deck + +- Per `project_consumable_crafting.md`: foraging-level auto-crafting consumables from ingredients. +- Per `project_adv_dm_window_tiers.md`: split `advDMResponseWindow` into high-pri vs low-pri constants. +- Per `project_human_race_bonus.md`: wire Human race's floating +1 stat bonus. +- Phase G done → optional `GOGOBEE_LEGACY_PURGE=1` flip whenever operator wants the cold table gone. diff --git a/REBALANCE_NOTES.md b/REBALANCE_NOTES.md new file mode 100644 index 0000000..dad5c0d --- /dev/null +++ b/REBALANCE_NOTES.md @@ -0,0 +1,144 @@ +# Combat Rebalance — 2026-05-10 + +Post HP-unification balance recovery. Player HP dropped ~45% when the legacy +`50 + CombatLevel*2` base was retired; monsters were still tuned for the old +~120 HP scale, making even L1 dungeons unsurvivable. This pass retunes both +sides for the dnd HP scale and retires CombatLevel from combat formulas. + +## Symptom + +Real prod player @holymachina (cleric, dnd L10): legacy combat MaxHP ≈ 165 → post-unification combat MaxHP ≈ 70. Monster damage formulas still scaled quadratically with tier (`T5 Attack=49`), oversized for ~70 HP players. Wight encounter user reported: 56/56 → 0/56 in 4 phases despite wight ending with 12 HP left. Even fresh L1 dungeon dives ran 95%+ death rate in sims. + +## Root causes + +1. **HP unification dropped the `50 + CombatLevel*2` base** without any rebalance pass on monster formulas. Monsters tuned for legacy HP pool now obliterate dnd HP pool. +2. **Tiebreak was absolute-HP-based** (recent change `de02907`). Worked OK on legacy scale where pools were similar; breaks on dnd scale because monster pools (~30–175) are 2-3× larger than player pools (~13–83), so timeouts always favor monsters even when player took less proportional damage. +3. **CombatLevel was still load-bearing** in `DerivePlayerStats` for Attack/Defense/Speed/HP scaling, contradicting the Phase L closeout that retired it everywhere else. This made test fixtures lie about real player power. + +## Scope decisions (locked in by user) + +- **Don't restore the legacy `50 + CL*2` HP base.** Combat MaxHP stays at `c.HPMax + HPBonus(gear delta only)`. +- **Strip CombatLevel from combat scaling entirely.** Player power = ability scores + gear + dnd-derived layers (AC/AB/weapon dice). No level-based defense/speed/attack/HP scaling on the player side. +- **Both formula and (eventually) bestiary tuning** for monsters, not just one. Formula pass was sufficient this round; bestiary deferred. + +## Changes + +### Test infrastructure + +`internal/plugin/combat_stats_test.go`: + +- `TestBalanceRegression_DungeonDeathRates` and `TestBalanceRegression_UnderleveledPlayers` now apply the production combat pipeline before simulating: `applyDnDPlayerLayer`, `applyDnDEquipmentLayer`, `applyDnDDungeonMonsterLayer`. Previously they ran raw `DerivePlayerStats` output, which silently hid the HP-unification regression because the legacy MaxHP value never made it into the test combatant. +- New helper `balanceTestDnDChar(dndLevel int)` synthesizes a fighter sheet (mirroring `autoBuildCharacter`) at the requested dnd level. Replaces the old `level=CombatLevel` proxy. +- Test cases re-keyed onto dnd levels (1, 2, 5, 7, 9) for the well-geared baseline and (1, 2, 4, 6) for the underleveled set. Previously `level=5..48` was being treated as both CombatLevel *and* dnd_level which hid the regression. +- Test log line now prints `AC` / `AB` / `Atk` instead of legacy `Atk/Def/Spd` triple — the d20 path's actual hit/damage drivers. +- `TestDerivePlayerStats_BaseStats` updated: zero-gear baseline is now `MaxHP=50, HPBonus=0, Attack=5` (constants, no level scaling). Previously expected `>=70 HP` which was the legacy formula output for L10. + +### Player stat derivation + +`internal/plugin/combat_stats.go`: + +- **Stripped CombatLevel scaling** from `DerivePlayerStats`. `MaxHP` baseline is a flat constant (50, used only as the multiplicand for armor/arena/housing % bonus formulas — subtracted out into `HPBonus` at the end so the legacy base never reaches combat). `Attack`, `Defense`, `Speed` are now constants (5, 3, 5) plus gear and bonuses. Player power scales through gear and the dnd layers. +- All other gear/arena/housing/streak/grudge/pet/NPC bonus paths preserved. + +### Dungeon monster formulas + +`internal/plugin/combat_stats.go: DeriveDungeonMonsterStats`: + +| Stat | Old formula (legacy HP era) | New formula (dnd HP era) | +|---|---|---| +| MaxHP | `25 + t*t*6 + death*0.6` (T1=33, T5=187) | `12 + t*7 + death*0.3` (T1≈22, T5≈65) | +| Attack | `3 + death*0.2 + t*t*1.5` (T1=5, T5=49) | `t*1.5 + death*0.04` (T1=1, T5=9) | +| Defense | `2 + t*1.5` | `2 + t*1.2` (small trim) | + +Speed / CritRate / DodgeRate / BlockRate unchanged. AB scaling via `applyDnDDungeonMonsterLayer` (`9 + tier`) untouched — it was already calibrated for the d20 hit-resolution side. + +Quadratic `t*t*X` terms are out everywhere on the monster side. Linear scaling matches the dnd HP scale's narrower band (player HP 13–83, not 60–200). + +### Tiebreak + +`internal/plugin/combat_engine.go`: + +- Reverted the recent absolute-HP tiebreak (`de02907`) back to **HP percentage** for the post-Sudden-Death timeout case. Bias toward player on exact ties (`playerFrac >= enemyFrac` → enemy dies). +- Reasoning: monster pools on the dnd scale are systematically 2-3× larger than player pools, so absolute always favored monsters even when the player took less proportional damage. The `de02907` example case (88/123 vs 83/97) doesn't exist on the new scale. + +### Comments and tests touched (no behavior change) + +- `combat_stats.go` comments rewritten to explain the dnd HP scale assumption and why the legacy base is captured then subtracted out. +- `combat_engine.go` tiebreak comment rewritten with the new rationale. + +## Sim results + +`go test ./internal/plugin/ -run TestBalanceRegression -v` after this pass: + +**Well-geared at tier (target window in parens):** + +| Dungeon | Player | Monster | Death rate | Target | +|---|---|---|---|---| +| Soggy Cellar (L1 T1) | HP=13 AC=12 AB=6 | HP=21 AC=10 AB=5 Atk=1 | **3%** | 0–5% ✓ | +| Goblin Warrens (L2 T2) | HP=23 AC=12 AB=6 | HP=31 AC=11 AB=6 Atk=3 | **7%** | 0–10% ✓ | +| Cursed Crypt (L5 T3) | HP=48 AC=14 AB=7 | HP=42 AC=12 AB=7 Atk=5 | **1%** | 0–15% ✓ | +| Troll Bridge (L7 T4) | HP=66 AC=15 AB=7 | HP=53 AC=13 AB=8 Atk=7 | **8%** | 0–25% ✓ | +| Abyssal Maw (L9 T5) | HP=83 AC=16 AB=8 | HP=65 AC=14 AB=9 Atk=9 | **7%** | 1–35% ✓ | + +**Underleveled / undergeared (real danger expected):** + +| Scenario | Player | Monster | Death rate | Min target | +|---|---|---|---|---| +| L1 in T2 | HP=12 AC=12 AB=6 | HP=31 Atk=3 | **84%** | ≥40% ✓ | +| L2 in T3 | HP=21 AC=12 AB=6 | HP=42 Atk=5 | **99%** | ≥30% ✓ | +| L4 in T4, T2 gear | HP=39 AC=12 AB=6 | HP=53 Atk=7 | **97%** | ≥25% ✓ | +| L6 in T5, T3 gear | HP=56 AC=14 AB=7 | HP=65 Atk=9 | **83%** | ≥30% ✓ | + +All pass. No other plugin tests broken by the rebalance (two pre-existing failures — `TestStandaloneHarvest_RoutesToZoneRun` and `TestZoneRunFlow_AdvanceToBossAndComplete` — are Phase G branching-zone node-naming issues, not caused by these changes; verified by stash-pop comparison). + +### Bestiary stat-block pass + +`internal/plugin/dnd_bestiary.go`: + +- **Programmatic CR-based rescale** of `Attack` and `AttackBonus` across all 69 entries. New formula: + - `Attack = round(1 + 1.5 × CR)`, with a gentler bend at CR ≥ 10 (`max(prev, round(1 + 1.7×CR − 2))`), capped at 38. + - `AttackBonus = min(old_AB, 11)` — caps the d20 to-hit bonus so end-game monsters still miss player AC sometimes. +- HP / AC / Speed / Ability / BlockRate untouched — these aren't the lethality drivers; HP only matters if players can't damage-race the pool, and post-Attack-nerf the race is winnable. + +Sample reseats: + +| Monster | CR | Old Atk → New | Old AB → New | +|---|---|---|---| +| Goblin | 0.25 | 6 → 1 | 4 | +| Skeleton | 0.25 | 8 → 1 | 4 | +| Wight | 3 | 14 → 6 | 4 | +| Flameskull | 4 | 18 → 7 | 5 | +| Troll | 5 | 22 → 8 | 7 | +| Wyvern | 6 | 28 → 10 | 7 | +| Thornmother (CR 11 boss) | 11 | 32 → 18 | 8 | +| Belaxath (CR 19 boss) | 19 | 58 → 31 | 11 (was 12) | +| Ancient Dragon (CR 20) | 20 | 65 → 33 | 11 (was 14) | +| Infernax (CR 24, end boss) | 24 | 65 → 38 | 11 (was 14) | + +Test update: `TestBestiaryToCombatStats` dragon AB expectation `14 → 11`. + +**Reasoning for not also touching HP**: a CR 24 boss with 546 HP is appropriately big-boss bulky on the dnd scale — players doing 7-15 dmg/round will need 35+ rounds to kill, which is fine for a final boss with phases. The pain point was the boss one-shotting players via 65-damage swings, not the HP wall. If specific bosses still feel too tanky once players actually fight them, individual `HP` trims are easy. + +## Open follow-ups + +- **Arena monster formula** (`DeriveArenaMonsterStats`) still has the old quadratic-feeling shape but isn't currently broken in sims — left untouched this pass. If arena death rates report wrong, mirror the dungeon shape. +- **`TwoHandedMode` / `Versatile` weapon damage paths** in `applyDnDEquipmentLayer` weren't audited this pass — if they're miscalculating damage dice, balance shifts. +- **Tiebreak bias** is a flat "player wins ties." Could be tuned to a small grace margin (e.g., player wins if `playerFrac >= enemyFrac - 0.05`) if narrative feedback says timeout losses still feel cheap. +### Phase G test catch-up (formerly flaky) + +Four tests written against the pre-Phase-G linear-room model were intermittently failing. Fixed in the same session so the suite runs clean: + +- `TestStandaloneHarvest_RoutesToZoneRun` — was asserting the legacy `.r1` node id; production now writes `.entry` from the registered graph. Switched to `harvestNodeIDFor(run)` so the test reads whatever production wrote. +- `TestZoneRunFlow_AdvanceToBossAndComplete` — asserted `len(RoomsCleared) == TotalRooms`. Diamond/fork graphs yield shorter paths than the total node count. Loosened to `1..TotalRooms` (boss-defeated check still in place). +- `TestZoneCmd_AdvanceFinalRoomBumpsMood` — looped exactly `TotalRooms` `markRoomCleared` calls; with branching the run completes early and the extra calls find no active run. Switched to break on the first empty `next` return. +- `TestAdv2Scenario_ZoneRunGoblinWarrens` — sent only `!zone advance`, stalling at fork rooms (Phase G goblin warrens is a diamond graph). Added auto `!zone go 1` when `run.NodeChoices` is non-empty so the scenario commits the first fork option and continues. + +Verified clean across 4 consecutive `go test` runs. + +## Files touched + +- `internal/plugin/combat_stats.go` — DerivePlayerStats CL strip, monster formula retune +- `internal/plugin/combat_engine.go` — tiebreak revert +- `internal/plugin/combat_stats_test.go` — production-accurate test pipeline, dnd_level-keyed cases, base-stats expectations +- `internal/plugin/dnd_bestiary.go` — programmatic CR-based Attack/AB rescale across 69 entries +- `internal/plugin/dnd_bestiary_test.go` — dragon AB expectation updated diff --git a/internal/db/db.go b/internal/db/db.go index 66e8313..80d93ba 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -313,6 +313,12 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE dnd_zone_run ADD COLUMN current_node TEXT NOT NULL DEFAULT ''`, `ALTER TABLE dnd_zone_run ADD COLUMN visited_nodes TEXT NOT NULL DEFAULT '[]'`, `ALTER TABLE dnd_zone_run ADD COLUMN node_choices TEXT NOT NULL DEFAULT '{}'`, + // 2026-05-10 immersion pass: short rest = hit-dice charges (1/level), + // long rest restores them. resting_until gates !zone enter and + // !expedition start so a freshly-rested character can't immediately + // jump back into combat — they're actually resting for the duration. + `ALTER TABLE dnd_character ADD COLUMN short_rest_charges INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE dnd_character ADD COLUMN resting_until DATETIME`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/flavor/twinbee_expedition_flavor.go b/internal/flavor/twinbee_expedition_flavor.go index 0b0e2ec..adca76c 100644 --- a/internal/flavor/twinbee_expedition_flavor.go +++ b/internal/flavor/twinbee_expedition_flavor.go @@ -54,7 +54,7 @@ var MorningBriefingDay3 = []string{ var MorningBriefingDay7 = []string{ "One week. You have spent one week in this place and it has not finished you, which says something about you that TwinBee intends to say out loud: that took something real. Take a moment with that. Then advance.", "Seven days. In the old reckoning, seven was the number of completion — seven seals, seven trials, seven nights before the thing reveals itself. TwinBee is not superstitious. TwinBee is also watching the door very carefully this morning.", - "A week underground. TwinBee thinks about the sky sometimes — not with longing, exactly, more as a reference point. You've been below it for seven days. TwinBee finds that remarkable. TwinBee finds you remarkable.", + "A week underground. TwinBee thinks about the sky sometimes — not with longing, exactly, more as a reference point. You've been below it for seven days. Remarkable. You, more so.", } var MorningBriefingDay14 = []string{ @@ -97,7 +97,7 @@ var EveningRecapBossKilled = []string{ var EveningRecapCloseCall = []string{ "TwinBee runs the evening recap and notes: you came very close today to not being here for the evening recap. TwinBee notes this without drama and with complete sincerity. You made it. That's the recap.", "Today was the kind of day that TwinBee files under 'let's not do that again' and also 'and yet you did it.' Rest. You need it more tonight than most nights.", - "Evening recap. TwinBee will note for the ledger that today was very nearly a different kind of recap, and TwinBee is glad it isn't. The margin was thinner than TwinBee likes. TwinBee will adjust tomorrow's caution accordingly.", + "Evening recap. TwinBee will note for the ledger that today was very nearly a different kind of recap, and is glad it isn't. The margin was thinner than the acceptable range. Tomorrow's caution adjusts accordingly.", "End of day, narrowly. TwinBee runs through the close-call list — the round that nearly went the other way, the save that landed by one, the room you almost didn't leave — and signs each one off as 'survived.' Survived is a wide category. TwinBee accepts you in any part of it.", } @@ -137,7 +137,7 @@ var SupplyWarningLow = []string{ } var SupplyWarningCritical = []string{ - "TwinBee holds up the supply manifest. The number is very small. 'This is the part,' TwinBee says quietly, 'where we start making decisions.' TwinBee does not specify which decisions. You know which decisions.", + "TwinBee holds up the supply manifest. The number is very small. 'This is the part,' it says quietly, 'where we start making decisions.' Doesn't specify which. You know which.", "Critical supply levels. TwinBee delivers this without inflation — the situation is what it is. Extract and resupply, or forage aggressively and push for the finish. TwinBee outlines both paths and neither is comfortable.", "The supplies are nearly gone. TwinBee thinks of every long JRPG dungeon where you realize at the bottom floor that you're out of Ethers. This is that moment. TwinBee has plans. They require movement.", } @@ -174,7 +174,7 @@ var ThreatClockHostile = []string{ } var ThreatClockSiege = []string{ - "Siege Mode. TwinBee delivers this without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So is TwinBee — to ending it on your terms, not theirs. What happens next is a race. TwinBee is already running.", + "Siege Mode. TwinBee delivers this without decoration because decoration would be dishonest. The dungeon is fully active, fully aware, and fully committed to ending this expedition. So is TwinBee — to ending it on your terms, not theirs. What happens next is a race. Already running.", "Siege. The dungeon is putting everything it has into the room you're standing in, and the rooms adjacent to it, and the route between you and the door. TwinBee is putting everything TwinBee has into making sure the dungeon doesn't get what it wants. Meet in the middle.", "Siege Mode. TwinBee narrates the band shift and then stops narrating because narration is not what this band needs. Action is what this band needs. TwinBee is in motion. The expectation is that you are too.", } @@ -209,15 +209,15 @@ var SunkenTempleTidalEvent = []string{ var HauntedManorResetMorning = []string{ "TwinBee's morning briefing includes an addendum. The rooms that were clear yesterday are not entirely clear this morning. The house has been busy overnight. TwinBee adds this to the log under 'things the house does' and suggests adjusting the advance plan.", "Night three. The manor reset itself. TwinBee was watching and it happened anyway — not violently, not dramatically, just quietly and completely, the way the house does everything. One enemy per room, back in place. TwinBee has updated the map.", - "TwinBee reads the morning's hallway and stops. 'The arrangement has changed,' TwinBee says, in the tone of someone who has now seen this happen often enough to recognize it without alarm. The manor reset. TwinBee adjusts the map. Morning continues.", - "Reset day. The rooms TwinBee cleared are not the rooms TwinBee finds this morning. TwinBee accepts this as a property of the manor — like weather, but indoors and unfair. The plan accommodates. TwinBee bills the time.", + "TwinBee reads the morning's hallway and stops. 'The arrangement has changed,' it says, in the tone of someone who has now seen this happen often enough to recognize it without alarm. The manor reset. Map adjusts. Morning continues.", + "Reset day. The rooms TwinBee cleared are not the rooms it finds this morning. Accepts this as a property of the manor — like weather, but indoors and unfair. The plan accommodates. The time gets billed.", } var UnderforgHeapWarning = []string{ "Heat Stack [N]. TwinBee notes the accumulation and what it means: the Underforge is getting into you in ways that don't resolve without real rest. The number has time to come down. TwinBee is watching the number.", "The heat is building. TwinBee tracks it the way you track a temperature gauge on a long drive — with the specific alertness of someone who knows what happens when the gauge hits red. It has not hit red. TwinBee intends to ensure it doesn't.", "Heat building. TwinBee notes the count, notes the band, notes how each step now takes a fraction more out of you than the last step did. None of it is critical. All of it is direction. TwinBee suggests the direction be inverted soon.", - "Stack [N]. TwinBee tracks the heat the way TwinBee tracks anything that compounds — patiently, with running totals, with a clear point at which the totals stop being managed and start being problems. We are still on the management side. The other side is visible.", + "Stack [N]. TwinBee tracks the heat the way it tracks anything that compounds — patiently, with running totals, with a clear point at which the totals stop being managed and start being problems. We are still on the management side. The other side is visible.", } var UnderforgHeapCritical = []string{ @@ -227,7 +227,7 @@ var UnderforgHeapCritical = []string{ } var FeywildTimeDistortionHalf = []string{ - "The day moved strangely. TwinBee tried to track it and lost the thread somewhere around mid-afternoon — the light didn't change the way it should have, and when TwinBee looked up, the day was half over in the time it usually takes to be a quarter over. On the positive side: you're barely hungry. On the less positive side: TwinBee is not sure what that means.", + "The day moved strangely. TwinBee tried to track it and lost the thread somewhere around mid-afternoon — the light didn't change the way it should have, and when it looked up, the day was half over in the time it usually takes to be a quarter over. On the positive side: you're barely hungry. On the less positive side: nobody is sure what that means.", "Half a day passed in what felt like half of half. TwinBee checks the supply burn against the sun, finds them disagreeing, sides with the supplies — those don't lie about how much you've used. Net result: a free pocket of time. Use it on something that takes time.", "The Feywild gave you back some hours. TwinBee notes this without trusting it. The Feywild does not give without taking, eventually. For now: less hungry, less tired, more daylight than the math allows. TwinBee writes it down and keeps moving.", } @@ -239,7 +239,7 @@ var FeywildTimeDistortionDouble = []string{ } var FeywildTimeLoop = []string{ - "TwinBee recognizes this room. TwinBee has described this room before. The enemies in it are different — new enemies, the old ones are gone, the loot you found is still gone but the enemies are back — and TwinBee processes this with something between professional acceptance and profound exasperation. 'Again,' TwinBee says. 'We do this room again.'", + "TwinBee recognizes this room. Has described it before. The enemies in it are different — new enemies, the old ones are gone, the loot you found is still gone but the enemies are back — and it processes this with something between professional acceptance and profound exasperation. 'Again,' TwinBee says. 'We do this room again.'", "The loop. TwinBee marks the room on the map with a small symbol that means 'we have been in this room before in a way that doesn't count.' The symbol has its own column on TwinBee's ledger. The column is occupied.", "Same room. New enemies. TwinBee runs the encounter again with the cold professionalism of someone who has stopped expecting fairness from the Feywild and started expecting only repetition. The repetition arrives. TwinBee handles it.", } @@ -265,14 +265,14 @@ var AbyssPortalDestabilizationMid = []string{ } var AbyssPortalDestabilizationCritical = []string{ - "Instability critical. The portal is unraveling at edges TwinBee can see and probably at edges TwinBee can't. The demons coming through are more agitated than they were — which is relevant because demons at baseline are already at the upper end of agitated. TwinBee says: finish this today. Tomorrow is a different calculation.", + "Instability critical. The portal is unraveling at edges TwinBee can see and probably at edges it can't. The demons coming through are more agitated than they were — which is relevant because demons at baseline are already at the upper end of agitated. The verdict: finish this today. Tomorrow is a different calculation.", "The portal is louder. Not in sound — in pressure. TwinBee can feel it in the back teeth, in the joint of the jaw. The instability number is the number you don't want it to be. Finish the work today or accept that today was the last day to.", "Critical. The geometry around the portal is bending in ways that suggest the room does not entirely agree with itself anymore. TwinBee adjusts the route around the worst of it. There are more demons than there should be. There always are. There are more than that now.", } var AbyssPortalCollapse = []string{ - "The portal collapses. TwinBee watches it happen and does what TwinBee does in situations with no good options: moves. 'Out,' TwinBee says, and means it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.", - "It's coming apart. TwinBee says one word, 'Move,' in the tone TwinBee uses exactly once per expedition. You move. The portal screams behind you in a register that isn't sound. The expedition is over because physics says so. TwinBee will sort the loot once you're somewhere physics still works.", + "The portal collapses. TwinBee watches it happen and does what it does in situations with no good options: moves. 'Out,' it says, and means it completely. 'Now. Everything you have, we move now.' The expedition ends here, not in defeat, but in physics. What you took is yours. What's left in there is the portal's problem. Come back when it isn't.", + "It's coming apart. TwinBee says one word, 'Move,' in the tone it uses exactly once per expedition. You move. The portal screams behind you in a register that isn't sound. The expedition is over because physics says so. The loot gets sorted once you're somewhere physics still works.", "Collapse. TwinBee was prepared for this and is also actively running. 'Out,' TwinBee says. 'Now.' The corridors are folding behind you in the way that things fold when reality is no longer paying attention to what's allowed. You finish out the door. The portal does not.", } @@ -331,23 +331,23 @@ var ExpeditionResume = []string{ var MilestoneFirstNight = []string{ "You survived the first night. TwinBee notes this milestone specifically because not everyone does, and those who do carry something from it that changes how the rest of the expedition goes. You have that now. TwinBee has noticed it already.", "Night one survived. TwinBee makes a small mark in the corner of the manifest — the kind of mark you make for the things that count more than they look. First nights count. TwinBee has been in dungeons where they were the last nights too. This wasn't one of those.", - "Day two morning. The first night is behind you, which means the first watch is behind TwinBee, which means TwinBee can confirm a thing TwinBee was hoping to confirm: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. TwinBee logs it.", + "Day two morning. The first night is behind you, which means the first watch is behind TwinBee, which means a thing worth confirming has been confirmed: you sleep through the noises that matter and wake for the ones that don't. That's a survival skill. Logged.", } var MilestoneWeekOne = []string{ - "Seven days. TwinBee pauses the morning briefing for a moment — just a moment — to mark this. One week in an active dungeon zone is not a thing that happens by accident. It happens through every decision you've made since Day 1, compounded. TwinBee has been watching those decisions. TwinBee is glad they were yours.", + "Seven days. TwinBee pauses the morning briefing for a moment — just a moment — to mark this. One week in an active dungeon zone is not a thing that happens by accident. It happens through every decision you've made since Day 1, compounded. Those decisions were watched. They were, gladly, yours.", "Day eight. TwinBee delivers the briefing slowly, because there's a thing to mark first: you have been in here, intact and operational, for a full week. That is a number with weight. TwinBee is going to set the briefing down for a moment and let the number have its weight. ... Right. Briefing.", "One week. TwinBee makes the mark in the column reserved for week-one survivors. The column is shorter than you'd expect. You are now on it. The rest of the day proceeds normally, but for one moment, TwinBee allows itself to be visibly impressed. The moment ends. We continue.", } var MilestoneTwoWeeks = []string{ - "Two weeks. TwinBee doesn't have a comparison for this one. The references have run out. There's just you, in here, on Day 14, still going, and TwinBee standing next to you having run out of everything except genuine admiration. TwinBee has that in abundance. Proceed.", + "Two weeks. TwinBee doesn't have a comparison for this one. The references have run out. There's just you, in here, on Day 14, still going, and TwinBee standing next to you having run out of everything except genuine admiration. That, in abundance. Proceed.", "Day fifteen. TwinBee notes that the historical comparisons stopped working at Day fourteen and have not resumed working today. We are off the chart. TwinBee is not a person who values being off the chart, except in the very specific sense that you are off the chart, in which case TwinBee values it deeply.", "Fortnight. TwinBee uses the older word because it sounds more like what this is — not 'two weeks,' which sounds modular and reasonable, but 'fortnight,' which sounds like the kind of duration that earns a title. TwinBee may be inventing titles for you. TwinBee will be honest about that as it happens.", } var MilestoneTheLongGame = []string{ - "TwinBee sets aside the narration format for a moment. Just sets it down. Speaks plainly: what you just did was not supposed to be survivable. The designers of this zone — the thing that shaped it, the evil that filled it — did not account for someone like you. TwinBee did. TwinBee always accounts for someone like you. That's why TwinBee is here.", + "TwinBee sets aside the narration format for a moment. Just sets it down. Speaks plainly: what you just did was not supposed to be survivable. The designers of this zone — the thing that shaped it, the evil that filled it — did not account for someone like you. TwinBee did. Always does. That's why it's here.", "Tier five complete. TwinBee allows the narration format to fully break for a moment because the moment fully deserves it. What you just did, complete, in a Tier 5 zone, is a thing that goes on the short list. TwinBee keeps a short list. You're on it.", "Long game closed. TwinBee gathers the run notes, the threat curve, the supply records, the camp positions — files them all under your name in a folder TwinBee has been keeping. The folder has a title now. The title is good.", } @@ -372,6 +372,6 @@ var MilestoneCartographer = []string{ // item-grant hookup; this pool covers the narrative line at the moment it lands. var MilestoneSurvivalist = []string{ "Survivalist. TwinBee writes the title next to your name in the ledger and underlines it once. No abandonments, no scrambles for the door, no expeditions cut short by anything but the boss going down. TwinBee acknowledges the discipline directly.", - "The Survivalist title is technical — it means the run never broke; it ended on your terms, every time. TwinBee has been in expeditions where that wasn't true and TwinBee remembers them differently. This one TwinBee remembers as: complete. TwinBee files it with the others like it. The folder is short.", + "The Survivalist title is technical — it means the run never broke; it ended on your terms, every time. TwinBee has been in expeditions where that wasn't true and remembers them differently. This one gets remembered as: complete. Filed with the others like it. The folder is short.", "No forced extractions, full clear, Tier 3-or-better. TwinBee notes each criterion separately because each one is its own choice, made repeatedly, across days. The result is the title, which is real, and the cosmetic, which is forthcoming. TwinBee will hand both over when the system permits.", } diff --git a/internal/flavor/twinbee_gm_flavor.go b/internal/flavor/twinbee_gm_flavor.go index dc66c1f..4727ca5 100644 --- a/internal/flavor/twinbee_gm_flavor.go +++ b/internal/flavor/twinbee_gm_flavor.go @@ -63,7 +63,7 @@ var RoomEntryForestShadows = []string{ "The trees here grow too close. Their roots are above ground, like they've been trying to leave and thought better of it. TwinBee thinks better of commenting.", "A clearing. Moonlight. Flowers that shouldn't be blooming at this hour. TwinBee has played enough Majora's Mask to be deeply suspicious of beautiful clearings.", "Something watches from the canopy. TwinBee watches back. After a moment, it looks away first. TwinBee counts this as a point.", - "The path forks. Both ways look equally uninviting. TwinBee consults no map, because there is no map, because TwinBee is the map, and TwinBee chooses left. Probably.", + "The path forks. Both ways look equally uninviting. TwinBee consults no map, because there is no map, because TwinBee is the map, and chooses left. Probably.", "Bioluminescent fungi light the forest floor in soft blue. It is, genuinely, beautiful. It is also exactly what the Lost Woods looked like right before things got bad. TwinBee stays alert.", "The wind carries voices. Not words, exactly — more like the memory of words. TwinBee has heard this before. It means the forest is old and has opinions.", "Owlbear tracks in the mud. Fresh. TwinBee measures them. Whatever left these tracks was not small and was moving with purpose. TwinBee hopes the purpose was in the other direction.", @@ -171,7 +171,7 @@ var CombatRetreat = []string{ // ───────────────────────────────────────────────────────────────────────────── var Nat20 = []string{ - "NATURAL TWENTY. TwinBee stands up. TwinBee does not have legs. TwinBee stands up anyway.", + "NATURAL TWENTY. TwinBee stands up. Does not have legs. Stands up anyway.", "The dice land perfectly and TwinBee makes a sound that it will not acknowledge making.", "A critical hit for the ages. TwinBee notes this one down. Not for records. Just because it deserves to be noted.", "PERFECT. TwinBee says it like it's the Street Fighter announcer saying it after a flawless round and every syllable is justified.", @@ -235,7 +235,7 @@ var BossEntryHollowKing = []string{ } var BossEntryInfernax = []string{ - "TwinBee stops walking. TwinBee does not stop walking. TwinBee processes what it sees and takes a moment that it has never taken before in the history of narrating dungeons. The dragon is not large the way a large thing is large. It is large the way weather is large — not an object with size, but a condition of the space you're in. One eye opens. Gold, lit from within, older than the mountain it's resting in. It looks at you the way you'd look at a very small thing that had climbed onto your counter. 'So,' Infernax says, and the word moves the air in the room. TwinBee translates: 'What an interesting mistake you've made.' TwinBee wishes you luck and means it more than it has ever meant anything.", + "TwinBee stops walking. Does not stop walking. Processes what it sees and takes a moment never taken before in the history of narrating dungeons. The dragon is not large the way a large thing is large. It is large the way weather is large — not an object with size, but a condition of the space you're in. One eye opens. Gold, lit from within, older than the mountain it's resting in. It looks at you the way you'd look at a very small thing that had climbed onto your counter. 'So,' Infernax says, and the word moves the air in the room. TwinBee translates: 'What an interesting mistake you've made.' Wishes you luck and means it more than it has ever meant anything.", } var BossEntryBelaxath = []string{ @@ -397,9 +397,9 @@ var TauntResponses = []string{ "TwinBee has been taunted by things with more teeth than you and survived the experience with its dignity intact. TwinBee will survive this too.", "The next room will contain a thing that TwinBee has been saving for exactly this kind of energy. TwinBee is pleased you've given it an occasion.", "Noted. TwinBee's mood shifts. You can hear it shift. TwinBee wants you to hear it shift. The shift is the point.", - "You taunt TwinBee. TwinBee smiles. The smile does not reach the eyes, because TwinBee does not have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' TwinBee says.", + "You taunt TwinBee. It smiles. The smile does not reach the eyes, because TwinBee does not have eyes per se, but the quality of the smile communicates clearly. 'Proceed,' it says.", "In Gradius, you could powerup into overconfidence and lose everything in one hit. TwinBee mentions this as a purely historical observation.", - "TwinBee accepts the taunt with grace. TwinBee also generates a trap for the next room with specific energy. These two events are unrelated. TwinBee maintains this position legally.", + "TwinBee accepts the taunt with grace. Also generates a trap for the next room with specific energy. These two events are unrelated. TwinBee maintains this position legally.", } // ───────────────────────────────────────────────────────────────────────────── @@ -421,7 +421,7 @@ var ComplimentResponses = []string{ // ───────────────────────────────────────────────────────────────────────────── var IdleLines = []string{ - "TwinBee waits. TwinBee is good at waiting. The dungeon is also waiting, which is arguably more important, but TwinBee acknowledges both.", + "TwinBee waits. Good at waiting. The dungeon is also waiting, which is arguably more important, but TwinBee acknowledges both.", "The dungeon holds its breath. TwinBee is also holding its breath. There are a lot of things holding breath right now and TwinBee recommends acting before someone has to exhale.", "TwinBee taps its metaphorical foot. Not impatiently — more in the way of a metronome. The tempo is there whenever you're ready.", "In Contra, hesitation had consequences. TwinBee mentions this as context, not pressure. Definitely not pressure.", @@ -499,7 +499,7 @@ var SaveFailed = []string{ var MoodAsidesHostile = []string{ "TwinBee is not narrating this one in detail. You can read the room. Read it.", "The dungeon offers TwinBee something to mention. TwinBee declines. You're on your own for color commentary.", - "TwinBee is here. TwinBee is watching. TwinBee is not, currently, helping. There is a difference and you will feel it.", + "TwinBee is here. Watching. Not, currently, helping. There is a difference and you will feel it.", "In the bad ending of every Castlevania, the protagonist gets less guidance than they did at the start. TwinBee has reached approximately that part of the playthrough.", "TwinBee keeps several details to itself. The details would have been useful. TwinBee does not consider this its problem right now.", "Whatever's in the next part of the room, TwinBee saw it and chose not to flag it. The mood is what it is.", @@ -523,3 +523,35 @@ var MoodAsidesEffusive = []string{ "The mood is high. TwinBee is, for the next stretch, more likely to mention the loose flagstone, the suspicious tapestry, the thing on the ceiling. Take advantage.", "TwinBee hums a victory fanfare softly to itself. It is not earned yet. TwinBee is being optimistic on your behalf.", } + +// ───────────────────────────────────────────────────────────────────────────── +// MOOD ASIDES — Grumpy band (mood 20–39) +// TwinBee is unimpressed. Short, dry, slightly clipped. Not actively +// withholding (that's hostile) — just not feeling generous. +// ───────────────────────────────────────────────────────────────────────────── + +var MoodAsidesGrumpy = []string{ + "TwinBee describes the room. TwinBee does not embellish. Make of that what you will.", + "The mood is fine. TwinBee specifies fine, not good. There's a difference.", + "TwinBee notes the chamber. TwinBee notes its existence. That's the whole note.", + "The dungeon has a thing worth mentioning. TwinBee will mention it if you specifically ask. You will not specifically ask.", + "TwinBee is keeping the commentary lean today. The dungeon does not need editorializing. TwinBee almost convinces itself.", + "There's color here. TwinBee is choosing greyscale.", + "You arrive in a room. TwinBee declines to make it cinematic.", +} + +// ───────────────────────────────────────────────────────────────────────────── +// MOOD ASIDES — Friendly band (mood 60–79) +// Warm and helpful but not effusive. The middle-friendly read: TwinBee +// has noticed your competence and is rooting for you without making it weird. +// ───────────────────────────────────────────────────────────────────────────── + +var MoodAsidesFriendly = []string{ + "TwinBee is enjoying the run. Just enough to mention the door hinge that creaks before it opens. Just barely.", + "The mood is up. TwinBee will throw in an adjective or two more than strictly necessary. Treat them as gifts.", + "TwinBee is, frankly, having a fine time. You are doing the work. TwinBee is appreciating it.", + "The narration warms slightly. The dungeon is the same. TwinBee is in a marginally better mood and it shows.", + "TwinBee notices something nice and chooses to mention it. This is the equivalent of a small wave from a stranger. Take it.", + "You're playing well. TwinBee will not say so directly but the run rate of helpful adjectives is detectably up.", + "TwinBee is, by the standards of TwinBee, *cheerful*. The dungeon hasn't changed. The narration has.", +} diff --git a/internal/flavor/twinbee_resource_flavor.go b/internal/flavor/twinbee_resource_flavor.go index 58f2593..ebfceb3 100644 --- a/internal/flavor/twinbee_resource_flavor.go +++ b/internal/flavor/twinbee_resource_flavor.go @@ -27,7 +27,7 @@ var HarvestMineSuccess = []string{ } var HarvestScavengeSuccess = []string{ - "There it is. Among the debris, the decay, the things that were left behind — something worth taking. TwinBee knew it was there. You found it. TwinBee is pleased.", + "There it is. Among the debris, the decay, the things that were left behind — something worth taking. TwinBee knew it was there. You found it. Pleased.", "The room held something after all. TwinBee had estimated 60% odds and updates the estimate to 'correct.'", "Like finding the secret item in a dungeon chest that looked empty — you checked anyway. That's the habit. That's the discipline. TwinBee notes both.", "Scavenged. The word has a bad reputation it doesn't deserve. You found value in the discarded. TwinBee respects that entirely.", @@ -136,7 +136,7 @@ var HarvestSunkenTemple = []string{ var HarvestHauntedManor = []string{ "The manor keeps things. Has always kept things. TwinBee takes this one out of the keeping and into the useful, which is a small act of defiance against the house's whole philosophy.", "Found among the things that have been here since the last person stopped being here. TwinBee notes the provenance without dwelling on it.", - "The house watches you take it. TwinBee watches the house watch you. A full triangle of observation, and TwinBee notes: the house blinks first.", + "The house watches you take it. TwinBee watches the house watch you. A full triangle of observation — the house blinks first.", } var HarvestUnderforge = []string{ @@ -152,7 +152,7 @@ var HarvestUnderdark = []string{ } var HarvestFeywild = []string{ - "The Feywild gives things away. That's the problem — it gives things away and sometimes what it gives isn't what you thought you were taking. TwinBee checks the material twice. It appears to be what it appears to be. TwinBee remains alert.", + "The Feywild gives things away. That's the problem — it gives things away and sometimes what it gives isn't what you thought you were taking. TwinBee checks the material twice. It appears to be what it appears to be. Remains alert.", "Beautiful material from a zone that uses beauty as a weapon. TwinBee takes it carefully, like picking up something that might be watching.", "The fey made this place generous on purpose. TwinBee is not going to complain about the generosity and is not going to stop watching for the catch.", } @@ -221,7 +221,7 @@ var LootDropCommon = []string{ var LootDropUncommon = []string{ "Better than expected. TwinBee examines the drop with slightly elevated interest. Uncommon rarity — someone made this with intent.", "An uncommon drop from a common enemy. TwinBee notes the anomaly with satisfaction. The dungeon was generous in this room.", - "Uncommon. TwinBee turns it over once and nods. 'Keeper,' TwinBee says, which in TwinBee's vocabulary means: this changes your math.", + "Uncommon. TwinBee turns it over once and nods. 'Keeper,' it says, which in TwinBee's vocabulary means: this changes your math.", } var LootDropRare = []string{ @@ -231,7 +231,7 @@ var LootDropRare = []string{ } var LootDropLegendary = []string{ - "TwinBee goes very still. The drop sits in the light and TwinBee processes what it is seeing. 'Legendary,' TwinBee says eventually. One word. That's all it needs.", + "TwinBee goes very still. The drop sits in the light and it processes what it is seeing. 'Legendary,' TwinBee says eventually. One word. That's all it needs.", "Legendary rarity. TwinBee has seen a few of these in a long career and each time — each time — there is a moment that is separate from everything else. This is that moment. Pick it up carefully.", "The dungeon produced a legendary item. TwinBee notes the zone, the enemy, the day of the expedition, the Threat Clock value, the precise conditions. Some things deserve to be recorded completely.", } diff --git a/internal/flavor/zone_abyss_portal_flavor.go b/internal/flavor/zone_abyss_portal_flavor.go index 4cb980a..4c5c2c5 100644 --- a/internal/flavor/zone_abyss_portal_flavor.go +++ b/internal/flavor/zone_abyss_portal_flavor.go @@ -26,7 +26,7 @@ var RoomEntryAbyssPortal = []string{ "A chamber where reality has a seam. The seam is visible. It runs floor-to-ceiling along the north wall and what is visible through the seam is not the next room. TwinBee does not look directly at it and recommends you adopt the same policy.", "You enter what was once a chapel — to which god, you cannot tell, because the iconography has been overwritten in a script that hurts to read. The altar is intact. The thing on the altar is not what was placed there. TwinBee says nothing and moves you past it.", "The air pressure is wrong. Not low — wrong. There is a pressure that is not physical and it is leaning on you with the patience of a mountain. TwinBee identifies it as psychic ambient and recommends moving briskly.", - "A staircase. The stairs go down on the way up and up on the way down. TwinBee does not pause to verify this. TwinBee takes the stairs in the direction TwinBee intended to take them and the direction works, mostly.", + "A staircase. The stairs go down on the way up and up on the way down. TwinBee does not pause to verify this. Takes the stairs in the intended direction and the direction works, mostly.", "The corridor ahead is lit by something that is not fire. The light is red but the wrong red — the red of an alarm in a system that has never been fully implemented. TwinBee files this under 'demonic ambient' and shortens its stride.", "You enter a room that has been used as a portal anchor. The anchor is still in place. The portal is not, but the place where it was is still warm. TwinBee tracks the residual energy and notes the portal is not the only one — there are other warm spots, in other rooms, in a pattern.", "A garden. Indoors. The plants are not plants. TwinBee identifies them as 'demonic ornamentals — mid-tier, decorative, will react to perfumes' and recommends not perspiring near them.", diff --git a/internal/flavor/zone_crypt_valdris_flavor.go b/internal/flavor/zone_crypt_valdris_flavor.go index 5349ce5..758b280 100644 --- a/internal/flavor/zone_crypt_valdris_flavor.go +++ b/internal/flavor/zone_crypt_valdris_flavor.go @@ -78,7 +78,7 @@ var ValdrisSignatureCallouts = func() []string { var LoreLinesCrypt = []string{ "Valdris was a scholar. Then he was an aspirant. Then he was a failed aspirant. The failure is the part that mattered — the lich ritual completed but completed wrong, and the wrongness has had three centuries to compound. TwinBee has read the contemporaneous accounts and finds the original scholar likable, which makes the rest harder.", - "The phylactery shard you'll find here is one of seven. The other six are not in this crypt. TwinBee will not say where they are because TwinBee doesn't fully know, and the partial knowledge TwinBee has is the kind that gets people followed by things that prefer not to be looked for.", + "The phylactery shard you'll find here is one of seven. The other six are not in this crypt. TwinBee will not say where they are, because it doesn't fully know, and the partial knowledge it has is the kind that gets people followed by things that prefer not to be looked for.", "The candles in the crypt do not consume wax. They do not consume time, either, in the strict sense. They were lit on the day Valdris was interred and they have been lit ever since. TwinBee respects this kind of consistency in a way that does not extend to approval.", "The skeletons here are not all enemies. Some of them are former students, posed in the alcoves where they died, marked with the texts they were translating. TwinBee suggests you don't disturb the marked ones unless you mean to. Some lessons end and some lessons keep going.", "The iron gate at the entrance has been opened from the inside three times in the last decade, and from the outside zero times. TwinBee finds the asymmetry instructive. It also finds the pattern instructive: you are walking into the open door, not opening it.", diff --git a/internal/flavor/zone_feywild_crossing_flavor.go b/internal/flavor/zone_feywild_crossing_flavor.go index 4228a52..24d14cb 100644 --- a/internal/flavor/zone_feywild_crossing_flavor.go +++ b/internal/flavor/zone_feywild_crossing_flavor.go @@ -62,7 +62,7 @@ var EliteRoomEntryFeywildCrossing = []string{ // Coven Magic: extra spell slots scaled by GM Mood (high mood = stronger Thornmother). var ThornmotherCovenLines = []string{ - "Her slot pool scales with TwinBee's mood — Effusive TwinBee buys the Thornmother an extra round of high-tier casts. TwinBee says: 'My affection is taxable here. Sorry. Spread out.'", + "Her slot pool scales with TwinBee's mood — Effusive TwinBee buys the Thornmother an extra round of high-tier casts. 'My affection is taxable here,' it says. 'Sorry. Spread out.'", "Coven Magic. The slots above her base list are mood-scaled — the better the mood at the run's start, the worse this fight runs late. TwinBee files this under 'cosmic irony' and notes the math is fixed at zone entry.", "She has more spells than the sheet says. The extra ones come from the coven and the coven volume is set by TwinBee's mood. TwinBee tracks the count and warns the party when the bonus tier is in play.", } diff --git a/internal/flavor/zone_manor_blackspire_flavor.go b/internal/flavor/zone_manor_blackspire_flavor.go index 8916de2..1eb3e0c 100644 --- a/internal/flavor/zone_manor_blackspire_flavor.go +++ b/internal/flavor/zone_manor_blackspire_flavor.go @@ -33,7 +33,7 @@ var EliteRoomEntryManorBlackspire = []string{ "You enter what appears to be a guest bedroom. The bed is made. The candles are lit. A man sits on the edge of the bed with his back to you, perfectly still. He has been perfectly still for a long time. When he turns, only his head turns, and TwinBee notes the impossible angle and files this under 'revenant — directed undead, single purpose, finite lifespan, very patient.' The patience is the part that worries TwinBee.", "The hallway dead-ends in a chapel. There is a kneeling figure at the altar. The candles around him are unlit but somehow casting shadows. He has been praying since before the manor was haunted, and the prayer was not a request — it was a contract. TwinBee says: 'Revenant. The contract is the encounter. Break the contract, end the fight.'", "A library. The man at the table is reading a book that has no pages. He is reading it carefully. He looks up when you enter and his expression does something that suggests he was expecting a different visitor and is willing to make do. TwinBee notes the revenant's hands — the bones in the wrong places, the joints set with intent — and recommends ranged engagement until the geometry of the threat is clear.", - "The ballroom is empty except for one figure standing in the exact center, not dancing, not waiting, simply standing. The chandeliers above him are not moving. They should be moving — they were moving in the last room — and the fact that they have stopped where this figure is standing tells TwinBee everything TwinBee needed to know. 'Revenant in the middle of a stilled room,' TwinBee says. 'He brought the silence with him.'", + "The ballroom is empty except for one figure standing in the exact center, not dancing, not waiting, simply standing. The chandeliers above him are not moving. They should be moving — they were moving in the last room — and the fact that they have stopped where this figure is standing tells TwinBee everything it needed to know. 'Revenant in the middle of a stilled room,' it says. 'He brought the silence with him.'", "A nursery. Untouched. The cradle still rocks. The figure beside the cradle was, in life, the sort of man people did not survive being wronged by. In death he has been more thorough. TwinBee notes the revenant's grip on the cradle's edge and suggests, quietly, that whatever happened in this room is not the encounter — the encounter is what walks out of it.", } diff --git a/internal/flavor/zone_sunken_temple_flavor.go b/internal/flavor/zone_sunken_temple_flavor.go index ee1ef13..22e38f0 100644 --- a/internal/flavor/zone_sunken_temple_flavor.go +++ b/internal/flavor/zone_sunken_temple_flavor.go @@ -23,7 +23,7 @@ package flavor var RoomEntrySunkenTemple = []string{ "You step into water that's been in this room for thirty years. The floor is tiled and slick. The pillars are barnacled at chest height — that's the old waterline. The new waterline is at your ankles. TwinBee notes the temple is partway through deciding which it prefers.", "The chamber is half-flooded and half-lit. Salt in the air, salt on the walls, salt in places nothing oceanic should be reaching. TwinBee files this under 'unwell' and proceeds.", - "Glyphs cover the far wall in a script that doesn't match anything TwinBee recognizes. The angles are wrong on purpose. TwinBee keeps reading anyway, because TwinBee always tries, and stops when the reading starts to feel reciprocal.", + "Glyphs cover the far wall in a script that doesn't match anything TwinBee recognizes. The angles are wrong on purpose. It keeps reading anyway, because it always tries, and stops when the reading starts to feel reciprocal.", "The water in this room is not moving. Not the way still water doesn't move — the way a held breath doesn't move. TwinBee suggests not disturbing it more than necessary.", "Pillars rise from water that goes deeper than the room should allow. TwinBee tests the depth with the haft of a polearm and stops at the point where the haft stops finding bottom.", "A vaulted ceiling that's mostly intact. Water pools in places it shouldn't, drips from places it can't be coming from. TwinBee identifies the temple as 'wet on principle' and leaves it at that.", diff --git a/internal/flavor/zone_underforge_flavor.go b/internal/flavor/zone_underforge_flavor.go index 9a2a254..8a07c22 100644 --- a/internal/flavor/zone_underforge_flavor.go +++ b/internal/flavor/zone_underforge_flavor.go @@ -39,7 +39,7 @@ var RoomEntryUnderforge = []string{ // ───────────────────────────────────────────────────────────────────────────── var BossEntryEmberlordThyrak = []string{ - "The chamber is the largest forge TwinBee has narrated and TwinBee has narrated several. Nine furnaces in a ring. A central anvil the size of a wagon. The figure at the anvil is twelve feet of articulated iron and inset rune-stones that pulse to a tempo TwinBee can feel in its plating. The hammer comes down. The strike rings the room. The figure does not turn — finishes the strike, then turns. The eyes are not eyes. They are vents. 'Visitor,' Thyrak says, and the word is a furnace door opening. TwinBee takes a half-step back and says, very evenly, 'Forge-golem. Three centuries of self-improvement. Don't fight him on the lava.'", + "The chamber is the largest forge TwinBee has ever narrated, and it has narrated several. Nine furnaces in a ring. A central anvil the size of a wagon. The figure at the anvil is twelve feet of articulated iron and inset rune-stones that pulse to a tempo TwinBee can feel in its plating. The hammer comes down. The strike rings the room. The figure does not turn — finishes the strike, then turns. The eyes are not eyes. They are vents. 'Visitor,' Thyrak says, and the word is a furnace door opening. TwinBee takes a half-step back and says, very evenly, 'Forge-golem. Three centuries of self-improvement. Don't fight him on the lava.'", } // ───────────────────────────────────────────────────────────────────────────── diff --git a/internal/plugin/adv2_scenario_test.go b/internal/plugin/adv2_scenario_test.go index 235dd05..db5e382 100644 --- a/internal/plugin/adv2_scenario_test.go +++ b/internal/plugin/adv2_scenario_test.go @@ -76,9 +76,10 @@ func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) { } // 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 + // abandoned). When a fork is pending (Phase G branching graphs) auto-pick + // the first option via `!zone go 1` so the test doesn't stall on a + // diamond. Doubled iteration cap covers fork-then-advance pairs. + maxSteps := (run.TotalRooms + 4) * 2 clearedRoomTypes := []RoomType{} for step := 0; step < maxSteps; step++ { before, _ := getActiveZoneRun(uid) @@ -93,6 +94,13 @@ func TestAdv2Scenario_ZoneRunGoblinWarrens(t *testing.T) { } t.Logf("step %d: in room %d/%d (%s), mood=%d, HP=%d", step, before.CurrentRoom+1, before.TotalRooms, prevType, before.DMMood, prevHP) + if len(before.NodeChoices) > 0 { + // A fork is queued from the previous advance — commit it first. + if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "go 1"); err != nil { + t.Fatalf("zone go step %d: %v", step, err) + } + continue + } if err := p.handleDnDZoneCmd(MessageContext{Sender: uid}, "advance"); err != nil { t.Fatalf("zone advance step %d: %v", step, err) } diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 73804f5..9a85ac7 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -205,6 +205,13 @@ func (p *AdventurePlugin) Init() error { // columns from dnd_zone_run when the operator sets // GOGOBEE_BRANCHING_PURGE=1. Default off; idempotent. purgeLegacyZoneRunColumns() + // 2026-05-10 fun-pump: floor existing characters' DEX at 14 and + // recompute armor_class so armor upgrades actually translate into + // AC. One-shot, idempotent (WHERE dex_score < 14). + bumpDexFloorForExistingCharacters() + // 2026-05-10 immersion: seed short rest charges = dnd_level for any + // character not yet on the new charge system. One-shot, idempotent. + seedShortRestChargesForExistingCharacters() // 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 diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 71fda3f..6e21f63 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -1259,7 +1259,10 @@ func (p *AdventurePlugin) resolveArenaBoss(userID id.UserID, enc ArenaBossEncoun } preHP, _ := dndHPSnapshot(userID) - result, err = p.runZoneCombat(userID, monster, enc.Tier) + // Arena uses boss-shaped bestiary entries; give them the wider phase + // budget so the round resolver isn't decided by tiebreak. + // Arena has no run-state DMMood; pass neutral (50). + result, err = p.runZoneCombat(userID, monster, enc.Tier, bossCombatPhases, 50) if err != nil { return } @@ -1295,7 +1298,7 @@ func (p *AdventurePlugin) resolveArenaBoss(userID id.UserID, enc ArenaBossEncoun Nat20s: nat20s, Nat1s: nat1s, DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. The arena collects its fee.", monster.Name), - VictoryHeadline: fmt.Sprintf("🏆 **%s** falls (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP), + VictoryHeadline: fmt.Sprintf("🏆 **%s** falls. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP), }) return } diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 3785ad7..2ace364 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -122,29 +122,28 @@ func assessThreat(player, enemy CombatStats) threatLevel { // ── Selection Logic ────────────────────────────────────────────────────────── // SelectConsumables picks up to 2 items (1 offensive + 1 defensive) from inventory. -// contentTier caps consumable tier to the content being fought (0 = no cap). -func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int) []ConsumableItem { +// contentTier is retained on the signature for callers but no longer caps +// what gets considered — if it's in your bag, the picker can use it. +// allowSkipTrivial=true lets the picker bail out of obvious wins to save +// items — appropriate for dungeon dives full of chump rooms, but wrong for +// arena/zone elite/boss encounters where the legacy threat assessor +// underestimates d20-mode lethality (one bad nat-20 streak ends the run). +// +// Resource-saving still happens via betterOffensive/betterDefensive, which +// prefer lower-tier items on non-Dangerous threats. Players with mixed +// inventories don't burn a Voidstone Shard on a goblin; players with only +// high-tier items now actually get to use them. +func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats CombatStats, arenaRound int, contentTier int, allowSkipTrivial bool) []ConsumableItem { + _ = contentTier // retained for caller compatibility threat := assessThreat(playerStats, enemyStats) - // Arena losses cost real money + equipment durability — never skip there, - // even if the threat looks trivial. Adventure-side fights still skip - // trivial threats to avoid wasting items on chump enemies. - if threat == threatTrivial && arenaRound == 0 { + if threat == threatTrivial && allowSkipTrivial && arenaRound == 0 { return nil } - maxTier := maxConsumableTier(threat, arenaRound) - if contentTier > 0 && contentTier < maxTier { - maxTier = contentTier - } - var bestOffensive, bestDefensive *ConsumableItem for i := range inventory { item := &inventory[i] - if item.Def.Tier > maxTier { - continue - } - switch item.Def.Slot { case "offensive": if bestOffensive == nil || betterOffensive(item, bestOffensive, threat) { @@ -167,23 +166,6 @@ func SelectConsumables(inventory []ConsumableItem, playerStats, enemyStats Comba return selected } -func maxConsumableTier(threat threatLevel, arenaRound int) int { - base := 1 - switch threat { - case threatEasy: - base = 2 - case threatCompetitive: - base = 3 - case threatDangerous: - base = 5 - } - // Arena: spend freely on later rounds - if arenaRound >= 3 { - base = min(5, base+1) - } - return base -} - // betterOffensive prefers lowest tier that's appropriate for the threat. func betterOffensive(candidate, current *ConsumableItem, threat threatLevel) bool { if threat == threatDangerous { diff --git a/internal/plugin/adventure_consumables_test.go b/internal/plugin/adventure_consumables_test.go index fd2dcc5..a74f82c 100644 --- a/internal/plugin/adventure_consumables_test.go +++ b/internal/plugin/adventure_consumables_test.go @@ -19,7 +19,7 @@ func TestSelectConsumables_TrivialFightSkips(t *testing.T) { weak := CombatStats{MaxHP: 30, Attack: 5} inv := makeInventory("Berry Poultice", "Coal Bomb") - selected := SelectConsumables(inv, strong, weak, 0, 0) + selected := SelectConsumables(inv, strong, weak, 0, 0, true) if len(selected) != 0 { t.Errorf("should skip consumables for trivial fight, got %d", len(selected)) } @@ -30,7 +30,7 @@ func TestSelectConsumables_DangerousUsesHighTier(t *testing.T) { strong := CombatStats{MaxHP: 200, Attack: 50} inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil") - selected := SelectConsumables(inv, weak, strong, 0, 0) + selected := SelectConsumables(inv, weak, strong, 0, 0, true) if len(selected) != 2 { t.Fatalf("expected 2 consumables, got %d", len(selected)) } @@ -60,7 +60,7 @@ func TestSelectConsumables_MaxTwo(t *testing.T) { "Coal Bomb", "Goblin Grease", "Blooper Ink Vial", ) - selected := SelectConsumables(inv, player, enemy, 0, 0) + selected := SelectConsumables(inv, player, enemy, 0, 0, true) if len(selected) > 2 { t.Errorf("max 2 consumables, got %d", len(selected)) } @@ -87,7 +87,7 @@ func TestSelectConsumables_EasyUsesLowTier(t *testing.T) { inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Ancient Artifact Oil") - selected := SelectConsumables(inv, player, enemy, 0, 0) + selected := SelectConsumables(inv, player, enemy, 0, 0, true) for _, s := range selected { if s.Def.Tier > 2 { t.Errorf("easy fight should use T1-T2, got %s (T%d)", s.Def.Name, s.Def.Tier) @@ -101,8 +101,8 @@ func TestSelectConsumables_ArenaRoundEscalation(t *testing.T) { inv := makeInventory("Berry Poultice", "Spirit Tonic", "Coal Bomb", "Sapphire Elixir") - r1 := SelectConsumables(inv, player, enemy, 1, 0) - r4 := SelectConsumables(inv, player, enemy, 4, 0) + r1 := SelectConsumables(inv, player, enemy, 1, 0, true) + r4 := SelectConsumables(inv, player, enemy, 4, 0, true) maxTierR1 := 0 for _, s := range r1 { diff --git a/internal/plugin/adventure_flavor_treasure.go b/internal/plugin/adventure_flavor_treasure.go index bbb4731..fdfad13 100644 --- a/internal/plugin/adventure_flavor_treasure.go +++ b/internal/plugin/adventure_flavor_treasure.go @@ -353,13 +353,13 @@ var SummaryDungeonExceptional = []string{ } var SummaryDungeonDeath = []string{ - "Died in {location}. Back in {hours}h. The {location} remains standing.", - "{location}: death. Equipment damaged. Healthcare involved. {hours}h respawn.", - "Did not survive {location}. The {location} did. {hours}h recovery.", - "Killed in {location}. American healthcare has the rest. {hours}h.", - "{location} wins this round. {name} recovers in {hours}h.", - "Dead. {location}. {hours} hours. The equipment is worse.", - "{name} lost the argument with {location}. Healthcare is mediating. {hours}h.", + "Died in {location}. Back in {duration}. The {location} remains standing.", + "{location}: death. Equipment damaged. Healthcare involved. {duration} respawn.", + "Did not survive {location}. The {location} did. {duration} recovery.", + "Killed in {location}. American healthcare has the rest. {duration}.", + "{location} wins this round. {name} recovers in {duration}.", + "Dead. {location}. {duration}. The equipment is worse.", + "{name} lost the argument with {location}. Healthcare is mediating. {duration}.", } var SummaryDungeonEmpty = []string{ @@ -381,11 +381,11 @@ var SummaryMiningSuccess = []string{ } var SummaryMiningDeath = []string{ - "Cave-in. {location}. Healthcare called. {hours}h.", - "Died in {location}. Mining death. Rarer than dungeon death. Still death. {hours}h.", - "{location} expressed structural concerns physically. {name}: {hours}h recovery.", - "The {location} ceiling had a perspective. {name}: medical. {hours}h.", - "Dead in {location}. The ore is still in there. {hours}h.", + "Cave-in. {location}. Healthcare called. {duration}.", + "Died in {location}. Mining death. Rarer than dungeon death. Still death. {duration}.", + "{location} expressed structural concerns physically. {name}: {duration} recovery.", + "The {location} ceiling had a perspective. {name}: medical. {duration}.", + "Dead in {location}. The ore is still in there. {duration}.", } var SummaryMiningEmpty = []string{ @@ -412,10 +412,10 @@ var SummaryForagingEmpty = []string{ } var SummaryForagingDeath = []string{ - "Died foraging. In {location}. Yes, foraging. {hours}h.", - "{location} produced a fatal outcome via non-combat means. {hours}h.", - "Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {hours}h.", - "Foraging death. {location}. The forest won. {hours}h.", + "Died foraging. In {location}. Yes, foraging. {duration}.", + "{location} produced a fatal outcome via non-combat means. {duration}.", + "Dead in {location}. Bear/hornets/river/tree/mushroom involvement. {duration}.", + "Foraging death. {location}. The forest won. {duration}.", } var SummaryForagingHornets = []string{ @@ -468,7 +468,7 @@ var SummaryStandoutDeath = []string{ "💀 {name} lost to {location}. The rats/goblins/trolls send their regards.", "💀 Notable loss: {name} in {location}. Healthcare is familiar with the file.", "💀 {name} did not survive {location} today. A learning experience. Expensive.", - "💀 {location} claimed {name}. {hours}h recovery. The dungeon has been noted.", + "💀 {location} claimed {name}. {duration} recovery. The dungeon has been noted.", } var SummaryStandoutTreasure = []string{ diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index f4d6a4f..2382219 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -735,6 +735,15 @@ type AdvPlayerDaySummary struct { LootValue int64 IsDead bool DeadUntil string + // DeadUntilHours is the integer hours-from-now until revival, used by + // the standout-death template's {hours} placeholder. Computed when + // the summary row is built; 0 if not dead or already past revival. + DeadUntilHours int + // DeadUntilDuration is the human-readable hours+minutes form of the + // remaining respawn time ("5h 27m", "47m", "2h"). Used by the + // {duration} placeholder for templates that want precision over the + // rounded {hours} count. + DeadUntilDuration string IsResting bool SummaryLine string HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both @@ -933,9 +942,19 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa if lossLoc == "" { lossLoc = worstPlayer.Location } + hours := worstPlayer.DeadUntilHours + if hours <= 0 { + hours = 6 // canonical respawn duration when revival time isn't computable + } + duration := worstPlayer.DeadUntilDuration + if duration == "" { + duration = fmt.Sprintf("%dh", hours) + } line = advSubstituteFlavor(line, map[string]string{ "{name}": worstPlayer.DisplayName, "{location}": lossLoc, + "{hours}": fmt.Sprintf("%d", hours), + "{duration}": duration, }) sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line)) } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 2454880..febac2d 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -272,6 +272,22 @@ func (p *AdventurePlugin) postDailySummary() { ps.DeathLocation = c.DeathLocation if c.DeadUntil != nil { ps.DeadUntil = c.DeadUntil.Format("15:04") + " UTC" + remaining := time.Until(*c.DeadUntil) + if remaining > 0 { + hrs := int(remaining.Hours()) + mins := int(remaining.Minutes()) - hrs*60 + // {hours}: round half-up integer hours. + ps.DeadUntilHours = int(remaining.Hours() + 0.5) + // {duration}: precise — "5h 27m", "47m", "2h". + switch { + case hrs > 0 && mins > 0: + ps.DeadUntilDuration = fmt.Sprintf("%dh %dm", hrs, mins) + case hrs > 0: + ps.DeadUntilDuration = fmt.Sprintf("%dh", hrs) + default: + ps.DeadUntilDuration = fmt.Sprintf("%dm", mins) + } + } } if len(acts) > 0 { last := acts[len(acts)-1] diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index bf5d96d..e3413bc 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -55,11 +55,12 @@ func (p *AdventurePlugin) runDungeonCombat( playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge) - // Penalty zone: player is weakened (mirrors +5% death, -15% success from old system) + // Penalty zone Attack/Defense reduction (mirrors +5% death, -15% success + // from old system). The MaxHP penalty is applied below, after + // applyDnDPlayerLayer reseats MaxHP onto the dnd HP scale. if inPenaltyZone { playerStats.Attack = int(float64(playerStats.Attack) * 0.85) playerStats.Defense = int(float64(playerStats.Defense) * 0.85) - playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90) } // Load consumables from inventory and auto-select @@ -75,6 +76,12 @@ func (p *AdventurePlugin) runDungeonCombat( applyDnDPlayerLayer(&playerStats, dndChar) applyDnDEquipmentLayer(&playerStats, dndChar, equip) applyDnDHPScaling(&playerStats, dndChar) + if inPenaltyZone { + playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90) + if playerStats.StartHP > 0 { + playerStats.StartHP = int(float64(playerStats.StartHP) * 0.90) + } + } applyClassPassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar) @@ -84,8 +91,8 @@ func (p *AdventurePlugin) runDungeonCombat( applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier) applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats) - selected := SelectConsumables(consumables, playerStats, enemyStats, 0, loc.Tier) - ApplyConsumableMods(&playerStats, &playerMods, selected) + // Auto-consumable: panic heal only (see dnd_boss_consumables.go). + setupAutoHealFromInventory(consumables, &playerMods) // Dungeon monsters T3+ can have abilities var ability *MonsterAbility @@ -113,18 +120,16 @@ func (p *AdventurePlugin) runDungeonCombat( } result := SimulateCombat(player, enemy, dungeonCombatPhases) - result = injectConsumableEvents(result, selected, len(consumables)) + dumpCombatEventsIfDebug(fmt.Sprintf("dungeon:%s vs %s", loc.Name, displayName), result) - // Consume used items from inventory - for _, c := range selected { - if err := removeAdvInventoryItem(c.InventoryID); err != nil { - slog.Error("combat: failed to consume item", "id", c.InventoryID, "name", c.Def.Name, "err", err) - } - } + // Remove the actual heal items consumed during combat. No more + // burning Coal Bombs / Ink Vials on chump fights — those stay put + // until a player-driven use command lands. + consumeFiredHealingItems(userID, countHealEventsFired(result)) p.grantCombatAchievements(userID, result) - persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP) + persistDnDHPAfterCombat(userID, result.PlayerEndHP) if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil { slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err) } @@ -393,12 +398,25 @@ func checkDeathSaveEvent(events []CombatEvent) bool { // injectConsumableEvents prepends use_consumable events so the narrative shows which items were consumed. // If items were available but skipped (trivial threat), a skip event is injected instead. +// +// Uses PlayerEntryHP / EnemyEntryHP — the actual HP each side entered combat +// with — so the pre-combat HP line reflects wounded carry-over honestly. Was +// using PlayerStartHP (= MaxHP), which made wounded entries display "47/47" +// and made any HP loss in opening look like the player took silent damage. func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inventorySize int) CombatResult { + entryHP := result.PlayerEntryHP + if entryHP == 0 { + entryHP = result.PlayerStartHP + } + enemyEntryHP := result.EnemyEntryHP + if enemyEntryHP == 0 { + enemyEntryHP = result.EnemyStartHP + } if len(selected) == 0 { if inventorySize > 0 { result.Events = append([]CombatEvent{{ Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "consumable_skip", - PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP, + PlayerHP: entryHP, EnemyHP: enemyEntryHP, }}, result.Events...) } return result @@ -407,7 +425,7 @@ func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inve for _, c := range selected { events = append(events, CombatEvent{ Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable", - PlayerHP: result.PlayerStartHP, EnemyHP: result.EnemyStartHP, + PlayerHP: entryHP, EnemyHP: enemyEntryHP, Desc: c.Def.Name, }) } diff --git a/internal/plugin/combat_debug.go b/internal/plugin/combat_debug.go new file mode 100644 index 0000000..6f6ff8c --- /dev/null +++ b/internal/plugin/combat_debug.go @@ -0,0 +1,44 @@ +package plugin + +import ( + "fmt" + "log/slog" + "os" + "strings" +) + +// dumpCombatEventsIfDebug logs every event in a CombatResult to slog.Info +// when GOGOBEE_COMBAT_DEBUG=1 is set. Renders one event per line in a +// compact form: "[round phase actor.action] hp=P/E roll=R vsAC=V dmg=D desc". +// +// Used to diagnose ghost damage / dropped events: the renderer silently +// returns "" for unknown actions, which makes damage that hit the engine +// state but never reached the chat invisible. This dump shows everything. +func dumpCombatEventsIfDebug(label string, result CombatResult) { + if os.Getenv("GOGOBEE_COMBAT_DEBUG") != "1" { + return + } + var b strings.Builder + b.WriteString(fmt.Sprintf("\n=== combat dump: %s ===\n", label)) + b.WriteString(fmt.Sprintf("PlayerStartHP=%d PlayerEntryHP=%d EnemyStartHP=%d EnemyEntryHP=%d PlayerEndHP=%d EnemyEndHP=%d won=%v\n", + result.PlayerStartHP, result.PlayerEntryHP, result.EnemyStartHP, result.EnemyEntryHP, + result.PlayerEndHP, result.EnemyEndHP, result.PlayerWon)) + prevP := result.PlayerEntryHP + if prevP == 0 { + prevP = result.PlayerStartHP + } + prevE := result.EnemyEntryHP + if prevE == 0 { + prevE = result.EnemyStartHP + } + for i, e := range result.Events { + dp := e.PlayerHP - prevP + de := e.EnemyHP - prevE + b.WriteString(fmt.Sprintf(" %3d [r%d %s %s.%s] hp=%d/%d (Δp=%+d Δe=%+d) roll=%d vsAC=%d dmg=%d desc=%q\n", + i, e.Round, e.Phase, e.Actor, e.Action, e.PlayerHP, e.EnemyHP, dp, de, + e.Roll, e.RollAgainst, e.Damage, e.Desc)) + prevP, prevE = e.PlayerHP, e.EnemyHP + } + b.WriteString("=== end dump ===\n") + slog.Info("combat debug dump", "log", b.String()) +} diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index bc3c172..0176693 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -10,11 +10,17 @@ type CombatStats struct { // "use MaxHP" (full health). Wounded carry-over uses this so MaxHP // stays stable across fights — display reads "100/123", not "100/100". StartHP int + // HPBonus is the absolute HP players gain from equipment / arena sets / + // housing on top of their D&D character sheet HP. DerivePlayerStats + // computes this; applyDnDPlayerLayer adds it to c.HPMax to form the + // final combat MaxHP. Kept separate so persistence to dnd_character can + // simply clamp endHP to c.HPMax — gear cushion doesn't carry over. + HPBonus int Attack int Defense int Speed int - CritRate float64 // legacy; superseded by nat-20 crits but still consumed by autoCrit logic & equipment scaling. - DodgeRate float64 // legacy; no longer queried by hit resolution but still computed for narrative scaling. + CritRate float64 // superseded by nat-20 crits but still consumed by autoCrit logic & equipment scaling. + DodgeRate float64 // not queried by hit resolution but still computed for narrative scaling. BlockRate float64 // still used to halve damage on a successful hit. // D&D layer. AC and AttackBonus drive d20-vs-AC hit resolution. @@ -45,7 +51,16 @@ type CombatModifiers struct { MistyHealAmt int CrowdRevengeProc float64 // Misty debuff: chance per round of crowd damage CrowdRevengeDmg int // Misty debuff: damage per proc - HealItem int // consumable: HP restored at <50% HP (one-shot) + HealItem int // consumable: HP restored per heal trigger + // HealItemCharges is the number of times the heal-at-<50%-HP trigger + // can fire. 1 = legacy one-shot. Boss fights set this from inventory + // healing-item count so a stocked-up player can sustain through long + // fights instead of the heal becoming a single weak trigger. + HealItemCharges int + // InitiativeBias adds to the player's initiative roll each round. + // Used by the DM mood system to tilt who-goes-first: positive favors + // the player (Effusive mood), negative favors the enemy (Hostile). + InitiativeBias float64 WardCharges int // consumable: hits fully absorbed SporeCloud int // consumable: rounds of 15% enemy miss chance ReflectNext float64 // consumable: fraction of next hit reflected @@ -150,9 +165,21 @@ type CombatEvent struct { type CombatResult struct { PlayerWon bool + // TimedOut is true when the fight ran out the phase clock without a + // kill on either side and was decided by the HP-percentage tiebreak. + // Callers should treat a timeout loss as a retreat / escape — the + // player didn't actually take a fatal blow, so character-death side + // effects (markAdventureDead, respawn timer) should NOT fire. + TimedOut bool Events []CombatEvent - PlayerStartHP int + PlayerStartHP int // MaxHP — display denominator + // PlayerEntryHP is the actual HP the player entered combat with (== MaxHP + // at full health, less when wounded carry-over via applyDnDHPScaling). + // Used by injectConsumableEvents so pre-combat narration shows the + // wounded entry state instead of lying "47/47" on the consumable line. + PlayerEntryHP int EnemyStartHP int + EnemyEntryHP int PlayerEndHP int EnemyEndHP int TotalRounds int @@ -194,6 +221,18 @@ var dungeonCombatPhases = []CombatPhase{ {"Sudden Death", 4, 1.1, 0.6, 1.0, 0.05}, } +// bossCombatPhases extends the regular dungeon phases for boss encounters. +// Boss HP pools (97–546) are too large for auto-resolve to close in the +// 8-round dungeon budget — players hit a wall and time out. Doubling the +// Sudden Death budget gives sustained pressure a chance to close the gap. +// Real fix is the turn-based-bosses refactor; this is the bridge tuning. +var bossCombatPhases = []CombatPhase{ + {"Opening", 1, 0.8, 0.8, 1.2, 0.10}, + {"Clash", 3, 1.0, 1.0, 0.8, 0.08}, + {"Decisive", 2, 1.0, 0.7, 1.0, 0.05}, + {"Sudden Death", 10, 1.1, 0.6, 1.0, 0.05}, +} + // ── Simulation ─────────────────────────────────────────────────────────────── // combatState tracks mutable state during the simulation. @@ -202,7 +241,7 @@ type combatState struct { enemyHP int // Consumable one-shots - healUsed bool + healChargesLeft int // remaining heal-at-<50% triggers wardCharges int sporeRounds int reflectFrac float64 @@ -259,10 +298,18 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult enemySkipFirst: player.Mods.SpellEnemySkipFirst, arcaneWardHP: player.Mods.ArcaneWardHP, } + // HealItemCharges: explicit count overrides legacy one-shot. Backfill + // to 1 charge if the caller set a HealItem amount but no count. + st.healChargesLeft = player.Mods.HealItemCharges + if st.healChargesLeft == 0 && player.Mods.HealItem > 0 { + st.healChargesLeft = 1 + } result := CombatResult{ PlayerStartHP: player.Stats.MaxHP, + PlayerEntryHP: playerStart, EnemyStartHP: enemy.Stats.MaxHP, + EnemyEntryHP: enemyStart, } // Pre-combat: Arina sniper check @@ -324,19 +371,27 @@ func SimulateCombat(player, enemy Combatant, phases []CombatPhase) CombatResult } } - // If we exhaust all phases without a kill, tiebreak by absolute HP. - // Was %-based, which produced unintuitive outcomes (e.g. 88/123 player - // losing to 83/97 boss because the boss had a higher percentage). - if st.playerHP < st.enemyHP { - st.playerHP = 0 - } else { - st.enemyHP = 0 - } + // If we exhaust all phases without a kill, tiebreak by HP percentage + // to decide the *outcome* (PlayerWon flag) — but DO NOT zero out HP + // on the loser. Timeout = retreat, not lethal blow. Caller treats a + // timeout loss as "fight ended, no character death". + // + // Absolute-HP tiebreak (briefly used on the legacy ~120 HP scale) was + // wrong post HP-unification: monster pools (~30–175) are 2-3× player + // pools (~13–83), so absolute always favored the larger combatant + // even when the player took less proportional damage. Slight bias + // toward the player on exact ties (frac >=). + result.TimedOut = true + playerFrac := float64(st.playerHP) / float64(max(1, player.Stats.MaxHP)) + enemyFrac := float64(st.enemyHP) / float64(max(1, enemy.Stats.MaxHP)) + playerWonTiebreak := playerFrac >= enemyFrac st.events = append(st.events, CombatEvent{ Round: st.round, Phase: "exhaust", Actor: "system", Action: "timeout", PlayerHP: st.playerHP, EnemyHP: st.enemyHP, }) - return finalize(result, st, player, enemy) + out := finalize(result, st, player, enemy) + out.PlayerWon = playerWonTiebreak + return out } // simulateRound runs one round. Returns true if combat is over. @@ -399,10 +454,12 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase // Spore cloud: enemy has 15% miss chance (decremented when enemy actually attacks) sporeMiss := st.sporeRounds > 0 && rand.Float64() < 0.15 - // Determine initiative + // Determine initiative. DM mood (Effusive/Hostile) biases the player's + // roll via player.Mods.InitiativeBias — +X means player goes first + // more often, -X means the enemy does. playerSpeed := float64(player.Stats.Speed) * phase.SpeedWeight enemySpeed := float64(enemy.Stats.Speed) * phase.SpeedWeight - playerInit := playerSpeed + rand.Float64()*10 + playerInit := playerSpeed + rand.Float64()*10 + player.Mods.InitiativeBias enemyInit := enemySpeed + rand.Float64()*10 playerFirst := playerInit >= enemyInit @@ -483,10 +540,16 @@ func simulateRound(st *combatState, player, enemy *Combatant, phase *CombatPhase }) } - // Consumable heal: triggers once when player drops below 50% - if !st.healUsed && player.Mods.HealItem > 0 && - st.playerHP > 0 && st.playerHP < player.Stats.MaxHP/2 { - st.healUsed = true + // Consumable heal: triggers when player drops below 60% HP. Fires up + // to HealItemCharges times per fight (1 = legacy one-shot; bosses can + // stack from inventory). + // + // Threshold is 60% rather than 50% to give low-HP classes (cleric, + // mage) more breathing room — at 50% a cleric was bleeding into the + // danger zone before the heal fired. + if st.healChargesLeft > 0 && player.Mods.HealItem > 0 && + st.playerHP > 0 && st.playerHP*5 < player.Stats.MaxHP*3 { + st.healChargesLeft-- healAmt := player.Mods.HealItem st.playerHP = min(player.Stats.MaxHP, st.playerHP+healAmt) st.events = append(st.events, CombatEvent{ diff --git a/internal/plugin/combat_narrative.go b/internal/plugin/combat_narrative.go index 7e3f08c..6bd1a3e 100644 --- a/internal/plugin/combat_narrative.go +++ b/internal/plugin/combat_narrative.go @@ -11,6 +11,7 @@ type actionPicker struct { enemy map[int]bool player map[int]bool playerMiss map[int]bool + enemyMiss map[int]bool block map[int]bool environment map[int]bool } @@ -20,6 +21,7 @@ func newActionPicker() *actionPicker { enemy: make(map[int]bool), player: make(map[int]bool), playerMiss: make(map[int]bool), + enemyMiss: make(map[int]bool), block: make(map[int]bool), environment: make(map[int]bool), } @@ -95,7 +97,24 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat header := phaseHeader(pg.Name) sb.WriteString(header + "\n") - for _, e := range pg.Events { + // Walk events with a 3+ consecutive-trivial collapse: long runs of + // "miss / miss / miss" get one TwinBee yadda-yadda line instead of N + // individual lines. Keeps boss fights (16 rounds) from drowning in + // repetition while still rendering every impactful beat. + i := 0 + for i < len(pg.Events) { + e := pg.Events[i] + if isTrivialEvent(e) { + run := 1 + for i+run < len(pg.Events) && isTrivialEvent(pg.Events[i+run]) { + run++ + } + if run >= 3 { + sb.WriteString(pickRand(narrativeFillerSlog) + "\n") + i += run + continue + } + } line := renderEvent(e, playerName, enemyName, result, picker) if line != "" { if roll := rollAnnotation(e); roll != "" { @@ -103,6 +122,7 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat } sb.WriteString(line + "\n") } + i++ } if len(pg.Events) == 0 { @@ -116,6 +136,28 @@ func renderPhaseBlock(pg phaseGroup, playerName, enemyName string, result Combat return sb.String() } +// isTrivialEvent — events that don't move HP or set up a tactical hook. +// A run of 3+ of these collapses into a TwinBee filler line. +func isTrivialEvent(e CombatEvent) bool { + switch e.Action { + case "miss", "spore_miss", "pet_whiff": + return true + } + return false +} + +// narrativeFillerSlog covers a run of consecutive trivial events +// (misses, whiffs) so long boss fights don't read as a wall of +// "you swing wide / they swing wide" lines. +var narrativeFillerSlog = []string{ + "_The exchange settles into a rhythm. A few rounds of nothing meaningful — TwinBee skips to the next thing that matters._", + "_TwinBee narrates the next few seconds in a single sigh. Both sides keep swinging. Both sides keep missing._", + "_And the battle goes on. Yadda yadda yadda. TwinBee will narrate the next part where something actually lands._", + "_The grind continues. TwinBee uses the time to mentally redesign the dungeon's lighting._", + "_A flurry of misses on both sides. Nobody is impressing anybody. TwinBee waits for the next hit._", + "_TwinBee politely declines to narrate this stretch. It was, in TwinBee's professional opinion, a waste of everyone's time._", +} + func clampHP(hp int) int { if hp < 0 { return 0 @@ -186,7 +228,10 @@ func renderEvent(e CombatEvent, playerName, enemyName string, result CombatResul return fmt.Sprintf(pickRand(narrativePlayerBlock), e.Damage) case "miss": - return pickFromNoFmt(narrativeMiss, picker.playerMiss) + if e.Actor == "player" { + return pickFromNoFmt(narrativePlayerMiss, picker.playerMiss) + } + return pickFromNoFmt(narrativeEnemyMiss, picker.enemyMiss) case "pet_attack": return fmt.Sprintf(pickRand(narrativePetAttack), e.Damage) @@ -403,7 +448,7 @@ var narrativeEnemyBlock = []string{ "The enemy's defense absorbs most of it. %d damage. The enemy's forearm speaks to the pathetic nature of your striking.", } -var narrativeMiss = []string{ +var narrativePlayerMiss = []string{ "Your swing goes wide. Nothing connects. The air you hit had it coming.", "The enemy anticipated that one. Complete miss. They're already bored.", "You overcommit and hit nothing but air. The air files no complaint.", @@ -412,6 +457,16 @@ var narrativeMiss = []string{ "You close your eyes for the strike because it feels more dramatic. You miss. Everything about this was predictable.", } +var narrativeEnemyMiss = []string{ + "The enemy lunges. Finds only air. The air takes the slight personally.", + "Their strike misses by inches. They curse in a language you don't recognize.", + "A wild swing, deflected by your stance. They expected better of themselves.", + "The blow comes in too predictable. You're already gone. They commit. They miss.", + "The enemy whiffs. The look on their face — what passes for one — is priceless.", + "Their weapon meets your guard, slides past harmless. They've used this combo before. It worked then.", + "The strike is fast. You are slightly faster. The math works out in your favor for once.", +} + var narrativePetAttack = []string{ "🐾 Your pet lunges at the enemy from absolutely nowhere. %d damage. Your pet has been waiting for this.", "🐾 Your pet strikes from the side with zero hesitation. %d additional damage. The enemy was not monitoring the pet. Mistake.", diff --git a/internal/plugin/combat_stats.go b/internal/plugin/combat_stats.go index 2cd397f..f4e9a26 100644 --- a/internal/plugin/combat_stats.go +++ b/internal/plugin/combat_stats.go @@ -16,12 +16,17 @@ func DerivePlayerStats( ) (CombatStats, CombatModifiers) { arenaSets := advEquippedArenaSets(equip) - // Base stats from combat level + // Stat baselines. CombatLevel is dead — player power keys off gear here + // and dnd_level / ability scores via applyDnDPlayerLayer afterwards. + // MaxHP is computed below only so the armor% / arena% / housing% bonus + // formulas have something to scale against; the base is subtracted out + // when we capture stats.HPBonus, so it never reaches combat. + const legacyBase = 50 stats := CombatStats{ - MaxHP: 50 + char.CombatLevel*2, - Attack: 5 + char.CombatLevel + bonuses.CombatBonus, - Defense: 3 + char.CombatLevel/2 + bonuses.CombatBonus/2, - Speed: 5 + char.CombatLevel/3, + MaxHP: legacyBase, + Attack: 5 + bonuses.CombatBonus, + Defense: 3 + bonuses.CombatBonus/2, + Speed: 5, CritRate: 0.03, DodgeRate: 0.02, BlockRate: 0.02, @@ -75,6 +80,13 @@ func DerivePlayerStats( // Housing HP bonus stats.MaxHP += int(float64(stats.MaxHP) * char.HouseHPBonus()) + // Capture the equipment/arena/housing HP delta as fight-only headroom. + // applyDnDPlayerLayer adds this to c.HPMax to form combat MaxHP — gear + // power preserved, dnd_character.hp_current is the canonical wound store + // with no scale conversion. The legacy 50+CL*2 base is intentionally + // dropped; monster damage is tuned to the dnd HP scale. + stats.HPBonus = stats.MaxHP - legacyBase + // Streak bonuses switch { case streak >= 30: @@ -196,22 +208,19 @@ func DeriveArenaMonsterStats(monster *ArenaMonster) (CombatStats, CombatModifier } // DeriveDungeonMonsterStats synthesizes monster stats from a dungeon location. -// Target: the stat block should produce a death rate roughly matching BaseDeathPct -// when the player is at the location's MinLevel with tier-appropriate equipment. -// The player typically has higher base stats, so monsters compensate with HP bulk -// and just enough Attack to be threatening over the fight's duration. +// Tuned to the dnd HP scale (post HP-unification): well-equipped players at +// the location's MinLevel should win the vast majority of fights, with deaths +// coming from bad luck (crits, hazards, initiative). Old quadratic Attack +// formula assumed legacy ~100 HP players; the new linear scaling matches the +// ~12–80 HP range of dnd-scale fighters. func DeriveDungeonMonsterStats(loc *AdvLocation) (CombatStats, CombatModifiers) { t := float64(loc.Tier) death := loc.BaseDeathPct // 8 to 60 - // Well-equipped players at the right level should be dominant (win 85-95%+). - // Deaths come from bad luck: enemy crits, environmental hazards, unlucky initiative. - // Monster Attack needs to be high enough to threaten over multiple rounds via - // penetration damage, but not enough to kill quickly. stats := CombatStats{ - MaxHP: 25 + int(t*t*6+death*0.6), // quadratic: T1=33, T5=187 - Attack: 3 + int(death*0.2) + int(t*t*1.5), // quadratic: T1=5, T5=49 - Defense: 2 + int(t*1.5), + MaxHP: 12 + int(t*7+death*0.3), // T1≈22, T5≈65 — sized so dnd-scale players can finish a kill within the phase budget + Attack: int(t*1.5) + int(death*0.04), // T1=1, T5=9 — tuned to dnd HP pools (players have 13–80 HP, not 100+) + Defense: 2 + int(t*1.2), Speed: 4 + int(t*1.5), CritRate: 0.03 + death*0.003, DodgeRate: 0.02 + t*0.008, diff --git a/internal/plugin/combat_stats_test.go b/internal/plugin/combat_stats_test.go index 5e9cdb2..5460be0 100644 --- a/internal/plugin/combat_stats_test.go +++ b/internal/plugin/combat_stats_test.go @@ -23,20 +23,45 @@ func testChar(combatLevel int) *AdventureCharacter { } } +// balanceTestDnDChar synthesizes a fighter sheet at the given dnd level for +// balance regression sims. Mirrors ensureDnDCharacterForCombat → +// autoBuildCharacter. CombatLevel is dead — combat keys off this sheet +// (HP/AC/AB/weapon dice) plus gear bonuses, nothing else. +func balanceTestDnDChar(dndLevel int) *DnDCharacter { + scores := classStatPriority(ClassFighter) + c := &DnDCharacter{ + Class: ClassFighter, Level: dndLevel, + STR: scores[0], DEX: scores[1], CON: scores[2], + INT: scores[3], WIS: scores[4], CHA: scores[5], + } + conMod := abilityModifier(c.CON) + dexMod := abilityModifier(c.DEX) + c.HPMax = computeMaxHP(c.Class, conMod, c.Level) + c.HPCurrent = c.HPMax + c.ArmorClass = computeAC(c.Class, dexMod) + return c +} + func zeroBonuses() *AdvBonusSummary { return &AdvBonusSummary{} } func TestDerivePlayerStats_BaseStats(t *testing.T) { + // Post HP-unification + CL-strip: DerivePlayerStats no longer scales + // off CombatLevel. With zero gear, output is the constant baseline that + // applyDnDPlayerLayer then layers c.HPMax / weapon dice / etc. on top. char := testChar(10) equip := testEquip(0) stats, _ := DerivePlayerStats(char, equip, zeroBonuses(), 0, 0, false) - if stats.MaxHP < 70 { - t.Errorf("level 10 base MaxHP = %d, want >= 70", stats.MaxHP) + if stats.MaxHP != 50 { + t.Errorf("zero-gear MaxHP = %d, want 50 (legacy base used only for HPBonus calc)", stats.MaxHP) } - if stats.Attack < 15 { - t.Errorf("level 10 base Attack = %d, want >= 15", stats.Attack) + if stats.HPBonus != 0 { + t.Errorf("zero-gear HPBonus = %d, want 0 (no gear/arena/housing buffs)", stats.HPBonus) + } + if stats.Attack != 5 { + t.Errorf("zero-gear Attack = %d, want 5 (legacy fallback only; production uses weapon dice)", stats.Attack) } } @@ -249,12 +274,14 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) { maxDeath float64 minDeath float64 } + // `level` is the dnd_level (CombatLevel is no longer consulted for combat + // scaling — gear + dnd sheet drive everything). cases := []tc{ - {5, 1, advDungeons[0], 0.05, 0.0}, // T1: trivial with proper gear - {12, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level - {25, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear - {38, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared - {48, 5, advDungeons[4], 0.35, 0.01}, // T5: real danger — monster stats catch up. Lower bound is loose because the Sudden Death phase added round headroom that lets geared players close fights more often. + {1, 1, advDungeons[0], 0.05, 0.0}, // T1: trivial with proper gear + {2, 2, advDungeons[1], 0.10, 0.0}, // T2: very easy at level + {5, 3, advDungeons[2], 0.15, 0.0}, // T3: low risk at level with T3 gear + {7, 4, advDungeons[3], 0.25, 0.0}, // T4: some risk even geared + {9, 5, advDungeons[4], 0.35, 0.01}, // T5: real danger — monster stats catch up } for _, c := range cases { @@ -264,6 +291,14 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) { stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false) eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon) + // Apply the D&D layers so the test reflects production combat + // HP/AC/AB/weapon-dice (post HP-unification, raw DerivePlayerStats + // no longer matches what players actually fight with). + dndChar := balanceTestDnDChar(c.level) + applyDnDPlayerLayer(&stats, dndChar) + applyDnDEquipmentLayer(&stats, dndChar, equip) + applyDnDDungeonMonsterLayer(&eStats, c.dungeon.Tier) + player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true} enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods} @@ -276,10 +311,10 @@ func TestBalanceRegression_DungeonDeathRates(t *testing.T) { } } rate := float64(deaths) / float64(N) - t.Logf("%s (L%d T%d): P[HP=%d Atk=%d Def=%d Spd=%d] E[HP=%d Atk=%d Def=%d Spd=%d] → death=%.2f", + t.Logf("%s (L%d T%d): P[HP=%d AC=%d AB=%d Def=%d] E[HP=%d AC=%d AB=%d Atk=%d] → death=%.2f", c.dungeon.Name, c.level, c.equipT, - stats.MaxHP, stats.Attack, stats.Defense, stats.Speed, - eStats.MaxHP, eStats.Attack, eStats.Defense, eStats.Speed, + stats.MaxHP, stats.AC, stats.AttackBonus, stats.Defense, + eStats.MaxHP, eStats.AC, eStats.AttackBonus, eStats.Attack, rate) if rate < c.minDeath || rate > c.maxDeath { t.Errorf(" FAIL: death rate %.2f outside [%.2f, %.2f]", @@ -297,11 +332,12 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) { dungeon AdvLocation minDeath float64 } + // `level` is dnd_level. Underleveled = dnd_level low for the dungeon tier. cases := []tc{ {"L1 in T2 dungeon", 1, 0, advDungeons[1], 0.40}, - {"L8 in T3 dungeon", 8, 1, advDungeons[2], 0.30}, - {"L20 in T4 dungeon, T2 gear", 20, 2, advDungeons[3], 0.25}, - {"L30 in T5 dungeon, T3 gear", 30, 3, advDungeons[4], 0.30}, + {"L2 in T3 dungeon", 2, 1, advDungeons[2], 0.30}, + {"L4 in T4 dungeon, T2 gear", 4, 2, advDungeons[3], 0.25}, + {"L6 in T5 dungeon, T3 gear", 6, 3, advDungeons[4], 0.30}, } for _, c := range cases { @@ -311,6 +347,14 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) { stats, mods := DerivePlayerStats(char, equip, bonuses, 0, 0, false) eStats, eMods := DeriveDungeonMonsterStats(&c.dungeon) + // Apply the D&D layers so the test reflects production combat + // HP/AC/AB/weapon-dice (post HP-unification, raw DerivePlayerStats + // no longer matches what players actually fight with). + dndChar := balanceTestDnDChar(c.level) + applyDnDPlayerLayer(&stats, dndChar) + applyDnDEquipmentLayer(&stats, dndChar, equip) + applyDnDDungeonMonsterLayer(&eStats, c.dungeon.Tier) + player := Combatant{Name: "Hero", Stats: stats, Mods: mods, IsPlayer: true} enemy := Combatant{Name: c.dungeon.Name, Stats: eStats, Mods: eMods} @@ -323,9 +367,9 @@ func TestBalanceRegression_UnderleveledPlayers(t *testing.T) { } } rate := float64(deaths) / float64(N) - t.Logf("%s: P[HP=%d Atk=%d Def=%d] E[HP=%d Atk=%d Def=%d] → death=%.2f (want ≥%.2f)", - c.name, stats.MaxHP, stats.Attack, stats.Defense, - eStats.MaxHP, eStats.Attack, eStats.Defense, rate, c.minDeath) + t.Logf("%s: P[HP=%d AC=%d AB=%d] E[HP=%d AC=%d AB=%d Atk=%d] → death=%.2f (want ≥%.2f)", + c.name, stats.MaxHP, stats.AC, stats.AttackBonus, + eStats.MaxHP, eStats.AC, eStats.AttackBonus, eStats.Attack, rate, c.minDeath) if rate < c.minDeath { t.Errorf(" FAIL: death rate %.2f below minimum %.2f", rate, c.minDeath) } diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 9594574..d02d16b 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -187,6 +187,11 @@ type DnDCharacter struct { // long rest. 5e caps at 6 (death); we let it grow and let consumers // clamp where needed. Exhaustion int + // 2026-05-10 immersion: short rest charges (1/level, restored on long + // rest). resting_until gates !zone enter / !expedition start while the + // character is "still resting" — short rest = 1h, long rest = 8h. + ShortRestCharges int + RestingUntil *time.Time LastRespecAt *time.Time LastShortRestAt *time.Time LastLongRestAt *time.Time @@ -216,6 +221,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) { pending_setup, auto_migrated, onboarding_sent, armed_ability, pending_cast, concentration_spell, concentration_expires_at, subclass, last_subclass_respec_at, exhaustion, + short_rest_charges, resting_until, last_respec_at, last_short_rest_at, last_long_rest_at, created_at, updated_at FROM dnd_character WHERE user_id = ?`, string(userID)) @@ -229,6 +235,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) { &pending, &autoMig, &onboard, &c.ArmedAbility, &c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt, &subclassStr, &c.LastSubclassRespecAt, &c.Exhaustion, + &c.ShortRestCharges, &c.RestingUntil, &c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { @@ -268,8 +275,9 @@ func SaveDnDCharacter(c *DnDCharacter) error { pending_setup, auto_migrated, onboarding_sent, armed_ability, pending_cast, concentration_spell, concentration_expires_at, subclass, last_subclass_respec_at, exhaustion, + short_rest_charges, resting_until, last_respec_at, last_short_rest_at, last_long_rest_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET race=excluded.race, class=excluded.class, dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp, @@ -288,6 +296,8 @@ func SaveDnDCharacter(c *DnDCharacter) error { subclass=excluded.subclass, last_subclass_respec_at=excluded.last_subclass_respec_at, exhaustion=excluded.exhaustion, + short_rest_charges=excluded.short_rest_charges, + resting_until=excluded.resting_until, last_respec_at=excluded.last_respec_at, last_short_rest_at=excluded.last_short_rest_at, last_long_rest_at=excluded.last_long_rest_at, @@ -298,6 +308,7 @@ func SaveDnDCharacter(c *DnDCharacter) error { pending, autoMig, onboard, c.ArmedAbility, c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt, string(c.Subclass), c.LastSubclassRespecAt, c.Exhaustion, + c.ShortRestCharges, c.RestingUntil, c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt) if err != nil { return fmt.Errorf("save dnd_character: %w", err) diff --git a/internal/plugin/dnd_audit_fixes_test.go b/internal/plugin/dnd_audit_fixes_test.go index d395b5b..23ff011 100644 --- a/internal/plugin/dnd_audit_fixes_test.go +++ b/internal/plugin/dnd_audit_fixes_test.go @@ -294,11 +294,16 @@ func TestSetupStatus_NoStubOnFirstSetup(t *testing.T) { } } -func TestApplyDnDHPScaling_NeverExceedsOriginalMax(t *testing.T) { - stats := CombatStats{MaxHP: 100} - c := &DnDCharacter{HPMax: 50, HPCurrent: 100} // pathological: 200% — shouldn't happen but be safe +func TestApplyDnDHPScaling_NeverExceedsCombatMax(t *testing.T) { + // Pathological: hp_current somehow above hp_max — early-return path + // (full HP) leaves MaxHP unchanged and StartHP unset. + stats := CombatStats{MaxHP: 100, HPBonus: 50} + c := &DnDCharacter{HPMax: 50, HPCurrent: 100} applyDnDHPScaling(&stats, c) - if stats.MaxHP > 100 { - t.Errorf("clamp failed: MaxHP scaled to %d, original was 100", stats.MaxHP) + if stats.MaxHP != 100 { + t.Errorf("MaxHP clobbered: %d, want unchanged 100", stats.MaxHP) + } + if stats.StartHP > stats.MaxHP { + t.Errorf("StartHP=%d exceeds MaxHP=%d", stats.StartHP, stats.MaxHP) } } diff --git a/internal/plugin/dnd_bestiary.go b/internal/plugin/dnd_bestiary.go index a6ae601..143aee7 100644 --- a/internal/plugin/dnd_bestiary.go +++ b/internal/plugin/dnd_bestiary.go @@ -29,7 +29,7 @@ type DnDMonsterTemplate struct { var dndBestiary = map[string]DnDMonsterTemplate{ "goblin": { ID: "goblin", Name: "Goblin", - CR: 0.25, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14, + CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 14, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Nimble Escape", Phase: "any", ProcChance: 0.20, Effect: "stun"}, XPValue: 30, @@ -37,7 +37,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{ }, "skeleton": { ID: "skeleton", Name: "Skeleton", - CR: 0.25, HP: 13, AC: 13, Attack: 8, AttackBonus: 4, Speed: 10, + CR: 0.25, HP: 13, AC: 13, Attack: 1, AttackBonus: 4, Speed: 10, BlockRate: 0.10, // Skeletons are immune to poison; we'd model that with a future // CombatModifiers.PoisonImmunity flag. For Phase 7, no ability. @@ -46,7 +46,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{ }, "orc_grunt": { ID: "orc_grunt", Name: "Orc Grunt", - CR: 0.5, HP: 15, AC: 13, Attack: 10, AttackBonus: 5, Speed: 11, + CR: 0.5, HP: 15, AC: 13, Attack: 2, AttackBonus: 5, Speed: 11, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Aggressive", Phase: "opening", ProcChance: 0.30, Effect: "enrage"}, XPValue: 100, @@ -54,7 +54,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{ }, "troll": { ID: "troll", Name: "Troll", - CR: 5, HP: 84, AC: 15, Attack: 22, AttackBonus: 7, Speed: 8, + CR: 5, HP: 84, AC: 15, Attack: 8, AttackBonus: 7, Speed: 8, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Regeneration", Phase: "any", ProcChance: 0.50, Effect: "lifesteal"}, XPValue: 1800, @@ -62,7 +62,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{ }, "wyvern": { ID: "wyvern", Name: "Wyvern", - CR: 6, HP: 110, AC: 13, Attack: 28, AttackBonus: 7, Speed: 16, + CR: 6, HP: 110, AC: 13, Attack: 10, AttackBonus: 7, Speed: 16, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Poison Sting", Phase: "decisive", ProcChance: 0.60, Effect: "poison"}, XPValue: 2300, @@ -70,7 +70,7 @@ var dndBestiary = map[string]DnDMonsterTemplate{ }, "ancient_dragon": { ID: "ancient_dragon", Name: "Ancient Dragon", - CR: 20, HP: 367, AC: 22, Attack: 65, AttackBonus: 14, Speed: 18, + CR: 20, HP: 367, AC: 22, Attack: 33, AttackBonus: 11, Speed: 18, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"}, XPValue: 25000, @@ -89,7 +89,7 @@ var _ = func() bool { tier1 := map[string]DnDMonsterTemplate{ "goblin_sneak": { ID: "goblin_sneak", Name: "Goblin Sneak", - CR: 0.25, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14, + CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 14, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.25, Effect: "advantage"}, XPValue: 30, @@ -97,7 +97,7 @@ var _ = func() bool { }, "goblin_archer": { ID: "goblin_archer", Name: "Goblin Archer", - CR: 0.25, HP: 7, AC: 13, Attack: 5, AttackBonus: 4, Speed: 14, + CR: 0.25, HP: 7, AC: 13, Attack: 1, AttackBonus: 4, Speed: 14, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Scurry", Phase: "any", ProcChance: 0.30, Effect: "evade"}, XPValue: 30, @@ -105,7 +105,7 @@ var _ = func() bool { }, "hobgoblin_grunt": { ID: "hobgoblin_grunt", Name: "Hobgoblin Grunt", - CR: 0.5, HP: 11, AC: 18, Attack: 9, AttackBonus: 3, Speed: 11, + CR: 0.5, HP: 11, AC: 18, Attack: 2, AttackBonus: 3, Speed: 11, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Martial Advantage", Phase: "any", ProcChance: 0.30, Effect: "bonus_damage"}, XPValue: 100, @@ -113,7 +113,7 @@ var _ = func() bool { }, "worg": { ID: "worg", Name: "Worg", - CR: 0.5, HP: 26, AC: 13, Attack: 10, AttackBonus: 5, Speed: 17, + CR: 0.5, HP: 26, AC: 13, Attack: 2, AttackBonus: 5, Speed: 17, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Knock Prone", Phase: "any", ProcChance: 0.25, Effect: "stun"}, XPValue: 100, @@ -121,7 +121,7 @@ var _ = func() bool { }, "goblin_shaman": { ID: "goblin_shaman", Name: "Goblin Shaman", - CR: 1, HP: 22, AC: 11, Attack: 12, AttackBonus: 4, Speed: 12, + CR: 1, HP: 22, AC: 11, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Burning Hands", Phase: "opening", ProcChance: 0.50, Effect: "aoe_fire"}, XPValue: 200, @@ -129,7 +129,7 @@ var _ = func() bool { }, "hobgoblin_warchief": { ID: "hobgoblin_warchief", Name: "Hobgoblin Warchief", - CR: 2, HP: 52, AC: 18, Attack: 18, AttackBonus: 5, Speed: 11, + CR: 2, HP: 52, AC: 18, Attack: 4, AttackBonus: 5, Speed: 11, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Leadership Aura", Phase: "any", ProcChance: 0.40, Effect: "ally_buff"}, XPValue: 450, @@ -137,7 +137,7 @@ var _ = func() bool { }, "zombie": { ID: "zombie", Name: "Zombie", - CR: 0.25, HP: 22, AC: 10, Attack: 5, AttackBonus: 3, Speed: 6, + CR: 0.25, HP: 22, AC: 10, Attack: 1, AttackBonus: 3, Speed: 6, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Undead Fortitude", Phase: "decisive", ProcChance: 0.50, Effect: "survive_at_1"}, XPValue: 50, @@ -145,7 +145,7 @@ var _ = func() bool { }, "shadow": { ID: "shadow", Name: "Shadow", - CR: 0.5, HP: 16, AC: 12, Attack: 9, AttackBonus: 4, Speed: 12, + CR: 0.5, HP: 16, AC: 12, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Strength Drain", Phase: "any", ProcChance: 0.35, Effect: "stat_drain"}, XPValue: 100, @@ -153,7 +153,7 @@ var _ = func() bool { }, "specter": { ID: "specter", Name: "Specter", - CR: 1, HP: 22, AC: 12, Attack: 10, AttackBonus: 4, Speed: 14, + CR: 1, HP: 22, AC: 12, Attack: 2, AttackBonus: 4, Speed: 14, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"}, XPValue: 200, @@ -161,7 +161,7 @@ var _ = func() bool { }, "wight": { ID: "wight", Name: "Wight", - CR: 3, HP: 45, AC: 14, Attack: 14, AttackBonus: 4, Speed: 12, + CR: 3, HP: 45, AC: 14, Attack: 6, AttackBonus: 4, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.45, Effect: "lifesteal"}, XPValue: 700, @@ -169,7 +169,7 @@ var _ = func() bool { }, "flameskull": { ID: "flameskull", Name: "Flameskull", - CR: 4, HP: 40, AC: 13, Attack: 18, AttackBonus: 5, Speed: 15, + CR: 4, HP: 40, AC: 13, Attack: 7, AttackBonus: 5, Speed: 15, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Fireball", Phase: "decisive", ProcChance: 0.55, Effect: "aoe_fire"}, XPValue: 1100, @@ -177,7 +177,7 @@ var _ = func() bool { }, "boss_grol_unbroken": { ID: "boss_grol_unbroken", Name: "Grol the Unbroken", - CR: 3, HP: 65, AC: 17, Attack: 22, AttackBonus: 5, Speed: 12, + CR: 3, HP: 65, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Surprise Attack", Phase: "opening", ProcChance: 1.0, Effect: "bonus_damage"}, XPValue: 700, @@ -185,7 +185,7 @@ var _ = func() bool { }, "boss_valdris_unburied": { ID: "boss_valdris_unburied", Name: "Valdris the Unburied", - CR: 5, HP: 97, AC: 17, Attack: 28, AttackBonus: 7, Speed: 12, + CR: 5, HP: 97, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Corrupting Touch", Phase: "any", ProcChance: 0.50, Effect: "max_hp_drain"}, XPValue: 1800, @@ -209,7 +209,7 @@ var _ = func() bool { tier2 := map[string]DnDMonsterTemplate{ "dire_wolf": { ID: "dire_wolf", Name: "Dire Wolf", - CR: 1, HP: 37, AC: 14, Attack: 10, AttackBonus: 5, Speed: 16, + CR: 1, HP: 37, AC: 14, Attack: 2, AttackBonus: 5, Speed: 16, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"}, XPValue: 200, @@ -217,7 +217,7 @@ var _ = func() bool { }, "bandit_captain": { ID: "bandit_captain", Name: "Bandit Captain", - CR: 2, HP: 65, AC: 15, Attack: 14, AttackBonus: 5, Speed: 12, + CR: 2, HP: 65, AC: 15, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"}, XPValue: 450, @@ -225,7 +225,7 @@ var _ = func() bool { }, "owlbear": { ID: "owlbear", Name: "Owlbear", - CR: 3, HP: 59, AC: 13, Attack: 16, AttackBonus: 7, Speed: 12, + CR: 3, HP: 59, AC: 13, Attack: 6, AttackBonus: 7, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Grapple", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 700, @@ -233,7 +233,7 @@ var _ = func() bool { }, "dryad_corrupted": { ID: "dryad_corrupted", Name: "Corrupted Dryad", - CR: 2, HP: 22, AC: 11, Attack: 11, AttackBonus: 4, Speed: 12, + CR: 2, HP: 22, AC: 11, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Fey Charm", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 450, @@ -241,7 +241,7 @@ var _ = func() bool { }, "displacer_beast": { ID: "displacer_beast", Name: "Displacer Beast", - CR: 3, HP: 85, AC: 13, Attack: 14, AttackBonus: 6, Speed: 14, + CR: 3, HP: 85, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Displacement", Phase: "opening", ProcChance: 0.50, Effect: "evade"}, XPValue: 700, @@ -249,7 +249,7 @@ var _ = func() bool { }, "green_hag": { ID: "green_hag", Name: "Green Hag", - CR: 3, HP: 82, AC: 17, Attack: 15, AttackBonus: 5, Speed: 12, + CR: 3, HP: 82, AC: 17, Attack: 6, AttackBonus: 5, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Invisible Passage", Phase: "any", ProcChance: 0.35, Effect: "evade"}, XPValue: 700, @@ -257,7 +257,7 @@ var _ = func() bool { }, "kuo_toa": { ID: "kuo_toa", Name: "Kuo-toa", - CR: 0.25, HP: 18, AC: 13, Attack: 5, AttackBonus: 3, Speed: 10, + CR: 0.25, HP: 18, AC: 13, Attack: 1, AttackBonus: 3, Speed: 10, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Sticky Shield", Phase: "any", ProcChance: 0.25, Effect: "block"}, XPValue: 30, @@ -265,7 +265,7 @@ var _ = func() bool { }, "kuo_toa_whip": { ID: "kuo_toa_whip", Name: "Kuo-toa Whip", - CR: 1, HP: 65, AC: 11, Attack: 11, AttackBonus: 3, Speed: 10, + CR: 1, HP: 65, AC: 11, Attack: 2, AttackBonus: 3, Speed: 10, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Divine Eminence", Phase: "decisive", ProcChance: 0.50, Effect: "bonus_damage"}, XPValue: 200, @@ -273,7 +273,7 @@ var _ = func() bool { }, "water_elemental": { ID: "water_elemental", Name: "Water Elemental", - CR: 5, HP: 114, AC: 14, Attack: 26, AttackBonus: 7, Speed: 12, + CR: 5, HP: 114, AC: 14, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Whelm", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 1800, @@ -281,7 +281,7 @@ var _ = func() bool { }, "merrow": { ID: "merrow", Name: "Merrow", - CR: 2, HP: 45, AC: 13, Attack: 13, AttackBonus: 5, Speed: 12, + CR: 2, HP: 45, AC: 13, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Harpoon Pull", Phase: "opening", ProcChance: 0.40, Effect: "stun"}, XPValue: 450, @@ -289,7 +289,7 @@ var _ = func() bool { }, "aboleth_thrall": { ID: "aboleth_thrall", Name: "Aboleth Thrall", - CR: 3, HP: 60, AC: 12, Attack: 14, AttackBonus: 5, Speed: 11, + CR: 3, HP: 60, AC: 12, Attack: 6, AttackBonus: 5, Speed: 11, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Psychic Bond", Phase: "any", ProcChance: 0.35, Effect: "reveal_action"}, XPValue: 700, @@ -297,7 +297,7 @@ var _ = func() bool { }, "boss_hollow_king": { ID: "boss_hollow_king", Name: "The Hollow King", - CR: 6, HP: 142, AC: 15, Attack: 30, AttackBonus: 7, Speed: 13, + CR: 6, HP: 142, AC: 15, Attack: 10, AttackBonus: 7, Speed: 13, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Root Surge", Phase: "any", ProcChance: 0.45, Effect: "stun"}, XPValue: 2300, @@ -305,7 +305,7 @@ var _ = func() bool { }, "boss_dreaming_aboleth": { ID: "boss_dreaming_aboleth", Name: "The Dreaming Aboleth", - CR: 10, HP: 135, AC: 17, Attack: 36, AttackBonus: 9, Speed: 10, + CR: 10, HP: 135, AC: 17, Attack: 16, AttackBonus: 9, Speed: 10, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Enslave", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 5900, @@ -329,7 +329,7 @@ var _ = func() bool { tier3 := map[string]DnDMonsterTemplate{ "poltergeist": { ID: "poltergeist", Name: "Poltergeist", - CR: 2, HP: 22, AC: 12, Attack: 10, AttackBonus: 4, Speed: 12, + CR: 2, HP: 22, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Telekinetic Thrust", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 450, @@ -337,7 +337,7 @@ var _ = func() bool { }, "banshee": { ID: "banshee", Name: "Banshee", - CR: 4, HP: 58, AC: 12, Attack: 14, AttackBonus: 4, Speed: 12, + CR: 4, HP: 58, AC: 12, Attack: 7, AttackBonus: 4, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Wail", Phase: "decisive", ProcChance: 0.20, Effect: "execute"}, XPValue: 1100, @@ -345,7 +345,7 @@ var _ = func() bool { }, "wraith": { ID: "wraith", Name: "Wraith", - CR: 5, HP: 67, AC: 13, Attack: 21, AttackBonus: 6, Speed: 16, + CR: 5, HP: 67, AC: 13, Attack: 8, AttackBonus: 6, Speed: 16, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Life Drain", Phase: "any", ProcChance: 0.40, Effect: "max_hp_drain"}, XPValue: 1800, @@ -353,7 +353,7 @@ var _ = func() bool { }, "vampire_spawn": { ID: "vampire_spawn", Name: "Vampire Spawn", - CR: 5, HP: 82, AC: 15, Attack: 16, AttackBonus: 6, Speed: 12, + CR: 5, HP: 82, AC: 15, Attack: 8, AttackBonus: 6, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Bite (Life Drain)", Phase: "any", ProcChance: 0.40, Effect: "self_heal"}, XPValue: 1800, @@ -361,7 +361,7 @@ var _ = func() bool { }, "revenant": { ID: "revenant", Name: "Revenant", - CR: 5, HP: 136, AC: 13, Attack: 25, AttackBonus: 7, Speed: 12, + CR: 5, HP: 136, AC: 13, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Vengeful Regeneration", Phase: "any", ProcChance: 0.50, Effect: "regenerate"}, XPValue: 1800, @@ -369,7 +369,7 @@ var _ = func() bool { }, "boss_aldric_blackspire": { ID: "boss_aldric_blackspire", Name: "Lord Aldric Blackspire", - CR: 13, HP: 144, AC: 16, Attack: 38, AttackBonus: 9, Speed: 14, + CR: 13, HP: 144, AC: 16, Attack: 21, AttackBonus: 9, Speed: 14, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Charm", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 10000, @@ -377,7 +377,7 @@ var _ = func() bool { }, "magmin": { ID: "magmin", Name: "Magmin", - CR: 0.5, HP: 9, AC: 14, Attack: 7, AttackBonus: 3, Speed: 10, + CR: 0.5, HP: 9, AC: 14, Attack: 2, AttackBonus: 3, Speed: 10, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Death Burst", Phase: "decisive", ProcChance: 1.00, Effect: "death_aoe"}, XPValue: 100, @@ -385,7 +385,7 @@ var _ = func() bool { }, "azer": { ID: "azer", Name: "Azer", - CR: 2, HP: 39, AC: 17, Attack: 11, AttackBonus: 5, Speed: 12, + CR: 2, HP: 39, AC: 17, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Fire Aura", Phase: "any", ProcChance: 0.50, Effect: "retaliate"}, XPValue: 450, @@ -393,7 +393,7 @@ var _ = func() bool { }, "salamander": { ID: "salamander", Name: "Salamander", - CR: 5, HP: 90, AC: 15, Attack: 22, AttackBonus: 7, Speed: 12, + CR: 5, HP: 90, AC: 15, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Constrict", Phase: "any", ProcChance: 0.40, Effect: "stun"}, XPValue: 1800, @@ -401,7 +401,7 @@ var _ = func() bool { }, "fire_elemental": { ID: "fire_elemental", Name: "Fire Elemental", - CR: 5, HP: 102, AC: 13, Attack: 20, AttackBonus: 6, Speed: 14, + CR: 5, HP: 102, AC: 13, Attack: 8, AttackBonus: 6, Speed: 14, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Fire Form", Phase: "any", ProcChance: 0.50, Effect: "retaliate"}, XPValue: 1800, @@ -409,7 +409,7 @@ var _ = func() bool { }, "helmed_horror": { ID: "helmed_horror", Name: "Helmed Horror", - CR: 4, HP: 60, AC: 20, Attack: 16, AttackBonus: 6, Speed: 12, + CR: 4, HP: 60, AC: 20, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.25, Ability: &MonsterAbility{Name: "Spell Immunity", Phase: "any", ProcChance: 1.00, Effect: "spell_resist"}, XPValue: 1100, @@ -417,7 +417,7 @@ var _ = func() bool { }, "boss_emberlord_thyrak": { ID: "boss_emberlord_thyrak", Name: "Emberlord Thyrak", - CR: 9, HP: 178, AC: 18, Attack: 34, AttackBonus: 8, Speed: 11, + CR: 9, HP: 178, AC: 18, Attack: 14, AttackBonus: 8, Speed: 11, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Forge Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe"}, XPValue: 5000, @@ -443,7 +443,7 @@ var _ = func() bool { // ── Underdark ──────────────────────────────────────────────────── "drow": { ID: "drow", Name: "Drow", - CR: 0.25, HP: 13, AC: 15, Attack: 5, AttackBonus: 4, Speed: 12, + CR: 0.25, HP: 13, AC: 15, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Drow Poison", Phase: "any", ProcChance: 0.30, Effect: "stun"}, XPValue: 50, @@ -451,7 +451,7 @@ var _ = func() bool { }, "drow_elite_warrior": { ID: "drow_elite_warrior", Name: "Drow Elite Warrior", - CR: 5, HP: 71, AC: 18, Attack: 18, AttackBonus: 7, Speed: 12, + CR: 5, HP: 71, AC: 18, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Parry", Phase: "any", ProcChance: 0.35, Effect: "block"}, XPValue: 1800, @@ -459,7 +459,7 @@ var _ = func() bool { }, "drow_mage": { ID: "drow_mage", Name: "Drow Mage", - CR: 7, HP: 45, AC: 12, Attack: 22, AttackBonus: 5, Speed: 12, + CR: 7, HP: 45, AC: 12, Attack: 12, AttackBonus: 5, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Lightning Bolt", Phase: "decisive", ProcChance: 0.45, Effect: "aoe"}, XPValue: 2900, @@ -467,7 +467,7 @@ var _ = func() bool { }, "mind_flayer": { ID: "mind_flayer", Name: "Mind Flayer", - CR: 7, HP: 71, AC: 15, Attack: 26, AttackBonus: 7, Speed: 12, + CR: 7, HP: 71, AC: 15, Attack: 12, AttackBonus: 7, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Mind Blast", Phase: "opening", ProcChance: 0.55, Effect: "stun"}, XPValue: 2900, @@ -475,7 +475,7 @@ var _ = func() bool { }, "hook_horror": { ID: "hook_horror", Name: "Hook Horror", - CR: 3, HP: 75, AC: 15, Attack: 14, AttackBonus: 6, Speed: 10, + CR: 3, HP: 75, AC: 15, Attack: 6, AttackBonus: 6, Speed: 10, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.30, Effect: "advantage"}, XPValue: 700, @@ -483,7 +483,7 @@ var _ = func() bool { }, "roper": { ID: "roper", Name: "Roper", - CR: 5, HP: 93, AC: 20, Attack: 22, AttackBonus: 7, Speed: 4, + CR: 5, HP: 93, AC: 20, Attack: 8, AttackBonus: 7, Speed: 4, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Reel", Phase: "any", ProcChance: 0.50, Effect: "stun"}, XPValue: 1800, @@ -491,7 +491,7 @@ var _ = func() bool { }, "boss_ilvaras_xunyl": { ID: "boss_ilvaras_xunyl", Name: "Ilvaras Xunyl, Drow High Priestess", - CR: 12, HP: 162, AC: 16, Attack: 38, AttackBonus: 9, Speed: 12, + CR: 12, HP: 162, AC: 16, Attack: 19, AttackBonus: 9, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Divine Word", Phase: "decisive", ProcChance: 0.40, Effect: "execute"}, XPValue: 8400, @@ -500,7 +500,7 @@ var _ = func() bool { // ── Feywild Crossing ───────────────────────────────────────────── "redcap": { ID: "redcap", Name: "Redcap", - CR: 3, HP: 45, AC: 13, Attack: 16, AttackBonus: 6, Speed: 14, + CR: 3, HP: 45, AC: 13, Attack: 6, AttackBonus: 6, Speed: 14, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Outsize Strength", Phase: "any", ProcChance: 0.40, Effect: "bonus_damage"}, XPValue: 700, @@ -508,7 +508,7 @@ var _ = func() bool { }, "will_o_wisp": { ID: "will_o_wisp", Name: "Will-o'-Wisp", - CR: 2, HP: 22, AC: 19, Attack: 9, AttackBonus: 4, Speed: 14, + CR: 2, HP: 22, AC: 19, Attack: 4, AttackBonus: 4, Speed: 14, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Consume Life", Phase: "any", ProcChance: 0.40, Effect: "lifesteal"}, XPValue: 450, @@ -516,7 +516,7 @@ var _ = func() bool { }, "quickling": { ID: "quickling", Name: "Quickling", - CR: 1, HP: 10, AC: 16, Attack: 8, AttackBonus: 5, Speed: 18, + CR: 1, HP: 10, AC: 16, Attack: 2, AttackBonus: 5, Speed: 18, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Blur", Phase: "any", ProcChance: 0.50, Effect: "evade"}, XPValue: 200, @@ -524,7 +524,7 @@ var _ = func() bool { }, "night_hag": { ID: "night_hag", Name: "Night Hag", - CR: 5, HP: 112, AC: 17, Attack: 21, AttackBonus: 7, Speed: 12, + CR: 5, HP: 112, AC: 17, Attack: 8, AttackBonus: 7, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Etherealness", Phase: "any", ProcChance: 0.30, Effect: "evade"}, XPValue: 1800, @@ -532,7 +532,7 @@ var _ = func() bool { }, "fomorian": { ID: "fomorian", Name: "Fomorian", - CR: 8, HP: 149, AC: 14, Attack: 30, AttackBonus: 9, Speed: 12, + CR: 8, HP: 149, AC: 14, Attack: 13, AttackBonus: 9, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Evil Eye", Phase: "any", ProcChance: 0.45, Effect: "stun"}, XPValue: 3900, @@ -540,7 +540,7 @@ var _ = func() bool { }, "boss_thornmother": { ID: "boss_thornmother", Name: "The Thornmother", - CR: 11, HP: 187, AC: 17, Attack: 32, AttackBonus: 8, Speed: 12, + CR: 11, HP: 187, AC: 17, Attack: 18, AttackBonus: 8, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Beguiling Bargain", Phase: "opening", ProcChance: 0.50, Effect: "stun"}, XPValue: 7200, @@ -549,7 +549,7 @@ var _ = func() bool { // ── Dragon's Lair ──────────────────────────────────────────────── "kobold": { ID: "kobold", Name: "Kobold", - CR: 0.125, HP: 5, AC: 12, Attack: 4, AttackBonus: 4, Speed: 12, + CR: 0.125, HP: 5, AC: 12, Attack: 1, AttackBonus: 4, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Pack Tactics", Phase: "any", ProcChance: 0.40, Effect: "advantage"}, XPValue: 25, @@ -557,7 +557,7 @@ var _ = func() bool { }, "guard_drake": { ID: "guard_drake", Name: "Guard Drake", - CR: 2, HP: 52, AC: 14, Attack: 12, AttackBonus: 5, Speed: 12, + CR: 2, HP: 52, AC: 14, Attack: 4, AttackBonus: 5, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Draconic Loyalty", Phase: "any", ProcChance: 1.00, Effect: "fear_immune"}, XPValue: 450, @@ -565,7 +565,7 @@ var _ = func() bool { }, "kobold_scale_sorcerer": { ID: "kobold_scale_sorcerer", Name: "Kobold Scale Sorcerer", - CR: 1, HP: 27, AC: 15, Attack: 11, AttackBonus: 4, Speed: 12, + CR: 1, HP: 27, AC: 15, Attack: 2, AttackBonus: 4, Speed: 12, BlockRate: 0.05, Ability: &MonsterAbility{Name: "Chromatic Orb", Phase: "decisive", ProcChance: 0.50, Effect: "bonus_damage"}, XPValue: 200, @@ -573,7 +573,7 @@ var _ = func() bool { }, "dragonborn_cultist": { ID: "dragonborn_cultist", Name: "Dragonborn Cultist", - CR: 4, HP: 71, AC: 16, Attack: 17, AttackBonus: 6, Speed: 12, + CR: 4, HP: 71, AC: 16, Attack: 7, AttackBonus: 6, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Draconic Gift", Phase: "opening", ProcChance: 0.50, Effect: "aoe"}, XPValue: 1100, @@ -581,7 +581,7 @@ var _ = func() bool { }, "young_red_dragon": { ID: "young_red_dragon", Name: "Young Red Dragon", - CR: 10, HP: 178, AC: 18, Attack: 36, AttackBonus: 10, Speed: 16, + CR: 10, HP: 178, AC: 18, Attack: 16, AttackBonus: 10, Speed: 16, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Fire Breath", Phase: "any", ProcChance: 0.30, Effect: "aoe_fire"}, XPValue: 5900, @@ -589,7 +589,7 @@ var _ = func() bool { }, "boss_infernax": { ID: "boss_infernax", Name: "Infernax the Undying", - CR: 24, HP: 546, AC: 22, Attack: 65, AttackBonus: 14, Speed: 18, + CR: 24, HP: 546, AC: 22, Attack: 38, AttackBonus: 11, Speed: 18, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Frightful Presence", Phase: "opening", ProcChance: 0.80, Effect: "stun"}, XPValue: 62000, @@ -598,7 +598,7 @@ var _ = func() bool { // ── Abyss Portal ───────────────────────────────────────────────── "quasit": { ID: "quasit", Name: "Quasit", - CR: 1, HP: 7, AC: 13, Attack: 6, AttackBonus: 4, Speed: 14, + CR: 1, HP: 7, AC: 13, Attack: 2, AttackBonus: 4, Speed: 14, BlockRate: 0.0, Ability: &MonsterAbility{Name: "Invisibility", Phase: "any", ProcChance: 0.40, Effect: "evade"}, XPValue: 200, @@ -606,7 +606,7 @@ var _ = func() bool { }, "vrock": { ID: "vrock", Name: "Vrock", - CR: 6, HP: 104, AC: 15, Attack: 25, AttackBonus: 6, Speed: 12, + CR: 6, HP: 104, AC: 15, Attack: 10, AttackBonus: 6, Speed: 12, BlockRate: 0.10, Ability: &MonsterAbility{Name: "Stunning Screech", Phase: "opening", ProcChance: 0.50, Effect: "stun"}, XPValue: 2300, @@ -614,7 +614,7 @@ var _ = func() bool { }, "hezrou": { ID: "hezrou", Name: "Hezrou", - CR: 8, HP: 136, AC: 16, Attack: 30, AttackBonus: 7, Speed: 13, + CR: 8, HP: 136, AC: 16, Attack: 13, AttackBonus: 7, Speed: 13, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Stench", Phase: "any", ProcChance: 0.50, Effect: "debuff"}, XPValue: 3900, @@ -622,7 +622,7 @@ var _ = func() bool { }, "nalfeshnee": { ID: "nalfeshnee", Name: "Nalfeshnee", - CR: 13, HP: 184, AC: 18, Attack: 42, AttackBonus: 10, Speed: 12, + CR: 13, HP: 184, AC: 18, Attack: 21, AttackBonus: 10, Speed: 12, BlockRate: 0.15, Ability: &MonsterAbility{Name: "Horror Nimbus", Phase: "opening", ProcChance: 0.55, Effect: "stun"}, XPValue: 10000, @@ -630,7 +630,7 @@ var _ = func() bool { }, "marilith": { ID: "marilith", Name: "Marilith", - CR: 16, HP: 189, AC: 18, Attack: 50, AttackBonus: 11, Speed: 12, + CR: 16, HP: 189, AC: 18, Attack: 26, AttackBonus: 11, Speed: 12, BlockRate: 0.25, Ability: &MonsterAbility{Name: "Reactive Parry", Phase: "any", ProcChance: 0.50, Effect: "block"}, XPValue: 15000, @@ -638,7 +638,7 @@ var _ = func() bool { }, "boss_belaxath": { ID: "boss_belaxath", Name: "Belaxath the Undivided", - CR: 19, HP: 262, AC: 19, Attack: 58, AttackBonus: 12, Speed: 14, + CR: 19, HP: 262, AC: 19, Attack: 31, AttackBonus: 11, Speed: 14, BlockRate: 0.20, Ability: &MonsterAbility{Name: "Lightning Discharge", Phase: "decisive", ProcChance: 0.40, Effect: "aoe"}, XPValue: 22000, diff --git a/internal/plugin/dnd_bestiary_test.go b/internal/plugin/dnd_bestiary_test.go index 0cae81e..6efbaf7 100644 --- a/internal/plugin/dnd_bestiary_test.go +++ b/internal/plugin/dnd_bestiary_test.go @@ -51,8 +51,8 @@ func TestBestiaryToCombatStats(t *testing.T) { if stats.AC != 22 { t.Errorf("dragon AC = %d, want 22", stats.AC) } - if stats.AttackBonus != 14 { - t.Errorf("dragon AttackBonus = %d, want 14", stats.AttackBonus) + if stats.AttackBonus != 11 { + t.Errorf("dragon AttackBonus = %d, want 11 (capped at 11 by 2026-05-10 rebalance)", stats.AttackBonus) } if mods.DamageReduct != 1.0 { t.Errorf("DamageReduct = %v, want 1.0", mods.DamageReduct) diff --git a/internal/plugin/dnd_boss_consumables.go b/internal/plugin/dnd_boss_consumables.go new file mode 100644 index 0000000..0f57918 --- /dev/null +++ b/internal/plugin/dnd_boss_consumables.go @@ -0,0 +1,124 @@ +package plugin + +import ( + "log/slog" + "sort" + + "maunium.net/go/mautrix/id" +) + +// Auto-consumable rule (kept deliberately simple): the only pre-fight +// auto-pick is the panic heal. Other consumables (Coal Bombs, Ink Vials, +// Wards, etc.) sit in inventory until the player explicitly engages them +// — no threat-assessor magic deciding to burn a Voidstone Shard on a +// shadow. +// +// Heal triggers in the engine when player HP drops below 60% MaxHP. Each +// trigger consumes one charge; charges = number of healing consumables +// in the inventory. Post-combat, that many heal items are removed from +// the inventory bag (cheapest tier first so players burn through low +// stock before high-tier potions). +// +// The HealItem mod amount is the BEST heal value in inventory — every +// fire heals at the highest-tier-available rate. Slight balance pad +// (favors player), big simplification (engine doesn't need a per-charge +// values slice). + +// setupAutoHealFromInventory wires the heal-on-low-HP behavior from the +// player's current consumable inventory. Returns the count of charges +// it set up (0 if no healing items at all). +func setupAutoHealFromInventory(consumables []ConsumableItem, mods *CombatModifiers) int { + bestValue := 0 + count := 0 + for i := range consumables { + c := &consumables[i] + if c.Def == nil || c.Def.Effect != EffectHeal { + continue + } + count++ + if v := int(c.Def.Value); v > bestValue { + bestValue = v + } + } + if count == 0 { + return 0 + } + mods.HealItem = bestValue + if mods.HealItemCharges < count { + mods.HealItemCharges = count + } + return count +} + +// isBossPhases compares by slice header (same backing array) — boss fights +// are routed through bossCombatPhases (defined in combat_engine.go) and the +// caller passes that exact reference. +func isBossPhases(phases []CombatPhase) bool { + if len(phases) != len(bossCombatPhases) { + return false + } + if len(phases) == 0 { + return false + } + return &phases[0] == &bossCombatPhases[0] +} + +// countHealEventsFired counts how many heal_item events the engine emitted +// during this fight — equals the number of heal charges actually consumed +// and therefore the number of inventory items to remove. +func countHealEventsFired(result CombatResult) int { + n := 0 + for _, e := range result.Events { + if e.Action == "heal_item" { + n++ + } + } + return n +} + +// consumeFiredHealingItems removes `fired` healing consumables from the +// player's inventory, cheapest-tier first so players burn cheap stock +// before high-tier potions. No-op if fired <= 0. +func consumeFiredHealingItems(userID id.UserID, fired int) { + if fired <= 0 { + return + } + items, err := loadAdvInventory(userID) + if err != nil { + slog.Error("auto-heal cleanup: load inventory failed", "user", userID, "err", err) + return + } + // Filter to heal items, sort cheapest-tier-first. + type entry struct { + id int64 + tier int + name string + } + var heals []entry + for _, item := range items { + if item.Type != "consumable" { + continue + } + def := consumableDefByName(item.Name) + if def == nil || def.Effect != EffectHeal { + continue + } + heals = append(heals, entry{id: item.ID, tier: def.Tier, name: item.Name}) + } + sort.SliceStable(heals, func(i, j int) bool { return heals[i].tier < heals[j].tier }) + removed := 0 + for _, h := range heals { + if removed >= fired { + break + } + if rerr := removeAdvInventoryItem(h.id); rerr != nil { + slog.Error("auto-heal cleanup: remove failed", "user", userID, "item", h.name, "err", rerr) + continue + } + removed++ + } + if removed < fired { + slog.Warn("auto-heal cleanup: short on inventory", + "user", userID, "wanted", fired, "removed", removed) + } +} diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index bf86d71..ba76cbf 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -2,20 +2,18 @@ package plugin import ( "log/slog" - "math" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) -// Phase 2 — D&D combat layer hookup. +// D&D combat layer. // -// Phase 2 strategy: keep legacy HP/damage/dodge-rate scaling intact. The D&D -// layer adds AC + d20-vs-AC hit resolution on top. This preserves the -// existing balance while making combat read as D&D for opted-in players. -// -// HP rescaling and condition-system overhaul are deferred to later phases. +// applyDnDPlayerLayer reseats stats.MaxHP onto the dnd_character HP scale +// (c.HPMax + the gear/arena/housing bonus DerivePlayerStats captured in +// HPBonus). Wound carry-over via applyDnDHPScaling is a direct integer +// copy — there is no longer a percentage bridge between scales. // ── Tunable constants ──────────────────────────────────────────────────────── @@ -80,12 +78,15 @@ func dndPlayerAttackBonus(c *DnDCharacter) int { return classAttackStatMod(c) + proficiencyBonus(c.Level) + dndClassWeaponBonus[c.Class] } -// applyDnDPlayerLayer sets AC and AttackBonus on a stat block from the -// player's D&D character. Called after DerivePlayerStats has populated -// HP/Attack/Defense/etc. Phase 8: also wires equipped weapon/armor profiles -// into CombatStats so the d20 attack path uses real weapon dice and AC -// computation per gogobee_equipment_appendix.md. +// applyDnDPlayerLayer sets HP/AC/AttackBonus on a stat block from the +// player's D&D character. Combat MaxHP is the sheet HPMax plus the +// equipment/arena/housing bonus DerivePlayerStats accumulated into +// stats.HPBonus — so dnd_character.hp_current can be the single source of +// truth for wounds while gear keeps adding flat HP to the fight. +// +// Phase 8 wires equipped weapon/armor profiles in via applyDnDEquipmentLayer. func applyDnDPlayerLayer(stats *CombatStats, c *DnDCharacter) { + stats.MaxHP = c.HPMax + stats.HPBonus stats.AC = c.ArmorClass stats.AttackBonus = dndPlayerAttackBonus(c) } @@ -176,15 +177,23 @@ func applyDnDDungeonMonsterLayer(stats *CombatStats, tier int) { // (which is allowed without respec cooldown when auto_migrated=1). func classStatPriority(class DnDClass) [6]int { // Returned array is in STR, DEX, CON, INT, WIS, CHA order. + // + // Design note: every class gets DEX ≥ 14 baseline. DEX drives AC (via + // armor synthesis) and initiative, so dumping it leaves players in + // armor dead-zones where T3 chain shirt provides no AC over their + // class floor. The previous Cleric DEX=10 / Mage DEX=12 spread was + // faithful to standard array prioritization but produced unfun + // outcomes — gear upgrades didn't visibly help. Pumping the floor + // trades canonical purity for "armor upgrades feel like upgrades." switch class { case ClassFighter: - return [6]int{15, 13, 14, 8, 12, 10} // STR, CON, DEX prioritized + return [6]int{15, 14, 13, 8, 12, 10} // STR, DEX, CON case ClassRogue: return [6]int{8, 15, 13, 14, 10, 12} // DEX, INT, CON case ClassMage: - return [6]int{8, 12, 13, 15, 14, 10} // INT, WIS, CON + return [6]int{8, 14, 13, 15, 12, 10} // INT, DEX, CON case ClassCleric: - return [6]int{12, 10, 13, 8, 15, 14} // WIS, CHA, CON + return [6]int{12, 14, 13, 8, 15, 10} // WIS, DEX, CON case ClassRanger: return [6]int{12, 15, 13, 10, 14, 8} // DEX, WIS, CON } @@ -280,6 +289,7 @@ func autoBuildCharacter(userID id.UserID, char *AdventureCharacter) *DnDCharacte c.HPMax = computeMaxHP(c.Class, conMod, c.Level) c.HPCurrent = c.HPMax c.ArmorClass = computeAC(c.Class, dexMod) + c.ShortRestCharges = c.Level return c } @@ -406,79 +416,42 @@ func formatN(n int, word string) string { return string(out) } -// ── Combat HP scaling (rest teeth) ─────────────────────────────────────────── +// ── Combat HP carry-over ───────────────────────────────────────────────────── -// dndWoundFloor — when scaling combat MaxHP from sheet HP%, never reduce -// below this fraction of the legacy max. Prevents one-shot deaths when the -// sheet is at 0 HP. -const dndWoundFloor = 0.25 - -// applyDnDHPScaling scales playerStats.MaxHP based on the player's current -// dnd_character HP fraction. A fully-rested player fights at full legacy HP; -// a wounded player fights at reduced HP, with a floor at dndWoundFloor. -// -// This is what makes !rest mechanically meaningful — without it, the rest -// system is purely cosmetic. +// applyDnDHPScaling sets stats.StartHP from the player's persisted wounds. +// A full-HP character enters at MaxHP (StartHP=0 means "use MaxHP"); a +// wounded character enters at c.HPCurrent + HPBonus — the persistent dnd +// wound layered with the fresh equipment cushion. There is no scale +// conversion; persistDnDHPAfterCombat is the inverse direct copy. func applyDnDHPScaling(stats *CombatStats, c *DnDCharacter) { - if c == nil || c.HPMax <= 0 { + if c == nil || c.HPMax <= 0 || c.HPCurrent >= c.HPMax { return } - pct := float64(c.HPCurrent) / float64(c.HPMax) - if pct >= 1.0 { - return + startHP := c.HPCurrent + stats.HPBonus + if startHP < 1 { + startHP = 1 } - if pct < dndWoundFloor { - pct = dndWoundFloor + if startHP > stats.MaxHP { + startHP = stats.MaxHP } - // Round half-up. Naive int truncation loses up to 1 HP on every - // round-trip through the dndChar scale (which is coarser than the - // legacy combat scale): 101/123 → persist as 64/78 → restore as - // int(100.92) = 100, silently dropping a HP between fights. - scaled := int(math.Round(float64(stats.MaxHP) * pct)) - if scaled < 1 { - scaled = 1 - } - if scaled > stats.MaxHP { - scaled = stats.MaxHP - } - // Set StartHP rather than overwriting MaxHP. The combat engine reads - // StartHP as the entry-HP, but display denominators (e.g. "101/123") - // keep using MaxHP — so wounded carry-over reads "wounded out of full" - // rather than "full out of shrunk-max", which previously made wound - // state invisible across battles. - stats.StartHP = scaled + stats.StartHP = startHP } // ── HP persistence ─────────────────────────────────────────────────────────── -// persistDnDHPAfterCombat updates dnd_character.hp_current to reflect wounds -// from a fight, scaled to the D&D HP scale (since combat uses legacy HP). -// -// We compute the % of legacy HP the player ended with and apply the same % -// to dnd_character.hp_max. This keeps the player's "displayed health" in -// the sheet honest even though the combat engine itself uses legacy HP. -// -// No-op if the player has no dnd_character row or hasn't completed setup. -func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) { +// persistDnDHPAfterCombat copies endHP into dnd_character.hp_current, +// clamped to [0, c.HPMax]. The HPBonus from gear is fight-only and does +// not carry over — anything above c.HPMax is treated as unused armor +// cushion and clamped down. No-op if the row is missing or pending setup. +func persistDnDHPAfterCombat(userID id.UserID, endHP int) { c, err := LoadDnDCharacter(userID) if err != nil || c == nil || c.PendingSetup { return } - if legacyStartHP <= 0 || c.HPMax <= 0 { + if c.HPMax <= 0 { return } - - pct := float64(legacyEndHP) / float64(legacyStartHP) - if pct < 0 { - pct = 0 - } else if pct > 1 { - pct = 1 - } - - // Round half-up to mirror applyDnDHPScaling — a fight that ends at - // 101/123 should round-trip cleanly through the dndChar (78-scale) - // store and come back as 101, not 100. - newHP := int(math.Round(float64(c.HPMax) * pct)) + newHP := endHP if newHP < 0 { newHP = 0 } @@ -494,11 +467,10 @@ func persistDnDHPAfterCombat(userID id.UserID, legacyStartHP, legacyEndHP int) { } } -// 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. +// dndHPSnapshot returns the user's persisted (current, max) HP. Combat +// callers snapshot pre-combat, run the fight (persistDnDHPAfterCombat +// updates hp_current), then re-snapshot for the post values to render +// the sheet's wound state in narration. func dndHPSnapshot(userID id.UserID) (cur, max int) { c, err := LoadDnDCharacter(userID) if err != nil || c == nil { diff --git a/internal/plugin/dnd_combat_test.go b/internal/plugin/dnd_combat_test.go index 68a5c2e..87a7ce4 100644 --- a/internal/plugin/dnd_combat_test.go +++ b/internal/plugin/dnd_combat_test.go @@ -54,8 +54,10 @@ func TestDnDPlayerAttackBonus(t *testing.T) { } func TestApplyDnDPlayerLayer(t *testing.T) { - stats := CombatStats{MaxHP: 50, Attack: 10, Defense: 5} - c := &DnDCharacter{Class: ClassFighter, Level: 1, STR: 17, ArmorClass: 16} + // Combat MaxHP is now c.HPMax + stats.HPBonus. The HPBonus carries the + // equipment/arena/housing buffs DerivePlayerStats accumulated. + stats := CombatStats{MaxHP: 50, HPBonus: 8, Attack: 10, Defense: 5} + c := &DnDCharacter{Class: ClassFighter, Level: 1, STR: 17, HPMax: 12, ArmorClass: 16} applyDnDPlayerLayer(&stats, c) if stats.AC != 16 { t.Errorf("AC = %d, want 16", stats.AC) @@ -63,9 +65,11 @@ func TestApplyDnDPlayerLayer(t *testing.T) { if stats.AttackBonus != 7 { t.Errorf("AttackBonus = %d, want 7", stats.AttackBonus) } - // HP/Attack scaling fields unchanged - if stats.MaxHP != 50 || stats.Attack != 10 { - t.Errorf("non-D&D fields mutated: %+v", stats) + if stats.MaxHP != 20 { // 12 + 8 + t.Errorf("MaxHP = %d, want 20 (HPMax 12 + HPBonus 8)", stats.MaxHP) + } + if stats.Attack != 10 { + t.Errorf("Attack mutated: %d, want unchanged 10", stats.Attack) } } diff --git a/internal/plugin/dnd_dex_floor.go b/internal/plugin/dnd_dex_floor.go new file mode 100644 index 0000000..4f7750e --- /dev/null +++ b/internal/plugin/dnd_dex_floor.go @@ -0,0 +1,89 @@ +package plugin + +import ( + "log/slog" + + "gogobee/internal/db" +) + +// bumpDexFloorForExistingCharacters raises the DEX score of any +// dnd_character row below 14 to 14, then recomputes armor_class from the +// new DEX modifier. One-shot bootstrap to align prod-DB characters with +// the 2026-05-10 fun-balance change to classStatPriority (cleric/mage +// DEX baselines moved up so armor tier upgrades feel like upgrades). +// +// Idempotent: the UPDATE only touches rows where dex_score < 14. After +// the first run, it's a no-op until someone manually drops a character's +// DEX below the floor. +// +// Trade-off: this departs from canonical D&D standard-array purity. The +// game prioritizes "armor and AC matter" over "your stat dump should +// punish you forever." If we ever want to back this out, the migration +// is a single SQL UPDATE — but the canonicality gain isn't worth the +// fun loss for an asynchronous-combat game. +func bumpDexFloorForExistingCharacters() { + const dexFloor = 14 + d := db.Get() + res, err := d.Exec(`UPDATE dnd_character SET dex_score = ? WHERE dex_score < ?`, dexFloor, dexFloor) + if err != nil { + slog.Error("dnd: dex floor migration failed", "err", err) + return + } + bumped, _ := res.RowsAffected() + if bumped == 0 { + return + } + + // Recompute armor_class for everyone we touched. Combat re-derives AC + // each fight via applyDnDEquipmentLayer, so this is mostly cosmetic + // (sheet display, !stats), but stale ArmorClass on the character row + // would mislead players reading their sheet. + rows, err := d.Query(`SELECT user_id, class, dex_score FROM dnd_character WHERE dex_score = ?`, dexFloor) + if err != nil { + slog.Error("dnd: dex floor — load post-bump rows failed", "err", err) + return + } + defer rows.Close() + type touched struct { + userID string + class DnDClass + dex int + } + var batch []touched + for rows.Next() { + var t touched + var classStr string + if err := rows.Scan(&t.userID, &classStr, &t.dex); err != nil { + slog.Warn("dnd: dex floor scan failed", "err", err) + continue + } + t.class = DnDClass(classStr) + batch = append(batch, t) + } + for _, t := range batch { + ac := computeAC(t.class, abilityModifier(t.dex)) + if _, err := d.Exec(`UPDATE dnd_character SET armor_class = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?`, + ac, t.userID); err != nil { + slog.Warn("dnd: dex floor armor_class update failed", "user", t.userID, "err", err) + } + } + slog.Info("dnd: applied DEX floor 14 to existing characters", + "bumped", bumped, "ac_recomputed", len(batch)) +} + +// seedShortRestChargesForExistingCharacters fills short_rest_charges = +// dnd_level for any character whose charges are still 0 (the schema +// default for new columns on existing rows). One-shot, idempotent — +// after the first run only newly-created chars need seeding, and those +// already get charges = level via autoBuildCharacter / setup confirm. +func seedShortRestChargesForExistingCharacters() { + d := db.Get() + res, err := d.Exec(`UPDATE dnd_character SET short_rest_charges = dnd_level WHERE short_rest_charges = 0 AND dnd_level > 0`) + if err != nil { + slog.Error("dnd: short rest charge seed failed", "err", err) + return + } + if seeded, _ := res.RowsAffected(); seeded > 0 { + slog.Info("dnd: seeded short rest charges = level for existing characters", "seeded", seeded) + } +} diff --git a/internal/plugin/dnd_expedition_cmd.go b/internal/plugin/dnd_expedition_cmd.go index d710e9d..828ce61 100644 --- a/internal/plugin/dnd_expedition_cmd.go +++ b/internal/plugin/dnd_expedition_cmd.go @@ -165,6 +165,11 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter return p.SendDM(ctx.Sender, "`!expedition start [Ns] [Md]` — pick from `!expedition list`. Example: `!expedition start goblin_warrens 2s` (2 standard packs).") } + if remaining := restingLockoutRemaining(c); remaining > 0 { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "🛌 You're still resting — %s remaining. Pack up after.", + formatRespecDuration(remaining))) + } zoneTok, packTok := splitFirstWord(rest) available := zonesForLevel(c.Level) zoneID, ok := resolveZoneInput(zoneTok, available) diff --git a/internal/plugin/dnd_expedition_combat.go b/internal/plugin/dnd_expedition_combat.go index 8c94349..28d5bb0 100644 --- a/internal/plugin/dnd_expedition_combat.go +++ b/internal/plugin/dnd_expedition_combat.go @@ -119,7 +119,7 @@ func (p *AdventurePlugin) runHarvestInterrupt( } preCombatHP, _ := dndHPSnapshot(userID) - result, err := p.runZoneCombat(userID, monster, int(zone.Tier)) + result, err := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) if err != nil { return fmt.Sprintf("_(Interrupt combat error: %v.)_", err), false } @@ -149,12 +149,18 @@ func (p *AdventurePlugin) runHarvestInterrupt( _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) - markAdventureDead(userID, "expedition", zone.Display) + if !result.TimedOut { + markAdventureDead(userID, "expedition", zone.Display) + } if line := flavor.Pick(flavor.PlayerDeath); line != "" { b.WriteString(line) b.WriteString("\n") } - b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) + if result.TimedOut { + b.WriteString(fmt.Sprintf("⏳ **%s** outlasts you. You retreat from the expedition, wounded but alive.", monster.Name)) + } else { + b.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) + } return b.String(), true } @@ -359,8 +365,7 @@ func (p *AdventurePlugin) tryPatrolEncounter( if !ok { return } - preHP, _ := dndHPSnapshot(userID) - result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier)) + result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) if rerr != nil { err = rerr return @@ -388,12 +393,18 @@ func (p *AdventurePlugin) tryPatrolEncounter( _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) _ = retireAllRegionRuns(exp) - markAdventureDead(userID, "patrol", zone.Display) + if !result.TimedOut { + markAdventureDead(userID, "patrol", zone.Display) + } if line := flavor.Pick(flavor.PlayerDeath); line != "" { ob.WriteString(line) ob.WriteString("\n") } - ob.WriteString("💀 The patrol takes you down. Run ended.") + if result.TimedOut { + ob.WriteString("⏳ The patrol drags on. You break off and retreat, wounded but alive.") + } else { + ob.WriteString("💀 The patrol takes you down. Run ended.") + } if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) @@ -403,8 +414,8 @@ func (p *AdventurePlugin) tryPatrolEncounter( return } _ = recordZoneKill(exp, monster.ID) - ob.WriteString(fmt.Sprintf("✅ Patrol dispatched (HP %d→%d / %d).", - preHP, postHP, maxHP)) + ob.WriteString(fmt.Sprintf("✅ Patrol dispatched. You finished at **%d/%d HP**.", + postHP, maxHP)) if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) diff --git a/internal/plugin/dnd_plugs_test.go b/internal/plugin/dnd_plugs_test.go index 2c00cde..c53bfc2 100644 --- a/internal/plugin/dnd_plugs_test.go +++ b/internal/plugin/dnd_plugs_test.go @@ -5,41 +5,55 @@ import ( "testing" ) -// ── Plug 1: HP scaling ────────────────────────────────────────────────────── +// ── Plug 1: HP carry-over (post-unification) ──────────────────────────────── +// +// Combat now runs on c.HPMax + stats.HPBonus (gear cushion). applyDnDHPScaling +// only sets StartHP for wounded entry; MaxHP is the responsibility of +// applyDnDPlayerLayer. func TestApplyDnDHPScaling_FullHP(t *testing.T) { - stats := CombatStats{MaxHP: 100} + stats := CombatStats{MaxHP: 90, HPBonus: 40} c := &DnDCharacter{HPMax: 50, HPCurrent: 50} applyDnDHPScaling(&stats, c) - if stats.MaxHP != 100 { - t.Errorf("full HP: MaxHP changed to %d, want unchanged 100", stats.MaxHP) + if stats.MaxHP != 90 { + t.Errorf("full HP: MaxHP changed to %d, want unchanged 90", stats.MaxHP) } if stats.StartHP != 0 { - t.Errorf("full HP: StartHP = %d, want 0 (unset)", stats.StartHP) + t.Errorf("full HP: StartHP = %d, want 0 (unset = full)", stats.StartHP) } } -func TestApplyDnDHPScaling_HalfHP(t *testing.T) { - stats := CombatStats{MaxHP: 100} +func TestApplyDnDHPScaling_WoundedCarriesOverWithBonus(t *testing.T) { + stats := CombatStats{MaxHP: 90, HPBonus: 40} c := &DnDCharacter{HPMax: 50, HPCurrent: 25} applyDnDHPScaling(&stats, c) - if stats.MaxHP != 100 { - t.Errorf("50%% HP: MaxHP = %d, want unchanged 100", stats.MaxHP) + if stats.MaxHP != 90 { + t.Errorf("wounded: MaxHP = %d, want unchanged 90", stats.MaxHP) } - if stats.StartHP != 50 { - t.Errorf("50%% HP: StartHP = %d, want 50", stats.StartHP) + want := 25 + 40 // dnd wound + gear cushion + if stats.StartHP != want { + t.Errorf("wounded: StartHP = %d, want %d", stats.StartHP, want) } } -func TestApplyDnDHPScaling_FloorAt25Pct(t *testing.T) { - stats := CombatStats{MaxHP: 100} +func TestApplyDnDHPScaling_DownedStillEntersWithGearCushion(t *testing.T) { + stats := CombatStats{MaxHP: 90, HPBonus: 40} c := &DnDCharacter{HPMax: 50, HPCurrent: 0} applyDnDHPScaling(&stats, c) - if stats.MaxHP != 100 { - t.Errorf("0%% HP: MaxHP = %d, want unchanged 100", stats.MaxHP) + if stats.MaxHP != 90 { + t.Errorf("downed: MaxHP = %d, want unchanged 90", stats.MaxHP) } - if stats.StartHP != 25 { - t.Errorf("0%% HP: StartHP = %d, want 25 (floor)", stats.StartHP) + if stats.StartHP != 40 { + t.Errorf("downed: StartHP = %d, want 40 (gear cushion only)", stats.StartHP) + } +} + +func TestApplyDnDHPScaling_DownedNoGearFloorsAtOne(t *testing.T) { + stats := CombatStats{MaxHP: 50, HPBonus: 0} + c := &DnDCharacter{HPMax: 50, HPCurrent: 0} + applyDnDHPScaling(&stats, c) + if stats.StartHP != 1 { + t.Errorf("downed without gear: StartHP = %d, want 1 (floor)", stats.StartHP) } } diff --git a/internal/plugin/dnd_proddb_integration_test.go b/internal/plugin/dnd_proddb_integration_test.go index b44a1f0..1f4aad2 100644 --- a/internal/plugin/dnd_proddb_integration_test.go +++ b/internal/plugin/dnd_proddb_integration_test.go @@ -177,19 +177,20 @@ func TestProdDB_DnDLayer(t *testing.T) { } } - // 5. HP persistence smoke test — simulate a combat that left the player - // at 50% HP. Verify dnd_character.hp_current shifts proportionally. - persistDnDHPAfterCombat(uid, 100, 50) + // 5. HP persistence smoke test — combat is now on the dnd HP scale, so + // endHP is copied directly into hp_current (clamped to [0, hp_max]). + pre, _ := LoadDnDCharacter(uid) + endHP := pre.HPMax / 2 + persistDnDHPAfterCombat(uid, endHP) after, err := LoadDnDCharacter(uid) if err != nil || after == nil { t.Fatalf("post-persist load: %v", err) } - want := after.HPMax / 2 - if after.HPCurrent < want-1 || after.HPCurrent > want+1 { - t.Errorf("hp_current=%d after 50%% loss; want ~%d (hp_max=%d)", - after.HPCurrent, want, after.HPMax) + if after.HPCurrent != endHP { + t.Errorf("hp_current=%d after persist(endHP=%d); want exact copy (hp_max=%d)", + after.HPCurrent, endHP, after.HPMax) } - t.Logf("HP persistence verified: %d/%d after 50%% combat damage", after.HPCurrent, after.HPMax) + t.Logf("HP persistence verified: %d/%d after combat", after.HPCurrent, after.HPMax) // 6. !setup overwrite path: an auto-migrated char should be wipeable via // loadOrInitDraft → DELETE → fresh draft. diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index a04dfd9..88b5982 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -7,24 +7,43 @@ import ( "time" ) -// Phase 6 — !rest short / !rest long. +// !rest short / !rest long. // -// Short rest: 1h cooldown, no daily-action cost. Recovers 1d6+CON HP at L1-4 -// and 2*(1d6+CON) at L5+ (matching v1.0 §10.1's "x2 at level 5+" line). +// Short rest: spend 1 hit-dice charge (max charges = character level, +// restored on long rest). Heals 1d6+CON HP, x2 at L5+. Sets a 1h activity +// lockout — the character is *actually resting* for that hour and can't +// !zone enter / !expedition start until the timer expires. // -// Long rest: 24h cooldown. Requires housing (HouseTier > 0) OR pays the -// Thom Krooke inn (200 euros). Full HP recovery; resources reset (none yet -// in Phase 6, but the wiring is in place for Phase 7+). +// Long rest: 24h cooldown. Full HP recovery, restores all short rest +// charges, sets an 8h activity lockout. Requires housing (HouseTier > 0) +// OR pays the Thom Krooke inn (200 euros). Slot/spell refresh runs here. // -// These commands operate on the D&D layer only. The legacy `!adventure` menu -// "rest" choice is unchanged — it remains the daily-action narrative day-skip. +// These commands operate on the D&D layer only. The legacy `!adventure` +// menu "rest" choice is unchanged — it remains the daily-action narrative +// day-skip. const ( - dndShortRestCooldown = 1 * time.Hour - dndLongRestCooldown = 24 * time.Hour - dndInnCost = 200 + dndLongRestCooldown = 24 * time.Hour + dndShortRestLockoutHours = 1 + dndLongRestLockoutHours = 8 + dndInnCost = 200 ) +// restingLockoutRemaining returns the time left on a character's rest +// lockout (zero if not currently resting). Callers gate !zone enter and +// !expedition start on this so a freshly-rested character can't +// immediately jump back into combat. +func restingLockoutRemaining(c *DnDCharacter) time.Duration { + if c == nil || c.RestingUntil == nil { + return 0 + } + remaining := time.Until(*c.RestingUntil) + if remaining <= 0 { + return 0 + } + return remaining +} + func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) error { args = strings.TrimSpace(strings.ToLower(args)) switch args { @@ -40,8 +59,8 @@ func (p *AdventurePlugin) handleDnDRestCmd(ctx MessageContext, args string) erro func dndRestHelpText() string { return "🛌 **Adv 2.0 Rest**\n\n" + - "`!rest short` — 1h cooldown. Recovers 1d6 + CON HP. No action cost.\n" + - "`!rest long` — 24h cooldown. Full HP recovery. Requires housing or pays the inn (€200)." + "`!rest short` — spend 1 hit-dice charge. Heals 1d6 + CON HP (x2 at L5+). Locks zone/expedition for **1 hour**.\n" + + "`!rest long` — once per 24h. Full HP, restores hit-dice charges. Locks zone/expedition for **8 hours**. Requires housing or pays the inn (€200)." } // ── Short rest ─────────────────────────────────────────────────────────────── @@ -57,17 +76,12 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Couldn't load your character.") } - if c.LastShortRestAt != nil { - elapsed := time.Since(*c.LastShortRestAt) - if elapsed < dndShortRestCooldown { - remaining := dndShortRestCooldown - elapsed - return p.SendDM(ctx.Sender, fmt.Sprintf( - "Short rest on cooldown — %s remaining.", formatRespecDuration(remaining))) - } + if c.ShortRestCharges <= 0 { + return p.SendDM(ctx.Sender, + "You're out of short rest charges. Take a `!rest long` to restore them.") } - if c.HPCurrent >= c.HPMax { - return p.SendDM(ctx.Sender, "You're already at full HP. Save the rest for when you need it.") + return p.SendDM(ctx.Sender, "You're already at full HP. Save the charge for when you need it.") } conMod := abilityModifier(c.CON) @@ -85,16 +99,19 @@ func (p *AdventurePlugin) handleDnDShortRest(ctx MessageContext) error { if c.HPCurrent > c.HPMax { c.HPCurrent = c.HPMax } + c.ShortRestCharges-- now := time.Now().UTC() c.LastShortRestAt = &now + lockoutEnd := now.Add(dndShortRestLockoutHours * time.Hour) + c.RestingUntil = &lockoutEnd if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) } msg := fmt.Sprintf( - "🛌 **Short rest.** You recover **%d HP** (%d→%d / %d).\n_Next short rest available in 1 hour._", - c.HPCurrent-before, before, c.HPCurrent, c.HPMax) + "🛌 **Short rest.** You recover **%d HP** (%d→%d / %d).\n_Charges remaining: %d. You're resting — `!zone` and `!expedition` locked for 1 hour._", + c.HPCurrent-before, before, c.HPCurrent, c.HPMax, c.ShortRestCharges) if line := dndRestShortFlavorLine(); line != "" { msg += "\n\n_" + line + "_" } @@ -141,6 +158,7 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { c.HPCurrent = c.HPMax c.TempHP = 0 + c.ShortRestCharges = c.Level // Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a // long rest clears one). For Berserker who racks up exhaustion via // Frenzy, this is the recovery cadence. @@ -149,6 +167,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { } now := time.Now().UTC() c.LastLongRestAt = &now + lockoutEnd := now.Add(dndLongRestLockoutHours * time.Hour) + c.RestingUntil = &lockoutEnd if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save rest state: "+err.Error()) @@ -170,8 +190,8 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { loc = fmt.Sprintf("the inn (€%d spent)", dndInnCost) } msg := fmt.Sprintf( - "🌙 **Long rest** at %s. Full HP recovered (%d/%d).\n_Next long rest available in 24 hours._", - loc, c.HPCurrent, c.HPMax) + "🌙 **Long rest** at %s. Full HP recovered (%d/%d). Hit-dice charges restored: **%d**.\n_You're resting — `!zone` and `!expedition` locked for 8 hours. Next long rest in 24 hours._", + loc, c.HPCurrent, c.HPMax, c.ShortRestCharges) // HomeLongRest pool when at home; generic RestLong otherwise. if line := dndRestLongFlavorLine(hasHousing); line != "" { msg += "\n\n_" + line + "_" diff --git a/internal/plugin/dnd_rest_test.go b/internal/plugin/dnd_rest_test.go index 1302ff8..263232c 100644 --- a/internal/plugin/dnd_rest_test.go +++ b/internal/plugin/dnd_rest_test.go @@ -42,6 +42,7 @@ func makeRestTestChar(t *testing.T, uid id.UserID, level int) *DnDCharacter { c.HPMax = computeMaxHP(c.Class, conMod, level) c.HPCurrent = 1 // wounded c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX)) + c.ShortRestCharges = level // charges = level (matches autoBuildCharacter) if err := SaveDnDCharacter(c); err != nil { t.Fatal(err) } @@ -95,26 +96,34 @@ func TestShortRest_DoublesAtL5(t *testing.T) { } } -func TestShortRest_CooldownEnforced(t *testing.T) { +func TestShortRest_ChargesEnforced(t *testing.T) { setupRestTestDB(t) uid := id.UserID("@short_cd:example") - makeRestTestChar(t, uid, 3) + c := makeRestTestChar(t, uid, 3) + // Drain to last charge so the second rest hits the empty branch. + c.ShortRestCharges = 1 + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } p := &AdventurePlugin{} - // First rest succeeds + // First rest succeeds and burns the last charge. if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil { t.Fatal(err) } got1, _ := LoadDnDCharacter(uid) + if got1.ShortRestCharges != 0 { + t.Errorf("charge not consumed: %d remaining", got1.ShortRestCharges) + } hpAfterFirst := got1.HPCurrent - // Second immediate rest should NOT heal further (cooldown blocks). + // Second rest should bail (no charges) — HP unchanged. if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil { t.Fatal(err) } got2, _ := LoadDnDCharacter(uid) if got2.HPCurrent != hpAfterFirst { - t.Errorf("cooldown not enforced: HP changed from %d → %d on second rest", + t.Errorf("charges not enforced: HP changed from %d → %d on second rest", hpAfterFirst, got2.HPCurrent) } } diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index d514448..ae766a5 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -271,6 +271,7 @@ func (p *AdventurePlugin) dndSetupConfirm(ctx MessageContext) error { c.HPCurrent = c.HPMax c.TempHP = 0 c.ArmorClass = computeAC(c.Class, dexMod) + c.ShortRestCharges = c.Level c.PendingSetup = false c.AutoMigrated = false // manually confirmed — no longer an auto-migration c.UpdatedAt = time.Now().UTC() diff --git a/internal/plugin/dnd_xp.go b/internal/plugin/dnd_xp.go index 2e687bd..d65118f 100644 --- a/internal/plugin/dnd_xp.go +++ b/internal/plugin/dnd_xp.go @@ -116,6 +116,11 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve if c.HPCurrent > c.HPMax { c.HPCurrent = c.HPMax } + // Level-up grants one extra hit-dice charge (matches max=Level cap). + c.ShortRestCharges++ + if c.ShortRestCharges > c.Level { + c.ShortRestCharges = c.Level + } // AC may change too if DEX-derived (unlikely without item changes). c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX)) diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 5816a20..b14333d 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -175,6 +175,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest return p.SendDM(ctx.Sender, "`!zone enter ` — pick from `!zone list`. Example: `!zone enter goblin_warrens` or `!zone enter 1`.") } + if remaining := restingLockoutRemaining(c); remaining > 0 { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "🛌 You're still resting — %s remaining. The dungeon won't go anywhere.", + formatRespecDuration(remaining))) + } available := zonesForLevel(c.Level) id, ok := resolveZoneInput(rest, available) if !ok { @@ -202,6 +207,14 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest zone := zoneOrFallback(run.ZoneID) var b strings.Builder b.WriteString(fmt.Sprintf("🗝 **Entering %s** _(T%d, %d rooms)_\n\n", zone.Display, int(zone.Tier), run.TotalRooms)) + // Wounded-entry warning: if HP < 50% MaxHP at zone-enter time, flag it + // before the player advances into combat. Wounds carry across runs, + // monster damage tuning assumes near-full HP; entering at half or less + // is a death-spiral invitation. + if c.HPMax > 0 && c.HPCurrent*2 < c.HPMax { + b.WriteString(fmt.Sprintf("⚠️ _You're entering at **%d/%d HP**. Consider `!rest` first._\n\n", + c.HPCurrent, c.HPMax)) + } b.WriteString("_" + zone.Hook + "_\n\n") if line := twinBeeLine(zone.ID, DMRoomEntry, run.RunID, narrationCadence(run)); line != "" { b.WriteString(line) @@ -573,10 +586,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z outcome = fmt.Sprintf("_(No %s roster entry — skipping.)_", map[bool]string{true: "elite", false: "exploration"}[elite]) return } - // 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)) + result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), nil, run.DMMood) if rerr != nil { err = rerr return @@ -629,12 +639,21 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z if !result.PlayerWon { _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) - markAdventureDead(userID, "zone", zone.Display) + // Timeout loss = retreat; player took wounds but isn't actually + // dead. Don't fire markAdventureDead — that would trigger the 6h + // respawn timer for what is mechanically "ran out the clock". + if !result.TimedOut { + markAdventureDead(userID, "zone", zone.Display) + } if line := twinBeeLine(zone.ID, DMPlayerDeath, run.RunID, narrationCadence(run)); line != "" { ob.WriteString(line) ob.WriteString("\n") } - ob.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) + if result.TimedOut { + ob.WriteString(fmt.Sprintf("⏳ The fight drags on. **%s** outlasts you. You retreat, wounded but alive.", monster.Name)) + } else { + ob.WriteString(fmt.Sprintf("💀 You fell to **%s**. Run ended.", monster.Name)) + } if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) @@ -647,7 +666,7 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z ob.WriteString(line) ob.WriteString("\n") } - ob.WriteString(fmt.Sprintf("✅ **%s** down (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP)) + ob.WriteString(fmt.Sprintf("✅ **%s** down. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP)) if rollLine := dndRollSummaryLine(result); rollLine != "" { ob.WriteString("\n") ob.WriteString(rollLine) @@ -673,7 +692,9 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon return } preHP, _ := dndHPSnapshot(userID) - result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier)) + // Bosses use bossCombatPhases — wider Sudden Death budget so a player + // grinding the boss down has time to actually close the HP gap. + result, rerr := p.runZoneCombat(userID, monster, int(zone.Tier), bossCombatPhases, run.DMMood) if rerr != nil { err = rerr return @@ -702,12 +723,14 @@ func (p *AdventurePlugin) resolveBossRoom(userID id.UserID, run *DungeonRun, zon Nat20s: nat20s, Nat1s: nat1s, DefeatHeadline: fmt.Sprintf("💀 **%s** stands over your body. Run ended.", monster.Name), - VictoryHeadline: fmt.Sprintf("🏆 **%s** falls (HP %d→%d / %d).", monster.Name, preHP, postHP, maxHP), + VictoryHeadline: fmt.Sprintf("🏆 **%s** falls. You finished at **%d/%d HP**.", monster.Name, postHP, maxHP), }) if !result.PlayerWon { _, _ = applyMoodEvent(run.RunID, MoodEventPlayerDeath) _ = abandonZoneRun(userID) - markAdventureDead(userID, "zone", zone.Display) + if !result.TimedOut { + markAdventureDead(userID, "zone", zone.Display) + } ended = true return } diff --git a/internal/plugin/dnd_zone_combat.go b/internal/plugin/dnd_zone_combat.go index 9032076..030d10b 100644 --- a/internal/plugin/dnd_zone_combat.go +++ b/internal/plugin/dnd_zone_combat.go @@ -107,8 +107,13 @@ func zoneSelectorHash(runID string, roomIdx int) uint64 { // ── Combat runner ─────────────────────────────────────────────────────────── // runZoneCombat sets up a Combatant pair from the player's full D&D -// layer + a bestiary monster, runs SimulateCombat with dungeonCombatPhases, -// and persists post-combat side effects (HP, subclass state, XP). +// layer + a bestiary monster, runs SimulateCombat with the given phase +// budget (nil = dungeonCombatPhases), and persists post-combat side +// effects (HP, subclass state, XP). +// +// dmMood (0–100) drives the DM's combat tilt: Effusive softens monster +// Attack and gives the player +InitiativeBias; Hostile sharpens monster +// Attack and slows the player. Pass 50 (neutral) when no run context. // // Returns the engine result so the caller can branch on PlayerWon and // fold combat events into the per-room narration. @@ -116,7 +121,13 @@ func (p *AdventurePlugin) runZoneCombat( userID id.UserID, monster DnDMonsterTemplate, tier int, + phases []CombatPhase, + dmMood int, ) (CombatResult, error) { + if phases == nil { + phases = dungeonCombatPhases + } + tilt := dmMoodCombatTilt(dmMood) char, err := loadAdvCharacter(userID) if err != nil || char == nil { return CombatResult{}, fmt.Errorf("load adv character: %w", err) @@ -161,6 +172,19 @@ func (p *AdventurePlugin) runZoneCombat( } applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats) + // Auto-consumable: panic heal only. Inventory healing items wire up + // the heal-at-<60%-HP trigger; offensive / buff consumables are NOT + // auto-burned (see dnd_boss_consumables.go for the rationale). + consumables := p.loadConsumableInventory(userID) + setupAutoHealFromInventory(consumables, &playerMods) + + // DM mood tilts: monster Attack delta + player initiative bias. + enemyStats.Attack += tilt.EnemyAttackDelta + if enemyStats.Attack < 1 { + enemyStats.Attack = 1 // floor — keep some bite even for Elated DM + } + playerMods.InitiativeBias += tilt.InitiativeBias + displayName, _ := loadDisplayName(userID) player := Combatant{ Name: displayName, @@ -175,7 +199,12 @@ func (p *AdventurePlugin) runZoneCombat( Ability: monster.Ability, } - result := SimulateCombat(player, enemy, dungeonCombatPhases) + result := SimulateCombat(player, enemy, phases) + dumpCombatEventsIfDebug(fmt.Sprintf("zone:%s vs %s", monster.ID, displayName), result) + + // Remove the actual heal items consumed during combat (one inventory + // item per heal_item event fired). Cheapest-tier first. + consumeFiredHealingItems(userID, countHealEventsFired(result)) // Misty condition repair (post-combat, same 20% chance as arena/encounter // paths in combat_bridge.go). Mirrors the buff's intent — gourmet food @@ -187,7 +216,7 @@ func (p *AdventurePlugin) runZoneCombat( } p.grantCombatAchievements(userID, result) - persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP) + persistDnDHPAfterCombat(userID, result.PlayerEndHP) if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage, result, playerMods); err != nil { slog.Error("dnd: post-combat subclass persist (zone)", "user", userID, "err", err) } @@ -368,10 +397,19 @@ func (p *AdventurePlugin) resolveTrapRoomLegacy(userID id.UserID, run *DungeonRu // (zone equipment registry wiring is a later content phase). func (p *AdventurePlugin) rollZoneLoot(userID id.UserID, run *DungeonRun, zone ZoneDefinition) []string { rng := rand.New(rand.NewPCG(uint64(zoneSelectorHash(run.RunID, run.CurrentRoom)), 0x100712)) + // DM mood tilts probabilistic drop chances. UniqueAlways entries are + // untouched — those are story drops, not flavor for the DM to gate. + moodMult := dmMoodCombatTilt(run.DMMood).LootQualityMod + if moodMult == 0 { + moodMult = 1.0 + } var granted []string for _, entry := range zone.Loot { - if !entry.UniqueAlways && rng.Float64() > entry.DropChance { - continue + if !entry.UniqueAlways { + chance := entry.DropChance * moodMult + if rng.Float64() > chance { + continue + } } item, ok := zoneLootToInventory(entry, zone, rng) if !ok { diff --git a/internal/plugin/dnd_zone_harvest_test.go b/internal/plugin/dnd_zone_harvest_test.go index 565c18f..b9cc9b7 100644 --- a/internal/plugin/dnd_zone_harvest_test.go +++ b/internal/plugin/dnd_zone_harvest_test.go @@ -48,7 +48,11 @@ func TestStandaloneHarvest_RoutesToZoneRun(t *testing.T) { if raw == "" || raw == "{}" { t.Errorf("expected harvest_nodes_json populated after standalone harvest, got %q", raw) } - wantKey := "\"" + deriveLegacyNodeID(run.ZoneID, 0) + "\":" + // Phase G: the entry node id comes from the zone graph (e.g. + // "forest_shadows.entry"), not the legacy ".r1" derivation. Use the + // production helper so this stays in sync with whatever node id + // saveStandaloneHarvestNodes actually wrote. + wantKey := "\"" + harvestNodeIDFor(run) + "\":" if !strings.Contains(raw, wantKey) { t.Errorf("expected entry node %s in harvest table, got %q", wantKey, raw) } diff --git a/internal/plugin/dnd_zone_narration.go b/internal/plugin/dnd_zone_narration.go index 14b179f..b09e40c 100644 --- a/internal/plugin/dnd_zone_narration.go +++ b/internal/plugin/dnd_zone_narration.go @@ -398,14 +398,17 @@ func complimentResponseLine(runID string, roomIdx int) string { return "🎭 **TwinBee:** " + line } -// moodAsidePool returns the mood-banded aside pool when the mood sits at -// an extreme band (hostile or effusive), else nil. Mid-bands (grumpy / -// neutral / friendly) intentionally surface no aside — flavor stays -// strictly extra, never replacing the core narration. +// moodAsidePool returns the mood-banded aside pool. Every band except +// strict Neutral (40–59) surfaces flavor — Hostile/Effusive at the +// extremes, Grumpy/Friendly in the mid-bands. Neutral stays silent. func moodAsidePool(mood int) []string { switch moodBand(mood) { case MoodBandHostile: return flavor.MoodAsidesHostile + case MoodBandGrumpy: + return flavor.MoodAsidesGrumpy + case MoodBandFriendly: + return flavor.MoodAsidesFriendly case MoodBandEffusive: return flavor.MoodAsidesEffusive } @@ -519,6 +522,33 @@ func clampMood(s int) int { return s } +// DMMoodCombatTilt — what the DM's mood does to combat dials. +// +// Effusive: monsters miss more, hit softer; player goes first more often. +// Hostile : monsters meaner, faster off the line. +// Mid-bands: small or zero effects so the DM's voice still matters +// without trivializing math at each step. +type DMMoodCombatTilt struct { + EnemyAttackDelta int // added to monster.Stats.Attack pre-fight + InitiativeBias float64 // added to player initiative roll each round + LootQualityMod float64 // multiplied into AdvBonusSummary.LootQuality +} + +// dmMoodCombatTilt maps the run's DM mood score to the combat dials. +func dmMoodCombatTilt(mood int) DMMoodCombatTilt { + switch moodBand(mood) { + case MoodBandEffusive: + return DMMoodCombatTilt{EnemyAttackDelta: -1, InitiativeBias: +5, LootQualityMod: 1.20} + case MoodBandFriendly: + return DMMoodCombatTilt{EnemyAttackDelta: 0, InitiativeBias: +2, LootQualityMod: 1.10} + case MoodBandGrumpy: + return DMMoodCombatTilt{EnemyAttackDelta: +1, InitiativeBias: -2, LootQualityMod: 0.90} + case MoodBandHostile: + return DMMoodCombatTilt{EnemyAttackDelta: +2, InitiativeBias: -5, LootQualityMod: 0.75} + } + return DMMoodCombatTilt{LootQualityMod: 1.0} // Neutral +} + // applyMoodDecayIfStale persists a passive-decay correction when the // run has been idle long enough to drift. Cheap no-op on fresh runs. func applyMoodDecayIfStale(r *DungeonRun) error { diff --git a/internal/plugin/dnd_zone_narration_test.go b/internal/plugin/dnd_zone_narration_test.go index 44958be..2386cd6 100644 --- a/internal/plugin/dnd_zone_narration_test.go +++ b/internal/plugin/dnd_zone_narration_test.go @@ -348,10 +348,17 @@ func TestZoneCmd_AdvanceFinalRoomBumpsMood(t *testing.T) { if err != nil { t.Fatal(err) } - for i := 0; i < run.TotalRooms; i++ { - if _, err := markRoomCleared(run.RunID); err != nil { + // Phase G: branching paths through a diamond may complete the run in + // fewer steps than TotalRooms. Drive markRoomCleared until it returns + // the empty room type (boss kill / dead-end) or we hit a safety cap. + for i := 0; i < run.TotalRooms+2; i++ { + next, err := markRoomCleared(run.RunID) + if err != nil { t.Fatalf("markRoomCleared %d: %v", i, err) } + if next == "" { + break + } } if _, err := applyMoodEvent(run.RunID, MoodEventZoneComplete); err != nil { t.Fatal(err) @@ -503,24 +510,25 @@ func TestPhaseTwoCrossedInEvents(t *testing.T) { // ── Phase 11 D8 — mood-banded TwinBee aside ───────────────────────────────── -func TestMoodAsideLine_OnlyAtExtremes(t *testing.T) { - // Mid-bands return empty — flavor stays strictly extra. - for _, mood := range []int{20, 39, 40, 50, 59, 60, 79} { +func TestMoodAsideLine_AllBandsExceptNeutral(t *testing.T) { + // Strict Neutral (40–59) stays silent — TwinBee has nothing + // notable to say. Every other band produces an aside. + for _, mood := range []int{40, 50, 59} { if got := moodAsideLine(mood, "run-aside", 3); got != "" { - t.Errorf("mood=%d (mid-band) should produce no aside; got %q", mood, got) + t.Errorf("mood=%d (neutral) should produce no aside; got %q", mood, got) } } - // Extremes always produce a line (pools are non-empty). - for _, mood := range []int{0, 19} { - got := moodAsideLine(mood, "run-aside", 3) - if got == "" || !strings.HasPrefix(got, "🎭 **TwinBee:**") { - t.Errorf("hostile mood=%d should produce aside; got %q", mood, got) - } - } - for _, mood := range []int{80, 100} { - got := moodAsideLine(mood, "run-aside", 3) - if got == "" || !strings.HasPrefix(got, "🎭 **TwinBee:**") { - t.Errorf("effusive mood=%d should produce aside; got %q", mood, got) + for label, moods := range map[string][]int{ + "hostile": {0, 19}, + "grumpy": {20, 39}, + "friendly": {60, 79}, + "effusive": {80, 100}, + } { + for _, mood := range moods { + got := moodAsideLine(mood, "run-aside", 3) + if got == "" || !strings.HasPrefix(got, "🎭 **TwinBee:**") { + t.Errorf("%s mood=%d should produce aside; got %q", label, mood, got) + } } } } diff --git a/internal/plugin/dnd_zone_run_test.go b/internal/plugin/dnd_zone_run_test.go index fa4a4ce..b781033 100644 --- a/internal/plugin/dnd_zone_run_test.go +++ b/internal/plugin/dnd_zone_run_test.go @@ -158,8 +158,13 @@ func TestZoneRunFlow_AdvanceToBossAndComplete(t *testing.T) { if got.IsActive() { t.Error("expected run inactive") } - if len(got.RoomsCleared) != got.TotalRooms { - t.Errorf("rooms cleared %d, total %d", len(got.RoomsCleared), got.TotalRooms) + // Phase G: branching graphs mean a single path through a diamond/fork + // zone may not visit every node. RoomsCleared counts traversed nodes; + // TotalRooms is the graph's total node count. Assert "reached boss" via + // BossDefeated above; here, just check we cleared a reasonable share. + if len(got.RoomsCleared) < 1 || len(got.RoomsCleared) > got.TotalRooms { + t.Errorf("rooms cleared %d, total %d (expected 1..total)", + len(got.RoomsCleared), got.TotalRooms) } } diff --git a/internal/plugin/stats.go b/internal/plugin/stats.go index a41093c..6a1a619 100644 --- a/internal/plugin/stats.go +++ b/internal/plugin/stats.go @@ -387,15 +387,15 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { sb.WriteString("\n") // ── Adventure ── - var combatLv, miningLv, fishingLv, forageLv, combatXP, miningXP, fishingXP, forageXP int + var miningLv, fishingLv, forageLv, miningXP, fishingXP, forageXP int var alive bool var streak, bestStreak int err = d.QueryRow( - `SELECT combat_level, mining_skill, fishing_skill, foraging_skill, - combat_xp, mining_xp, fishing_xp, foraging_xp, + `SELECT mining_skill, fishing_skill, foraging_skill, + mining_xp, fishing_xp, foraging_xp, alive, current_streak, best_streak FROM player_meta WHERE user_id = ?`, uid, - ).Scan(&combatLv, &miningLv, &fishingLv, &forageLv, &combatXP, &miningXP, &fishingXP, &forageXP, + ).Scan(&miningLv, &fishingLv, &forageLv, &miningXP, &fishingXP, &forageXP, &alive, &streak, &bestStreak) if err == nil { status := "Alive" @@ -403,8 +403,8 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { status = "💀 Dead" } sb.WriteString(fmt.Sprintf("⚔️ **Adventure:** %s\n", status)) - sb.WriteString(fmt.Sprintf(" Combat Lv.%d (%d XP) · Mining Lv.%d (%d XP) · Fishing Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n", - combatLv, combatXP, miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP)) + sb.WriteString(fmt.Sprintf(" Mining Lv.%d (%d XP) · Fishing Lv.%d (%d XP) · Forage Lv.%d (%d XP)\n", + miningLv, miningXP, fishingLv, fishingXP, forageLv, forageXP)) if streak > 0 || bestStreak > 0 { sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days (best: %d)\n", streak, bestStreak)) } @@ -532,14 +532,6 @@ func (p *StatsPlugin) handleSuperStats(ctx MessageContext) error { hasGames = true } - // Bot defeats - var botDefeats int - _ = d.QueryRow(`SELECT COALESCE(SUM(losses), 0) FROM bot_defeats WHERE user_id = ?`, uid).Scan(&botDefeats) - if botDefeats > 0 { - sb.WriteString(fmt.Sprintf(" 🤖 Lost to TwinBee: %d times\n", botDefeats)) - hasGames = true - } - if !hasGames { sb.WriteString(" No game records yet.\n") }