mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
WIP: in-flight combat/expedition/flavor changes
Bundle of uncommitted working-tree edits across combat engine, expedition cycle, flavor pools, and TwinBee/zone narration. Includes new files: combat_debug.go, dnd_boss_consumables.go, dnd_dex_floor.go, plus CHANGES_24H.md and REBALANCE_NOTES.md scratch notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
44
internal/plugin/combat_debug.go
Normal file
44
internal/plugin/combat_debug.go
Normal file
@@ -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())
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
124
internal/plugin/dnd_boss_consumables.go
Normal file
124
internal/plugin/dnd_boss_consumables.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
internal/plugin/dnd_dex_floor.go
Normal file
89
internal/plugin/dnd_dex_floor.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,11 @@ func (p *AdventurePlugin) expeditionCmdStart(ctx MessageContext, c *DnDCharacter
|
||||
return p.SendDM(ctx.Sender,
|
||||
"`!expedition start <zone> [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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 + "_"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -175,6 +175,11 @@ func (p *AdventurePlugin) zoneCmdEnter(ctx MessageContext, c *DnDCharacter, rest
|
||||
return p.SendDM(ctx.Sender,
|
||||
"`!zone enter <id|#>` — 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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user