mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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).
|
||||
`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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <name>`)\n\n")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
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