Files
gogobee/internal/plugin/dnd_boss_consumables.go
prosolis 4e412219f3 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>
2026-05-12 21:59:19 -07:00

125 lines
3.6 KiB
Go

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)
}
}