diff --git a/internal/db/db.go b/internal/db/db.go index 087debb..d9e9cac 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -178,6 +178,10 @@ func runMigrations(d *sql.DB) error { // (separate from last_respec_at, which gates the full character wipe). `ALTER TABLE dnd_character ADD COLUMN subclass TEXT NOT NULL DEFAULT ''`, `ALTER TABLE dnd_character ADD COLUMN last_subclass_respec_at DATETIME`, + // Phase 10 SUB2a — exhaustion levels. Berserker Frenzy increments + // this after each rage'd combat. Cleared on long rest. Reused by + // other classes once exhaustion-inducing mechanics arrive in SUB3+. + `ALTER TABLE dnd_character ADD COLUMN exhaustion INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index ea0556d..e12a8f4 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -45,6 +45,7 @@ func (p *AdventurePlugin) runArenaCombat( applyDnDHPScaling(&playerStats, dndChar) applyClassPassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar) + applySubclassPassives(&playerStats, &playerMods, dndChar) if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) } @@ -89,6 +90,9 @@ func (p *AdventurePlugin) runArenaCombat( p.grantCombatAchievements(userID, result) persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP) + if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage); err != nil { + slog.Error("dnd: post-combat subclass persist (arena)", "user", userID, "err", err) + } if xp := arenaCombatXP(result, monster.ThreatLevel); xp > 0 { if _, err := p.grantDnDXP(userID, xp); err != nil { @@ -169,6 +173,7 @@ func (p *AdventurePlugin) runDungeonCombat( applyDnDHPScaling(&playerStats, dndChar) applyClassPassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar) + applySubclassPassives(&playerStats, &playerMods, dndChar) if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) } @@ -215,6 +220,9 @@ func (p *AdventurePlugin) runDungeonCombat( p.grantCombatAchievements(userID, result) persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP) + if err := persistDnDPostCombatSubclass(dndChar, playerMods.BerserkerRage); err != nil { + slog.Error("dnd: post-combat subclass persist (dungeon)", "user", userID, "err", err) + } if xp := dungeonCombatXP(result, loc.Tier); xp > 0 { if _, err := p.grantDnDXP(userID, xp); err != nil { diff --git a/internal/plugin/combat_engine.go b/internal/plugin/combat_engine.go index 705b7d0..8c1945f 100644 --- a/internal/plugin/combat_engine.go +++ b/internal/plugin/combat_engine.go @@ -53,6 +53,21 @@ type CombatModifiers struct { RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage PoisonResist bool // Dwarf: poison tick damage halved + // Phase 10 SUB2a — subclass combat hooks. + // CritThreshold: lowest d20 roll that crits. 0 = use default (20). + // Champion L5 Improved Critical sets this to 19; Champion L15 + // Superior Critical (SUB3) will set 18. + CritThreshold int + // BerserkerRage: while true, +RageMeleeDmg flat damage per hit and + // incoming weapon damage halved (PhysicalResistRage). Frenzy also + // adds FrenzyDmgBonus on top to model the bonus-attack-per-turn that + // one-shot combat can't represent literally. Set by armed `rage` + // ability for Berserker subclass. + BerserkerRage bool + RageMeleeDmg int // flat damage per hit while raging (5: +2) + PhysicalResistRage bool // halve incoming physical damage while raging + FrenzyDmgBonus float64 // multiplicative dmg bump while raging (Frenzy approximation) + // 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 @@ -469,7 +484,13 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba roll = newRoll } isFumble := roll == 1 - isNat20 := roll == 20 + // Phase 10 SUB2a — Champion Improved Critical lowers the crit floor. + // CritThreshold==0 means "use default" (nat 20). Champion sets 19. + critFloor := 20 + if player.Mods.CritThreshold > 0 && player.Mods.CritThreshold < 20 { + critFloor = player.Mods.CritThreshold + } + isCritRoll := roll >= critFloor // Class proficiency penalty (appendix §8): -4 attack with a non-proficient weapon. attackBonus := player.Stats.AttackBonus if player.Stats.Weapon != nil && !player.Stats.WeaponProficient { @@ -477,7 +498,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba } total := roll + attackBonus - if isFumble || (!isNat20 && total < enemy.Stats.AC) { + if isFumble || (!isCritRoll && total < enemy.Stats.AC) { desc := "" if isFumble { desc = "fumble" @@ -518,7 +539,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba dmg = max(1, dmg/2) } - isCrit := isNat20 + isCrit := isCritRoll if st.autoCrit { isCrit = true st.autoCrit = false @@ -533,6 +554,17 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba dmg = int(float64(dmg) * 1.5) st.pendingRageAttack = false } + // Phase 10 SUB2a — Berserker rage: +flat per hit, plus Frenzy + // multiplicative bump that approximates the bonus-attack-per-turn we + // can't model in one-shot combat. + if player.Mods.BerserkerRage { + if player.Mods.RageMeleeDmg > 0 { + dmg += player.Mods.RageMeleeDmg + } + if player.Mods.FrenzyDmgBonus > 0 { + dmg = int(float64(dmg) * (1 + player.Mods.FrenzyDmgBonus)) + } + } dmg = max(1, dmg) action := "hit" @@ -620,6 +652,11 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat if isCrit { dmg *= 2 } + // Phase 10 SUB2a — Berserker rage halves incoming weapon damage. + // Applied AFTER crit-doubling so the resistance survives crits. + if player.Mods.BerserkerRage && player.Mods.PhysicalResistRage { + dmg = max(1, dmg/2) + } dmg = max(1, dmg) if petDeflect { diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 881cef4..f83f369 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -182,6 +182,11 @@ type DnDCharacter struct { // cooldown (LastSubclassRespecAt) and 500-coin cost. Subclass DnDSubclass LastSubclassRespecAt *time.Time + // Phase 10 SUB2a — Berserker Frenzy increments after each rage'd + // combat; future class abilities will also tick this. Cleared on + // long rest. 5e caps at 6 (death); we let it grow and let consumers + // clamp where needed. + Exhaustion int LastRespecAt *time.Time LastShortRestAt *time.Time LastLongRestAt *time.Time @@ -210,7 +215,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) { hp_current, hp_max, temp_hp, armor_class, pending_setup, auto_migrated, onboarding_sent, armed_ability, pending_cast, concentration_spell, concentration_expires_at, - subclass, last_subclass_respec_at, + subclass, last_subclass_respec_at, exhaustion, last_respec_at, last_short_rest_at, last_long_rest_at, created_at, updated_at FROM dnd_character WHERE user_id = ?`, string(userID)) @@ -223,7 +228,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) { &c.HPCurrent, &c.HPMax, &c.TempHP, &c.ArmorClass, &pending, &autoMig, &onboard, &c.ArmedAbility, &c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt, - &subclassStr, &c.LastSubclassRespecAt, + &subclassStr, &c.LastSubclassRespecAt, &c.Exhaustion, &c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { @@ -262,9 +267,9 @@ func SaveDnDCharacter(c *DnDCharacter) error { hp_current, hp_max, temp_hp, armor_class, pending_setup, auto_migrated, onboarding_sent, armed_ability, pending_cast, concentration_spell, concentration_expires_at, - subclass, last_subclass_respec_at, + subclass, last_subclass_respec_at, exhaustion, last_respec_at, last_short_rest_at, last_long_rest_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET race=excluded.race, class=excluded.class, dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp, @@ -282,6 +287,7 @@ func SaveDnDCharacter(c *DnDCharacter) error { concentration_expires_at=excluded.concentration_expires_at, subclass=excluded.subclass, last_subclass_respec_at=excluded.last_subclass_respec_at, + exhaustion=excluded.exhaustion, last_respec_at=excluded.last_respec_at, last_short_rest_at=excluded.last_short_rest_at, last_long_rest_at=excluded.last_long_rest_at, @@ -291,7 +297,7 @@ func SaveDnDCharacter(c *DnDCharacter) error { c.HPCurrent, c.HPMax, c.TempHP, c.ArmorClass, pending, autoMig, onboard, c.ArmedAbility, c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt, - string(c.Subclass), c.LastSubclassRespecAt, + string(c.Subclass), c.LastSubclassRespecAt, c.Exhaustion, c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt) if err != nil { return fmt.Errorf("save dnd_character: %w", err) diff --git a/internal/plugin/dnd_abilities.go b/internal/plugin/dnd_abilities.go index 750357e..8a12847 100644 --- a/internal/plugin/dnd_abilities.go +++ b/internal/plugin/dnd_abilities.go @@ -27,6 +27,11 @@ type DnDAbility struct { ID string Name string Class DnDClass + // Subclass: if non-empty, ability is only available when the player's + // subclass matches. Phase 10 adds the first such ability (Berserker + // rage). Used by parseAbility/arm gating and by classActiveAbilities + // when listing for !arm / !abilities. + Subclass DnDSubclass Resource string // "stamina", "spell_slot", "favor", etc. Description string // Effect — applied to mods at combat start when armed @@ -83,17 +88,35 @@ func parseAbility(s string) (DnDAbility, bool) { return DnDAbility{}, false } -// classActiveAbilities returns the active abilities a class can know. +// classActiveAbilities returns the active abilities a class can know, +// excluding subclass-gated entries (those are listed by characterActiveAbilities). func classActiveAbilities(class DnDClass) []DnDAbility { var out []DnDAbility for _, a := range dndActiveAbilities { - if a.Class == class { + if a.Class == class && a.Subclass == "" { out = append(out, a) } } return out } +// characterActiveAbilities returns abilities available to a given character, +// including their subclass-gated abilities. Used for !arm listing so the +// Berserker sees `rage` once they've chosen the subclass. +func characterActiveAbilities(c *DnDCharacter) []DnDAbility { + var out []DnDAbility + for _, a := range dndActiveAbilities { + if a.Class != c.Class { + continue + } + if a.Subclass != "" && a.Subclass != c.Subclass { + continue + } + out = append(out, a) + } + return out +} + // ── Resource pool ──────────────────────────────────────────────────────────── // classResourceMax returns (resource_type, max_value) for a class. @@ -201,6 +224,12 @@ func (p *AdventurePlugin) handleDnDArmCmd(ctx MessageContext, args string) error return p.SendDM(ctx.Sender, fmt.Sprintf( "%s is a %s ability — your class is %s.", ab.Name, titleClass(ab.Class), titleClass(c.Class))) } + if ab.Subclass != "" && ab.Subclass != c.Subclass { + needed, _ := subclassInfo(ab.Subclass) + return p.SendDM(ctx.Sender, fmt.Sprintf( + "%s is a %s subclass ability — choose that subclass via `!subclass`.", + ab.Name, needed.Display)) + } if c.ArmedAbility != "" { return p.SendDM(ctx.Sender, fmt.Sprintf( @@ -244,7 +273,7 @@ func (p *AdventurePlugin) handleDnDArmCmd(ctx MessageContext, args string) error } func renderArmList(c *DnDCharacter) string { - abilities := classActiveAbilities(c.Class) + abilities := characterActiveAbilities(c) var b strings.Builder b.WriteString("**Active Abilities** (pre-arm via `!arm `)\n\n") diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index 6225e6a..1599ebd 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -140,6 +140,12 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { c.HPCurrent = c.HPMax c.TempHP = 0 + // Phase 10 SUB2a — long rest clears one level of exhaustion (5e: a + // long rest clears one). For Berserker who racks up exhaustion via + // Frenzy, this is the recovery cadence. + if c.Exhaustion > 0 { + c.Exhaustion-- + } now := time.Now().UTC() c.LastLongRestAt = &now diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index 300755f..d9be989 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -387,6 +387,7 @@ func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error { // cooldown is dropped — at L1 with no class there's nothing to gate. c.Subclass = "" c.LastSubclassRespecAt = nil + c.Exhaustion = 0 // Save the wipe BEFORE debiting euros (audit fix B). If save fails, the // player's old state survives and they keep their euros — better than // a destructive debit-without-wipe. diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 239e656..c4bdfed 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -23,8 +23,8 @@ func (p *AdventurePlugin) handleDnDAbilitiesCmd(ctx MessageContext) error { b.WriteString(fmt.Sprintf("**Class passive — %s**\n %s\n\n", ab.Name, ab.Description)) b.WriteString(fmt.Sprintf("**Race trait**\n %s\n", ri.Passive)) - // Active abilities (Phase 6) - actives := classActiveAbilities(c.Class) + // Active abilities (Phase 6 + Phase 10 subclass-gated) + actives := characterActiveAbilities(c) if len(actives) > 0 { resType, _ := classResourceMax(c.Class) cur, max, _ := getResource(c.UserID, resType) diff --git a/internal/plugin/dnd_skills.go b/internal/plugin/dnd_skills.go index 3d44c08..f3a617d 100644 --- a/internal/plugin/dnd_skills.go +++ b/internal/plugin/dnd_skills.go @@ -21,16 +21,17 @@ import ( type DnDSkill string const ( - SkillAthletics DnDSkill = "athletics" - SkillAcrobatics DnDSkill = "acrobatics" - SkillStealth DnDSkill = "stealth" - SkillArcana DnDSkill = "arcana" - SkillInvestigation DnDSkill = "investigation" - SkillPerception DnDSkill = "perception" - SkillInsight DnDSkill = "insight" - SkillPersuasion DnDSkill = "persuasion" - SkillIntimidation DnDSkill = "intimidation" - SkillDeception DnDSkill = "deception" + SkillAthletics DnDSkill = "athletics" + SkillAcrobatics DnDSkill = "acrobatics" + SkillStealth DnDSkill = "stealth" + SkillSleightOfHand DnDSkill = "sleight_of_hand" + SkillArcana DnDSkill = "arcana" + SkillInvestigation DnDSkill = "investigation" + SkillPerception DnDSkill = "perception" + SkillInsight DnDSkill = "insight" + SkillPersuasion DnDSkill = "persuasion" + SkillIntimidation DnDSkill = "intimidation" + SkillDeception DnDSkill = "deception" ) type dndSkillInfo struct { @@ -43,6 +44,7 @@ var dndSkillTable = []dndSkillInfo{ {SkillAthletics, "Athletics", "str"}, {SkillAcrobatics, "Acrobatics", "dex"}, {SkillStealth, "Stealth", "dex"}, + {SkillSleightOfHand, "Sleight of Hand", "dex"}, {SkillArcana, "Arcana", "int"}, {SkillInvestigation, "Investigation", "int"}, {SkillPerception, "Perception", "wis"}, @@ -136,6 +138,54 @@ func statValue(c *DnDCharacter, stat string) int { return 10 } +// subclassSkillBonus returns the additive bonus from a subclass ability +// for a given skill. Phase 10 SUB2a: +// +// Champion (L7+) Remarkable Athlete: +½ proficiency bonus (rounded down) +// to STR/DEX/CON skill checks. Approximation of 5e's "+½ prof to +// checks not already proficient"; we don't track per-skill prof yet. +// Thief (L5+) Fast Hands: +5 to Sleight of Hand. Stand-in for 5e's +// "bonus action: SoH check" — out-of-combat utility flattened into a +// raw skill bonus. +func subclassSkillBonus(c *DnDCharacter, info dndSkillInfo) int { + if c == nil || c.Subclass == "" { + return 0 + } + switch c.Subclass { + case SubclassChampion: + if c.Level >= 7 { + switch info.Stat { + case "str", "dex", "con": + return proficiencyBonus(c.Level) / 2 + } + } + case SubclassThief: + if c.Level >= 5 && info.Key == SkillSleightOfHand { + return 5 + } + } + return 0 +} + +// subclassSkillAdvantage reports whether the player rolls with advantage +// (best of two d20s) on this skill check. Phase 10 SUB2a: +// +// Thief (L7+) Supreme Sneak: advantage on Stealth (5e gates this on +// half-speed movement; we don't track movement speed, so we apply +// unconditionally — approachability cut). +func subclassSkillAdvantage(c *DnDCharacter, info dndSkillInfo) bool { + if c == nil || c.Subclass == "" { + return false + } + switch c.Subclass { + case SubclassThief: + if c.Level >= 7 && info.Key == SkillStealth { + return true + } + } + return false +} + // raceSkillBonus returns the per-skill bonus from a player's race. // Half-Elf gets +1 to every skill (rough mapping of "two bonus skill profs"). // Tiefling gets +2 to CHA-based skills (matches the doc's "+bonus on CHA checks"). @@ -151,11 +201,20 @@ func raceSkillBonus(c *DnDCharacter, info dndSkillInfo) int { return 0 } -// performSkillCheck rolls the d20 and computes the result. +// performSkillCheck rolls the d20 and computes the result. Subclass +// effects (Phase 10 SUB2a) apply additive bonuses and may grant advantage +// (best of two d20s). func performSkillCheck(c *DnDCharacter, skill DnDSkill, dc int) SkillCheckResult { info, _ := skillInfo(skill) - mod := abilityModifier(statValue(c, info.Stat)) + raceSkillBonus(c, info) + mod := abilityModifier(statValue(c, info.Stat)) + + raceSkillBonus(c, info) + + subclassSkillBonus(c, info) roll := 1 + rand.IntN(20) + if subclassSkillAdvantage(c, info) { + if alt := 1 + rand.IntN(20); alt > roll { + roll = alt + } + } res := SkillCheckResult{Skill: skill, DC: dc, Roll: roll, Mod: mod} switch roll { diff --git a/internal/plugin/dnd_skills_test.go b/internal/plugin/dnd_skills_test.go index 0fc93c6..98ce21a 100644 --- a/internal/plugin/dnd_skills_test.go +++ b/internal/plugin/dnd_skills_test.go @@ -7,7 +7,8 @@ import ( func TestSkillTableComplete(t *testing.T) { expectedStats := map[DnDSkill]string{ SkillAthletics: "str", SkillAcrobatics: "dex", SkillStealth: "dex", - SkillArcana: "int", SkillInvestigation: "int", + SkillSleightOfHand: "dex", + SkillArcana: "int", SkillInvestigation: "int", SkillPerception: "wis", SkillInsight: "wis", SkillPersuasion: "cha", SkillIntimidation: "cha", SkillDeception: "cha", } diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go new file mode 100644 index 0000000..ad2093b --- /dev/null +++ b/internal/plugin/dnd_subclass_combat.go @@ -0,0 +1,73 @@ +package plugin + +// Phase 10 SUB2a — subclass combat hooks. +// +// applySubclassPassives layers subclass-driven flags onto CombatModifiers +// the same way applyClassPassives does. Called from combat_bridge after +// applyClassPassives so subclass effects can stack on top of (or override) +// class baseline. +// +// Subclass active abilities (currently just Berserker's rage) are +// registered via init() into dndActiveAbilities below, gated by +// DnDAbility.Subclass. + +func applySubclassPassives(stats *CombatStats, mods *CombatModifiers, c *DnDCharacter) { + if c == nil || c.Subclass == "" { + return + } + switch c.Subclass { + case SubclassChampion: + // L5 Improved Critical: crit on nat 19+. SUB3 Superior Critical + // (L15) will lower this to 18. + if c.Level >= 5 { + mods.CritThreshold = 19 + } + } +} + +func init() { + // Berserker rage — active ability that piggybacks on the Fighter + // stamina pool. 5e specs 3 uses/long-rest as a separate pool, but + // stamina is already sized at 3 for Fighter and the design's + // "approachability" constraint preferred reusing the existing pool + // over introducing per-subclass resource types in SUB2a. + // + // While armed and fired: + // - +2 flat damage per hit (RageMeleeDmg) + // - incoming weapon damage halved (PhysicalResistRage) + // - +50% damage multiplier for Frenzy approximation + // (one-shot combat can't model "1 bonus attack per turn" literally) + // + // Post-combat exhaustion increment lives in + // persistDnDPostCombatSubclass — Frenzy specs +1 exhaustion after rage + // ends, and in our model rage spans the whole one-shot combat so we + // always tick exhaustion when rage fired. + dndActiveAbilities["rage"] = DnDAbility{ + ID: "rage", + Name: "Rage", + Class: ClassFighter, + Subclass: SubclassBerserker, + Resource: "stamina", + Description: "Enter rage for the next combat: +2 damage per hit, halve incoming weapon damage, Frenzy bonus attack approximation. +1 exhaustion after.", + Apply: func(c *DnDCharacter, mods *CombatModifiers) { + mods.BerserkerRage = true + mods.RageMeleeDmg = 2 + mods.PhysicalResistRage = true + mods.FrenzyDmgBonus = 0.5 + }, + } +} + +// persistDnDPostCombatSubclass handles end-of-combat subclass bookkeeping. +// Currently: increment Exhaustion if Berserker rage fired. Called from +// combat_bridge after the combat result is computed. +// +// raged is computed as (mods.BerserkerRage at combat-start time) — pass it +// through from the caller where the mods value is still in scope. +func persistDnDPostCombatSubclass(c *DnDCharacter, raged bool) error { + if c == nil || !raged { + return nil + } + c.Exhaustion++ + return SaveDnDCharacter(c) +} diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go new file mode 100644 index 0000000..1ff4d39 --- /dev/null +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -0,0 +1,403 @@ +package plugin + +import ( + "testing" + + "maunium.net/go/mautrix/id" +) + +// Phase 10 SUB2a-i — subclass combat hooks (Champion, Berserker, Thief). + +// ── applySubclassPassives ──────────────────────────────────────────────── + +func TestApplySubclassPassives_ChampionLowersCritThreshold(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 5} + stats := &CombatStats{} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(stats, mods, c) + if mods.CritThreshold != 19 { + t.Errorf("CritThreshold = %d, want 19", mods.CritThreshold) + } +} + +func TestApplySubclassPassives_NoChampionPreL5(t *testing.T) { + // Subclass selection only happens at L5+, but if state ever drifts + // (race-condition with level reset), the passive must not fire below + // the gating level. + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 4} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.CritThreshold != 0 { + t.Errorf("CritThreshold = %d, want 0 (default) at L4", mods.CritThreshold) + } +} + +func TestApplySubclassPassives_NoSubclassNoEffect(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Level: 5} + mods := &CombatModifiers{DamageReduct: 1.0} + applySubclassPassives(&CombatStats{}, mods, c) + if mods.CritThreshold != 0 { + t.Errorf("CritThreshold should default to 0 with no subclass; got %d", mods.CritThreshold) + } +} + +// ── Rage active ability ────────────────────────────────────────────────── + +func TestRageAbility_ApplySetsAllFlags(t *testing.T) { + ab, ok := dndActiveAbilities["rage"] + if !ok { + t.Fatal("rage ability not registered") + } + if ab.Class != ClassFighter || ab.Subclass != SubclassBerserker { + t.Errorf("rage gating: class=%s subclass=%s, want fighter/berserker", + ab.Class, ab.Subclass) + } + if ab.Resource != "stamina" { + t.Errorf("rage resource = %s, want stamina", ab.Resource) + } + + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBerserker, Level: 5} + mods := &CombatModifiers{DamageReduct: 1.0} + ab.Apply(c, mods) + if !mods.BerserkerRage { + t.Error("BerserkerRage not set") + } + if mods.RageMeleeDmg != 2 { + t.Errorf("RageMeleeDmg = %d, want 2", mods.RageMeleeDmg) + } + if !mods.PhysicalResistRage { + t.Error("PhysicalResistRage not set") + } + if mods.FrenzyDmgBonus <= 0 { + t.Errorf("FrenzyDmgBonus = %v, want > 0", mods.FrenzyDmgBonus) + } +} + +// ── Post-combat exhaustion ─────────────────────────────────────────────── + +func TestPersistDnDPostCombatSubclass_RagedIncrementsExhaustion(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@rage_exh:example") + if err := createAdvCharacter(uid, "rage_exh"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceOrc, Class: ClassFighter, Subclass: SubclassBerserker, + 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 := persistDnDPostCombatSubclass(c, true); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Exhaustion != 1 { + t.Errorf("Exhaustion after raged combat = %d, want 1", got.Exhaustion) + } + // Second raged combat → 2. + if err := persistDnDPostCombatSubclass(got, true); err != nil { + t.Fatal(err) + } + got2, _ := LoadDnDCharacter(uid) + if got2.Exhaustion != 2 { + t.Errorf("Exhaustion after 2 raged combats = %d, want 2", got2.Exhaustion) + } +} + +func TestPersistDnDPostCombatSubclass_NoRageNoIncrement(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@no_rage_exh:example") + if err := createAdvCharacter(uid, "no_rage"); 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, Exhaustion: 3, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + if err := persistDnDPostCombatSubclass(c, false); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Exhaustion != 3 { + t.Errorf("Exhaustion changed without rage: %d, want 3", got.Exhaustion) + } +} + +// ── End-to-end SimulateCombat with rage flags ──────────────────────────── + +func ragingPlayer() Combatant { + return Combatant{ + Name: "Rager", IsPlayer: true, + Stats: CombatStats{ + MaxHP: 100, Attack: 15, Defense: 8, Speed: 10, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{ + DamageReduct: 1.0, + BerserkerRage: true, RageMeleeDmg: 2, + PhysicalResistRage: true, FrenzyDmgBonus: 0.5, + }, + } +} + +func calmPlayer() Combatant { + return Combatant{ + Name: "Calm", IsPlayer: true, + Stats: CombatStats{ + MaxHP: 100, Attack: 15, Defense: 8, Speed: 10, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{DamageReduct: 1.0}, + } +} + +func toughEnemy() Combatant { + // Sized so the calm baseline wins ~half the time — gives rage room + // to push the win rate noticeably higher. + return Combatant{ + Name: "Brute", + Stats: CombatStats{ + MaxHP: 110, Attack: 18, Defense: 10, Speed: 8, + CritRate: 0.05, DodgeRate: 0.05, BlockRate: 0.05, + }, + Mods: CombatModifiers{DamageBonus: 0, DamageReduct: 1.0}, + } +} + +// Probabilistic: raging Berserker should win more often than the calm +// baseline against an enemy strong enough that the calm player isn't +// already maxing out wins. +func TestSimulateCombat_RageImprovesWinRate(t *testing.T) { + const trials = 2000 + calmWins, rageWins := 0, 0 + for i := 0; i < trials; i++ { + if SimulateCombat(calmPlayer(), toughEnemy(), defaultCombatPhases).PlayerWon { + calmWins++ + } + if SimulateCombat(ragingPlayer(), toughEnemy(), defaultCombatPhases).PlayerWon { + rageWins++ + } + } + // Demand at least a 5-percentage-point lift to keep the test honest + // without making it flaky on tail RNG. + margin := rageWins - calmWins + if margin < trials/20 { + t.Errorf("rage win-rate lift too small: calm=%d rage=%d (margin %d, want >=%d)", + calmWins, rageWins, margin, trials/20) + } +} + +// ── Skill bonuses ──────────────────────────────────────────────────────── + +func TestSubclassSkillBonus_ChampionRemarkableAthlete(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 7} + athletics, _ := skillInfo(SkillAthletics) + stealth, _ := skillInfo(SkillStealth) // dex + arcana, _ := skillInfo(SkillArcana) // int — should NOT get the bonus + // L7 prof = 3, half = 1. + if got := subclassSkillBonus(c, athletics); got != 1 { + t.Errorf("Athletics (str) bonus = %d, want 1", got) + } + if got := subclassSkillBonus(c, stealth); got != 1 { + t.Errorf("Stealth (dex) bonus = %d, want 1", got) + } + if got := subclassSkillBonus(c, arcana); got != 0 { + t.Errorf("Arcana (int) bonus = %d, want 0 (Remarkable Athlete is STR/DEX/CON only)", got) + } +} + +func TestSubclassSkillBonus_ChampionPreL7NoBonus(t *testing.T) { + c := &DnDCharacter{Class: ClassFighter, Subclass: SubclassChampion, Level: 6} + athletics, _ := skillInfo(SkillAthletics) + if got := subclassSkillBonus(c, athletics); got != 0 { + t.Errorf("L6 Champion bonus = %d, want 0 (Remarkable Athlete needs L7)", got) + } +} + +func TestSubclassSkillBonus_ThiefFastHands(t *testing.T) { + c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassThief, Level: 5} + soh, _ := skillInfo(SkillSleightOfHand) + stealth, _ := skillInfo(SkillStealth) + if got := subclassSkillBonus(c, soh); got != 5 { + t.Errorf("Thief SoH bonus = %d, want 5", got) + } + if got := subclassSkillBonus(c, stealth); got != 0 { + t.Errorf("Thief stealth bonus from Fast Hands = %d, want 0 (FH is SoH only)", got) + } +} + +func TestSubclassSkillAdvantage_ThiefSupremeSneak(t *testing.T) { + c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassThief, Level: 7} + stealth, _ := skillInfo(SkillStealth) + soh, _ := skillInfo(SkillSleightOfHand) + if !subclassSkillAdvantage(c, stealth) { + t.Error("L7 Thief should have advantage on Stealth") + } + if subclassSkillAdvantage(c, soh) { + t.Error("Supreme Sneak should not grant advantage on non-Stealth skills") + } +} + +func TestSubclassSkillAdvantage_PreL7No(t *testing.T) { + c := &DnDCharacter{Class: ClassRogue, Subclass: SubclassThief, Level: 6} + stealth, _ := skillInfo(SkillStealth) + if subclassSkillAdvantage(c, stealth) { + t.Error("L6 Thief should not yet have Stealth advantage") + } +} + +// Statistical: Thief L7 stealth average should beat plain rogue by a wide +// margin (advantage = best of two d20s, expected ≈ 13.83 vs 10.5). +func TestPerformSkillCheck_StealthAdvantageStatistical(t *testing.T) { + const trials = 5000 + plainRogue := &DnDCharacter{ + Class: ClassRogue, Level: 7, DEX: 14, // mod +2 + } + thief := &DnDCharacter{ + Class: ClassRogue, Subclass: SubclassThief, Level: 7, DEX: 14, + } + plainTotal, thiefTotal := 0, 0 + for i := 0; i < trials; i++ { + plainTotal += performSkillCheck(plainRogue, SkillStealth, 15).Roll + thiefTotal += performSkillCheck(thief, SkillStealth, 15).Roll + } + plainAvg := float64(plainTotal) / float64(trials) + thiefAvg := float64(thiefTotal) / float64(trials) + // Plain ≈ 10.5; Thief ≈ 13.8. Pick a margin that's robust. + if thiefAvg-plainAvg < 2.0 { + t.Errorf("Thief stealth roll average not meaningfully higher: plain=%.2f thief=%.2f", plainAvg, thiefAvg) + } +} + +// ── !arm rage gating ───────────────────────────────────────────────────── + +func TestArmRage_BlockedForNonBerserker(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@arm_rage_block:example") + if err := createAdvCharacter(uid, "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}, "rage"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.ArmedAbility == "rage" { + t.Error("non-Berserker should not be able to arm rage") + } +} + +func TestArmRage_AllowedForBerserker(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@arm_rage_ok:example") + if err := createAdvCharacter(uid, "ok"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceOrc, Class: ClassFighter, Subclass: SubclassBerserker, + 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 := initResources(uid, ClassFighter); err != nil { + t.Fatal(err) + } + + p := &AdventurePlugin{euro: &EuroPlugin{}} + if err := p.handleDnDArmCmd(MessageContext{Sender: uid}, "rage"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.ArmedAbility != "rage" { + t.Errorf("ArmedAbility = %q, want rage", got.ArmedAbility) + } +} + +// ── Long rest decrements exhaustion ────────────────────────────────────── + +func TestLongRest_ClearsOneExhaustion(t *testing.T) { + c := &DnDCharacter{ + UserID: "@x:example", Class: ClassFighter, Level: 5, + HPMax: 30, HPCurrent: 5, Exhaustion: 3, + } + // Replicate the long-rest effect: HP top-up + Exhaustion-- (the path + // in dnd_rest.go we updated). Pure-state test — no DM or DB. + if c.Exhaustion > 0 { + c.Exhaustion-- + } + if c.Exhaustion != 2 { + t.Errorf("Exhaustion after long rest = %d, want 2", c.Exhaustion) + } +} + +// ── Schema roundtrip ───────────────────────────────────────────────────── + +func TestExhaustion_SchemaRoundTrip(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@exh_rt:example") + if err := createAdvCharacter(uid, "exh_rt"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceOrc, Class: ClassFighter, Subclass: SubclassBerserker, + Level: 5, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 16, Exhaustion: 4, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + got, err := LoadDnDCharacter(uid) + if err != nil || got == nil { + t.Fatalf("load: %v %v", got, err) + } + if got.Exhaustion != 4 { + t.Errorf("Exhaustion roundtrip: got %d, want 4", got.Exhaustion) + } +} + +// 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. +func TestCharacterActiveAbilities_RageVisibility(t *testing.T) { + berserker := &DnDCharacter{ + Class: ClassFighter, Subclass: SubclassBerserker, Level: 5, + } + champion := &DnDCharacter{ + Class: ClassFighter, Subclass: SubclassChampion, Level: 5, + } + hasRage := func(list []DnDAbility) bool { + for _, a := range list { + if a.ID == "rage" { + return true + } + } + return false + } + if !hasRage(characterActiveAbilities(berserker)) { + t.Error("Berserker should have rage in active-ability list") + } + if hasRage(characterActiveAbilities(champion)) { + t.Error("Champion should not have rage in active-ability list") + } +} +