mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Tedium-removal pass driven by live play feedback. Three big threads: Expedition autopilot Phase 4 — background auto-run + harvest-until-dry: - New expeditionAutoRunTicker walks active expeditions every 15min (5min tick, per-expedition CAS on new last_autorun_at column). Skips combat sessions, briefing/recap quiet windows, expeditions <30min old. - Walks up to 3 rooms/tick with compact narration; suppresses DMs when 0 rooms walked or just hitting the per-tick cap (no "stretch complete" filler). Player only hears from the bot when a real decision is needed. - Compact mode: one-line combat narration for trash/elite, auto-resolves elite doorways via the forward-sim engine (boss still pauses). Threaded via new advanceOnceWithOpts → resolveRoom → resolveCombatRoom. - Auto-harvest now grinds each Common/Uncommon node until dry (cap at 8 attempts/visit), mirroring manual !scavenge retries. Rare+ still pauses. - Ambient ticker: anti-repeat by Kind via new last_ambient_kind column (avoids two pack_rat DMs in a row when the pool only has 6 lines). - !expedition go <n> now routes to the fork-choice handler when active + numeric; fork footer rewritten to suggest !expedition go instead of !zone go. Boss/Elite doorway: formatNextRoomMessage routes the action hint by next room type and autopilot loop breaks via new nextRoomType field so "Room X/Y — Boss" doesn't double-print with contradictory hints (!zone advance vs !fight). - runAutopilotWalk extracted from expeditionCmdRun so foreground and background share the loop body. Rogue Sneak Attack actually exists now: - Audit found the rogue's "Sneak Attack" passive was AutoCritFirst + 5% damage rider. No Nd6, ever. Phase 2 Monte Carlo masked this with a small flat buff; the class's defining mechanic never matched its tooltip or 5e identity. - Added SneakAttackDie int to CombatModifiers, per-hit Nd6 in combat_primitives.go (same lane as DivineStrikePerHit). Scales with level: 1d6 at L1-2 ... 4d6 at L7-8 ... capped at 10d6 at L19-20. - AutoCritFirst + 5% rider retained as bonuses on top of working sneak attack — preserves the opener-burst feel. - L7 rogue expected per-hit ~8 → ~22 damage. Valdris fight math goes from "16 rounds to kill, die in 8" to winnable. TwinBee voice sweep (Phase B3): - ~530 line changes across 24 files replacing third-person "TwinBee notes/files/tracks/respects/..." constructions with first-person inside flavor string literals. Code identifiers (TwinBeeLine, twinBeeLine, etc.), chat-prefix labels (🎭 **TwinBee:**), item names (TwinBee's Bell), achievements, and game-title references in fun.go (Konami TwinBee ship) left intact. - Catches a real bug: Valdris fight rendered "TwinBee marks the Legendary Resistance" mid-combat in third person, contradicting the Phase B2 convention. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
656 lines
31 KiB
Go
656 lines
31 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
|
||
|
||
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss bool) string {
|
||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
|
||
// 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 p.dropMagicItemLoot(userID, mi, tier)
|
||
}
|
||
}
|
||
|
||
tierVal := tier
|
||
zoneTier := zoneTierFromID(zoneID)
|
||
item := AdvItem{
|
||
Name: entry.Name,
|
||
Type: entry.ItemType,
|
||
Tier: zoneTier,
|
||
Value: int64(entry.BaseValue),
|
||
SkillSource: fmt.Sprintf("zone_loot:%s:%s", zoneID, tierVal),
|
||
}
|
||
if err := addAdvInventoryItem(userID, item); err != nil {
|
||
return fmt.Sprintf("_(Loot drop persistence error: %v.)_", err)
|
||
}
|
||
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 b.String()
|
||
}
|
||
|
||
// 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.",
|
||
}
|