D&D: class passives + subclasses for the five caster classes

Adds signature class passives for Druid/Bard/Sorcerer/Warlock/Paladin in
dnd_passives.go, each riding an existing CombatModifiers channel so a
playable caster is no longer mechanically empty: Druid Wild Resilience
(damage reduction), Bard Bardic Inspiration (initiative), Sorcerer Innate
Sorcery (CHA-scaled pre-combat burst), Warlock Agonizing Blast (+10%
damage), Paladin Divine Smite (level-scaled burst).

Adds 15 subclasses (3 per caster, registry now 30) using canonical 5e
archetype names, with passive-only L5/7/10/15 effects in
applySubclassPassives — consistent with the majority of the original
fifteen, no new resource pools or active abilities. subclassesForClass is
now non-empty for casters, so the L5 !subclass prompt and sheet display
light up through the existing generic plumbing.

Tests: TestApplyClassPassives extended to all ten classes and the new
channels; TestSubclassRegistry_* updated to expect 30 entries across all
ten classes.
This commit is contained in:
prosolis
2026-05-14 15:18:29 -07:00
parent 6ef8b9fd0a
commit d6ea08bba6
5 changed files with 480 additions and 14 deletions

View File

@@ -39,6 +39,28 @@ var dndClassAbilities = map[DnDClass]DnDClassAbility{
Name: "Hunter's Mark",
Description: "You read your prey's weak points: +5% damage and +1 to attack rolls.",
},
// Open5e caster scaffold — one signature passive each, riding existing
// CombatModifiers channels the same way the original five do.
ClassDruid: {
Name: "Wild Resilience",
Description: "The wild lends you its toughness: incoming damage is reduced 5%.",
},
ClassBard: {
Name: "Bardic Inspiration",
Description: "Quick wit keeps you a step ahead: +1 to your initiative each round.",
},
ClassSorcerer: {
Name: "Innate Sorcery",
Description: "Raw magic spills out as the fight begins, dealing immediate damage scaled by your Charisma.",
},
ClassWarlock: {
Name: "Agonizing Blast",
Description: "Your pact-fueled eldritch power adds +10% to all damage you deal.",
},
ClassPaladin: {
Name: "Divine Smite",
Description: "You channel a burst of radiant power on engagement, dealing flat damage that grows with your level.",
},
}
// applyRacePassives sets the combat-impacting flags from the player's race.
@@ -85,5 +107,22 @@ func applyClassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharact
case ClassRanger:
mods.DamageBonus += 0.05
stats.AttackBonus++
case ClassDruid:
// Wild Resilience — multiplicative, so it stacks cleanly with the
// subclass DamageReduct riders. DamageReduct is initialized to 1.0
// by DerivePlayerStats before passives run.
mods.DamageReduct *= 0.95
case ClassBard:
mods.InitiativeBias += 1
case ClassSorcerer:
// Innate Sorcery — pre-combat burst, CHA-scaled like the Sorcerer's
// spellcasting stat. Floors at the flat 3 for low-CHA builds.
mods.FlatDmgStart += 3 + abilityModifier(c.CHA)
case ClassWarlock:
mods.DamageBonus += 0.10
case ClassPaladin:
// Divine Smite — radiant burst on engage, scaling with level so it
// stays relevant against tougher foes.
mods.FlatDmgStart += 4 + c.Level/2
}
}

View File

@@ -39,6 +39,26 @@ const (
SubclassHunter DnDSubclass = "hunter"
SubclassBeastMaster DnDSubclass = "beast_master"
SubclassGloomStalker DnDSubclass = "gloom_stalker"
// Druid — Open5e caster scaffold
SubclassCircleLand DnDSubclass = "circle_land"
SubclassCircleMoon DnDSubclass = "circle_moon"
SubclassCircleShepherd DnDSubclass = "circle_shepherd"
// Bard
SubclassCollegeLore DnDSubclass = "college_lore"
SubclassCollegeValor DnDSubclass = "college_valor"
SubclassCollegeGlamour DnDSubclass = "college_glamour"
// Sorcerer
SubclassDraconicBloodline DnDSubclass = "draconic_bloodline"
SubclassWildMagic DnDSubclass = "wild_magic"
SubclassStormSorcery DnDSubclass = "storm_sorcery"
// Warlock
SubclassFiendPatron DnDSubclass = "fiend_patron"
SubclassArchfeyPatron DnDSubclass = "archfey_patron"
SubclassGreatOldOne DnDSubclass = "great_old_one"
// Paladin
SubclassOathDevotion DnDSubclass = "oath_devotion"
SubclassOathVengeance DnDSubclass = "oath_vengeance"
SubclassOathAncients DnDSubclass = "oath_ancients"
)
// DnDSubclassInfo — registry row. Tagline summarizes identity in one line
@@ -52,9 +72,14 @@ type DnDSubclassInfo struct {
Flavor string
}
// dndSubclassRegistry — 15 entries, ordered first by class (Fighter,
// Rogue, Mage, Cleric, Ranger) then by the design doc's listed order
// within each class. The selection prompt presents them in this order.
// dndSubclassRegistry — 30 entries (3 per class × 10 classes), ordered
// first by class (Fighter, Rogue, Mage, Cleric, Ranger, then the Open5e
// caster scaffold: Druid, Bard, Sorcerer, Warlock, Paladin) then by the
// design doc's listed order within each class. The selection prompt
// presents them in this order. The caster subclasses use canonical 5e
// archetype names, consistent with the original fifteen — see the
// avoid-dnd-naming note: the naming concern is code identifiers and
// marketing, not in-game content.
var dndSubclassRegistry = []DnDSubclassInfo{
// Fighter
{SubclassChampion, ClassFighter, "Champion",
@@ -110,6 +135,61 @@ var dndSubclassRegistry = []DnDSubclassInfo{
{SubclassGloomStalker, ClassRanger, "Gloom Stalker",
"Darkness specialist. Exceptional underground.",
"You were already good in the dark. Now you're better. TwinBee notes: the Underdark is waiting."},
// Druid — Open5e caster scaffold
{SubclassCircleLand, ClassDruid, "Circle of the Land",
"Versatile caster; recovery and warding.",
"The land remembers you, and answers when you call. A measured path — and a durable one."},
{SubclassCircleMoon, ClassDruid, "Circle of the Moon",
"Combat shapeshifter; tanky beast forms.",
"You don't cast at the fight so much as become it. Tooth, claw, and a great deal of hide."},
{SubclassCircleShepherd, ClassDruid, "Circle of the Shepherd",
"Summoner; spirit totems and conjured allies.",
"You never walk into a fight alone — the wild walks in with you."},
// Bard
{SubclassCollegeLore, ClassBard, "College of Lore",
"Knowledge and control; turn the enemy's edge dull.",
"You've read the room, the foe, and three books about both. The fight is half-won before it starts."},
{SubclassCollegeValor, ClassBard, "College of Valor",
"Combat bard; songs that sharpen sword and shield.",
"A song with a blade in it. The old kind of bard — the kind that came back from the war."},
{SubclassCollegeGlamour, ClassBard, "College of Glamour",
"Charm and majesty; enthrall and disarm the foe.",
"You wear the Feywild's glamour like a borrowed coat. Few can bear to strike at something so dazzling."},
// Sorcerer
{SubclassDraconicBloodline, ClassSorcerer, "Draconic Bloodline",
"Durable blaster; draconic scales and elemental punch.",
"There's a dragon somewhere back down your line, and it left the door unlocked."},
{SubclassWildMagic, ClassSorcerer, "Wild Magic",
"Chaotic burst; luck bent hard in your favor.",
"Your magic doesn't take orders so much as suggestions. Loud, unpredictable, occasionally glorious."},
{SubclassStormSorcery, ClassSorcerer, "Storm Sorcery",
"Mobile elemental caster; wind, speed, and thunder.",
"You move first because the storm always does. Everything after is just weather."},
// Warlock
{SubclassFiendPatron, ClassWarlock, "The Fiend",
"Damage and grit; power borrowed from the lower planes.",
"The bargain was steep, the firepower is steeper. Try not to read the fine print mid-fight."},
{SubclassArchfeyPatron, ClassWarlock, "The Archfey",
"Charm and escape; fey tricks that unmake the attack.",
"Your patron deals in glamour and misdirection — and so, now, do you. Hard to hit what won't hold still."},
{SubclassGreatOldOne, ClassWarlock, "The Great Old One",
"Psychic control; minds bent, foes unmade from within.",
"Something vast and distant is half-listening through you. Best not to think too hard about which half."},
// Paladin
{SubclassOathDevotion, ClassPaladin, "Oath of Devotion",
"Holy warrior; sacred weapon and protective auras.",
"You took the oath and meant every word of it. The light leans your way for it."},
{SubclassOathVengeance, ClassPaladin, "Oath of Vengeance",
"Relentless attacker; mark a foe and bring it down.",
"Someone, somewhere, has earned this. You've simply volunteered to deliver it."},
{SubclassOathAncients, ClassPaladin, "Oath of the Ancients",
"Resilient nature-paladin; warding light, hard to fell.",
"You guard the green and growing things, and the old light guards you back. Stubbornly."},
}
// subclassInfo returns the registry row for id, or (zero, false).

View File

@@ -340,6 +340,331 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar
if c.Level >= 15 {
mods.DamageReduct *= 0.90
}
// ── Open5e caster scaffold — Druid / Bard / Sorcerer / Warlock /
// Paladin subclasses. Passive-only, consistent with the majority of the
// original fifteen (Champion, Thief, the Mage and Ranger subclasses):
// no new resource pools or active abilities. Each L5/7/10/15 tier rides
// an existing CombatModifiers channel — per-case notes name the 5e
// ability each proxy stands in for. DamageReduct is initialized to 1.0
// by DerivePlayerStats, so the `*=` reductions compose cleanly.
case SubclassCircleLand:
// L5 Natural Recovery: a measured self-heal — rides HealItem (fires
// once at <50% HP). L7 Land's Stride: +2 Speed (initiative). L10
// Nature's Ward: resistance to a slice of elemental/poison damage,
// collapsed to a flat 8% incoming reduction. L15 Nature's Sanctuary:
// foes hesitate to close — 1 round of SporeCloud — plus a further 10%.
if c.Level >= 5 {
mods.HealItem += 5
}
if c.Level >= 7 {
stats.Speed += 2
}
if c.Level >= 10 {
mods.DamageReduct *= 0.92
}
if c.Level >= 15 {
mods.SporeCloud++
mods.DamageReduct *= 0.90
}
case SubclassCircleMoon:
// L5 Combat Wild Shape: a bestial form's HP pool — an ArcaneWardHP
// buffer scaling with level — plus the form's heavier hits (+10%
// damage). L7 Primal Strike: beast attacks bite harder (+5%). L10
// Elemental Wild Shape: an elemental form's opening slam — a
// FlatDmgStart burst. L15 Archdruid: the form barely tires — a flat
// 15% incoming reduction.
if c.Level >= 5 {
if buf := 3 * c.Level; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
mods.DamageBonus += 0.05
}
if c.Level >= 10 {
mods.FlatDmgStart += 8
}
if c.Level >= 15 {
mods.DamageReduct *= 0.85
}
case SubclassCircleShepherd:
// L5 Spirit Totem (Bear): a warding spirit grants a temp-HP buffer.
// L7 Mighty Summoner: a conjured beast joins the fight — rides the
// pet channel. L10 Guardian Spirit: the spirits mend you — HealItem.
// L15 Faithful Summons: the conjured ally is tougher and strikes
// more often.
if c.Level >= 5 {
if buf := 2 * c.Level; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
}
if c.Level >= 7 {
if mods.PetAttackProc < 0.25 {
mods.PetAttackProc = 0.25
}
if dmg := 3 + c.Level/2; mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
if c.Level >= 10 {
mods.HealItem += c.Level / 2
}
if c.Level >= 15 {
if mods.PetAttackProc < 0.45 {
mods.PetAttackProc = 0.45
}
if dmg := 8 + c.Level/2; mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
case SubclassCollegeLore:
// L5 Cutting Words: a barbed quip throws the foe off — 2 rounds of
// SporeCloud miss chance. L7 Additional Magical Secrets: borrowed
// versatility, a flat +1 to attack rolls. L10 Peerless Skill: a
// self-aimed Bardic Inspiration die on the opener (FirstAttackBonus).
// L15: deeper Magical Secrets keep paying out — +10% damage.
if c.Level >= 5 {
mods.SporeCloud += 2
}
if c.Level >= 7 {
stats.AttackBonus++
}
if c.Level >= 10 {
mods.FirstAttackBonus += 5
}
if c.Level >= 15 {
mods.DamageBonus += 0.10
}
case SubclassCollegeValor:
// L5 Combat Inspiration: songs that sharpen the blade (+10% damage).
// L7 Extra Attack: throughput — +1 attack and a further +10% damage
// proxy for the second swing. L10 Battle Magic: a bonus-action
// strike after a spell — a FlatDmgStart burst. L15: +1 AC, the
// late-game shield half of Combat Inspiration.
if c.Level >= 5 {
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
stats.AttackBonus++
mods.DamageBonus += 0.10
}
if c.Level >= 10 {
mods.FlatDmgStart += 6
}
if c.Level >= 15 {
stats.AC++
}
case SubclassCollegeGlamour:
// L5 Mantle of Inspiration: a fey-glamour temp-HP buffer. L7 Mantle
// of Majesty: the foe is briefly enthralled — skips its first
// attack. L10 Enthralling Performance: lingering fascination — 2
// rounds of SporeCloud miss chance. L15 Unbreakable Majesty: foes
// struggle to bring themselves to strike you — a flat 15% reduction.
if c.Level >= 5 {
if buf := 2 * c.Level; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
}
if c.Level >= 7 {
mods.SpellEnemySkipFirst = true
}
if c.Level >= 10 {
mods.SporeCloud += 2
}
if c.Level >= 15 {
mods.DamageReduct *= 0.85
}
case SubclassDraconicBloodline:
// L5 Draconic Resilience: scaled hide — +1 AC and an HP buffer. L7
// Elemental Affinity: CHA folded into spell damage — +10%. L10
// Dragon Wings: mobility and a tougher frame — an 8% reduction and a
// small damage bump. L15 Draconic Presence: a frightful aura — 2
// rounds of SporeCloud miss chance.
if c.Level >= 5 {
stats.AC++
if mods.ArcaneWardHP < c.Level {
mods.ArcaneWardHP = c.Level
}
}
if c.Level >= 7 {
mods.DamageBonus += 0.10
}
if c.Level >= 10 {
mods.DamageReduct *= 0.92
mods.DamageBonus += 0.05
}
if c.Level >= 15 {
mods.SporeCloud += 2
}
case SubclassWildMagic:
// L5 Tides of Chaos: fortune favors the opener — advantage on the
// first attack. L7 Bend Luck: nudge a roll your way —
// FirstAttackBonus. L10 Controlled Chaos: a surge you actually aimed
// — a FlatDmgStart burst. L15 Spell Bombardment: dice that keep
// exploding — +15% damage.
if c.Level >= 5 {
mods.AssassinateAdvantage = true
}
if c.Level >= 7 {
mods.FirstAttackBonus += 3
}
if c.Level >= 10 {
mods.FlatDmgStart += 10
}
if c.Level >= 15 {
mods.DamageBonus += 0.15
}
case SubclassStormSorcery:
// L5 Tempestuous Magic: the storm moves first — +1 initiative and
// +2 Speed. L7 Heart of the Storm: a thunderclap as you cast — a
// level-scaled FlatDmgStart burst. L10 Storm Guide: you ride the
// weather — a small 5% reduction and 1 round of SporeCloud. L15
// Storm's Fury: incoming blows are answered with lightning —
// ReflectNext on the first hit.
if c.Level >= 5 {
mods.InitiativeBias += 1
stats.Speed += 2
}
if c.Level >= 7 {
mods.FlatDmgStart += 5 + c.Level/2
}
if c.Level >= 10 {
mods.DamageReduct *= 0.95
mods.SporeCloud++
}
if c.Level >= 15 {
mods.ReflectNext += 0.30
}
case SubclassFiendPatron:
// L5 Dark One's Blessing: temp HP as foes fall — modeled as an
// up-front CHA-scaled buffer. L7 Dark One's Own Luck: a nudged roll
// — FirstAttackBonus. L10 Fiendish Resilience: chosen resistance — a
// flat 10% incoming reduction. L15 Hurl Through Hell: a foe sent
// briefly to the lower planes — a heavy FlatDmgStart burst.
if c.Level >= 5 {
chaMod := abilityModifier(c.CHA)
if chaMod < 0 {
chaMod = 0
}
if buf := c.Level + chaMod; mods.ArcaneWardHP < buf {
mods.ArcaneWardHP = buf
}
}
if c.Level >= 7 {
mods.FirstAttackBonus += 3
}
if c.Level >= 10 {
mods.DamageReduct *= 0.90
}
if c.Level >= 15 {
mods.FlatDmgStart += 12
}
case SubclassArchfeyPatron:
// L5 Fey Presence: the foe is briefly charmed/frightened — skips its
// first attack. L7 Misty Escape: you blink out of harm's way — 2
// rounds of SporeCloud miss chance. L10 Beguiling Defenses: hostile
// magic rebounds — ReflectNext on the first hit. L15 Dark Delirium:
// the foe is lost in illusion — more SporeCloud and a small edge.
if c.Level >= 5 {
mods.SpellEnemySkipFirst = true
}
if c.Level >= 7 {
mods.SporeCloud += 2
}
if c.Level >= 10 {
mods.ReflectNext += 0.25
}
if c.Level >= 15 {
mods.SporeCloud += 2
mods.DamageBonus += 0.05
}
case SubclassGreatOldOne:
// L5 Entropic Ward: a missed attack tilts the next exchange — a
// small reduction and 1 round of SporeCloud. L7 Thought Shield:
// psychic resilience — a flat 8% incoming reduction. L10 Create
// Thrall: a dominated mind fights beside you — the pet channel. L15:
// deeper psychic dominion keeps the thrall in the fight and sharpens
// your own assault (+10% damage).
if c.Level >= 5 {
mods.DamageReduct *= 0.95
mods.SporeCloud++
}
if c.Level >= 7 {
mods.DamageReduct *= 0.92
}
if c.Level >= 10 {
if mods.PetAttackProc < 0.30 {
mods.PetAttackProc = 0.30
}
if dmg := 4 + c.Level/2; mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
if c.Level >= 15 {
mods.DamageBonus += 0.10
}
case SubclassOathDevotion:
// L5 Sacred Weapon: a blade lit with the oath — +1 attack and
// radiant damage on every hit (DivineStrikePerHit, the channel the
// Cleric domains use). L7 Aura of Devotion: a small warding aura —
// 5% reduction. L10 Aura of Courage/Purity: the aura hardens — a
// further 8% reduction. L15 Holy Nimbus: a radiant aura that sears
// the foe on engagement — a FlatDmgStart burst.
if c.Level >= 5 {
stats.AttackBonus++
mods.DivineStrikePerHit += 4
}
if c.Level >= 7 {
mods.DamageReduct *= 0.95
}
if c.Level >= 10 {
mods.DamageReduct *= 0.92
}
if c.Level >= 15 {
mods.FlatDmgStart += 10
}
case SubclassOathVengeance:
// L5 Vow of Enmity: advantage against the marked foe plus the focus
// to press it — advantage on the opener and +10% damage. L7
// Relentless Avenger: you keep pace with a fleeing target — +2 Speed
// and a small damage bump. L10 Soul of Vengeance: an extra strike at
// the marked foe — +10% damage. L15: the hunt never lets up —
// a FlatDmgStart opener.
if c.Level >= 5 {
mods.AssassinateAdvantage = true
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
stats.Speed += 2
mods.DamageBonus += 0.05
}
if c.Level >= 10 {
mods.DamageBonus += 0.10
}
if c.Level >= 15 {
mods.FlatDmgStart += 6
}
case SubclassOathAncients:
// L5 Nature's Wrath: the green and growing things slow the foe — a
// small reduction. L7 Aura of Warding: resistance to spell damage —
// a further 8% reduction. L10 Undying Sentinel: you refuse to fall —
// survive one otherwise-lethal hit (DeathSave). L15 Elder Champion:
// the old light blazes up — +1 attack and +10% damage.
if c.Level >= 5 {
mods.DamageReduct *= 0.95
}
if c.Level >= 7 {
mods.DamageReduct *= 0.92
}
if c.Level >= 10 {
mods.DeathSave = true
}
if c.Level >= 15 {
stats.AttackBonus++
mods.DamageBonus += 0.10
}
}
}

View File

@@ -12,9 +12,9 @@ import (
// ── Registry sanity ──────────────────────────────────────────────────────
func TestSubclassRegistry_FifteenEntriesThreePerClass(t *testing.T) {
if got := len(dndSubclassRegistry); got != 15 {
t.Fatalf("registry size: got %d, want 15", got)
func TestSubclassRegistry_ThirtyEntriesThreePerClass(t *testing.T) {
if got := len(dndSubclassRegistry); got != 30 {
t.Fatalf("registry size: got %d, want 30", got)
}
perClass := map[DnDClass]int{}
for _, s := range dndSubclassRegistry {
@@ -23,7 +23,10 @@ func TestSubclassRegistry_FifteenEntriesThreePerClass(t *testing.T) {
t.Errorf("incomplete entry: %+v", s)
}
}
for _, cls := range []DnDClass{ClassFighter, ClassRogue, ClassMage, ClassCleric, ClassRanger} {
for _, cls := range []DnDClass{
ClassFighter, ClassRogue, ClassMage, ClassCleric, ClassRanger,
ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin,
} {
if perClass[cls] != 3 {
t.Errorf("%s: %d subclasses, want 3", cls, perClass[cls])
}

View File

@@ -219,17 +219,27 @@ func TestApplyClassPassives(t *testing.T) {
wantAtkBonusAdd int
wantAutoCrit bool
wantHealItem int
wantDmgReduct float64
wantFlatStart int
wantInitBias float64
}{
{ClassFighter, 0.05, 0, false, 0},
{ClassRogue, 0, 0, true, 0},
{ClassMage, 0, 1, false, 0},
{ClassCleric, 0, 0, false, 5},
{ClassRanger, 0.05, 1, false, 0},
{ClassFighter, 0.05, 0, false, 0, 1.0, 0, 0},
{ClassRogue, 0, 0, true, 0, 1.0, 0, 0},
{ClassMage, 0, 1, false, 0, 1.0, 0, 0},
{ClassCleric, 0, 0, false, 5, 1.0, 0, 0},
{ClassRanger, 0.05, 1, false, 0, 1.0, 0, 0},
// Open5e caster scaffold. CHA 10 → +0 mod, so Sorcerer's FlatDmgStart
// is the flat 3; Paladin at L1 is 4 + 0.
{ClassDruid, 0, 0, false, 0, 0.95, 0, 0},
{ClassBard, 0, 0, false, 0, 1.0, 0, 1},
{ClassSorcerer, 0, 0, false, 0, 1.0, 3, 0},
{ClassWarlock, 0.10, 0, false, 0, 1.0, 0, 0},
{ClassPaladin, 0, 0, false, 0, 1.0, 4, 0},
}
for _, tc := range cases {
stats := CombatStats{AttackBonus: 5}
mods := CombatModifiers{}
applyClassPassives(&stats, &mods, &DnDCharacter{Class: tc.class})
mods := CombatModifiers{DamageReduct: 1.0}
applyClassPassives(&stats, &mods, &DnDCharacter{Class: tc.class, Level: 1, CHA: 10})
if mods.DamageBonus != tc.wantDmgBonus {
t.Errorf("%s: DamageBonus=%v, want %v", tc.class, mods.DamageBonus, tc.wantDmgBonus)
}
@@ -242,5 +252,14 @@ func TestApplyClassPassives(t *testing.T) {
if mods.HealItem != tc.wantHealItem {
t.Errorf("%s: HealItem=%d, want %d", tc.class, mods.HealItem, tc.wantHealItem)
}
if mods.DamageReduct != tc.wantDmgReduct {
t.Errorf("%s: DamageReduct=%v, want %v", tc.class, mods.DamageReduct, tc.wantDmgReduct)
}
if mods.FlatDmgStart != tc.wantFlatStart {
t.Errorf("%s: FlatDmgStart=%d, want %d", tc.class, mods.FlatDmgStart, tc.wantFlatStart)
}
if mods.InitiativeBias != tc.wantInitBias {
t.Errorf("%s: InitiativeBias=%v, want %v", tc.class, mods.InitiativeBias, tc.wantInitBias)
}
}
}