Adv 2.0 D&D Phase 10 SUB2d: Ranger subclasses (Hunter/Beast Master/Gloom Stalker)

L5/L7 abilities for the three Ranger subclasses, plus Gloom Stalker
Stealth advantage from Umbral Sight. Beast Master pet floors use a
max-style merge so the legacy adventure-pet path can still upgrade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 10:56:57 -07:00
parent a733d52166
commit b56e53300e
4 changed files with 275 additions and 3 deletions

View File

@@ -241,9 +241,12 @@ Each subclass selection triggers a TwinBee narration line and a one-line flavor
- [ ] `!respec` command (cooldown + cost)
### Phase SUB2 — Ability Implementation
- [ ] All Level 5 abilities per subclass
- [ ] Level 7 abilities per subclass
- [ ] Berserker Rage mechanic and Exhaustion tracking
- [x] Fighter (SUB2a-i) and Rogue Assassin/Battle Master (SUB2a-ii) L5/L7
- [x] Arcane Trickster spellcasting (SUB2-AT)
- [x] Mage subclasses L5/L7 (SUB2b)
- [x] Cleric subclasses L5/L7 + Channel Divinity (SUB2c)
- [x] Ranger subclasses L5/L7 (SUB2d)
- [x] Berserker Rage mechanic and Exhaustion tracking
### Phase SUB3 — Advanced Abilities
- [ ] Level 10 and 15 abilities per subclass

View File

@@ -194,6 +194,14 @@ func subclassSkillAdvantage(c *DnDCharacter, info dndSkillInfo) bool {
if c.Level >= 7 && info.Key == SkillStealth {
return true
}
case SubclassGloomStalker:
// L5 Umbral Sight: in 5e the player is invisible to creatures
// relying on darkvision while in darkness. Adventure zones don't
// track lighting, so we apply unconditionally — same approachability
// shortcut Thief Supreme Sneak takes.
if c.Level >= 5 && info.Key == SkillStealth {
return true
}
}
return false
}

View File

@@ -79,6 +79,62 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar
if c.Level >= 7 {
mods.SporeCloud += 2
}
case SubclassHunter:
// L5 Hunter's Prey: 5e gives a one-of-three pick (Colossus Slayer:
// +1d8 vs. damaged foes once per turn / Giant Killer reaction /
// Horde Breaker second attack). One-shot combat can't model the
// pick UI, so we collapse to the most generally-applicable flavor
// — Colossus Slayer — as a flat +10% damage bump (any hit after
// the opener lands on an already-damaged target).
// L7 Defensive Tactics: collapse to Multiattack Defense — +1 AC.
if c.Level >= 5 {
mods.DamageBonus += 0.10
}
if c.Level >= 7 {
stats.AC++
}
case SubclassBeastMaster:
// L5 Ranger's Companion + Exceptional Training: a baseline combat
// pet shows up on initiative and pokes the enemy. Use max-style
// floors — if the player already has a stronger NPC pet active
// (e.g. a higher-level adventure pet via combat_stats.go), keep
// theirs. PetAttackDmg scales with level so the companion grows
// with the Ranger.
// L7 Bestial Fury: pet attacks twice when commanded. We can't
// schedule a second swing in the existing pet hook, so we bump
// the proc rate (extra swings per fight) and per-hit damage.
if c.Level >= 5 {
proc := 0.20
dmg := 2 + c.Level/2
if c.Level >= 7 {
proc = 0.35
dmg += 3
}
if mods.PetAttackProc < proc {
mods.PetAttackProc = proc
}
if mods.PetAttackDmg < dmg {
mods.PetAttackDmg = dmg
}
}
case SubclassGloomStalker:
// L5 Dread Ambusher: +WIS to initiative and +1 attack with +1d8 in
// the first round. Initiative rides on Speed in our model; the
// extra opener damage rides AssassinateBonusDmg (consumed on first
// hit only — the same channel the Assassin uses).
// L5 Umbral Sight: the in-darkness stealth advantage is wired in
// subclassSkillAdvantage; here we just give the combat half.
// L7 Stalker's Flurry: 5e re-rolls a missed attack. The closest
// engine primitive is AssassinateAdvantage (best of two on the
// first roll), which is a slightly tighter expression of the
// same fantasy ("you don't whiff your opener").
if c.Level >= 5 {
stats.Speed += abilityModifier(c.WIS)
mods.AssassinateBonusDmg += 5 + abilityModifier(c.WIS)
}
if c.Level >= 7 {
mods.AssassinateAdvantage = true
}
}
}

View File

@@ -0,0 +1,205 @@
package plugin
import "testing"
// Phase 10 SUB2d — Ranger subclasses (Hunter / Beast Master / Gloom Stalker).
// ── Hunter ──────────────────────────────────────────────────────────────
func TestApplySubclassPassives_HunterL5DamageBonus(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassHunter, Level: 5}
stats := &CombatStats{AC: 14}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(stats, mods, c)
if mods.DamageBonus < 0.099 || mods.DamageBonus > 0.101 {
t.Errorf("Hunter L5 DamageBonus = %v, want ~0.10", mods.DamageBonus)
}
if stats.AC != 14 {
t.Errorf("Hunter L5 AC = %d, want 14 (no Defensive Tactics yet)", stats.AC)
}
}
func TestApplySubclassPassives_HunterL7AC(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassHunter, Level: 7}
stats := &CombatStats{AC: 14}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(stats, mods, c)
if stats.AC != 15 {
t.Errorf("Hunter L7 AC = %d, want 15 (+1 Defensive Tactics)", stats.AC)
}
}
func TestApplySubclassPassives_HunterPreL5(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassHunter, Level: 4}
stats := &CombatStats{AC: 14}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(stats, mods, c)
if mods.DamageBonus != 0 || stats.AC != 14 {
t.Errorf("pre-L5 Hunter leaked: dmg=%v ac=%d", mods.DamageBonus, stats.AC)
}
}
// ── Beast Master ────────────────────────────────────────────────────────
func TestApplySubclassPassives_BeastMasterL5SetsCompanion(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 5}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(&CombatStats{}, mods, c)
if mods.PetAttackProc < 0.199 || mods.PetAttackProc > 0.201 {
t.Errorf("BM L5 PetAttackProc = %v, want ~0.20", mods.PetAttackProc)
}
// L5 → 2 + 5/2 = 4.
if mods.PetAttackDmg != 4 {
t.Errorf("BM L5 PetAttackDmg = %d, want 4", mods.PetAttackDmg)
}
}
func TestApplySubclassPassives_BeastMasterL7BestialFury(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 7}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(&CombatStats{}, mods, c)
if mods.PetAttackProc < 0.349 || mods.PetAttackProc > 0.351 {
t.Errorf("BM L7 PetAttackProc = %v, want ~0.35", mods.PetAttackProc)
}
// L7 → 2 + 7/2 + 3 = 2 + 3 + 3 = 8.
if mods.PetAttackDmg != 8 {
t.Errorf("BM L7 PetAttackDmg = %d, want 8", mods.PetAttackDmg)
}
}
// If the player already has a stronger pet (e.g. via the legacy adventure
// pet path in combat_stats.go), the subclass floor must not downgrade it.
func TestApplySubclassPassives_BeastMasterDoesntDowngradeStrongerPet(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 5}
mods := &CombatModifiers{
DamageReduct: 1.0,
PetAttackProc: 0.50,
PetAttackDmg: 10,
}
applySubclassPassives(&CombatStats{}, mods, c)
if mods.PetAttackProc != 0.50 {
t.Errorf("PetAttackProc = %v, want 0.50 preserved", mods.PetAttackProc)
}
if mods.PetAttackDmg != 10 {
t.Errorf("PetAttackDmg = %d, want 10 preserved", mods.PetAttackDmg)
}
}
func TestApplySubclassPassives_BeastMasterPreL5(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassBeastMaster, Level: 4}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(&CombatStats{}, mods, c)
if mods.PetAttackProc != 0 || mods.PetAttackDmg != 0 {
t.Errorf("pre-L5 BM leaked pet flags: proc=%v dmg=%d", mods.PetAttackProc, mods.PetAttackDmg)
}
}
// ── Gloom Stalker ───────────────────────────────────────────────────────
func TestApplySubclassPassives_GloomStalkerL5DreadAmbusher(t *testing.T) {
c := &DnDCharacter{
Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 5,
WIS: 16, // mod +3
}
stats := &CombatStats{Speed: 10}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(stats, mods, c)
if stats.Speed != 13 {
t.Errorf("GS L5 Speed = %d, want 13 (10 + WIS +3)", stats.Speed)
}
if mods.AssassinateBonusDmg != 8 {
t.Errorf("GS L5 AssassinateBonusDmg = %d, want 8 (5 + WIS +3)", mods.AssassinateBonusDmg)
}
if mods.AssassinateAdvantage {
t.Error("GS L5 should not yet have AssassinateAdvantage (Stalker's Flurry gates at L7)")
}
}
func TestApplySubclassPassives_GloomStalkerL7AddsAdvantage(t *testing.T) {
c := &DnDCharacter{
Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 7,
WIS: 14, // mod +2
}
stats := &CombatStats{Speed: 10}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(stats, mods, c)
if !mods.AssassinateAdvantage {
t.Error("GS L7 should set AssassinateAdvantage")
}
if mods.AssassinateBonusDmg != 7 {
t.Errorf("GS L7 AssassinateBonusDmg = %d, want 7 (5 + WIS +2)", mods.AssassinateBonusDmg)
}
}
func TestApplySubclassPassives_GloomStalkerPreL5(t *testing.T) {
c := &DnDCharacter{
Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 4, WIS: 16,
}
stats := &CombatStats{Speed: 10}
mods := &CombatModifiers{DamageReduct: 1.0}
applySubclassPassives(stats, mods, c)
if stats.Speed != 10 || mods.AssassinateBonusDmg != 0 || mods.AssassinateAdvantage {
t.Errorf("pre-L5 GS leaked: speed=%d bonus=%d adv=%v",
stats.Speed, mods.AssassinateBonusDmg, mods.AssassinateAdvantage)
}
}
// ── Skill: Umbral Sight stealth advantage ───────────────────────────────
func TestSubclassSkillAdvantage_GloomStalkerStealth(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 5}
stealth, _ := skillInfo(SkillStealth)
soh, _ := skillInfo(SkillSleightOfHand)
if !subclassSkillAdvantage(c, stealth) {
t.Error("L5 Gloom Stalker should roll Stealth with advantage (Umbral Sight)")
}
if subclassSkillAdvantage(c, soh) {
t.Error("Umbral Sight should not grant advantage on non-Stealth skills")
}
}
func TestSubclassSkillAdvantage_GloomStalkerPreL5No(t *testing.T) {
c := &DnDCharacter{Class: ClassRanger, Subclass: SubclassGloomStalker, Level: 4}
stealth, _ := skillInfo(SkillStealth)
if subclassSkillAdvantage(c, stealth) {
t.Error("pre-L5 Gloom Stalker should not yet have stealth advantage")
}
}
// ── End-to-end: Hunter colossus-slayer damage lift ──────────────────────
func TestSimulateCombat_HunterDamageBonusImprovesWinRate(t *testing.T) {
const trials = 1500
build := func(bonus float64) Combatant {
return Combatant{
Name: "Ranger", IsPlayer: true,
Stats: CombatStats{
MaxHP: 100, Attack: 15, Defense: 8, Speed: 10, AttackBonus: 5, AC: 16,
CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05,
},
Mods: CombatModifiers{DamageReduct: 1.0, DamageBonus: bonus},
}
}
enemy := func() Combatant {
return Combatant{
Name: "Brute",
Stats: CombatStats{
MaxHP: 110, Attack: 14, Defense: 10, Speed: 8, AC: 14, AttackBonus: 3,
CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05,
},
Mods: CombatModifiers{DamageReduct: 1.0},
}
}
plain, hunter := 0, 0
for i := 0; i < trials; i++ {
if SimulateCombat(build(0), enemy(), defaultCombatPhases).PlayerWon {
plain++
}
if SimulateCombat(build(0.10), enemy(), defaultCombatPhases).PlayerWon {
hunter++
}
}
if hunter <= plain {
t.Errorf("Hunter +10%% damage didn't help: plain=%d hunter=%d", plain, hunter)
}
}