mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
Adv 2.0 D&D Phase 10 SUB2a-i: Champion + Berserker + Thief L5/L7
First mechanical slice of the subclass system. Each subclass gets its L5 and L7 abilities wired through the existing combat engine + skill check substrate; no new resource pools introduced. Champion (L5/L7): - Improved Critical: new Mods.CritThreshold lowers crit floor to nat 19+ in resolvePlayerAttack. Default 20 preserved for non-Champions. - Remarkable Athlete: +½ proficiency bonus to STR/DEX/CON skill checks via subclassSkillBonus, layered on raceSkillBonus. Berserker (L5/L7): - Rage: new !arm-able active ability gated to Berserker subclass via DnDAbility.Subclass field. Reuses the Fighter stamina pool (3/long-rest matches 5e's rage uses). On fire: +2 flat damage per hit, halve incoming weapon damage, +50% damage approximation for Frenzy's bonus-attack-per- turn (one-shot combat can't model that literally). - Frenzy exhaustion: new exhaustion column on dnd_character; incremented by persistDnDPostCombatSubclass after any rage'd combat. Decremented by one per long rest. - Mindless Rage: documented; charm/frighten conditions don't exist in the combat engine yet, so no functional effect until P11 zone bosses land. Thief (L5/L7): - Sleight of Hand added to dndSkillTable. - Fast Hands: +5 to Sleight of Hand checks via subclassSkillBonus. - Supreme Sneak: advantage on Stealth (best of two d20s) via new subclassSkillAdvantage hook in performSkillCheck. 5e's half-speed gate dropped — we don't track movement speed. Plumbing: - applySubclassPassives layered after applyClassPassives in both arena and dungeon combat bridges. - DnDAbility.Subclass enables per-subclass gating; characterActiveAbilities filters !arm and !abilities lists by both class and subclass. - Existing !respec full-wipe also clears Exhaustion. Tests: Champion CritThreshold gating across L4/L5/no-subclass; rage Apply flag-set; rage probabilistic win-rate lift vs tougher enemy; exhaustion increment only on raged combats; long-rest decrement; Champion bonus on STR/DEX/CON only and only at L7+; Thief Fast Hands SoH-only; Thief Supreme Sneak L7+ Stealth-only; statistical advantage roll lifts Thief stealth average by ≥2 vs plain Rogue; !arm rage subclass gating; schema roundtrip; characterActiveAbilities visibility.
This commit is contained in:
@@ -178,6 +178,10 @@ func runMigrations(d *sql.DB) error {
|
|||||||
// (separate from last_respec_at, which gates the full character wipe).
|
// (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 subclass TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE dnd_character ADD COLUMN last_subclass_respec_at DATETIME`,
|
`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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func (p *AdventurePlugin) runArenaCombat(
|
|||||||
applyDnDHPScaling(&playerStats, dndChar)
|
applyDnDHPScaling(&playerStats, dndChar)
|
||||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||||
|
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
@@ -89,6 +90,9 @@ func (p *AdventurePlugin) runArenaCombat(
|
|||||||
p.grantCombatAchievements(userID, result)
|
p.grantCombatAchievements(userID, result)
|
||||||
|
|
||||||
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
|
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 xp := arenaCombatXP(result, monster.ThreatLevel); xp > 0 {
|
||||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||||
@@ -169,6 +173,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
|||||||
applyDnDHPScaling(&playerStats, dndChar)
|
applyDnDHPScaling(&playerStats, dndChar)
|
||||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||||
|
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
@@ -215,6 +220,9 @@ func (p *AdventurePlugin) runDungeonCombat(
|
|||||||
p.grantCombatAchievements(userID, result)
|
p.grantCombatAchievements(userID, result)
|
||||||
|
|
||||||
persistDnDHPAfterCombat(userID, result.PlayerStartHP, result.PlayerEndHP)
|
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 xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
|
||||||
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
if _, err := p.grantDnDXP(userID, xp); err != nil {
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ type CombatModifiers struct {
|
|||||||
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
|
RageReady bool // Orc: when HP first drops <50%, next attack deals +50% damage
|
||||||
PoisonResist bool // Dwarf: poison tick damage halved
|
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
|
// Phase 9 — pending spell resolution. Set by applyPendingCast in
|
||||||
// dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is
|
// dnd_spell_combat.go before SimulateCombat runs. SpellPreDamage is
|
||||||
// dealt as a pre-combat event with SpellPreDamageDesc as the narrative
|
// 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
|
roll = newRoll
|
||||||
}
|
}
|
||||||
isFumble := roll == 1
|
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.
|
// Class proficiency penalty (appendix §8): -4 attack with a non-proficient weapon.
|
||||||
attackBonus := player.Stats.AttackBonus
|
attackBonus := player.Stats.AttackBonus
|
||||||
if player.Stats.Weapon != nil && !player.Stats.WeaponProficient {
|
if player.Stats.Weapon != nil && !player.Stats.WeaponProficient {
|
||||||
@@ -477,7 +498,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
|||||||
}
|
}
|
||||||
total := roll + attackBonus
|
total := roll + attackBonus
|
||||||
|
|
||||||
if isFumble || (!isNat20 && total < enemy.Stats.AC) {
|
if isFumble || (!isCritRoll && total < enemy.Stats.AC) {
|
||||||
desc := ""
|
desc := ""
|
||||||
if isFumble {
|
if isFumble {
|
||||||
desc = "fumble"
|
desc = "fumble"
|
||||||
@@ -518,7 +539,7 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
|||||||
dmg = max(1, dmg/2)
|
dmg = max(1, dmg/2)
|
||||||
}
|
}
|
||||||
|
|
||||||
isCrit := isNat20
|
isCrit := isCritRoll
|
||||||
if st.autoCrit {
|
if st.autoCrit {
|
||||||
isCrit = true
|
isCrit = true
|
||||||
st.autoCrit = false
|
st.autoCrit = false
|
||||||
@@ -533,6 +554,17 @@ func resolvePlayerAttack(st *combatState, player, enemy *Combatant, phase *Comba
|
|||||||
dmg = int(float64(dmg) * 1.5)
|
dmg = int(float64(dmg) * 1.5)
|
||||||
st.pendingRageAttack = false
|
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)
|
dmg = max(1, dmg)
|
||||||
|
|
||||||
action := "hit"
|
action := "hit"
|
||||||
@@ -620,6 +652,11 @@ func resolveEnemyAttack(st *combatState, player, enemy *Combatant, phase *Combat
|
|||||||
if isCrit {
|
if isCrit {
|
||||||
dmg *= 2
|
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)
|
dmg = max(1, dmg)
|
||||||
|
|
||||||
if petDeflect {
|
if petDeflect {
|
||||||
|
|||||||
@@ -182,6 +182,11 @@ type DnDCharacter struct {
|
|||||||
// cooldown (LastSubclassRespecAt) and 500-coin cost.
|
// cooldown (LastSubclassRespecAt) and 500-coin cost.
|
||||||
Subclass DnDSubclass
|
Subclass DnDSubclass
|
||||||
LastSubclassRespecAt *time.Time
|
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
|
LastRespecAt *time.Time
|
||||||
LastShortRestAt *time.Time
|
LastShortRestAt *time.Time
|
||||||
LastLongRestAt *time.Time
|
LastLongRestAt *time.Time
|
||||||
@@ -210,7 +215,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
|||||||
hp_current, hp_max, temp_hp, armor_class,
|
hp_current, hp_max, temp_hp, armor_class,
|
||||||
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
||||||
pending_cast, concentration_spell, concentration_expires_at,
|
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,
|
last_respec_at, last_short_rest_at, last_long_rest_at,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM dnd_character WHERE user_id = ?`, string(userID))
|
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,
|
&c.HPCurrent, &c.HPMax, &c.TempHP, &c.ArmorClass,
|
||||||
&pending, &autoMig, &onboard, &c.ArmedAbility,
|
&pending, &autoMig, &onboard, &c.ArmedAbility,
|
||||||
&c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt,
|
&c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt,
|
||||||
&subclassStr, &c.LastSubclassRespecAt,
|
&subclassStr, &c.LastSubclassRespecAt, &c.Exhaustion,
|
||||||
&c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt,
|
&c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt,
|
||||||
&c.CreatedAt, &c.UpdatedAt)
|
&c.CreatedAt, &c.UpdatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -262,9 +267,9 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
|||||||
hp_current, hp_max, temp_hp, armor_class,
|
hp_current, hp_max, temp_hp, armor_class,
|
||||||
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
||||||
pending_cast, concentration_spell, concentration_expires_at,
|
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)
|
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
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
race=excluded.race, class=excluded.class,
|
race=excluded.race, class=excluded.class,
|
||||||
dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp,
|
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,
|
concentration_expires_at=excluded.concentration_expires_at,
|
||||||
subclass=excluded.subclass,
|
subclass=excluded.subclass,
|
||||||
last_subclass_respec_at=excluded.last_subclass_respec_at,
|
last_subclass_respec_at=excluded.last_subclass_respec_at,
|
||||||
|
exhaustion=excluded.exhaustion,
|
||||||
last_respec_at=excluded.last_respec_at,
|
last_respec_at=excluded.last_respec_at,
|
||||||
last_short_rest_at=excluded.last_short_rest_at,
|
last_short_rest_at=excluded.last_short_rest_at,
|
||||||
last_long_rest_at=excluded.last_long_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,
|
c.HPCurrent, c.HPMax, c.TempHP, c.ArmorClass,
|
||||||
pending, autoMig, onboard, c.ArmedAbility,
|
pending, autoMig, onboard, c.ArmedAbility,
|
||||||
c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt,
|
c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt,
|
||||||
string(c.Subclass), c.LastSubclassRespecAt,
|
string(c.Subclass), c.LastSubclassRespecAt, c.Exhaustion,
|
||||||
c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt)
|
c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("save dnd_character: %w", err)
|
return fmt.Errorf("save dnd_character: %w", err)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ type DnDAbility struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Class DnDClass
|
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.
|
Resource string // "stamina", "spell_slot", "favor", etc.
|
||||||
Description string
|
Description string
|
||||||
// Effect — applied to mods at combat start when armed
|
// Effect — applied to mods at combat start when armed
|
||||||
@@ -83,17 +88,35 @@ func parseAbility(s string) (DnDAbility, bool) {
|
|||||||
return DnDAbility{}, false
|
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 {
|
func classActiveAbilities(class DnDClass) []DnDAbility {
|
||||||
var out []DnDAbility
|
var out []DnDAbility
|
||||||
for _, a := range dndActiveAbilities {
|
for _, a := range dndActiveAbilities {
|
||||||
if a.Class == class {
|
if a.Class == class && a.Subclass == "" {
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
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 ────────────────────────────────────────────────────────────
|
// ── Resource pool ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// classResourceMax returns (resource_type, max_value) for a class.
|
// 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(
|
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||||
"%s is a %s ability — your class is %s.", ab.Name, titleClass(ab.Class), titleClass(c.Class)))
|
"%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 != "" {
|
if c.ArmedAbility != "" {
|
||||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
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 {
|
func renderArmList(c *DnDCharacter) string {
|
||||||
abilities := classActiveAbilities(c.Class)
|
abilities := characterActiveAbilities(c)
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("**Active Abilities** (pre-arm via `!arm <name>`)\n\n")
|
b.WriteString("**Active Abilities** (pre-arm via `!arm <name>`)\n\n")
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error {
|
|||||||
|
|
||||||
c.HPCurrent = c.HPMax
|
c.HPCurrent = c.HPMax
|
||||||
c.TempHP = 0
|
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()
|
now := time.Now().UTC()
|
||||||
c.LastLongRestAt = &now
|
c.LastLongRestAt = &now
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error {
|
|||||||
// cooldown is dropped — at L1 with no class there's nothing to gate.
|
// cooldown is dropped — at L1 with no class there's nothing to gate.
|
||||||
c.Subclass = ""
|
c.Subclass = ""
|
||||||
c.LastSubclassRespecAt = nil
|
c.LastSubclassRespecAt = nil
|
||||||
|
c.Exhaustion = 0
|
||||||
// Save the wipe BEFORE debiting euros (audit fix B). If save fails, the
|
// 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
|
// player's old state survives and they keep their euros — better than
|
||||||
// a destructive debit-without-wipe.
|
// a destructive debit-without-wipe.
|
||||||
|
|||||||
@@ -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("**Class passive — %s**\n %s\n\n", ab.Name, ab.Description))
|
||||||
b.WriteString(fmt.Sprintf("**Race trait**\n %s\n", ri.Passive))
|
b.WriteString(fmt.Sprintf("**Race trait**\n %s\n", ri.Passive))
|
||||||
|
|
||||||
// Active abilities (Phase 6)
|
// Active abilities (Phase 6 + Phase 10 subclass-gated)
|
||||||
actives := classActiveAbilities(c.Class)
|
actives := characterActiveAbilities(c)
|
||||||
if len(actives) > 0 {
|
if len(actives) > 0 {
|
||||||
resType, _ := classResourceMax(c.Class)
|
resType, _ := classResourceMax(c.Class)
|
||||||
cur, max, _ := getResource(c.UserID, resType)
|
cur, max, _ := getResource(c.UserID, resType)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const (
|
|||||||
SkillAthletics DnDSkill = "athletics"
|
SkillAthletics DnDSkill = "athletics"
|
||||||
SkillAcrobatics DnDSkill = "acrobatics"
|
SkillAcrobatics DnDSkill = "acrobatics"
|
||||||
SkillStealth DnDSkill = "stealth"
|
SkillStealth DnDSkill = "stealth"
|
||||||
|
SkillSleightOfHand DnDSkill = "sleight_of_hand"
|
||||||
SkillArcana DnDSkill = "arcana"
|
SkillArcana DnDSkill = "arcana"
|
||||||
SkillInvestigation DnDSkill = "investigation"
|
SkillInvestigation DnDSkill = "investigation"
|
||||||
SkillPerception DnDSkill = "perception"
|
SkillPerception DnDSkill = "perception"
|
||||||
@@ -43,6 +44,7 @@ var dndSkillTable = []dndSkillInfo{
|
|||||||
{SkillAthletics, "Athletics", "str"},
|
{SkillAthletics, "Athletics", "str"},
|
||||||
{SkillAcrobatics, "Acrobatics", "dex"},
|
{SkillAcrobatics, "Acrobatics", "dex"},
|
||||||
{SkillStealth, "Stealth", "dex"},
|
{SkillStealth, "Stealth", "dex"},
|
||||||
|
{SkillSleightOfHand, "Sleight of Hand", "dex"},
|
||||||
{SkillArcana, "Arcana", "int"},
|
{SkillArcana, "Arcana", "int"},
|
||||||
{SkillInvestigation, "Investigation", "int"},
|
{SkillInvestigation, "Investigation", "int"},
|
||||||
{SkillPerception, "Perception", "wis"},
|
{SkillPerception, "Perception", "wis"},
|
||||||
@@ -136,6 +138,54 @@ func statValue(c *DnDCharacter, stat string) int {
|
|||||||
return 10
|
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.
|
// 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").
|
// 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").
|
// 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
|
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 {
|
func performSkillCheck(c *DnDCharacter, skill DnDSkill, dc int) SkillCheckResult {
|
||||||
info, _ := skillInfo(skill)
|
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)
|
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}
|
res := SkillCheckResult{Skill: skill, DC: dc, Roll: roll, Mod: mod}
|
||||||
|
|
||||||
switch roll {
|
switch roll {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
func TestSkillTableComplete(t *testing.T) {
|
func TestSkillTableComplete(t *testing.T) {
|
||||||
expectedStats := map[DnDSkill]string{
|
expectedStats := map[DnDSkill]string{
|
||||||
SkillAthletics: "str", SkillAcrobatics: "dex", SkillStealth: "dex",
|
SkillAthletics: "str", SkillAcrobatics: "dex", SkillStealth: "dex",
|
||||||
|
SkillSleightOfHand: "dex",
|
||||||
SkillArcana: "int", SkillInvestigation: "int",
|
SkillArcana: "int", SkillInvestigation: "int",
|
||||||
SkillPerception: "wis", SkillInsight: "wis",
|
SkillPerception: "wis", SkillInsight: "wis",
|
||||||
SkillPersuasion: "cha", SkillIntimidation: "cha", SkillDeception: "cha",
|
SkillPersuasion: "cha", SkillIntimidation: "cha", SkillDeception: "cha",
|
||||||
|
|||||||
73
internal/plugin/dnd_subclass_combat.go
Normal file
73
internal/plugin/dnd_subclass_combat.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
403
internal/plugin/dnd_subclass_combat_test.go
Normal file
403
internal/plugin/dnd_subclass_combat_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user