Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2

Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 08:21:44 -07:00
parent 8e0fe0230c
commit 9e1a1f606c
80 changed files with 19088 additions and 210 deletions

View File

@@ -0,0 +1,486 @@
package plugin
import (
"math/rand/v2"
"strconv"
"strings"
)
// Phase 8 — equipment profile registry per gogobee_equipment_appendix.md.
//
// Catalogs every weapon and armor entry from the appendix as data tables,
// plus a synthesis layer that maps legacy AdvEquipment rows (which only
// know slot + tier + name) onto a sensible D&D profile. This gives us
// real weapon damage dice and armor AC computation in combat without
// requiring a loot-side rewrite.
// ── Weapon profiles ──────────────────────────────────────────────────────────
// WeaponCategory groups weapons for class-proficiency lookup.
type WeaponCategory int
const (
WeaponCatSimpleMelee WeaponCategory = iota
WeaponCatSimpleRanged
WeaponCatMartialMelee
WeaponCatMartialRanged
)
// WeaponProperty enumerates the weapon properties from appendix §1.
type WeaponProperty string
const (
PropFinesse WeaponProperty = "finesse"
PropLight WeaponProperty = "light"
PropHeavy WeaponProperty = "heavy"
PropTwoHanded WeaponProperty = "two_handed"
PropVersatile WeaponProperty = "versatile"
PropThrown WeaponProperty = "thrown"
PropAmmunition WeaponProperty = "ammunition"
PropLoading WeaponProperty = "loading"
PropReach WeaponProperty = "reach"
)
// WeaponProfile mirrors the spec's struct. DamageDie is parsed from "1d8",
// "2d6", "1d10" etc. into Count/Sides on construction.
type WeaponProfile struct {
ID string
Name string
Category WeaponCategory
DamageCount int // dice count
DamageSides int // die sides
DamageType string // slashing, piercing, bludgeoning
Properties []WeaponProperty
VersaCount int // versatile two-handed dice (0 if not versatile)
VersaSides int
MagicBonus int // 0 for mundane
MagicProp string // empty for mundane
NamedItem bool // true for §7.2 named magic weapons
}
// HasProperty reports whether the weapon has the given property.
func (w *WeaponProfile) HasProperty(p WeaponProperty) bool {
for _, q := range w.Properties {
if q == p {
return true
}
}
return false
}
// dndWeaponRegistry — every weapon from appendix §2 and §3.
var dndWeaponRegistry = []WeaponProfile{
// §2.1 Simple Melee
{ID: "wpn_club", Name: "Club", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 4, DamageType: "bludgeoning", Properties: []WeaponProperty{PropLight}},
{ID: "wpn_dagger", Name: "Dagger", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 4, DamageType: "piercing", Properties: []WeaponProperty{PropFinesse, PropLight, PropThrown}},
{ID: "wpn_greatclub", Name: "Greatclub", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 8, DamageType: "bludgeoning", Properties: []WeaponProperty{PropTwoHanded}},
{ID: "wpn_handaxe", Name: "Handaxe", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 6, DamageType: "slashing", Properties: []WeaponProperty{PropLight, PropThrown}},
{ID: "wpn_javelin", Name: "Javelin", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 6, DamageType: "piercing", Properties: []WeaponProperty{PropThrown}},
{ID: "wpn_light_hammer", Name: "Light Hammer", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 4, DamageType: "bludgeoning", Properties: []WeaponProperty{PropLight, PropThrown}},
{ID: "wpn_mace", Name: "Mace", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 6, DamageType: "bludgeoning"},
{ID: "wpn_quarterstaff", Name: "Quarterstaff", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 6, DamageType: "bludgeoning", Properties: []WeaponProperty{PropVersatile}, VersaCount: 1, VersaSides: 8},
{ID: "wpn_sickle", Name: "Sickle", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 4, DamageType: "slashing", Properties: []WeaponProperty{PropLight}},
{ID: "wpn_spear", Name: "Spear", Category: WeaponCatSimpleMelee, DamageCount: 1, DamageSides: 6, DamageType: "piercing", Properties: []WeaponProperty{PropThrown, PropVersatile}, VersaCount: 1, VersaSides: 8},
// §2.2 Simple Ranged
{ID: "wpn_crossbow_light", Name: "Light Crossbow", Category: WeaponCatSimpleRanged, DamageCount: 1, DamageSides: 8, DamageType: "piercing", Properties: []WeaponProperty{PropAmmunition, PropLoading, PropTwoHanded}},
{ID: "wpn_dart", Name: "Dart", Category: WeaponCatSimpleRanged, DamageCount: 1, DamageSides: 4, DamageType: "piercing", Properties: []WeaponProperty{PropFinesse, PropThrown}},
{ID: "wpn_shortbow", Name: "Shortbow", Category: WeaponCatSimpleRanged, DamageCount: 1, DamageSides: 6, DamageType: "piercing", Properties: []WeaponProperty{PropAmmunition, PropTwoHanded}},
{ID: "wpn_sling", Name: "Sling", Category: WeaponCatSimpleRanged, DamageCount: 1, DamageSides: 4, DamageType: "bludgeoning", Properties: []WeaponProperty{PropAmmunition}},
// §3.1 Martial Melee
{ID: "wpn_battleaxe", Name: "Battleaxe", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "slashing", Properties: []WeaponProperty{PropVersatile}, VersaCount: 1, VersaSides: 10},
{ID: "wpn_flail", Name: "Flail", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "bludgeoning"},
{ID: "wpn_glaive", Name: "Glaive", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 10, DamageType: "slashing", Properties: []WeaponProperty{PropHeavy, PropReach, PropTwoHanded}},
{ID: "wpn_greataxe", Name: "Greataxe", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 12, DamageType: "slashing", Properties: []WeaponProperty{PropHeavy, PropTwoHanded}},
{ID: "wpn_greatsword", Name: "Greatsword", Category: WeaponCatMartialMelee, DamageCount: 2, DamageSides: 6, DamageType: "slashing", Properties: []WeaponProperty{PropHeavy, PropTwoHanded}},
{ID: "wpn_halberd", Name: "Halberd", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 10, DamageType: "slashing", Properties: []WeaponProperty{PropHeavy, PropReach, PropTwoHanded}},
{ID: "wpn_lance", Name: "Lance", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 12, DamageType: "piercing", Properties: []WeaponProperty{PropReach}},
{ID: "wpn_longsword", Name: "Longsword", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "slashing", Properties: []WeaponProperty{PropVersatile}, VersaCount: 1, VersaSides: 10},
{ID: "wpn_maul", Name: "Maul", Category: WeaponCatMartialMelee, DamageCount: 2, DamageSides: 6, DamageType: "bludgeoning", Properties: []WeaponProperty{PropHeavy, PropTwoHanded}},
{ID: "wpn_morningstar", Name: "Morningstar", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "piercing"},
{ID: "wpn_pike", Name: "Pike", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 10, DamageType: "piercing", Properties: []WeaponProperty{PropHeavy, PropReach, PropTwoHanded}},
{ID: "wpn_rapier", Name: "Rapier", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "piercing", Properties: []WeaponProperty{PropFinesse}},
{ID: "wpn_scimitar", Name: "Scimitar", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 6, DamageType: "slashing", Properties: []WeaponProperty{PropFinesse, PropLight}},
{ID: "wpn_shortsword", Name: "Shortsword", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 6, DamageType: "piercing", Properties: []WeaponProperty{PropFinesse, PropLight}},
{ID: "wpn_trident", Name: "Trident", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 6, DamageType: "piercing", Properties: []WeaponProperty{PropThrown, PropVersatile}, VersaCount: 1, VersaSides: 8},
{ID: "wpn_war_pick", Name: "War Pick", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "piercing"},
{ID: "wpn_warhammer", Name: "Warhammer", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 8, DamageType: "bludgeoning", Properties: []WeaponProperty{PropVersatile}, VersaCount: 1, VersaSides: 10},
{ID: "wpn_whip", Name: "Whip", Category: WeaponCatMartialMelee, DamageCount: 1, DamageSides: 4, DamageType: "slashing", Properties: []WeaponProperty{PropFinesse, PropReach}},
// §3.2 Martial Ranged
{ID: "wpn_crossbow_hand", Name: "Hand Crossbow", Category: WeaponCatMartialRanged, DamageCount: 1, DamageSides: 6, DamageType: "piercing", Properties: []WeaponProperty{PropAmmunition, PropLight, PropLoading}},
{ID: "wpn_crossbow_heavy", Name: "Heavy Crossbow", Category: WeaponCatMartialRanged, DamageCount: 1, DamageSides: 10, DamageType: "piercing", Properties: []WeaponProperty{PropAmmunition, PropHeavy, PropLoading, PropTwoHanded}},
{ID: "wpn_longbow", Name: "Longbow", Category: WeaponCatMartialRanged, DamageCount: 1, DamageSides: 8, DamageType: "piercing", Properties: []WeaponProperty{PropAmmunition, PropHeavy, PropTwoHanded}},
}
// weaponByID returns the WeaponProfile for an ID, or nil.
func weaponByID(id string) *WeaponProfile {
for i := range dndWeaponRegistry {
if dndWeaponRegistry[i].ID == id {
return &dndWeaponRegistry[i]
}
}
return nil
}
// ── Armor profiles ───────────────────────────────────────────────────────────
// ArmorType identifies the proficiency category an armor falls under.
type ArmorType int
const (
ArmorTypeLight ArmorType = iota
ArmorTypeMedium
ArmorTypeHeavy
ArmorTypeShield
)
// ArmorProfile is the spec's struct. MaxDEXBonus convention:
// -1 = unlimited (light armor takes full DEX mod)
// 2 = medium (cap at +2)
// 0 = heavy (no DEX bonus)
// 0 for shields (shields don't add DEX)
type ArmorProfile struct {
ID string
Name string
Type ArmorType
BaseAC int // 0 for shields (shields use ShieldBonus)
ShieldBonus int // +2 base for shields, 0 for armor
MaxDEXBonus int
STRRequire int
StealthDisad bool
MagicBonus int
MagicProp string
}
var dndArmorRegistry = []ArmorProfile{
// §5.1 Light
{ID: "arm_padded", Name: "Padded", Type: ArmorTypeLight, BaseAC: 11, MaxDEXBonus: -1, StealthDisad: true},
{ID: "arm_leather", Name: "Leather", Type: ArmorTypeLight, BaseAC: 11, MaxDEXBonus: -1},
{ID: "arm_studded", Name: "Studded Leather", Type: ArmorTypeLight, BaseAC: 12, MaxDEXBonus: -1},
// §5.2 Medium
{ID: "arm_hide", Name: "Hide", Type: ArmorTypeMedium, BaseAC: 12, MaxDEXBonus: 2},
{ID: "arm_chain_shirt", Name: "Chain Shirt", Type: ArmorTypeMedium, BaseAC: 13, MaxDEXBonus: 2},
{ID: "arm_scale_mail", Name: "Scale Mail", Type: ArmorTypeMedium, BaseAC: 14, MaxDEXBonus: 2, StealthDisad: true},
{ID: "arm_breastplate", Name: "Breastplate", Type: ArmorTypeMedium, BaseAC: 14, MaxDEXBonus: 2},
{ID: "arm_half_plate", Name: "Half Plate", Type: ArmorTypeMedium, BaseAC: 15, MaxDEXBonus: 2, StealthDisad: true},
// §5.3 Heavy
{ID: "arm_ring_mail", Name: "Ring Mail", Type: ArmorTypeHeavy, BaseAC: 14, MaxDEXBonus: 0, StealthDisad: true},
{ID: "arm_chain_mail", Name: "Chain Mail", Type: ArmorTypeHeavy, BaseAC: 16, MaxDEXBonus: 0, STRRequire: 13, StealthDisad: true},
{ID: "arm_splint", Name: "Splint", Type: ArmorTypeHeavy, BaseAC: 17, MaxDEXBonus: 0, STRRequire: 15, StealthDisad: true},
{ID: "arm_plate", Name: "Plate", Type: ArmorTypeHeavy, BaseAC: 18, MaxDEXBonus: 0, STRRequire: 15, StealthDisad: true},
// §5.4 Shield
{ID: "arm_shield", Name: "Shield", Type: ArmorTypeShield, BaseAC: 0, ShieldBonus: 2, MaxDEXBonus: 0},
}
func armorByID(id string) *ArmorProfile {
for i := range dndArmorRegistry {
if dndArmorRegistry[i].ID == id {
return &dndArmorRegistry[i]
}
}
return nil
}
// ── Class proficiency matrix (appendix §9) ──────────────────────────────────
// dndClassWeaponProficiency reports whether a class is proficient with a
// weapon. Rogue's restricted martial list (Shortsword, Rapier, Scimitar,
// Longsword, Hand crossbow) is hard-coded.
func dndClassWeaponProficiency(class DnDClass, w *WeaponProfile) bool {
if w == nil {
return true
}
// All classes are proficient with simple weapons.
if w.Category == WeaponCatSimpleMelee || w.Category == WeaponCatSimpleRanged {
return true
}
switch class {
case ClassFighter, ClassRanger:
return true // proficient with all martial
case ClassRogue:
// Restricted martial list per appendix §9.
switch w.ID {
case "wpn_shortsword", "wpn_rapier", "wpn_scimitar", "wpn_longsword", "wpn_crossbow_hand":
return true
}
return false
case ClassMage, ClassCleric:
// Mage: daggers + staves only. Cleric: simple only. Both already
// covered by the simple-weapon early-exit. Here we're in martial.
return false
}
return false
}
// dndClassArmorProficiency returns whether the class is proficient with the
// given armor type. Per appendix §9 + main design doc §3.2.
func dndClassArmorProficiency(class DnDClass, a *ArmorProfile) bool {
if a == nil {
return true
}
switch class {
case ClassFighter:
return true // Fighter wears anything
case ClassRanger, ClassCleric:
return a.Type == ArmorTypeLight || a.Type == ArmorTypeMedium || a.Type == ArmorTypeShield
case ClassRogue:
return a.Type == ArmorTypeLight
case ClassMage:
return false // Mage proficient with no armor
}
return false
}
// ── Damage dice rolling ─────────────────────────────────────────────────────
// rollWeaponDamage rolls the weapon's damage dice and adds the ability mod
// + magic bonus. If twoHanded is true and the weapon is versatile, rolls
// the larger versatile die instead. Returns the unmodified dice total too
// for the crit doubling math.
func rollWeaponDamage(w *WeaponProfile, abilityMod int, twoHanded bool) (total, dice int) {
count, sides := w.DamageCount, w.DamageSides
if twoHanded && w.HasProperty(PropVersatile) && w.VersaCount > 0 {
count, sides = w.VersaCount, w.VersaSides
}
for i := 0; i < count; i++ {
dice += 1 + rand.IntN(sides)
}
total = dice + abilityMod + w.MagicBonus
if total < 1 {
total = 1
}
return
}
// avgWeaponDamage returns the expected (mean) damage for a weapon given the
// ability mod. Used by tests and balance checks. Versatile two-handed mode
// not considered here — tests pick the form they want.
func avgWeaponDamage(w *WeaponProfile, abilityMod int) float64 {
count, sides := float64(w.DamageCount), float64(w.DamageSides)
avg := count*(sides+1)/2 + float64(abilityMod) + float64(w.MagicBonus)
if avg < 1 {
avg = 1
}
return avg
}
// ── AC computation per appendix ──────────────────────────────────────────────
// computeArmorAC implements the appendix's ComputeAC helper. Returns the
// total AC; set armor=nil for unarmored, shield=nil for no shield.
func computeArmorAC(armor, shield *ArmorProfile, dexMod int) int {
base := 10
dexApplied := dexMod
magicBonus := 0
if armor != nil {
base = armor.BaseAC
magicBonus = armor.MagicBonus
switch armor.MaxDEXBonus {
case -1:
dexApplied = dexMod
case 2:
if dexMod > 2 {
dexApplied = 2
} else {
dexApplied = dexMod
}
case 0:
dexApplied = 0
}
}
shieldBonus := 0
if shield != nil {
shieldBonus = shield.ShieldBonus + shield.MagicBonus
}
return base + dexApplied + magicBonus + shieldBonus
}
// ── Legacy gear synthesis ────────────────────────────────────────────────────
//
// Existing AdvEquipment rows have name, tier, slot, masterwork, arena_tier
// — but no D&D weapon ID. We synthesize a sensible profile from these fields
// so combat gets real weapon dice and armor AC without a loot rewrite.
//
// Legacy → D&D mapping per slot:
// Weapon:
// Tier 1 → Club / Dagger by name keyword
// Tier 2 → Mace / Quarterstaff
// Tier 3 → Longsword (versatile)
// Tier 4 → Battleaxe (versatile, two-handed mode)
// Tier 5 → Greatsword (2d6)
// Tier 6+ → Greatsword + magic_bonus = (tier - 5)
// Armor:
// Tier 1 → Padded
// Tier 2 → Leather
// Tier 3 → Chain Shirt (medium)
// Tier 4 → Scale Mail (medium)
// Tier 5 → Half Plate (medium)
// Tier 6+ → Plate (heavy) + magic_bonus = (tier - 5)
// Tool slot may include shields by name; otherwise no AC contribution.
//
// This is intentionally generous — we want existing high-tier players to
// see a damage upgrade, not a downgrade, on the rebrand to D&D dice.
// synthesizeWeaponProfile inspects a legacy AdvEquipment row and returns a
// best-fit WeaponProfile. Returns nil if the slot isn't a weapon.
func synthesizeWeaponProfile(eq *AdvEquipment) *WeaponProfile {
if eq == nil || eq.Slot != SlotWeapon {
return nil
}
tier := eq.Tier
if eq.Masterwork && tier < 5 {
tier = 5 // Masterwork promotes to top-tier base
}
nameLower := strings.ToLower(eq.Name)
var base *WeaponProfile
switch {
case tier <= 1:
if strings.Contains(nameLower, "dagger") || strings.Contains(nameLower, "knife") {
base = weaponByID("wpn_dagger")
} else {
base = weaponByID("wpn_club")
}
case tier == 2:
if strings.Contains(nameLower, "staff") || strings.Contains(nameLower, "wand") {
base = weaponByID("wpn_quarterstaff")
} else if strings.Contains(nameLower, "axe") {
base = weaponByID("wpn_handaxe")
} else {
base = weaponByID("wpn_mace")
}
case tier == 3:
if strings.Contains(nameLower, "bow") {
base = weaponByID("wpn_shortbow")
} else if strings.Contains(nameLower, "axe") {
base = weaponByID("wpn_battleaxe")
} else {
base = weaponByID("wpn_longsword")
}
case tier == 4:
if strings.Contains(nameLower, "bow") {
base = weaponByID("wpn_longbow")
} else if strings.Contains(nameLower, "axe") {
base = weaponByID("wpn_battleaxe")
} else if strings.Contains(nameLower, "hammer") {
base = weaponByID("wpn_warhammer")
} else {
base = weaponByID("wpn_longsword")
}
default: // tier 5+
if strings.Contains(nameLower, "bow") {
base = weaponByID("wpn_longbow")
} else if strings.Contains(nameLower, "axe") {
base = weaponByID("wpn_greataxe")
} else if strings.Contains(nameLower, "hammer") || strings.Contains(nameLower, "maul") {
base = weaponByID("wpn_maul")
} else {
base = weaponByID("wpn_greatsword")
}
}
if base == nil {
// Should never happen — registry has all the IDs above. Fallback club.
base = weaponByID("wpn_club")
}
// Copy so we can mutate magic bonus without polluting the registry.
out := *base
if eq.Tier > 5 {
out.MagicBonus = eq.Tier - 5 // T6 = +1, T7 = +2, T8 = +3
if out.MagicBonus > 3 {
out.MagicBonus = 3
}
}
if eq.Masterwork && out.MagicBonus < 1 {
out.MagicBonus = 1
}
return &out
}
// synthesizeArmorProfile inspects a legacy AdvEquipment row and returns a
// best-fit ArmorProfile for the chest (armor) slot. Returns nil for other slots.
func synthesizeArmorProfile(eq *AdvEquipment) *ArmorProfile {
if eq == nil || eq.Slot != SlotArmor {
return nil
}
tier := eq.Tier
if eq.Masterwork && tier < 5 {
tier = 5
}
var base *ArmorProfile
switch {
case tier <= 1:
base = armorByID("arm_padded")
case tier == 2:
base = armorByID("arm_leather")
case tier == 3:
base = armorByID("arm_chain_shirt")
case tier == 4:
base = armorByID("arm_scale_mail")
case tier == 5:
base = armorByID("arm_half_plate")
default:
base = armorByID("arm_plate")
}
if base == nil {
base = armorByID("arm_padded")
}
out := *base
if eq.Tier > 5 {
out.MagicBonus = eq.Tier - 5
if out.MagicBonus > 3 {
out.MagicBonus = 3
}
}
if eq.Masterwork && out.MagicBonus < 1 {
out.MagicBonus = 1
}
return &out
}
// synthesizeShield — tool slot can hold shields when name matches.
// Returns nil if the equipped tool isn't a shield.
func synthesizeShield(eq *AdvEquipment) *ArmorProfile {
if eq == nil || eq.Slot != SlotTool {
return nil
}
if !strings.Contains(strings.ToLower(eq.Name), "shield") {
return nil
}
base := armorByID("arm_shield")
if base == nil {
return nil
}
out := *base
if eq.Tier > 5 {
out.MagicBonus = eq.Tier - 5
if out.MagicBonus > 3 {
out.MagicBonus = 3
}
}
if eq.Masterwork && out.MagicBonus < 1 {
out.MagicBonus = 1
}
return &out
}
// ── Misc helpers ────────────────────────────────────────────────────────────
// parseDamageDie parses strings like "1d8" or "2d6" into (count, sides).
// Used by tests; the registry above pre-parses these at compile time.
func parseDamageDie(s string) (count, sides int, ok bool) {
parts := strings.SplitN(strings.ToLower(strings.TrimSpace(s)), "d", 2)
if len(parts) != 2 {
return 0, 0, false
}
count, err := strconv.Atoi(parts[0])
if err != nil || count < 1 {
return 0, 0, false
}
sides, err = strconv.Atoi(parts[1])
if err != nil || sides < 2 {
return 0, 0, false
}
return count, sides, true
}