diff --git a/gogobee_subclass_system.md b/gogobee_subclass_system.md index 9f0f788..48e2272 100644 --- a/gogobee_subclass_system.md +++ b/gogobee_subclass_system.md @@ -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 diff --git a/internal/plugin/dnd_skills.go b/internal/plugin/dnd_skills.go index acf80b3..5d95aeb 100644 --- a/internal/plugin/dnd_skills.go +++ b/internal/plugin/dnd_skills.go @@ -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 } diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index f9edf03..d8e2271 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -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 + } } } diff --git a/internal/plugin/dnd_subclass_ranger_test.go b/internal/plugin/dnd_subclass_ranger_test.go new file mode 100644 index 0000000..f0b30dc --- /dev/null +++ b/internal/plugin/dnd_subclass_ranger_test.go @@ -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) + } +}