mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Treasure, masterwork, and consumable drops each had zero call sites: they only ever fired from the legacy daily activity loop, which adventure.go now intercepts with a deprecation DM. Hook all three to the zone-combat seam. A1 - treasure: rollAdvTreasureDropDetailed takes a weight, applied to the base rate. Boss x4, elite x2, standard x1, plus one x1 roll on zone clear. Near-miss DMs now fire only for weighted moments; at x1 on autopilot they'd land on ~3% of every kill. A2 - masterwork: the catalog is keyed to mining/fishing/foraging, so the dungeon lookup returned nil and the hook would have been a silent no-op. masterworkDefForZone rolls across all three slot lines at the zone's tier. Flavor now follows loc.Activity rather than the item's catalog line, with a new dungeon pool - a crypt boss must not narrate a pickaxe striking ore. A3 - consumables + ingredients: the audit found more than the four named ingredients were stranded. generateAdvLoot is reachable only from resolveDungeonAction, which has no callers, so all four legacy loot tables were dead and every one of the 12 recipes was uncraftable. rollZoneIngredient draws from those tables directly at the zone's tier, reviving them wholesale rather than re-keying 24 ingredients into the per-zone slates.
803 lines
37 KiB
Go
803 lines
37 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
"strings"
|
||
|
||
"gogobee/internal/flavor"
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Resource & Combat Integration §5 — Zone-Contextual Loot Tables.
|
||
//
|
||
// Drops fire on combat victory (room/elite/boss/harvest-interrupt/patrol).
|
||
// Drop tier is selected from enemy CR per the §5 bracket table; the item
|
||
// itself is rolled from a per-zone slate so a goblin warrens uncommon
|
||
// reads differently than an underdark uncommon. Sell values follow the
|
||
// §8.1 rarity brackets so R5's `!sell` can pay out cleanly later.
|
||
//
|
||
// New file lives next to dnd_zone_loot.go; no existing files touched
|
||
// outside the combat-resolution call sites that invoke `dropZoneLoot`.
|
||
|
||
// LootTier is the dropped-item rarity bracket selected by enemy CR.
|
||
type LootTier string
|
||
|
||
const (
|
||
LootTierCommon LootTier = "common"
|
||
LootTierUncommon LootTier = "uncommon"
|
||
LootTierRare LootTier = "rare"
|
||
LootTierEpic LootTier = "epic"
|
||
LootTierLegendary LootTier = "legendary"
|
||
)
|
||
|
||
// ZoneLootDrop is one item slot in a zone's loot table.
|
||
type ZoneLootDrop struct {
|
||
Name string
|
||
ItemType string // "consumable", "scroll", "trinket", "gear", "trophy", "currency"
|
||
BaseValue int // §8.1 midpoint
|
||
}
|
||
|
||
// rarityFor maps the loot tier to a DnDRarity (kept separate so the
|
||
// §8.1 sell brackets line up cleanly with the equipment rarity scheme).
|
||
func (t LootTier) rarity() DnDRarity {
|
||
switch t {
|
||
case LootTierCommon:
|
||
return RarityCommon
|
||
case LootTierUncommon:
|
||
return RarityUncommon
|
||
case LootTierRare:
|
||
return RarityRare
|
||
case LootTierEpic:
|
||
return RarityEpic
|
||
case LootTierLegendary:
|
||
return RarityLegendary
|
||
}
|
||
return RarityCommon
|
||
}
|
||
|
||
// dropTierFromCR implements the §5 enemy-CR → drop-tier table.
|
||
// - guaranteed: tier that always drops if a drop fires.
|
||
// - bonus: a higher-tier upgrade roll (empty = no upgrade roll).
|
||
// - bonusPct: chance the drop is upgraded to `bonus`.
|
||
// - dropChance: probability the slot drops at all (1.0 = guaranteed slot).
|
||
func dropTierFromCR(cr float32, isBoss bool) (guaranteed LootTier, bonus LootTier, bonusPct float64, dropChance float64) {
|
||
if isBoss {
|
||
// §5: bosses minimum Rare, with a small Epic upgrade.
|
||
return LootTierRare, LootTierEpic, 0.25, 1.0
|
||
}
|
||
switch {
|
||
case cr >= 13:
|
||
return LootTierEpic, LootTierLegendary, 0.05, 1.0
|
||
case cr >= 9:
|
||
return LootTierRare, LootTierEpic, 0.10, 1.0
|
||
case cr >= 5:
|
||
return LootTierUncommon, LootTierRare, 0.15, 1.0
|
||
case cr >= 2:
|
||
return LootTierCommon, LootTierUncommon, 0.30, 1.0
|
||
default: // CR 0–1
|
||
return LootTierCommon, "", 0.0, 0.70
|
||
}
|
||
}
|
||
|
||
// zoneLootTables — full §5 registry: 10 zones × 5 tiers, biome-flavored.
|
||
// Common slots lean on shared exploration items (rations, scrap, salve)
|
||
// re-skinned per zone; rares pull from each zone's signature gear; the
|
||
// legendary row is single-entry per zone and reads as a trophy.
|
||
var zoneLootTables = map[ZoneID]map[LootTier][]ZoneLootDrop{
|
||
ZoneGoblinWarrens: {
|
||
LootTierCommon: {
|
||
{Name: "Goblin Field Ration", ItemType: "consumable", BaseValue: 8},
|
||
{Name: "Stolen Coin Pouch", ItemType: "currency", BaseValue: 12},
|
||
{Name: "Worg-Tooth Trinket", ItemType: "trinket", BaseValue: 10},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Hobgoblin Captain's Insignia", ItemType: "trophy", BaseValue: 45},
|
||
{Name: "Crude Alchemy Vial", ItemType: "consumable", BaseValue: 35},
|
||
{Name: "Goblin-Forged Shortblade", ItemType: "gear", BaseValue: 50},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "War Standard Banner", ItemType: "trophy", BaseValue: 200},
|
||
{Name: "Shaman's Carved Stave", ItemType: "gear", BaseValue: 220},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Hobgoblin General's Cuirass", ItemType: "gear", BaseValue: 800},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "King Grobnar's Iron Crown", ItemType: "trophy", BaseValue: 4500},
|
||
},
|
||
},
|
||
ZoneCryptValdris: {
|
||
LootTierCommon: {
|
||
{Name: "Tarnished Burial Coin", ItemType: "currency", BaseValue: 10},
|
||
{Name: "Cracked Bone Charm", ItemType: "trinket", BaseValue: 8},
|
||
{Name: "Funeral-Wrap Linen", ItemType: "trinket", BaseValue: 9},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Pre-Empire Signet Ring", ItemType: "trinket", BaseValue: 50},
|
||
{Name: "Necrotic-Etched Dagger", ItemType: "gear", BaseValue: 55},
|
||
{Name: "Sealed Funerary Vial", ItemType: "consumable", BaseValue: 40},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Valdris Scriptorium Tablet", ItemType: "trophy", BaseValue: 240},
|
||
{Name: "Silver-Inlaid Reliquary", ItemType: "trinket", BaseValue: 220},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Crypt-Lord's Burial Mantle", ItemType: "gear", BaseValue: 850},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Valdris's Sealing Sigil", ItemType: "trophy", BaseValue: 5000},
|
||
},
|
||
},
|
||
ZoneForestShadows: {
|
||
LootTierCommon: {
|
||
{Name: "Foraged Trail Loaf", ItemType: "consumable", BaseValue: 9},
|
||
{Name: "Carved Wooden Talisman", ItemType: "trinket", BaseValue: 11},
|
||
{Name: "Bundle of Shadow Reed", ItemType: "trinket", BaseValue: 8},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Hunter's Bone Bow Charm", ItemType: "trinket", BaseValue: 50},
|
||
{Name: "Dryad-Touched Salve", ItemType: "consumable", BaseValue: 45},
|
||
{Name: "Owlbear-Talon Knife", ItemType: "gear", BaseValue: 55},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Fey-Stained Scroll Case", ItemType: "scroll", BaseValue: 230},
|
||
{Name: "Elder Druid's Walking Stave", ItemType: "gear", BaseValue: 240},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Greenwarden's Ironbark Vest", ItemType: "gear", BaseValue: 900},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Heart-Wood of the First Grove", ItemType: "trophy", BaseValue: 5500},
|
||
},
|
||
},
|
||
ZoneSunkenTemple: {
|
||
LootTierCommon: {
|
||
{Name: "Salt-Crusted Coin", ItemType: "currency", BaseValue: 9},
|
||
{Name: "Pearl Sliver", ItemType: "trinket", BaseValue: 12},
|
||
{Name: "Pressure-Sealed Vial", ItemType: "consumable", BaseValue: 11},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Kuo-toa Spear-Head", ItemType: "gear", BaseValue: 50},
|
||
{Name: "Coral-Set Pendant", ItemType: "trinket", BaseValue: 55},
|
||
{Name: "Tide-Blessed Censer", ItemType: "trinket", BaseValue: 45},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Drowned Acolyte's Tome", ItemType: "scroll", BaseValue: 240},
|
||
{Name: "Aboleth-Glass Lens", ItemType: "trinket", BaseValue: 250},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Tide-Caller's Trident Head", ItemType: "gear", BaseValue: 950},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Dar'eth's Drowned Crown", ItemType: "trophy", BaseValue: 6000},
|
||
},
|
||
},
|
||
ZoneManorBlackspire: {
|
||
LootTierCommon: {
|
||
{Name: "Tarnished Silver Spoon", ItemType: "trinket", BaseValue: 10},
|
||
{Name: "Forty-Year-Old Tincture", ItemType: "consumable", BaseValue: 12},
|
||
{Name: "Mourner's Cameo", ItemType: "trinket", BaseValue: 11},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Vampire-Hunter's Stake Set", ItemType: "gear", BaseValue: 55},
|
||
{Name: "Cursed Family Locket", ItemType: "trinket", BaseValue: 50},
|
||
{Name: "Estate-Sealed Wine Bottle", ItemType: "consumable", BaseValue: 45},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Blackspire Family Diary", ItemType: "scroll", BaseValue: 240},
|
||
{Name: "Ghost-Iron Candelabrum", ItemType: "trinket", BaseValue: 230},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Manor-Lord's Funeral Coat", ItemType: "gear", BaseValue: 950},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Blackspire Patriarch's Death Mask", ItemType: "trophy", BaseValue: 6000},
|
||
},
|
||
},
|
||
ZoneUnderforge: {
|
||
LootTierCommon: {
|
||
{Name: "Forge-Slag Trinket", ItemType: "trinket", BaseValue: 11},
|
||
{Name: "Iron-Filing Pouch", ItemType: "trinket", BaseValue: 9},
|
||
{Name: "Dwarven Hardtack", ItemType: "consumable", BaseValue: 10},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Azer-Forged Hammer Head", ItemType: "gear", BaseValue: 55},
|
||
{Name: "Heat-Etched Sigil Plate", ItemType: "trinket", BaseValue: 50},
|
||
{Name: "Salamander-Oil Flask", ItemType: "consumable", BaseValue: 50},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Master-Smith's Ledger Page", ItemType: "scroll", BaseValue: 250},
|
||
{Name: "Mithral-Thread Gauntlet", ItemType: "gear", BaseValue: 280},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Forge-Marshal's Anvil-Charm", ItemType: "trinket", BaseValue: 1000},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "The First Hammer's Striking Head", ItemType: "trophy", BaseValue: 7000},
|
||
},
|
||
},
|
||
ZoneUnderdark: {
|
||
LootTierCommon: {
|
||
{Name: "Cave-Spider Silk Skein", ItemType: "trinket", BaseValue: 11},
|
||
{Name: "Dim-Glow Mushroom Bundle", ItemType: "consumable", BaseValue: 10},
|
||
{Name: "Drow-Worked Coin", ItemType: "currency", BaseValue: 12},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Drow Hand-Crossbow Bolt Case", ItemType: "gear", BaseValue: 55},
|
||
{Name: "Faerzress-Etched Charm", ItemType: "trinket", BaseValue: 50},
|
||
{Name: "Diluted Drow Sleep Vial", ItemType: "consumable", BaseValue: 50},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Mind Flayer Codex Page", ItemType: "scroll", BaseValue: 270},
|
||
{Name: "Drow-Adamantine Bracer", ItemType: "gear", BaseValue: 290},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Matron's House-Sigil Brooch", ItemType: "trinket", BaseValue: 1100},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Eyeless King's Skull-Crown", ItemType: "trophy", BaseValue: 7500},
|
||
},
|
||
},
|
||
ZoneFeywildCrossing: {
|
||
LootTierCommon: {
|
||
{Name: "Pressed Fey Petal", ItemType: "trinket", BaseValue: 12},
|
||
{Name: "Honey-Wax Candle", ItemType: "consumable", BaseValue: 10},
|
||
{Name: "Dream-Spun Thread", ItemType: "trinket", BaseValue: 11},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Pixie-Wing Glassine", ItemType: "trinket", BaseValue: 55},
|
||
{Name: "Wisp-Lantern Charm", ItemType: "trinket", BaseValue: 60},
|
||
{Name: "Hag-Brewed Bitter Cordial", ItemType: "consumable", BaseValue: 50},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Time-Locked Music Box", ItemType: "trinket", BaseValue: 280},
|
||
{Name: "Fey Court Invitation Scroll", ItemType: "scroll", BaseValue: 260},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Summer-Knight's Thorn Buckler", ItemType: "gear", BaseValue: 1100},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Thornmother's Heart-Thorn Reliquary", ItemType: "trophy", BaseValue: 8000},
|
||
},
|
||
},
|
||
ZoneDragonsLair: {
|
||
LootTierCommon: {
|
||
{Name: "Half-Melted Gold Coin", ItemType: "currency", BaseValue: 13},
|
||
{Name: "Volcanic Glass Sliver", ItemType: "trinket", BaseValue: 11},
|
||
{Name: "Kobold Field Pouch", ItemType: "consumable", BaseValue: 10},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Drake-Scale Bracer", ItemType: "gear", BaseValue: 55},
|
||
{Name: "Kobold Trap-Mechanism", ItemType: "trinket", BaseValue: 50},
|
||
{Name: "Drake-Blood Cordial", ItemType: "consumable", BaseValue: 50},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Hoard-Inventory Page", ItemType: "scroll", BaseValue: 280},
|
||
{Name: "Dragonfire-Forged Spike", ItemType: "gear", BaseValue: 290},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Wyrmguard's Cinder-Cloak Clasp", ItemType: "trinket", BaseValue: 1150},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Infernax's Tooth-Reliquary", ItemType: "trophy", BaseValue: 9000},
|
||
},
|
||
},
|
||
ZoneAbyssPortal: {
|
||
LootTierCommon: {
|
||
{Name: "Brimstone-Caked Coin", ItemType: "currency", BaseValue: 13},
|
||
{Name: "Sulfur-Wax Stub", ItemType: "consumable", BaseValue: 11},
|
||
{Name: "Demon-Ichor Smear-Vial", ItemType: "trinket", BaseValue: 12},
|
||
},
|
||
LootTierUncommon: {
|
||
{Name: "Quasit-Bound Ring", ItemType: "trinket", BaseValue: 55},
|
||
{Name: "Corrupted-Metal Buckle", ItemType: "gear", BaseValue: 50},
|
||
{Name: "Planar-Shard Pendant", ItemType: "trinket", BaseValue: 55},
|
||
},
|
||
LootTierRare: {
|
||
{Name: "Abyssal-Marked Codex Sheet", ItemType: "scroll", BaseValue: 290},
|
||
{Name: "Marilith-Steel Edge", ItemType: "gear", BaseValue: 300},
|
||
},
|
||
LootTierEpic: {
|
||
{Name: "Nalfeshnee's Honor-Sigil", ItemType: "trinket", BaseValue: 1200},
|
||
},
|
||
LootTierLegendary: {
|
||
{Name: "Belaxath's Bound Truename Tablet", ItemType: "trophy", BaseValue: 10000},
|
||
},
|
||
},
|
||
}
|
||
|
||
// rollZoneLoot rolls a single combat-victory drop. Returns ok=false when
|
||
// the drop chance fails (low-CR misses) or the zone has no table.
|
||
//
|
||
// Resolution order:
|
||
// 1. Pick guaranteed/bonus tier from CR via dropTierFromCR.
|
||
// 2. If dropChance fails → no drop.
|
||
// 3. Bonus upgrade roll → bonusPct chance to step up the tier.
|
||
// 4. Pick a uniform random entry from the zone's slate at that tier;
|
||
// fall back down a tier if the slate is empty.
|
||
func rollZoneLoot(zoneID ZoneID, cr float32, isBoss bool, rng *rand.Rand) (ZoneLootDrop, LootTier, bool) {
|
||
guaranteed, bonus, bonusPct, dropChance := dropTierFromCR(cr, isBoss)
|
||
if rngFloat(rng) >= dropChance {
|
||
return ZoneLootDrop{}, "", false
|
||
}
|
||
tier := guaranteed
|
||
if bonus != "" && rngFloat(rng) < bonusPct {
|
||
tier = bonus
|
||
}
|
||
zone, ok := zoneLootTables[zoneID]
|
||
if !ok {
|
||
return ZoneLootDrop{}, "", false
|
||
}
|
||
if entry, ok := pickLootEntry(zone, tier, rng); ok {
|
||
return entry, tier, true
|
||
}
|
||
// Fallback: walk down tiers if the chosen one is empty.
|
||
for _, fallback := range []LootTier{LootTierEpic, LootTierRare, LootTierUncommon, LootTierCommon} {
|
||
if fallback == tier {
|
||
continue
|
||
}
|
||
if entry, ok := pickLootEntry(zone, fallback, rng); ok {
|
||
return entry, fallback, true
|
||
}
|
||
}
|
||
return ZoneLootDrop{}, "", false
|
||
}
|
||
|
||
func pickLootEntry(zone map[LootTier][]ZoneLootDrop, tier LootTier, rng *rand.Rand) (ZoneLootDrop, bool) {
|
||
slate := zone[tier]
|
||
if len(slate) == 0 {
|
||
return ZoneLootDrop{}, false
|
||
}
|
||
idx := rngIntN(rng, len(slate))
|
||
return slate[idx], true
|
||
}
|
||
|
||
// rngFloat / rngIntN: nil-safe wrappers so tests can pass a seeded rng
|
||
// while production paths use the package-global generator.
|
||
func rngFloat(rng *rand.Rand) float64 {
|
||
if rng == nil {
|
||
return rand.Float64()
|
||
}
|
||
return rng.Float64()
|
||
}
|
||
|
||
func rngIntN(rng *rand.Rand, n int) int {
|
||
if n <= 0 {
|
||
return 0
|
||
}
|
||
if rng == nil {
|
||
return rand.IntN(n)
|
||
}
|
||
return rng.IntN(n)
|
||
}
|
||
|
||
// dropZoneLoot is the combat-resolution call site. Rolls a drop, deposits
|
||
// it into adventure_inventory if one fired, and returns a narration block
|
||
// (empty when no drop). Reuses §5 LootDropCommon/Uncommon/Rare/Legendary
|
||
// flavor pools — no new flavor file (per feedback_reuse_existing_flavor).
|
||
// magicItemDropChance — once a drop fires, the chance it is substituted with
|
||
// an Open5e SRD registry magic item of the rolled tier's rarity instead of
|
||
// the biome slate item. Keeps magic items a treat, not the norm.
|
||
const magicItemDropChance = 0.15
|
||
|
||
// Treasure-roll weights by the kind of fight that earned the roll. The base
|
||
// rates (advTreasureDropRates, 1.5%→0.15%) are tuned for one roll per day of
|
||
// legacy activity; expedition combat rolls far more often, so standard kills
|
||
// stay at ×1 and the weight goes to the moments a player remembers.
|
||
const (
|
||
advTreasureWeightStandard = 1.0
|
||
advTreasureWeightElite = 2.0
|
||
advTreasureWeightBoss = 4.0
|
||
advTreasureWeightZoneClear = 1.0
|
||
)
|
||
|
||
// advLocForZone adapts a zone into the AdvLocation shape the treasure and
|
||
// masterwork systems were written against, back when every drop came from a
|
||
// legacy daily activity. Zones are dungeons.
|
||
func advLocForZone(zoneID ZoneID) *AdvLocation {
|
||
name := string(zoneID)
|
||
if z, ok := getZone(zoneID); ok {
|
||
name = z.Display
|
||
}
|
||
return &AdvLocation{
|
||
Name: name,
|
||
Activity: AdvActivityDungeon,
|
||
Tier: zoneTierFromID(zoneID),
|
||
}
|
||
}
|
||
|
||
// rollZoneTreasure gives the treasure table a shot at a zone moment. Silent
|
||
// on the overwhelmingly common no-drop path; checkTreasureDrop owns all
|
||
// player-facing output (discovery DM, auto-swap, T5 room announce).
|
||
func (p *AdventurePlugin) rollZoneTreasure(userID id.UserID, zoneID ZoneID, weight float64) {
|
||
char, err := loadAdvCharacter(userID)
|
||
if err != nil || char == nil {
|
||
return
|
||
}
|
||
p.checkTreasureDrop(userID, char, advLocForZone(zoneID), weight)
|
||
}
|
||
|
||
// rollZoneMasterwork gives the masterwork catalog a shot at an elite or boss
|
||
// kill. Arena gear stays the premium line (×1.5 effective tier vs ×1.25), so
|
||
// this competes with the shop, not with the arena.
|
||
func (p *AdventurePlugin) rollZoneMasterwork(userID id.UserID, zoneID ZoneID, isBoss bool) {
|
||
equip, err := loadAdvEquipment(userID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
outcome := AdvOutcomeSuccess
|
||
if isBoss {
|
||
outcome = AdvOutcomeExceptional
|
||
}
|
||
p.checkMasterworkDrop(userID, equip, advLocForZone(zoneID), outcome)
|
||
}
|
||
|
||
// advIngredientDropChance — per-win chance a crafting ingredient drops.
|
||
//
|
||
// Every recipe ingredient in adventure_consumables.go lives in the legacy
|
||
// gathering tables (advDungeonLoot/advMiningLoot/advForagingLoot/
|
||
// advFishingLoot), which the Phase R transition left with no live caller:
|
||
// generateAdvLoot is only reachable from resolveDungeonAction, and nothing
|
||
// calls that. Rather than re-key 24 ingredients into the per-zone slates,
|
||
// zone combat draws from the legacy tables directly at the zone's tier —
|
||
// their values are already tuned, and every recipe becomes craftable again.
|
||
const advIngredientDropChance = 0.15
|
||
|
||
// advIngredientActivities are the four legacy tables a zone kill can draw an
|
||
// ingredient from. Zone tier indexes the table tier directly.
|
||
var advIngredientActivities = []AdvActivityType{
|
||
AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing,
|
||
}
|
||
|
||
// rollZoneIngredient draws one crafting ingredient from a random legacy
|
||
// gathering table at the zone's tier. Returns nil on the common no-drop path.
|
||
func rollZoneIngredient(zoneTier int) *AdvItem {
|
||
if rand.Float64() >= advIngredientDropChance {
|
||
return nil
|
||
}
|
||
act := advIngredientActivities[rand.IntN(len(advIngredientActivities))]
|
||
defs := advLootTable(act)[zoneTier]
|
||
if len(defs) == 0 {
|
||
return nil
|
||
}
|
||
d := defs[rand.IntN(len(defs))]
|
||
value := d.MinValue
|
||
if d.MaxValue > d.MinValue {
|
||
value += rand.Int64N(d.MaxValue - d.MinValue + 1)
|
||
}
|
||
return &AdvItem{
|
||
Name: d.Name,
|
||
Type: d.Type,
|
||
Tier: zoneTier,
|
||
Value: value,
|
||
SkillSource: "zone_ingredient:" + string(act),
|
||
}
|
||
}
|
||
|
||
// grantZoneItem deposits a rolled item and returns its narration line.
|
||
func (p *AdventurePlugin) grantZoneItem(userID id.UserID, item *AdvItem, icon string) string {
|
||
if err := addAdvInventoryItem(userID, *item); err != nil {
|
||
slog.Error("zone loot: failed to add item", "user", userID, "item", item.Name, "err", err)
|
||
return ""
|
||
}
|
||
return fmt.Sprintf("%s **%s** — %d coin baseline.", icon, item.Name, item.Value)
|
||
}
|
||
|
||
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss, isElite bool) string {
|
||
// Treasure rolls on every win, independent of whether the zone loot
|
||
// table produced an item — the two systems are separate rewards.
|
||
weight := advTreasureWeightStandard
|
||
switch {
|
||
case isBoss:
|
||
weight = advTreasureWeightBoss
|
||
case isElite:
|
||
weight = advTreasureWeightElite
|
||
}
|
||
p.rollZoneTreasure(userID, zoneID, weight)
|
||
|
||
// Masterwork is an elite/boss reward only — it's a permanent gear
|
||
// upgrade, not a per-kill trinket.
|
||
if isBoss || isElite {
|
||
p.rollZoneMasterwork(userID, zoneID, isBoss)
|
||
}
|
||
|
||
// Consumables and ingredients roll on any win, and independently of the
|
||
// zone slate below — a dry slate roll shouldn't cost the player these.
|
||
zTier := zoneTierFromID(zoneID)
|
||
var extra []string
|
||
if item := RollConsumableDrop(AdvActivityDungeon, zTier); item != nil {
|
||
if line := p.grantZoneItem(userID, item, "🧪"); line != "" {
|
||
extra = append(extra, line)
|
||
}
|
||
}
|
||
if item := rollZoneIngredient(zTier); item != nil {
|
||
if line := p.grantZoneItem(userID, item, "🌿"); line != "" {
|
||
extra = append(extra, line)
|
||
}
|
||
}
|
||
trailer := strings.Join(extra, "\n")
|
||
|
||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||
if !ok {
|
||
return trailer
|
||
}
|
||
|
||
// Magic-item substitution: swap the biome slate item for a registry
|
||
// magic item of the same rarity tier (see magic_items_gameplay.go).
|
||
if rngFloat(nil) < magicItemDropChance {
|
||
if mi, miOK := pickMagicItemForRarity(tier.rarity(), nil); miOK {
|
||
return joinLootLines(p.dropMagicItemLoot(userID, mi, tier), trailer)
|
||
}
|
||
}
|
||
|
||
tierVal := tier
|
||
item := AdvItem{
|
||
Name: entry.Name,
|
||
Type: entry.ItemType,
|
||
Tier: zTier,
|
||
Value: int64(entry.BaseValue),
|
||
SkillSource: fmt.Sprintf("zone_loot:%s:%s", zoneID, tierVal),
|
||
}
|
||
if err := addAdvInventoryItem(userID, item); err != nil {
|
||
return joinLootLines(fmt.Sprintf("_(Loot drop persistence error: %v.)_", err), trailer)
|
||
}
|
||
var b strings.Builder
|
||
if line := lootFlavorLine(tierVal); line != "" {
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
icon := lootIcon(tierVal)
|
||
desc := zoneItemDescription(zoneID, entry.Name)
|
||
if desc != "" {
|
||
b.WriteString(fmt.Sprintf("%s **%s** (%s) — %d coin baseline. _%s_",
|
||
icon, entry.Name, tierVal, entry.BaseValue, desc))
|
||
} else {
|
||
b.WriteString(fmt.Sprintf("%s **%s** (%s) — %d coin baseline.",
|
||
icon, entry.Name, tierVal, entry.BaseValue))
|
||
}
|
||
return joinLootLines(b.String(), trailer)
|
||
}
|
||
|
||
// joinLootLines stitches the zone-slate narration together with the
|
||
// consumable/ingredient trailer, tolerating either being empty.
|
||
func joinLootLines(parts ...string) string {
|
||
var kept []string
|
||
for _, s := range parts {
|
||
if s != "" {
|
||
kept = append(kept, s)
|
||
}
|
||
}
|
||
return strings.Join(kept, "\n")
|
||
}
|
||
|
||
// dropMagicItemLoot deposits a registry magic item as a combat-victory drop
|
||
// and returns its narration block. Potions/scrolls land as "consumable" so
|
||
// the combat pipeline auto-uses them; everything else is a "magic_item"
|
||
// sellable/equippable via the Curios shelf and `!adventure equip-magic`.
|
||
func (p *AdventurePlugin) dropMagicItemLoot(userID id.UserID, mi MagicItem, tier LootTier) string {
|
||
item := magicItemSell(mi)
|
||
item.SkillSource = "magic_item:" + mi.ID
|
||
if err := addAdvInventoryItem(userID, item); err != nil {
|
||
// Players see a tidy line; the raw err goes to the log so we can
|
||
// still chase the bug without leaking SQL into the chat.
|
||
slog.Error("magic-item: loot drop persist failed",
|
||
"user", userID, "item", mi.ID, "err", err)
|
||
return "_(The drop slips through your fingers — try again later.)_"
|
||
}
|
||
var b strings.Builder
|
||
if line := lootFlavorLine(tier); line != "" {
|
||
b.WriteString(line)
|
||
b.WriteString("\n")
|
||
}
|
||
// Pretty-print the rarity (very_rare → Very Rare) and call attunement
|
||
// what it does, not what 5e named it. Potions/scrolls earn their own
|
||
// auto-use tag so players don't think they need to do something with it.
|
||
tag := magicItemRarityLabel(rarityLootTierNum(mi.Rarity))
|
||
if mi.Attunement {
|
||
tag += " · needs bonding"
|
||
}
|
||
if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll {
|
||
tag += " · auto-uses in combat"
|
||
}
|
||
b.WriteString(fmt.Sprintf("✨ **%s** _(%s)_ — %d coin baseline.",
|
||
mi.Name, tag, mi.Value))
|
||
if mi.Desc != "" {
|
||
b.WriteString(fmt.Sprintf(" _%s_", mi.Desc))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// lootFlavorLine maps a tier to one of the existing TwinBee loot pools.
|
||
// Epic falls through to the Rare pool (next-best fit); Legendary uses its
|
||
// own pool. No new flavor file written here.
|
||
func lootFlavorLine(tier LootTier) string {
|
||
switch tier {
|
||
case LootTierCommon:
|
||
return flavor.Pick(flavor.LootDropCommon)
|
||
case LootTierUncommon:
|
||
return flavor.Pick(flavor.LootDropUncommon)
|
||
case LootTierRare, LootTierEpic:
|
||
return flavor.Pick(flavor.LootDropRare)
|
||
case LootTierLegendary:
|
||
return flavor.Pick(flavor.LootDropLegendary)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func lootIcon(tier LootTier) string {
|
||
switch tier {
|
||
case LootTierCommon:
|
||
return "🎁"
|
||
case LootTierUncommon:
|
||
return "🎁✨"
|
||
case LootTierRare:
|
||
return "💎"
|
||
case LootTierEpic:
|
||
return "💎✨"
|
||
case LootTierLegendary:
|
||
return "👑"
|
||
}
|
||
return "🎁"
|
||
}
|
||
|
||
// zoneItemDescription is the §5 zone-contextual item flavor table.
|
||
// Populated for Potion of Healing across all 10 zones (the design
|
||
// doc's exemplar) plus a per-item flavor matrix for the entries each
|
||
// zone's loot table actually drops (R4b polish). Falls back to "" so
|
||
// callers render the bare name when no flavor is on file.
|
||
func zoneItemDescription(zoneID ZoneID, itemName string) string {
|
||
if itemName == "Potion of Healing" {
|
||
return potionOfHealingZoneFlavor[zoneID]
|
||
}
|
||
if zoneMap, ok := zoneItemFlavor[zoneID]; ok {
|
||
if line, ok := zoneMap[itemName]; ok {
|
||
return line
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// zoneItemFlavor — per-zone, per-item flavor for the zone loot tables
|
||
// above. Keys must match `ZoneLootDrop.Name` exactly. Items not listed
|
||
// here render with no italicized flavor line, just the name + tier +
|
||
// coin baseline. Tone target: matches the §5 Potion-of-Healing
|
||
// exemplar — short, in-character, biome-distinctive.
|
||
var zoneItemFlavor = map[ZoneID]map[string]string{
|
||
ZoneGoblinWarrens: {
|
||
"Goblin Field Ration": "Wrapped in burlap. Smells like onions, fear, and someone's last meal.",
|
||
"Stolen Coin Pouch": "Mismatched currency, three different empires represented. The goblins didn't discriminate.",
|
||
"Worg-Tooth Trinket": "Strung on sinew. Still has bone fragments embedded in the gum-line.",
|
||
"Hobgoblin Captain's Insignia": "Bronze, dented, and unmistakably stolen from somewhere it mattered.",
|
||
"Crude Alchemy Vial": "The label is in goblin shorthand. The contents fizz when you tilt it.",
|
||
"Goblin-Forged Shortblade": "Edge holds. Balance is wrong. Built by hand, not by a smith.",
|
||
"War Standard Banner": "Heraldry of three warbands stitched over each other. The bottom layer is older than the empire.",
|
||
"Shaman's Carved Stave": "Knotwood, rune-burned. Hums faintly when you stop paying attention to it.",
|
||
"Hobgoblin General's Cuirass": "Plate-and-leather, embossed with a sigil that's been scraped halfway off and re-stamped.",
|
||
"King Grobnar's Iron Crown": "Three sizes too large for any goblin who ever lived. Whatever wore this first wasn't a goblin.",
|
||
},
|
||
ZoneCryptValdris: {
|
||
"Tarnished Burial Coin": "Stamped with a face nobody alive remembers. Heavier than it should be.",
|
||
"Cracked Bone Charm": "Wrist-strung. Whoever wore it didn't make it out.",
|
||
"Funeral-Wrap Linen": "Brittle, brown, and still smelling faintly of myrrh.",
|
||
"Pre-Empire Signet Ring": "The crest pre-dates any standing kingdom. The metal is still warm somehow.",
|
||
"Necrotic-Etched Dagger": "Black-veined steel. Cold to hold, colder to put down.",
|
||
"Sealed Funerary Vial": "Wax-stoppered. Whatever's inside has had centuries to think about itself.",
|
||
"Valdris Scriptorium Tablet": "Stone, carved in a script that splits opinion among the few scholars who can read it.",
|
||
"Silver-Inlaid Reliquary": "Empty now. Someone took whatever was inside and didn't replace it.",
|
||
"Crypt-Lord's Burial Mantle": "Heavy embroidery. The threads aren't thread.",
|
||
"Valdris's Sealing Sigil": "It hums. The Crypt is older than the empire. This is older than the Crypt.",
|
||
},
|
||
ZoneForestShadows: {
|
||
"Foraged Trail Loaf": "Dense, dark, and faintly bitter. Travelers' bread, baked under a canopy that doesn't quite let light through.",
|
||
"Carved Wooden Talisman": "Birch, knife-shaped, etched with a ward against something the carver wouldn't name.",
|
||
"Bundle of Shadow Reed": "Cut along the streambank. Smells like cold water and rot.",
|
||
"Hunter's Bone Bow Charm": "Small. Femur-bone of something none of the local hunters will identify.",
|
||
"Dryad-Touched Salve": "Pine-resin base. Heals fast. Tastes like sap if you're foolish enough to try.",
|
||
"Owlbear-Talon Knife": "Talon set into a wood handle. The talon is still sharp and the wood is older than the kill.",
|
||
"Fey-Stained Scroll Case": "The leather has faint iridescence under direct light. Don't ask where it was tanned.",
|
||
"Elder Druid's Walking Stave": "Living wood, still budding. Doesn't take kindly to being indoors.",
|
||
"Greenwarden's Ironbark Vest": "Bark-plated, woven with sapling fibre. The forest grew this for someone.",
|
||
"Heart-Wood of the First Grove": "A palm-sized chunk of something that pulses if you hold it long enough. Don't.",
|
||
},
|
||
ZoneSunkenTemple: {
|
||
"Salt-Crusted Coin": "The denomination is unreadable but the metal is real silver. Bring it to Thom Krooke; he knows the era.",
|
||
"Pearl Sliver": "Fragment, not a whole pearl. Something cracked it open trying to eat what was inside.",
|
||
"Pressure-Sealed Vial": "Cold to touch. Pop the seal and it whistles air for a full second before it stops.",
|
||
"Kuo-toa Spear-Head": "Bone-and-shell composite. Wickedly barbed. Still smells like the deep.",
|
||
"Coral-Set Pendant": "The coral is bleached except where it touched the wearer's skin.",
|
||
"Tide-Blessed Censer": "Brass, pitted. Still holds a faint trace of incense and old prayer.",
|
||
"Drowned Acolyte's Tome": "Pages stuck together. The ones that separate cleanly are the ones you should read carefully.",
|
||
"Aboleth-Glass Lens": "Looks ordinary. Look through it and you see things ten feet to the left of where they actually are.",
|
||
"Tide-Caller's Trident Head": "The barbs are folded inward. Whoever forged this didn't want it pulled out clean.",
|
||
"Dar'eth's Drowned Crown": "Coral-encrusted gold. Fits no human skull comfortably.",
|
||
},
|
||
ZoneManorBlackspire: {
|
||
"Tarnished Silver Spoon": "Estate-marked. The set it belonged to is presumably still in a drawer somewhere upstairs.",
|
||
"Forty-Year-Old Tincture": "Label dated 1486. Still potent. The Blackspires kept good apothecaries.",
|
||
"Mourner's Cameo": "A widow's profile, ivory-on-jet. Likeness unknown; mood unmistakable.",
|
||
"Vampire-Hunter's Stake Set": "Three stakes, one mallet, all hawthorn. The mallet has been used.",
|
||
"Cursed Family Locket": "Don't open it. (You'll open it. They always do.)",
|
||
"Estate-Sealed Wine Bottle": "Wax intact. The label's faded but the vintage is legible — and good.",
|
||
"Blackspire Family Diary": "Penmanship is neat for the first hundred pages and then becomes other things.",
|
||
"Ghost-Iron Candelabrum": "Cold even when lit. The flames lean toward you regardless of where you stand.",
|
||
"Manor-Lord's Funeral Coat": "Tailored. Mourning-black. Smells faintly of formaldehyde and rosewater.",
|
||
"Blackspire Patriarch's Death Mask": "Plaster, eyes closed. The expression is wrong for someone who died in their sleep.",
|
||
},
|
||
ZoneUnderforge: {
|
||
"Forge-Slag Trinket": "A cooled drip of something that used to be molten. Dwarves keep these. Nobody knows why.",
|
||
"Iron-Filing Pouch": "Heavy for its size. Magnetic. Useful, somehow, to someone, eventually.",
|
||
"Dwarven Hardtack": "You could break a tooth on this. The dwarves who made it would consider that a feature.",
|
||
"Azer-Forged Hammer Head": "Still warm. Will be warm tomorrow. Will be warm a hundred years from now.",
|
||
"Heat-Etched Sigil Plate": "The runes were burned in by hand. The hand that did it was not a hand that minded heat.",
|
||
"Salamander-Oil Flask": "Sealed in glass. The oil ripples even when nothing else does.",
|
||
"Master-Smith's Ledger Page": "Old dwarven shorthand. Inventory of a forge that stopped recording entries the year the elementals woke up.",
|
||
"Mithral-Thread Gauntlet": "Light enough to be wrong. Sharper than it has any right to be along the knuckles.",
|
||
"Forge-Marshal's Anvil-Charm": "A miniature anvil on a chain. Heavy enough to be impractical. Worn anyway.",
|
||
"The First Hammer's Striking Head": "The dwarven creation-myths name this. They don't agree on which dwarf swung it.",
|
||
},
|
||
ZoneUnderdark: {
|
||
"Cave-Spider Silk Skein": "Wound on a bone-spool. Tensile strength embarrassing to surface looms.",
|
||
"Dim-Glow Mushroom Bundle": "Tied with hair. Edible. Slightly hallucinogenic. Drow eat them as a snack.",
|
||
"Drow-Worked Coin": "Etched on both sides. The faces are not faces.",
|
||
"Drow Hand-Crossbow Bolt Case": "Six bolts. Two are tipped with something dark. Two are tipped with something darker. Two are bare.",
|
||
"Faerzress-Etched Charm": "Magic-disrupting. Useful as a hex-breaker; useless inside an active wardline.",
|
||
"Diluted Drow Sleep Vial": "House-watered for trade with the surface. Still drops a person flat in twenty seconds.",
|
||
"Mind Flayer Codex Page": "The script reads itself into your head if you stare too long. Don't.",
|
||
"Drow-Adamantine Bracer": "Forged in fae-fire. Black with a violet undersheen. Doesn't tarnish.",
|
||
"Matron's House-Sigil Brooch": "Eight-legged crest. The matron who wore it is presumably no longer wearing it.",
|
||
"Eyeless King's Skull-Crown": "Bone-set, with sockets where stones should go. Worn smooth by something patient.",
|
||
},
|
||
ZoneFeywildCrossing: {
|
||
"Pressed Fey Petal": "Color shifts when you blink. Smells like a season you don't have a name for.",
|
||
"Honey-Wax Candle": "Burns blue. The wax pools upward.",
|
||
"Dream-Spun Thread": "Spider silk except it sings if you pluck it.",
|
||
"Pixie-Wing Glassine": "Pressed wing-membrane between two slips of glass. Iridescence depends on who is watching.",
|
||
"Wisp-Lantern Charm": "Glows softly when held; goes dark when set down. Doesn't ask permission to do either.",
|
||
"Hag-Brewed Bitter Cordial": "Black, syrupy, and tastes exactly like one specific regret.",
|
||
"Time-Locked Music Box": "Plays a tune that's three notes longer than the box should physically be able to hold.",
|
||
"Fey Court Invitation Scroll": "Sealed in honeycomb wax. The invitation is for a party that hasn't happened yet, or already did.",
|
||
"Summer-Knight's Thorn Buckler": "Thorn-bossed, briar-rimmed. The thorns flex when struck and then re-form.",
|
||
"Thornmother's Heart-Thorn Reliquary": "Sealed bronze, briar-wrapped. The thorn inside still pulses on a slow rhythm.",
|
||
},
|
||
ZoneDragonsLair: {
|
||
"Half-Melted Gold Coin": "Slumped flat by heat. Still gold. Still legal tender if Thom Krooke can read the year.",
|
||
"Volcanic Glass Sliver": "Sharper than steel and a quarter the weight. Edges chip on impact.",
|
||
"Kobold Field Pouch": "Trail rations, a fire-starter, two prayer-beads to a god named Kurtulmak.",
|
||
"Drake-Scale Bracer": "Overlapping scales, still attached to a strip of original drake-hide. Heat-resistant.",
|
||
"Kobold Trap-Mechanism": "Pre-built, single-use, and surprisingly clever. Read the inscribed instructions before activating.",
|
||
"Drake-Blood Cordial": "Distilled fire. Burns going down. Fire-resistance for an hour and a hangover for a day.",
|
||
"Hoard-Inventory Page": "An accountant's record of items the dragon owns. The dragon does not know it lost this page.",
|
||
"Dragonfire-Forged Spike": "Blackened steel, never cools. Driven into stone with the hilt-end first.",
|
||
"Wyrmguard's Cinder-Cloak Clasp": "Brass-and-obsidian. The clasp is dragonshape. The wearer was, briefly, a problem.",
|
||
"Infernax's Tooth-Reliquary": "A single tooth, mounted in gold-and-bone. The dragon is younger by exactly this tooth.",
|
||
},
|
||
ZoneAbyssPortal: {
|
||
"Brimstone-Caked Coin": "Sulfur-yellow at the edges. Burns the nose if you sniff it. (You won't sniff it twice.)",
|
||
"Sulfur-Wax Stub": "Half-burned demon-tallow. Doesn't smell like tallow.",
|
||
"Demon-Ichor Smear-Vial": "Stoppered tight. The contents move on their own when you turn away.",
|
||
"Quasit-Bound Ring": "Tiny, plain, and humming. Don't put it on. There's something in there.",
|
||
"Corrupted-Metal Buckle": "Was steel once. Is something else now. The shape held; the metal didn't.",
|
||
"Planar-Shard Pendant": "Looks like a fragment of a much larger reality. Held wrong, it cuts where there's nothing to cut.",
|
||
"Abyssal-Marked Codex Sheet": "Vellum, demon-scribed. The script reads in a voice that isn't yours.",
|
||
"Marilith-Steel Edge": "Six-bladed weapon-fragment, broken from a longer edge. The break is centuries old. The edge is sharp.",
|
||
"Nalfeshnee's Honor-Sigil": "A demon's idea of dignity. Brass, blood-stained, embossed with a name you can't pronounce.",
|
||
"Belaxath's Bound Truename Tablet": "Stone, demon-runed. The truename is bound by inscription. Read it and the binding holds. Speak it and it doesn't.",
|
||
},
|
||
}
|
||
|
||
// potionOfHealingZoneFlavor — §5 Zone Loot Flavor by Biome, verbatim.
|
||
var potionOfHealingZoneFlavor = map[ZoneID]string{
|
||
ZoneGoblinWarrens: "A stolen vial, still stoppered with a rag. It smells like goblin and better days.",
|
||
ZoneCryptValdris: "A glass vial embedded in a burial offering. Whoever left it didn't need it.",
|
||
ZoneForestShadows: "Bark-sealed, filled with something deep red. It tastes like the forest before it went wrong.",
|
||
ZoneSunkenTemple: "Salt-rimed and pressure-sealed. A temple offering, preserved by the deep cold.",
|
||
ZoneManorBlackspire: "Found in the medicine cabinet, dated forty years ago. Remarkably, still effective.",
|
||
ZoneUnderforge: "Hot to the touch even now. The dwarves made things to last.",
|
||
ZoneUnderdark: "Drow-made. The formula is different. The effect is the same. The ingredients are not discussed.",
|
||
ZoneFeywildCrossing: "It shifts color when you look at it sideways. The fey make medicine the way they make everything: beautifully and with caveats.",
|
||
ZoneDragonsLair: "A kobold medic's field kit, looted from a belt pouch. The kobolds take care of their own.",
|
||
ZoneAbyssPortal: "Glows faintly red. Still works. I recommend not asking what's in it.",
|
||
}
|