diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 8c1945f..e485f38 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -68,6 +68,20 @@ type CombatModifiers struct { PhysicalResistRage bool // halve incoming physical damage while raging FrenzyDmgBonus float64 // multiplicative dmg bump while raging (Frenzy approximation) + // Phase 10 SUB2a-ii — Battle Master + Assassin. + // FirstAttackBonus: flat bonus added to the player's first d20 attack + // roll only (Battle Master Precision Attack ≈ +d8). Consumed on first + // roll regardless of hit/miss (5e: "before or after rolling"). + // AssassinateAdvantage: re-roll the player's first miss (better of two + // d20s on the first attack) — proxy for "advantage vs. creatures that + // haven't acted yet". + // AssassinateBonusDmg: flat extra damage stacked on the first hit (rides + // the existing AutoCritFirst doubling — proxy for Death Strike's + // "crits vs. surprised"). + FirstAttackBonus int + AssassinateAdvantage bool + AssassinateBonusDmg int + // Phase 9 — pending spell resolution. Set by applyPendingCast in // dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is // dealt as a pre-combat event with SpellPreDamageDesc as the narrative @@ -185,6 +199,11 @@ type combatState struct { // the enemy would otherwise attack). enemySkipFirst bool + // Phase 10 SUB2a-ii first-attack one-shots. + firstAttackBonusUsed bool + assassinateRerollUsed bool + assassinateBonusUsed bool + round int events []CombatEvent } @@ -483,6 +502,21 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba }) roll = newRoll } + // Phase 10 SUB2a-ii — Assassin advantage: re-roll the first attack of + // the fight, take the better of two d20s. Models 5e's "advantage vs. + // creatures that haven't acted yet" applied to the opening strike only. + if player.Mods.AssassinateAdvantage && !st.assassinateRerollUsed { + st.assassinateRerollUsed = true + alt := 1 + rand.IntN(20) + if alt > roll { + st.events = append(st.events, CombatEvent{ + Round: st.round, Phase: phaseName, Actor: "player", Action: "assassinate_advantage", + PlayerHP: st.playerHP, EnemyHP: st.enemyHP, + Roll: alt, RollAgainst: enemy.Stats.AC, Desc: "Assassinate", + }) + roll = alt + } + } isFumble := roll == 1 // Phase 10 SUB2a — Champion Improved Critical lowers the crit floor. // CritThreshold==0 means "use default" (nat 20). Champion sets 19. @@ -496,6 +530,12 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba if player.Stats.Weapon != nil && !player.Stats.WeaponProficient { attackBonus -= 4 } + // Phase 10 SUB2a-ii — Battle Master Precision Attack: +d8 (modeled as + // flat +4) to the first attack roll only. Consumed even on miss. + if player.Mods.FirstAttackBonus > 0 && !st.firstAttackBonusUsed { + attackBonus += player.Mods.FirstAttackBonus + st.firstAttackBonusUsed = true + } total := roll + attackBonus if isFumble || (!isCritRoll && total < enemy.Stats.AC) { @@ -565,6 +605,15 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba dmg = int(float64(dmg) * (1 + player.Mods.FrenzyDmgBonus)) } } + // Phase 10 SUB2a-ii — Assassin Death Strike proxy: bonus damage on the + // first hit only. Stacks on top of the Rogue's Sneak Attack auto-crit + // (which already doubled the base damage above) — the bonus itself is + // applied AFTER the crit doubling so the math is "double base + flat + // surprise damage". Consumed on first hit regardless of crit status. + if player.Mods.AssassinateBonusDmg > 0 && !st.assassinateBonusUsed { + dmg += player.Mods.AssassinateBonusDmg + st.assassinateBonusUsed = true + } dmg = max(1, dmg) action := "hit" diff --git a/internal/plugin/dnd_abilities.go b/internal/plugin/dnd_abilities.go index 8a12847..04a55f7 100644 --- a/internal/plugin/dnd_abilities.go +++ b/internal/plugin/dnd_abilities.go @@ -152,6 +152,35 @@ func initResources(userID id.UserID, class DnDClass) error { return err } +// subclassResourceMax — resources granted by a subclass on top of the class +// pool. Phase 10 SUB2a-ii: Battle Master gets 4 superiority dice (5e +// long-rest refresh in our model; spec says short-rest, but we keep parity +// with the existing class pools' refresh cadence). Returns ("", 0) if the +// subclass has no extra pool. +func subclassResourceMax(sub DnDSubclass) (string, int) { + switch sub { + case SubclassBattleMaster: + return "superiority", 4 + } + return "", 0 +} + +// initSubclassResources adds the subclass-specific resource pool. Called +// from applySubclassChoice; idempotent. If a player switches subclasses, +// the prior pool's row is left in place — refreshAllResources still touches +// it but no ability references it once the subclass changes, so it's inert. +func initSubclassResources(userID id.UserID, sub DnDSubclass) error { + resType, max := subclassResourceMax(sub) + if resType == "" { + return nil + } + _, err := db.Get().Exec(` + INSERT OR IGNORE INTO dnd_resources (user_id, resource_type, current_value, max_value) + VALUES (?, ?, ?, ?)`, + string(userID), resType, max, max) + return err +} + // getResource returns (current, max). Returns (0, 0, true) if no row exists. func getResource(userID id.UserID, resType string) (int, int, error) { var cur, max int diff --git a/internal/plugin/dnd_skills.go b/internal/plugin/dnd_skills.go index f3a617d..acf80b3 100644 --- a/internal/plugin/dnd_skills.go +++ b/internal/plugin/dnd_skills.go @@ -163,6 +163,18 @@ func subclassSkillBonus(c *DnDCharacter, info dndSkillInfo) int { if c.Level >= 5 && info.Key == SkillSleightOfHand { return 5 } + case SubclassAssassin: + // L5 Infiltration Expertise: crafting a false identity is a + // Deception skill workout. L7 Impostor — perfect mimicry of voice + // and manner — bumps the bonus further. + if info.Key == SkillDeception { + if c.Level >= 7 { + return 10 + } + if c.Level >= 5 { + return 5 + } + } } return 0 } diff --git a/internal/plugin/dnd_subclass_cmd.go b/internal/plugin/dnd_subclass_cmd.go index 11d4703..651f65f 100644 --- a/internal/plugin/dnd_subclass_cmd.go +++ b/internal/plugin/dnd_subclass_cmd.go @@ -119,6 +119,11 @@ func (p *AdventurePlugin) applySubclassChoice( if err := SaveDnDCharacter(c); err != nil { return p.SendDM(ctx.Sender, "Couldn't save subclass choice: "+err.Error()) } + // Phase 10 SUB2a-ii — provision subclass-specific resource pools (e.g. + // Battle Master superiority dice). Idempotent. Failure is non-fatal — + // the player still gets the subclass; resources can be re-initialized + // next long rest. + _ = initSubclassResources(ctx.Sender, chosen) if charged { // Debit last; race vs. balance change is rare and strictly safer // to favor the player than risk taking euros without applying the diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index ad2093b..b8c3e19 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -22,6 +22,27 @@ func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDChar if c.Level >= 5 { mods.CritThreshold = 19 } + case SubclassBattleMaster: + // L7 Know Your Enemy: 5e gives factual info about the target after + // 1 min observation. We don't model "observation time" — proxy the + // tactical edge as a flat +1 to attack rolls from L7 onward. + if c.Level >= 7 { + stats.AttackBonus++ + } + case SubclassAssassin: + // L5 Assassinate: advantage on the opening strike + bonus damage + // stacked on top of the Rogue's existing Sneak Attack auto-crit + // (proxy for "crits vs. surprised targets"). Bonus scales with + // level; L7 Impostor — flavored as deeper study of the mark — adds + // a small surprise-damage bump. + if c.Level >= 5 { + mods.AssassinateAdvantage = true + bonus := c.Level + if c.Level >= 7 { + bonus += 3 + } + mods.AssassinateBonusDmg = bonus + } } } @@ -56,6 +77,52 @@ func init() { mods.FrenzyDmgBonus = 0.5 }, } + + // Phase 10 SUB2a-ii — Battle Master superiority-die maneuvers. Each + // consumes one die from the new "superiority" resource pool (4/long-rest + // at L5; long-rest refresh in our model — 5e refreshes on short rest). + // We don't model 5e's interactive "before/after the roll" choice; a die + // commits at !arm time and fires on the next combat. + // + // Three non-reaction maneuvers covered: + // - Precision Attack → +d8 (≈+4 flat) on first attack roll + // - Tripping Attack → enemy skips its first attack (reuses Phase 9 + // SpellEnemySkipFirst) + // - Rally → temp-HP buffer at <50% (reuses HealItem; self + // buff since one-shot combat has no ally-target UI) + dndActiveAbilities["precision_attack"] = DnDAbility{ + ID: "precision_attack", + Name: "Precision Attack", + Class: ClassFighter, + Subclass: SubclassBattleMaster, + Resource: "superiority", + Description: "Add +d8 (≈+4) to your first attack roll this combat (consumes 1 superiority die).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.FirstAttackBonus += 4 + }, + } + dndActiveAbilities["trip_attack"] = DnDAbility{ + ID: "trip_attack", + Name: "Tripping Attack", + Class: ClassFighter, + Subclass: SubclassBattleMaster, + Resource: "superiority", + Description: "Trip the enemy on engagement: they skip their first attack (consumes 1 superiority die).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.SpellEnemySkipFirst = true + }, + } + dndActiveAbilities["rally"] = DnDAbility{ + ID: "rally", + Name: "Rally", + Class: ClassFighter, + Subclass: SubclassBattleMaster, + Resource: "superiority", + Description: "Steel yourself for the fight: heal at low HP for d8 + CHA mod (consumes 1 superiority die).", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.HealItem += 4 + abilityModifier(c.CHA) + }, + } } // persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping. diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index 1ff4d39..cd816e3 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -375,6 +375,308 @@ func TestExhaustion_SchemaRoundTrip(t *testing.T) { } } +// ── SUB2a-ii — Battle Master + Assassin ───────────────────────────────── + +func TestApplySubclassPassives_BattleMasterL7AttackBonus(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 7} + stats := &CombatStats{AttackBonus: 3} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if stats.AttackBonus != 4 { + t.Errorf("L7 Battle Master AttackBonus = %d, want 4 (3 base + 1 Know Your Enemy)", stats.AttackBonus) + } +} + +func TestApplySubclassPassives_BattleMasterPreL7NoBonus(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 6} + stats := &CombatStats{AttackBonus: 3} + applySubclassPassives(stats, &CombatModifiers{DamageReduct: 1.0}, c) + if stats.AttackBonus != 3 { + t.Errorf("L6 Battle Master AttackBonus = %d, want 3 (no Know Your Enemy yet)", stats.AttackBonus) + } +} + +func TestApplySubclassPassives_AssassinL5(t *testing.T) { + c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 5} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if !mods.AssassinateAdvantage { + t.Error("L5 Assassin should set AssassinateAdvantage") + } + if mods.AssassinateBonusDmg != 5 { + t.Errorf("L5 Assassin BonusDmg = %d, want 5", mods.AssassinateBonusDmg) + } +} + +func TestApplySubclassPassives_AssassinL7Bumps(t *testing.T) { + c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 7} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.AssassinateBonusDmg != 10 { + t.Errorf("L7 Assassin BonusDmg = %d, want 10 (level 7 + 3 Impostor)", mods.AssassinateBonusDmg) + } +} + +// Manoeuvre Apply functions wire the right mods. + +func TestPrecisionAttack_ApplySetsFirstAttackBonus(t *testing.T) { + ab, ok := dndActiveAbilities["precision_attack"] + if !ok { + t.Fatal("precision_attack not registered") + } + if ab.Class != ClassFighter || ab.Subclass != SubclassBattleMaster { + t.Errorf("gating: %s/%s, want fighter/battle_master", ab.Class, ab.Subclass) + } + if ab.Resource != "superiority" { + t.Errorf("resource = %s, want superiority", ab.Resource) + } + mods := &CombatModifiers{DamageReduct: 1.0} + ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods) + if mods.FirstAttackBonus != 4 { + t.Errorf("FirstAttackBonus = %d, want 4", mods.FirstAttackBonus) + } +} + +func TestTripAttack_ApplySetsEnemySkipFirst(t *testing.T) { + ab := dndActiveAbilities["trip_attack"] + mods := &CombatModifiers{DamageReduct: 1.0} + ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5}, mods) + if !mods.SpellEnemySkipFirst { + t.Error("Tripping Attack should set SpellEnemySkipFirst") + } +} + +func TestRally_ApplySetsHealItem(t *testing.T) { + ab := dndActiveAbilities["rally"] + mods := &CombatModifiers{DamageReduct: 1.0} + // CHA 14 → +2 mod, expect HealItem += 6. + ab.Apply(&DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 5, CHA: 14}, mods) + if mods.HealItem != 6 { + t.Errorf("Rally HealItem = %d, want 6 (4 + CHA mod 2)", mods.HealItem) + } +} + +// ── Resource init at subclass selection ────────────────────────────────── + +func TestInitSubclassResources_BattleMasterGetsSuperiority(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@bm_init:example") + if err := createAdvCharacter(uid, "bm_init"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: 5, + STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 16, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := initSubclassResources(uid, SubclassBattleMaster); err != nil { + t.Fatal(err) + } + cur, max, err := getResource(uid, "superiority") + if err != nil { + t.Fatal(err) + } + if cur != 4 || max != 4 { + t.Errorf("superiority pool = %d/%d, want 4/4", cur, max) + } +} + +func TestInitSubclassResources_NonBattleMasterIsNoop(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@bm_noop:example") + if err := createAdvCharacter(uid, "bm_noop"); err != nil { + t.Fatal(err) + } + if err := initSubclassResources(uid, SubclassChampion); err != nil { + t.Fatal(err) + } + cur, max, err := getResource(uid, "superiority") + if err != nil { + t.Fatal(err) + } + if cur != 0 || max != 0 { + t.Errorf("superiority pool for Champion = %d/%d, want 0/0", cur, max) + } +} + +// !arm gating for subclass-only abilities. + +func TestArmPrecisionAttack_BlockedForChampion(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@bm_block:example") + if err := createAdvCharacter(uid, "bm_block"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassChampion, + Level: 6, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 16, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := initResources(uid, ClassFighter); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{euro: &EuroPlugin{}} + if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "precision_attack"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.ArmedAbility == "precision_attack" { + t.Error("non-Battle-Master should not be able to arm precision_attack") + } +} + +func TestArmPrecisionAttack_AllowedForBattleMaster(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@bm_ok:example") + if err := createAdvCharacter(uid, "bm_ok"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Subclass: SubclassBattleMaster, + Level: 5, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 16, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := initSubclassResources(uid, SubclassBattleMaster); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{euro: &EuroPlugin{}} + if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "precision_attack"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.ArmedAbility != "precision_attack" { + t.Errorf("ArmedAbility = %q, want precision_attack", got.ArmedAbility) + } + cur, _, _ := getResource(uid, "superiority") + if cur != 3 { + t.Errorf("superiority after arm = %d, want 3", cur) + } +} + +// ── Skill bonuses ──────────────────────────────────────────────────────── + +func TestSubclassSkillBonus_AssassinDeception(t *testing.T) { + dec, _ := skillInfo(SkillDeception) + persuasion, _ := skillInfo(SkillPersuasion) + + l5 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 5} + if got := subclassSkillBonus(l5, dec); got != 5 { + t.Errorf("L5 Assassin Deception = %d, want 5", got) + } + if got := subclassSkillBonus(l5, persuasion); got != 0 { + t.Errorf("L5 Assassin Persuasion = %d, want 0 (Infiltration is Deception only)", got) + } + + l7 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 7} + if got := subclassSkillBonus(l7, dec); got != 10 { + t.Errorf("L7 Assassin Deception = %d, want 10 (Impostor)", got) + } + + l4 := &DnDCharacter{Class: ClassRogue, Subclass: SubclassAssassin, Level: 4} + if got := subclassSkillBonus(l4, dec); got != 0 { + t.Errorf("L4 Assassin Deception = %d, want 0 (subclass abilities gate at L5)", got) + } +} + +// ── End-to-end: one-shot first-attack effects ──────────────────────────── + +func bmPrecisionPlayer() Combatant { + return Combatant{ + Name: "BM", 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, FirstAttackBonus: 4}, + } +} + +func plainBMPlayer() Combatant { + c := bmPrecisionPlayer() + c.Mods.FirstAttackBonus = 0 + return c +} + +// Probabilistic: a stiff +4 on the opening swing should noticeably lift +// hit-rate against an enemy AC tuned to mostly-shrug the baseline player. +func TestSimulateCombat_FirstAttackBonusImprovesEarlyHits(t *testing.T) { + const trials = 1500 + hardEnemy := Combatant{ + Name: "Wall", + Stats: CombatStats{ + MaxHP: 80, Attack: 10, Defense: 6, Speed: 6, AC: 22, AttackBonus: 4, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{DamageReduct: 1.0}, + } + plainWins, bmWins := 0, 0 + for i := 0; i < trials; i++ { + if SimulateCombat(plainBMPlayer(), hardEnemy, defaultCombatPhases).PlayerWon { + plainWins++ + } + if SimulateCombat(bmPrecisionPlayer(), hardEnemy, defaultCombatPhases).PlayerWon { + bmWins++ + } + } + if bmWins-plainWins < 25 { + t.Errorf("Precision Attack lift too small: plain=%d bm=%d", plainWins, bmWins) + } +} + +// Surface check: AssassinateBonusDmg only consumed once. +func TestResolvePlayerAttack_AssassinateBonusFirstHitOnly(t *testing.T) { + // Drive resolvePlayerAttack via a SimulateCombat run and confirm the + // total enemy damage taken on hit is the *base* damage on subsequent + // hits (i.e. only the first hit got the +bonus). Statistical compare + // against an Assassin L5 vs. an identical player without the bonus. + const trials = 800 + build := func(bonus int) Combatant { + return Combatant{ + Name: "Assn", 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, AutoCritFirst: true, AssassinateBonusDmg: bonus, + }, + } + } + enemy := func() Combatant { + return Combatant{ + Name: "Mark", + Stats: CombatStats{ + MaxHP: 80, Attack: 12, Defense: 8, Speed: 8, AC: 14, AttackBonus: 3, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{DamageReduct: 1.0}, + } + } + plainWins, assnWins := 0, 0 + for i := 0; i < trials; i++ { + if SimulateCombat(build(0), enemy(), defaultCombatPhases).PlayerWon { + plainWins++ + } + if SimulateCombat(build(8), enemy(), defaultCombatPhases).PlayerWon { + assnWins++ + } + } + if assnWins <= plainWins { + t.Errorf("Assassinate bonus damage didn't help: plain=%d assn=%d", plainWins, assnWins) + } +} + // Surface-level sanity: rage shows up in characterActiveAbilities for a // Berserker but not a Champion. Tests the gating helper directly so we // don't need the resource-pool DB read that renderArmList does.