mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
487 lines
21 KiB
Go
487 lines
21 KiB
Go
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
|
|
}
|