From a733d52166a1c306a1ee5b389e52ba007aaf163b Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 8 May 2026 10:51:11 -0700 Subject: [PATCH] Adv 2.0 D&D Phase 10 SUB2c: Cleric subclasses (Life/War/Trickery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel Divinity resource pool (2/long-rest) shared across all three Cleric domains. One armed expression per fight, mirroring Battle Master. Life Domain - L5 Disciple of Life: heal spells restore +(2 + slot level) extra HP (lifeDomainHealBonus, hooked into resolveHealOutOfCombat). - L5 Preserve Life: Channel Divinity heal — sets HealItem to 5×Cleric level, capped at HPMax/2; fires when player drops below 50%. - L7 Blessed Healer skipped — no allies in 1v1 combat to "heal an ally". War Domain - L5 War Priest passive: +1 attack bonus + 0.15 DamageBonus, proxy for bonus-action extra-attack throughput one-shot combat can't model discretely. - L5 Guided Strike: Channel Divinity → +10 to first attack roll (FirstAttackBonus). - L7 War God's Blessing skipped — ally reaction, no allies. Trickery Domain - L5 Invoke Duplicity: Channel Divinity → AssassinateAdvantage (reroll first miss) + 0.10 DamageBonus, proxy for "advantage on attacks vs. creatures adjacent to the duplicate". - L7 Cloak of Shadows passive: +2 SporeCloud rounds (15% enemy miss) proxy for the brief invisibility window. - L5 Blessing of the Trickster skipped — non-combat Stealth flavor. 15 new tests, all green. Same pre-existing TestSimulateCombat_FirstAttackBonusImprovesEarlyHits flake. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/dnd_abilities.go | 5 + internal/plugin/dnd_cast.go | 1 + internal/plugin/dnd_subclass_cleric_test.go | 253 ++++++++++++++++++++ internal/plugin/dnd_subclass_combat.go | 86 +++++++ 4 files changed, 345 insertions(+) create mode 100644 internal/plugin/dnd_subclass_cleric_test.go diff --git a/internal/plugin/dnd_abilities.go b/internal/plugin/dnd_abilities.go index 04a55f7..9b7bdbe 100644 --- a/internal/plugin/dnd_abilities.go +++ b/internal/plugin/dnd_abilities.go @@ -161,6 +161,11 @@ func subclassResourceMax(sub DnDSubclass) (string, int) { switch sub { case SubclassBattleMaster: return "superiority", 4 + case SubclassLifeDomain, SubclassWarDomain, SubclassTrickeryDomain: + // Phase 10 SUB2c — shared Channel Divinity pool for Cleric domains. + // 5e is 1/short-rest at L2 and 2/short-rest at L6; long-rest refresh + // in our model. + return "channel_divinity", 2 } return "", 0 } diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index a9712df..9974e05 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -235,6 +235,7 @@ func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDChara heal += 1 + rand.IntN(faces) } heal += abilityModifier(c.WIS) + heal += lifeDomainHealBonus(c, spell, slotLevel) before := c.HPCurrent c.HPCurrent = min(c.HPMax, c.HPCurrent+heal) diff --git a/internal/plugin/dnd_subclass_cleric_test.go b/internal/plugin/dnd_subclass_cleric_test.go new file mode 100644 index 0000000..c9de847 --- /dev/null +++ b/internal/plugin/dnd_subclass_cleric_test.go @@ -0,0 +1,253 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// Phase 10 SUB2c — Cleric Channel Divinity (Life / War / Trickery). + +// ── Life Domain ───────────────────────────────────────────────────────── + +func TestLifeDomain_DiscipleOfLifeAddsToHeal(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassLifeDomain, Level: 5} + cure, _ := lookupSpell("cure_wounds") // L1 heal + if got := lifeDomainHealBonus(c, cure, 1); got != 3 { + t.Errorf("Disciple of Life L1 slot bonus = %d, want 3 (2 + slot 1)", got) + } + if got := lifeDomainHealBonus(c, cure, 3); got != 5 { + t.Errorf("upcast L3 bonus = %d, want 5 (2 + slot 3)", got) + } +} + +func TestLifeDomain_DiscipleOfLifePreL5Skipped(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassLifeDomain, Level: 4} + cure, _ := lookupSpell("cure_wounds") + if got := lifeDomainHealBonus(c, cure, 1); got != 0 { + t.Errorf("pre-L5 Life Domain bonus = %d, want 0", got) + } +} + +func TestLifeDomain_DiscipleOfLifeNotForOtherSubclass(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassWarDomain, Level: 5} + cure, _ := lookupSpell("cure_wounds") + if got := lifeDomainHealBonus(c, cure, 1); got != 0 { + t.Errorf("War Domain bonus = %d, want 0", got) + } +} + +func TestLifeDomain_DiscipleOfLifeOnlyOnHealSpells(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassLifeDomain, Level: 5} + bolt, _ := lookupSpell("guiding_bolt") // damage spell + if got := lifeDomainHealBonus(c, bolt, 1); got != 0 { + t.Errorf("non-heal spell bonus = %d, want 0", got) + } +} + +// Preserve Life Channel Divinity — armed ability heals at low HP. +func TestPreserveLife_HealItemSetByArmedAbility(t *testing.T) { + ab, ok := dndActiveAbilities["preserve_life"] + if !ok { + t.Fatal("preserve_life not registered") + } + if ab.Class != ClassCleric || ab.Subclass != SubclassLifeDomain { + t.Errorf("preserve_life gating: class=%s sub=%s, want cleric/life_domain", + ab.Class, ab.Subclass) + } + if ab.Resource != "channel_divinity" { + t.Errorf("preserve_life resource = %s, want channel_divinity", ab.Resource) + } + c := &DnDCharacter{ + Class: ClassCleric, Subclass: SubclassLifeDomain, Level: 5, + HPMax: 50, + } + mods := &CombatModifiers{} + ab.Apply(c, mods) + // 5×L = 25, capped at HPMax/2 = 25. Both equal here. + if mods.HealItem != 25 { + t.Errorf("preserve_life HealItem = %d, want 25 (5×L=25, cap HPMax/2=25)", mods.HealItem) + } +} + +func TestPreserveLife_CappedAtHalfMaxHP(t *testing.T) { + ab := dndActiveAbilities["preserve_life"] + // L7 → 5*7=35 raw; HPMax 40 → cap 20. + c := &DnDCharacter{ + Class: ClassCleric, Subclass: SubclassLifeDomain, Level: 7, + HPMax: 40, + } + mods := &CombatModifiers{} + ab.Apply(c, mods) + if mods.HealItem != 20 { + t.Errorf("preserve_life capped HealItem = %d, want 20 (HPMax/2)", mods.HealItem) + } +} + +// ── War Domain ────────────────────────────────────────────────────────── + +func TestWarDomain_WarPriestPassive(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassWarDomain, Level: 5} + stats := &CombatStats{AttackBonus: 4} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if stats.AttackBonus != 5 { + t.Errorf("War Priest AttackBonus = %d, want 5 (4 + 1)", stats.AttackBonus) + } + if mods.DamageBonus < 0.149 || mods.DamageBonus > 0.151 { + t.Errorf("War Priest DamageBonus = %v, want ~0.15", mods.DamageBonus) + } +} + +func TestWarDomain_PreL5NoPassive(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassWarDomain, Level: 4} + stats := &CombatStats{AttackBonus: 4} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if stats.AttackBonus != 4 || mods.DamageBonus != 0 { + t.Errorf("pre-L5 War Domain leaked: atk=%d dmg=%v", stats.AttackBonus, mods.DamageBonus) + } +} + +func TestGuidedStrike_FirstAttackBonus(t *testing.T) { + ab, ok := dndActiveAbilities["guided_strike"] + if !ok { + t.Fatal("guided_strike not registered") + } + if ab.Class != ClassCleric || ab.Subclass != SubclassWarDomain { + t.Errorf("guided_strike gating: class=%s sub=%s", ab.Class, ab.Subclass) + } + if ab.Resource != "channel_divinity" { + t.Errorf("guided_strike resource = %s, want channel_divinity", ab.Resource) + } + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassWarDomain, Level: 5} + mods := &CombatModifiers{} + ab.Apply(c, mods) + if mods.FirstAttackBonus != 10 { + t.Errorf("guided_strike FirstAttackBonus = %d, want 10", mods.FirstAttackBonus) + } +} + +// ── Trickery Domain ───────────────────────────────────────────────────── + +func TestTrickeryDomain_CloakOfShadowsL7Passive(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassTrickeryDomain, Level: 7} + mods := &CombatModifiers{} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.SporeCloud != 2 { + t.Errorf("Cloak of Shadows SporeCloud = %d, want 2", mods.SporeCloud) + } +} + +func TestTrickeryDomain_PreL7NoCloak(t *testing.T) { + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassTrickeryDomain, Level: 6} + mods := &CombatModifiers{} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.SporeCloud != 0 { + t.Errorf("pre-L7 Trickery SporeCloud = %d, want 0", mods.SporeCloud) + } +} + +func TestInvokeDuplicity_AdvantageAndDamage(t *testing.T) { + ab, ok := dndActiveAbilities["invoke_duplicity"] + if !ok { + t.Fatal("invoke_duplicity not registered") + } + if ab.Class != ClassCleric || ab.Subclass != SubclassTrickeryDomain { + t.Errorf("invoke_duplicity gating: class=%s sub=%s", ab.Class, ab.Subclass) + } + c := &DnDCharacter{Class: ClassCleric, Subclass: SubclassTrickeryDomain, Level: 5} + mods := &CombatModifiers{} + ab.Apply(c, mods) + if !mods.AssassinateAdvantage { + t.Error("AssassinateAdvantage not set") + } + if mods.DamageBonus < 0.099 || mods.DamageBonus > 0.101 { + t.Errorf("DamageBonus = %v, want ~0.10", mods.DamageBonus) + } +} + +// ── Channel Divinity resource pool ────────────────────────────────────── + +func TestChannelDivinity_ResourceMaxForAllClericSubs(t *testing.T) { + for _, sub := range []DnDSubclass{ + SubclassLifeDomain, SubclassWarDomain, SubclassTrickeryDomain, + } { + res, max := subclassResourceMax(sub) + if res != "channel_divinity" { + t.Errorf("%s resource = %q, want channel_divinity", sub, res) + } + if max != 2 { + t.Errorf("%s channel_divinity max = %d, want 2", sub, max) + } + } +} + +func TestChannelDivinity_InitAndSpend(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@cd_pool:example") + if err := createAdvCharacter(uid, "cd_pool"); err != nil { + t.Fatal(err) + } + if err := initSubclassResources(uid, SubclassLifeDomain); err != nil { + t.Fatal(err) + } + cur, max, err := getResource(uid, "channel_divinity") + if err != nil { + t.Fatal(err) + } + if cur != 2 || max != 2 { + t.Errorf("channel_divinity init = %d/%d, want 2/2", cur, max) + } + ok, err := spendResource(uid, "channel_divinity", 1) + if err != nil || !ok { + t.Fatalf("spend failed: ok=%v err=%v", ok, err) + } + cur, _, _ = getResource(uid, "channel_divinity") + if cur != 1 { + t.Errorf("after spend cur = %d, want 1", cur) + } +} + +// ── End-to-end heal hook ──────────────────────────────────────────────── + +func TestResolveHealOutOfCombat_LifeDomainBonusApplied(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@life_heal:example") + if err := createAdvCharacter(uid, "life_heal"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassCleric, Subclass: SubclassLifeDomain, + Level: 5, STR: 10, DEX: 10, CON: 12, INT: 8, WIS: 16, CHA: 12, // WIS +3 + HPMax: 40, HPCurrent: 1, ArmorClass: 14, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := setSpellSlotsForLevel(uid, ClassCleric, 5); err != nil { + t.Fatal(err) + } + cure, _ := lookupSpell("cure_wounds") + p := &AdventurePlugin{} + // Run several casts to bracket the variance: heal range is + // 1d8 + WIS(+3) + Disciple(2+1=3) = [7..14]. Without the +3 bonus, + // max would be 11 — so any HP gain ≥ 12 proves the bonus landed. + highWater := 0 + for i := 0; i < 20; i++ { + c.HPCurrent = 1 + _ = SaveDnDCharacter(c) + // Refund a slot for each iteration. + _ = setSpellSlotsForLevel(uid, ClassCleric, 5) + if err := p.resolveHealOutOfCombat(MessageContext{Sender: uid}, c, cure, 1); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.HPCurrent-1 > highWater { + highWater = got.HPCurrent - 1 + } + } + if highWater < 12 { + t.Errorf("Life Domain heal high-water = %d, want >=12 (proves +3 Disciple bonus)", highWater) + } +} diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index acc8922..f9edf03 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -60,6 +60,25 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar } mods.AssassinateBonusDmg = bonus } + case SubclassWarDomain: + // L5 War Priest: bonus-action weapon attack, usable WIS-mod times per + // long rest. One-shot combat can't model an extra discrete attack, so + // we proxy throughput with a flat +1 attack bonus and a small damage + // bump. L7 War God's Blessing is a reaction granting an ally +10 to + // attack — no allies in 1v1 combat, so it's a no-op. + if c.Level >= 5 { + stats.AttackBonus++ + mods.DamageBonus += 0.15 + } + case SubclassTrickeryDomain: + // L7 Cloak of Shadows: turn invisible until you attack/cast/end of + // next turn. We proxy the brief defensive window as 2 rounds of 15% + // enemy miss chance via SporeCloud (same channel a fog-cloud-style + // consumable would use). L5 Blessing of the Trickster (Stealth + // advantage on an ally) is non-combat flavor — skipped. + if c.Level >= 7 { + mods.SporeCloud += 2 + } } } @@ -140,6 +159,53 @@ func init() { mods.HealItem += 4 + abilityModifier(c.CHA) }, } + + // Phase 10 SUB2c — Cleric Channel Divinity. All three Cleric subclasses + // share the "channel_divinity" resource pool (2/long-rest in our model; + // 5e refreshes 1/short-rest at L2 and 2/short-rest at L6). One armed + // expression per fight, mirroring the Battle Master pattern. + dndActiveAbilities["preserve_life"] = DnDAbility{ + ID: "preserve_life", + Name: "Preserve Life", + Class: ClassCleric, + Subclass: SubclassLifeDomain, + Resource: "channel_divinity", + Description: "Channel Divinity: pour life force in at low HP — heals 5× Cleric level (consumes 1 channel divinity).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + // 5e caps the heal at half max HP per target; in 1v1 the entire + // pool lands on the caster. HealItem fires once when player + // drops below 50%, which matches Preserve Life's "in trouble" + // fantasy. + heal := 5 * c.Level + if c.HPMax > 0 && heal > c.HPMax/2 { + heal = c.HPMax / 2 + } + mods.HealItem += heal + }, + } + dndActiveAbilities["guided_strike"] = DnDAbility{ + ID: "guided_strike", + Name: "Guided Strike", + Class: ClassCleric, + Subclass: SubclassWarDomain, + Resource: "channel_divinity", + Description: "Channel Divinity: +10 to your first attack roll this combat (consumes 1 channel divinity).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.FirstAttackBonus += 10 + }, + } + dndActiveAbilities["invoke_duplicity"] = DnDAbility{ + ID: "invoke_duplicity", + Name: "Invoke Duplicity", + Class: ClassCleric, + Subclass: SubclassTrickeryDomain, + Resource: "channel_divinity", + Description: "Channel Divinity: an illusory duplicate flanks the foe — advantage on your first attack and +10% damage (consumes 1 channel divinity).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.AssassinateAdvantage = true + mods.DamageBonus += 0.10 + }, + } } // persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping. @@ -169,6 +235,26 @@ func persistDnDPostCombatSubclass(c *DnDCharacter, raged bool, result CombatResu return SaveDnDCharacter(c) } +// lifeDomainHealBonus returns the extra HP a Life Domain Cleric's healing +// spell restores. Disciple of Life (L5): +(2 + spell level) HP, with cantrips +// counting as level 1 for the formula. Blessed Healer (L7) — "when casting a +// healing spell on an ally, you regain 2 + slot level HP" — has no ally in +// 1v1 play and is intentionally skipped here. Returns 0 for non-Life-Domain +// callers, non-heal spells, or pre-L5 levels. +func lifeDomainHealBonus(c *DnDCharacter, spell SpellDefinition, slotLevel int) int { + if c == nil || c.Class != ClassCleric || c.Subclass != SubclassLifeDomain { + return 0 + } + if c.Level < 5 || spell.Effect != EffectSpellHeal { + return 0 + } + lvl := slotLevel + if lvl < 1 { + lvl = 1 + } + return 2 + lvl +} + // grimHarvestHeal returns the HP to restore when a Necromancy Mage's queued // spell delivered the killing blow. 5e: heal 2× spell level on kill (3× if // the spell is necrotic). Slot=0 = nothing was stashed → no heal. Returns 0