mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 10 SUB2c: Cleric subclasses (Life/War/Trickery)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
253
internal/plugin/dnd_subclass_cleric_test.go
Normal file
253
internal/plugin/dnd_subclass_cleric_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user