Files
gogobee/internal/plugin/dnd_equipment_profiles_test.go
prosolis 3369d7d8fe gofmt: bring internal/ and cmd/ back to gofmt -l clean
Mechanical `gofmt -w ./internal ./cmd`. Mostly struct-field realignment that
had drifted, plus a few trailing-newline fixes. No behaviour change — gofmt is
semantics-preserving, and build/vet/test are green either side.

Split out from the code-review fixes that follow so those stay reviewable
instead of hiding inside a wall of realignment.
2026-07-10 07:18:07 -07:00

503 lines
17 KiB
Go

package plugin
import (
"testing"
)
// ── Damage die parser ──────────────────────────────────────────────────────
func TestParseDamageDie(t *testing.T) {
cases := []struct {
s string
count, sides int
ok bool
}{
{"1d8", 1, 8, true},
{"2d6", 2, 6, true},
{"1D10", 1, 10, true},
{" 1d12 ", 1, 12, true},
{"d8", 0, 0, false}, // no count
{"1d", 0, 0, false}, // no sides
{"1d1", 0, 0, false}, // sides < 2
{"banana", 0, 0, false},
{"", 0, 0, false},
}
for _, c := range cases {
count, sides, ok := parseDamageDie(c.s)
if ok != c.ok || count != c.count || sides != c.sides {
t.Errorf("parseDamageDie(%q) = (%d,%d,%v); want (%d,%d,%v)",
c.s, count, sides, ok, c.count, c.sides, c.ok)
}
}
}
// ── Registry coverage ──────────────────────────────────────────────────────
func TestWeaponRegistryComplete(t *testing.T) {
// Spot-check that key weapons from appendix §2 + §3 are present and
// have the right damage dice. If any of these fail, the registry has
// drifted from the appendix.
cases := map[string]struct {
count, sides int
dmgType string
}{
"wpn_dagger": {1, 4, "piercing"},
"wpn_longsword": {1, 8, "slashing"},
"wpn_greatsword": {2, 6, "slashing"},
"wpn_greataxe": {1, 12, "slashing"},
"wpn_maul": {2, 6, "bludgeoning"},
"wpn_quarterstaff": {1, 6, "bludgeoning"},
"wpn_shortbow": {1, 6, "piercing"},
"wpn_longbow": {1, 8, "piercing"},
"wpn_crossbow_heavy": {1, 10, "piercing"},
}
for id, want := range cases {
w := weaponByID(id)
if w == nil {
t.Errorf("missing weapon %s", id)
continue
}
if w.DamageCount != want.count || w.DamageSides != want.sides {
t.Errorf("%s damage = %dd%d, want %dd%d", id, w.DamageCount, w.DamageSides, want.count, want.sides)
}
if w.DamageType != want.dmgType {
t.Errorf("%s damage type = %s, want %s", id, w.DamageType, want.dmgType)
}
}
}
func TestVersatileWeaponsHaveLargerDie(t *testing.T) {
for _, id := range []string{"wpn_quarterstaff", "wpn_spear", "wpn_battleaxe", "wpn_longsword", "wpn_warhammer", "wpn_trident"} {
w := weaponByID(id)
if w == nil {
t.Errorf("missing %s", id)
continue
}
if !w.HasProperty(PropVersatile) {
t.Errorf("%s should be versatile", id)
}
if w.VersaSides <= w.DamageSides {
t.Errorf("%s versa die (%dd%d) not larger than one-handed (%dd%d)",
id, w.VersaCount, w.VersaSides, w.DamageCount, w.DamageSides)
}
}
}
func TestArmorRegistryAppendixValues(t *testing.T) {
// Direct values from appendix §5.
cases := map[string]struct {
baseAC int
armorType ArmorType
maxDexBonus int
strReq int
stealth bool
}{
"arm_padded": {11, ArmorTypeLight, -1, 0, true},
"arm_leather": {11, ArmorTypeLight, -1, 0, false},
"arm_studded": {12, ArmorTypeLight, -1, 0, false},
"arm_chain_shirt": {13, ArmorTypeMedium, 2, 0, false},
"arm_scale_mail": {14, ArmorTypeMedium, 2, 0, true},
"arm_breastplate": {14, ArmorTypeMedium, 2, 0, false},
"arm_half_plate": {15, ArmorTypeMedium, 2, 0, true},
"arm_chain_mail": {16, ArmorTypeHeavy, 0, 13, true},
"arm_splint": {17, ArmorTypeHeavy, 0, 15, true},
"arm_plate": {18, ArmorTypeHeavy, 0, 15, true},
}
for id, want := range cases {
a := armorByID(id)
if a == nil {
t.Errorf("missing %s", id)
continue
}
if a.BaseAC != want.baseAC || a.Type != want.armorType ||
a.MaxDEXBonus != want.maxDexBonus || a.STRRequire != want.strReq ||
a.StealthDisad != want.stealth {
t.Errorf("%s mismatch: got base=%d type=%d dex=%d str=%d stealth=%v; want %+v",
id, a.BaseAC, a.Type, a.MaxDEXBonus, a.STRRequire, a.StealthDisad, want)
}
}
}
// ── ComputeAC math ──────────────────────────────────────────────────────────
func TestComputeArmorAC_Unarmored(t *testing.T) {
// Unarmored, DEX +3 → 13
if got := computeArmorAC(nil, nil, 3); got != 13 {
t.Errorf("unarmored DEX+3 AC = %d, want 13", got)
}
// Unarmored + shield, DEX +0 → 12
if got := computeArmorAC(nil, armorByID("arm_shield"), 0); got != 12 {
t.Errorf("unarmored + shield DEX+0 AC = %d, want 12", got)
}
}
func TestComputeArmorAC_Light(t *testing.T) {
leather := armorByID("arm_leather")
// Leather (11) + DEX +4 → 15
if got := computeArmorAC(leather, nil, 4); got != 15 {
t.Errorf("leather DEX+4 AC = %d, want 15", got)
}
// Studded (12) + DEX +5 → 17 (light has no cap)
if got := computeArmorAC(armorByID("arm_studded"), nil, 5); got != 17 {
t.Errorf("studded DEX+5 AC = %d, want 17", got)
}
}
func TestComputeArmorAC_MediumCapped(t *testing.T) {
// Half plate (15) + DEX +4 → 17 (capped at +2)
if got := computeArmorAC(armorByID("arm_half_plate"), nil, 4); got != 17 {
t.Errorf("half plate DEX+4 AC = %d, want 17 (cap)", got)
}
// Chain shirt (13) + DEX +1 → 14 (under cap)
if got := computeArmorAC(armorByID("arm_chain_shirt"), nil, 1); got != 14 {
t.Errorf("chain shirt DEX+1 AC = %d, want 14", got)
}
}
func TestComputeArmorAC_HeavyIgnoresDex(t *testing.T) {
// Plate (18) + DEX +5 → 18 (no DEX)
if got := computeArmorAC(armorByID("arm_plate"), nil, 5); got != 18 {
t.Errorf("plate DEX+5 AC = %d, want 18 (no DEX)", got)
}
// Chain mail (16) + shield + DEX -1 → 18
if got := computeArmorAC(armorByID("arm_chain_mail"), armorByID("arm_shield"), -1); got != 18 {
t.Errorf("chain mail + shield AC = %d, want 18", got)
}
}
func TestComputeArmorAC_MagicBonus(t *testing.T) {
plate := *armorByID("arm_plate")
plate.MagicBonus = 2
// +2 plate (18 + 2) → 20
if got := computeArmorAC(&plate, nil, 0); got != 20 {
t.Errorf("+2 plate AC = %d, want 20", got)
}
}
// ── Weapon damage rolls ─────────────────────────────────────────────────────
func TestRollWeaponDamage_Bounds(t *testing.T) {
greatsword := weaponByID("wpn_greatsword") // 2d6
for i := 0; i < 1000; i++ {
total, dice := rollWeaponDamage(nil, greatsword, 3, false)
if dice < 2 || dice > 12 {
t.Fatalf("greatsword dice out of [2,12]: %d", dice)
}
if total != dice+3 {
t.Fatalf("total %d != dice %d + mod 3", total, dice)
}
}
}
func TestRollWeaponDamage_VersatileTwoHanded(t *testing.T) {
longsword := weaponByID("wpn_longsword") // 1d8 / 1d10
// One-handed
for i := 0; i < 100; i++ {
_, dice := rollWeaponDamage(nil, longsword, 0, false)
if dice < 1 || dice > 8 {
t.Errorf("longsword 1H dice = %d, want [1,8]", dice)
}
}
// Two-handed (versatile)
for i := 0; i < 100; i++ {
_, dice := rollWeaponDamage(nil, longsword, 0, true)
if dice < 1 || dice > 10 {
t.Errorf("longsword 2H dice = %d, want [1,10]", dice)
}
}
}
func TestRollWeaponDamage_FloorAt1(t *testing.T) {
// Pathological: 1d4 with -10 mod always rolls 1+(-10) = negative, floored to 1.
dagger := weaponByID("wpn_dagger")
for i := 0; i < 100; i++ {
total, _ := rollWeaponDamage(nil, dagger, -10, false)
if total < 1 {
t.Errorf("damage floor violated: %d", total)
}
}
}
func TestAvgWeaponDamage(t *testing.T) {
// 1d8 + 0 mean = 4.5
got := avgWeaponDamage(weaponByID("wpn_longsword"), 0)
if got != 4.5 {
t.Errorf("longsword avg = %v, want 4.5", got)
}
// 2d6 + 3 mean = 7 + 3 = 10
got = avgWeaponDamage(weaponByID("wpn_greatsword"), 3)
if got != 10.0 {
t.Errorf("greatsword+3 avg = %v, want 10", got)
}
}
// ── Class proficiency ──────────────────────────────────────────────────────
func TestClassWeaponProficiency(t *testing.T) {
cases := []struct {
class DnDClass
wpnID string
want bool
}{
// Simple weapons: everyone proficient
{ClassMage, "wpn_dagger", true},
{ClassCleric, "wpn_mace", true},
{ClassRogue, "wpn_quarterstaff", true},
// Martial: Fighter/Ranger always
{ClassFighter, "wpn_greatsword", true},
{ClassRanger, "wpn_longbow", true},
// Mage/Cleric: NOT proficient with martial
{ClassMage, "wpn_longsword", false},
{ClassCleric, "wpn_greatsword", false},
// Rogue: restricted martial list
{ClassRogue, "wpn_shortsword", true},
{ClassRogue, "wpn_rapier", true},
{ClassRogue, "wpn_longsword", true},
{ClassRogue, "wpn_crossbow_hand", true},
{ClassRogue, "wpn_greatsword", false},
{ClassRogue, "wpn_warhammer", false},
}
for _, c := range cases {
w := weaponByID(c.wpnID)
if got := dndClassWeaponProficiency(c.class, w); got != c.want {
t.Errorf("class=%s wpn=%s = %v, want %v", c.class, c.wpnID, got, c.want)
}
}
}
func TestClassArmorProficiency(t *testing.T) {
cases := []struct {
class DnDClass
armorID string
want bool
}{
{ClassFighter, "arm_plate", true},
{ClassFighter, "arm_leather", true},
{ClassFighter, "arm_shield", true},
{ClassRanger, "arm_chain_shirt", true}, // medium
{ClassRanger, "arm_plate", false}, // heavy
{ClassCleric, "arm_chain_shirt", true},
{ClassCleric, "arm_plate", false},
{ClassCleric, "arm_shield", true},
{ClassRogue, "arm_leather", true},
{ClassRogue, "arm_chain_shirt", false}, // medium
{ClassRogue, "arm_shield", false},
{ClassMage, "arm_leather", false},
{ClassMage, "arm_shield", false},
}
for _, c := range cases {
a := armorByID(c.armorID)
if got := dndClassArmorProficiency(c.class, a); got != c.want {
t.Errorf("class=%s armor=%s = %v, want %v", c.class, c.armorID, got, c.want)
}
}
}
// ── Legacy synthesis ───────────────────────────────────────────────────────
func TestSynthesizeWeaponProfile_TierMapping(t *testing.T) {
cases := []struct {
name string
tier int
wantBase string
}{
{"Iron Club", 1, "wpn_club"},
{"Wooden Dagger", 1, "wpn_dagger"},
{"Steel Mace", 2, "wpn_mace"},
{"Apprentice Staff", 2, "wpn_quarterstaff"},
{"Iron Sword", 3, "wpn_longsword"},
{"Hunter's Bow", 3, "wpn_shortbow"},
{"Knight's Sword", 4, "wpn_longsword"},
{"Greatsword", 5, "wpn_greatsword"},
{"Battleaxe", 5, "wpn_greataxe"},
}
for _, c := range cases {
eq := &AdvEquipment{Slot: SlotWeapon, Tier: c.tier, Name: c.name}
w := synthesizeWeaponProfile(eq)
if w == nil {
t.Errorf("%s tier %d: synthesize returned nil", c.name, c.tier)
continue
}
base := weaponByID(c.wantBase)
if w.DamageCount != base.DamageCount || w.DamageSides != base.DamageSides {
t.Errorf("%s tier %d: synthesized %dd%d, expected %dd%d (%s)",
c.name, c.tier, w.DamageCount, w.DamageSides,
base.DamageCount, base.DamageSides, c.wantBase)
}
}
}
func TestSynthesizeWeaponProfile_HighTierMagicBonus(t *testing.T) {
eq := &AdvEquipment{Slot: SlotWeapon, Tier: 7, Name: "Legendary Sword"}
w := synthesizeWeaponProfile(eq)
if w == nil {
t.Fatal("synthesize returned nil")
}
if w.MagicBonus != 2 { // tier 7 - 5 = +2
t.Errorf("tier 7 magic bonus = %d, want 2", w.MagicBonus)
}
}
func TestSynthesizeWeaponProfile_MasterworkPromotes(t *testing.T) {
eq := &AdvEquipment{Slot: SlotWeapon, Tier: 2, Name: "Sword", Masterwork: true}
w := synthesizeWeaponProfile(eq)
if w.DamageSides < 6 {
t.Errorf("masterwork promoted should be at least tier-5 base (1d8/2d6); got %dd%d",
w.DamageCount, w.DamageSides)
}
if w.MagicBonus < 1 {
t.Errorf("masterwork should grant +1 minimum, got +%d", w.MagicBonus)
}
}
func TestSynthesizeWeaponProfile_NonWeaponSlotReturnsNil(t *testing.T) {
eq := &AdvEquipment{Slot: SlotArmor, Tier: 3, Name: "Plate"}
if got := synthesizeWeaponProfile(eq); got != nil {
t.Errorf("non-weapon slot should return nil, got %+v", got)
}
}
func TestSynthesizeArmorProfile_TierMapping(t *testing.T) {
cases := []struct {
tier int
wantBaseAC int
wantType ArmorType
}{
{1, 11, ArmorTypeLight}, // Padded
{2, 11, ArmorTypeLight}, // Leather
{3, 13, ArmorTypeMedium}, // Chain Shirt
{4, 14, ArmorTypeMedium}, // Scale Mail
{5, 15, ArmorTypeMedium}, // Half Plate
{6, 18, ArmorTypeHeavy}, // Plate
}
for _, c := range cases {
eq := &AdvEquipment{Slot: SlotArmor, Tier: c.tier, Name: "Armor"}
a := synthesizeArmorProfile(eq)
if a == nil {
t.Errorf("tier %d: synth returned nil", c.tier)
continue
}
if a.BaseAC != c.wantBaseAC || a.Type != c.wantType {
t.Errorf("tier %d: got AC=%d type=%d, want AC=%d type=%d",
c.tier, a.BaseAC, a.Type, c.wantBaseAC, c.wantType)
}
}
}
func TestSynthesizeShield_NameMatch(t *testing.T) {
cases := []struct {
slot EquipmentSlot
name string
want bool
}{
{SlotTool, "Iron Shield", true},
{SlotTool, "Tower Shield", true},
{SlotTool, "Pickaxe", false},
{SlotTool, "", false},
{SlotWeapon, "Shield", false}, // wrong slot
}
for _, c := range cases {
eq := &AdvEquipment{Slot: c.slot, Name: c.name, Tier: 3}
got := synthesizeShield(eq) != nil
if got != c.want {
t.Errorf("slot=%s name=%q: shield=%v, want %v", c.slot, c.name, got, c.want)
}
}
}
// ── pickWeaponAbilityMod ────────────────────────────────────────────────────
func TestPickWeaponAbilityMod(t *testing.T) {
c := &DnDCharacter{STR: 16, DEX: 14, CON: 13, INT: 8, WIS: 10, CHA: 12}
// STR mod = +3, DEX mod = +2.
cases := []struct {
wpnID string
want int
}{
{"wpn_greatsword", 3}, // melee non-finesse → STR
{"wpn_longsword", 3},
{"wpn_dagger", 3}, // finesse melee, but STR > DEX, picks STR
{"wpn_longbow", 2}, // ranged → DEX
{"wpn_shortbow", 2},
}
for _, tc := range cases {
got := pickWeaponAbilityMod(weaponByID(tc.wpnID), c)
if got != tc.want {
t.Errorf("%s: ability mod = %d, want %d", tc.wpnID, got, tc.want)
}
}
// Now with DEX > STR: finesse should pick DEX.
c2 := &DnDCharacter{STR: 10, DEX: 18, CON: 14}
if got := pickWeaponAbilityMod(weaponByID("wpn_dagger"), c2); got != 4 {
t.Errorf("DEX-favored finesse: got %d, want 4 (DEX +4)", got)
}
if got := pickWeaponAbilityMod(weaponByID("wpn_greatsword"), c2); got != 0 {
t.Errorf("non-finesse with DEX>STR: got %d, want 0 (STR +0)", got)
}
}
// ── End-to-end: applyDnDEquipmentLayer ─────────────────────────────────────
func TestApplyDnDEquipmentLayer_FighterFullKit(t *testing.T) {
c := &DnDCharacter{
Race: RaceHuman, Class: ClassFighter, Level: 5,
STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 8,
ArmorClass: 10, // base
}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Slot: SlotWeapon, Tier: 5, Name: "Greatsword"},
SlotArmor: {Slot: SlotArmor, Tier: 6, Name: "Plate"},
}
stats := CombatStats{}
applyDnDPlayerLayer(&stats, c)
applyDnDEquipmentLayer(&stats, c, equip)
if stats.Weapon == nil {
t.Fatal("weapon not set")
}
if stats.Weapon.DamageCount != 1 || stats.Weapon.DamageSides != 12 {
// Greatsword name + tier 5 → wpn_greataxe (1d12) per synth name match
// (greatsword name routes through "axe" branch? No, "greatsword" doesn't
// contain "axe". Let me check: tier 5 default else branch → wpn_greatsword (2d6).
// Adjust test if synthesis differs.
if !(stats.Weapon.DamageCount == 2 && stats.Weapon.DamageSides == 6) {
t.Errorf("greatsword tier 5 weapon = %dd%d, expected 2d6 or 1d12",
stats.Weapon.DamageCount, stats.Weapon.DamageSides)
}
}
if !stats.WeaponProficient {
t.Error("Fighter should be proficient with synthesized weapon")
}
// Plate (tier 6) → +1 plate, AC = 18+1 = 19; Phase 5-B player floor
// adds +3, so combat-stat AC = 22.
if stats.AC != 22 {
t.Errorf("Fighter+plate+1 AC = %d, want 22 (19 base + phase5BACBonus)", stats.AC)
}
// Two-handed mode: greatsword has TwoHanded property (no shield).
// Greatsword's properties include Heavy + TwoHanded — TwoHandedMode set.
if !stats.TwoHandedMode {
t.Error("expected TwoHandedMode for greatsword without shield")
}
}
func TestApplyDnDEquipmentLayer_MageWithMartialWeapon(t *testing.T) {
// Mage equips a longsword → not proficient, -4 attack penalty path.
c := &DnDCharacter{
Class: ClassMage, Level: 1, STR: 8, DEX: 14, CON: 12, INT: 16, WIS: 13, CHA: 10,
ArmorClass: 10,
}
equip := map[EquipmentSlot]*AdvEquipment{
SlotWeapon: {Slot: SlotWeapon, Tier: 3, Name: "Longsword"},
}
stats := CombatStats{}
applyDnDPlayerLayer(&stats, c)
applyDnDEquipmentLayer(&stats, c, equip)
if stats.Weapon == nil {
t.Fatal("weapon nil")
}
if stats.WeaponProficient {
t.Error("Mage should NOT be proficient with longsword")
}
}