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:
prosolis
2026-05-08 10:51:11 -07:00
parent 596e17dc73
commit a733d52166
4 changed files with 345 additions and 0 deletions

View File

@@ -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