D&D: wire the Open5e magic-item registry into live gameplay

Magic items now reach players through three surfaces: zone loot drops,
Luigi's "Curios" shelf, and combat effects. Effects are formulaic
(Rarity scalar x Kind), mirroring the bestiary tuning pass, with an
empty magicItemEffectOverlay as the hand-authored refinement path.

- magic_items_gameplay.go: rarity index, magic_item_equipped persistence
  (new table, DnDSlot-keyed), codified effect formula, applyMagicItemEffects
  combat hook, potion/scroll -> ConsumableDef bridge, !adventure equip-magic
- dropZoneLoot: 15% magic-item substitution roll by tier rarity
- Luigi's Curios category: daily UTC-seeded 8-item rotation
- combat_bridge / combat_session_build: applyMagicItemEffects after passives
- consumableDefByName falls through so loot/shop potions auto-resolve
- renderDnDSheet: new Magic Items section

Equippable items live entirely in the DnDSlot scheme, separate from the
legacy tier-gear. Attunement items equip inert until attuned (3-slot cap).
This commit is contained in:
prosolis
2026-05-14 18:38:57 -07:00
parent 297ce3d786
commit 0d666beea3
12 changed files with 996 additions and 10 deletions

View File

@@ -376,11 +376,25 @@ func rngIntN(rng *rand.Rand, n int) int {
// 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{
@@ -410,6 +424,33 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
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 {
return fmt.Sprintf("_(Loot drop persistence error: %v.)_", err)
}
var b strings.Builder
if line := lootFlavorLine(tier); line != "" {
b.WriteString(line)
b.WriteString("\n")
}
tag := string(mi.Rarity)
if mi.Attunement {
tag += ", attunement"
}
b.WriteString(fmt.Sprintf("✨ **%s** (%s %s) — %d coin baseline.",
mi.Name, mi.Kind, 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.