Adv 2.0 D&D Phase 10 SUB3a-ii: Battle Master L10/L15

L10 Improved Combat Superiority — superiority dice grow d8 → d10. The
two scalar maneuvers (Precision Attack +4→+5; Rally heal +4→+5) bump in
their Apply hooks; Trip Attack has no scalar to scale.

L15 Relentless — 5e regenerates one die when initiative is rolled with
an empty pool. Proxied as +1 max pool size (4→5), which gives the same
"always at least one die per encounter" guarantee across the long-rest
cycle without introducing per-fight regen state.

subclassResourceMax now takes level; initSubclassResources upserts the
max (and bumps current by the delta) so existing characters grow on
level-up. Hooked into the dnd_xp level-up loop so Battle Masters
crossing L15 see the new die without waiting for a long rest. 4 new
tests; no regression in subclass/resource/level-up paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-08 11:17:55 -07:00
parent 97c2c37396
commit 64c70dda27
6 changed files with 162 additions and 22 deletions

View File

@@ -155,12 +155,19 @@ func initResources(userID id.UserID, class DnDClass) error {
// subclassResourceMax — resources granted by a subclass on top of the class
// pool. Phase 10 SUB2a-ii: Battle Master gets 4 superiority dice (5e
// long-rest refresh in our model; spec says short-rest, but we keep parity
// with the existing class pools' refresh cadence). Returns ("", 0) if the
// subclass has no extra pool.
func subclassResourceMax(sub DnDSubclass) (string, int) {
// with the existing class pools' refresh cadence). Phase 10 SUB3a-ii: at
// L15 Relentless gives "regen 1 die when initiative rolled with empty pool" —
// proxied here as a +1 max (5 dice total) so the player always has at least
// one extra encounter's worth across the long-rest cycle. Returns ("", 0) if
// the subclass has no extra pool.
func subclassResourceMax(sub DnDSubclass, level int) (string, int) {
switch sub {
case SubclassBattleMaster:
return "superiority", 4
max := 4
if level >= 15 {
max = 5
}
return "superiority", max
case SubclassLifeDomain, SubclassWarDomain, SubclassTrickeryDomain:
// Phase 10 SUB2c — shared Channel Divinity pool for Cleric domains.
// 5e is 1/short-rest at L2 and 2/short-rest at L6; long-rest refresh
@@ -170,19 +177,32 @@ func subclassResourceMax(sub DnDSubclass) (string, int) {
return "", 0
}
// initSubclassResources adds the subclass-specific resource pool. Called
// from applySubclassChoice; idempotent. If a player switches subclasses,
// the prior pool's row is left in place — refreshAllResources still touches
// it but no ability references it once the subclass changes, so it's inert.
func initSubclassResources(userID id.UserID, sub DnDSubclass) error {
resType, max := subclassResourceMax(sub)
// initSubclassResources adds the subclass-specific resource pool, or grows
// the existing pool's max if the level-gated cap increased (e.g. Battle
// Master L15 Relentless bumps the cap from 4 → 5). Called from
// applySubclassChoice and the level-up loop; idempotent. If a player
// switches subclasses, the prior pool's row is left in place —
// refreshAllResources still touches it but no ability references it once the
// subclass changes, so it's inert.
func initSubclassResources(userID id.UserID, sub DnDSubclass, level int) error {
resType, max := subclassResourceMax(sub, level)
if resType == "" {
return nil
}
_, err := db.Get().Exec(`
if _, err := db.Get().Exec(`
INSERT OR IGNORE INTO dnd_resources (user_id, resource_type, current_value, max_value)
VALUES (?, ?, ?, ?)`,
string(userID), resType, max, max)
string(userID), resType, max, max); err != nil {
return err
}
// Grow max (and current) if the cap rose since the row was first written.
// Don't shrink — players who somehow have a higher cap stored should keep it.
_, err := db.Get().Exec(`
UPDATE dnd_resources
SET current_value = current_value + (? - max_value),
max_value = ?
WHERE user_id = ? AND resource_type = ? AND max_value < ?`,
max, max, string(userID), resType, max)
return err
}

View File

@@ -173,7 +173,7 @@ func TestChannelDivinity_ResourceMaxForAllClericSubs(t *testing.T) {
for _, sub := range []DnDSubclass{
SubclassLifeDomain, SubclassWarDomain, SubclassTrickeryDomain,
} {
res, max := subclassResourceMax(sub)
res, max := subclassResourceMax(sub, 5)
if res != "channel_divinity" {
t.Errorf("%s resource = %q, want channel_divinity", sub, res)
}
@@ -189,7 +189,7 @@ func TestChannelDivinity_InitAndSpend(t *testing.T) {
if err := createAdvCharacter(uid, "cd_pool"); err != nil {
t.Fatal(err)
}
if err := initSubclassResources(uid, SubclassLifeDomain); err != nil {
if err := initSubclassResources(uid, SubclassLifeDomain, 5); err != nil {
t.Fatal(err)
}
cur, max, err := getResource(uid, "channel_divinity")

View File

@@ -123,7 +123,7 @@ func (p *AdventurePlugin) applySubclassChoice(
// Battle Master superiority dice). Idempotent. Failure is non-fatal —
// the player still gets the subclass; resources can be re-initialized
// next long rest.
_ = initSubclassResources(ctx.Sender, chosen)
_ = initSubclassResources(ctx.Sender, chosen, c.Level)
// Phase 10 SUB2-AT — Arcane Trickster bootstraps a Mage-list spellbook
// + third-caster slot pool on selection. Idempotent and non-fatal: the
// next `!cast`/`!spells` call also runs ensureSpellsForCharacter.

View File

@@ -221,9 +221,15 @@ func init() {
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Add +d8 (≈+4) to your first attack roll this combat (consumes 1 superiority die).",
Description: "Add +d8 (≈+4, +5 at L10) to your first attack roll this combat (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.FirstAttackBonus += 4
// Phase 10 SUB3a-ii — L10 Improved Combat Superiority bumps the
// die from d8 (avg 4.5) to d10 (avg 5.5).
bonus := 4
if c != nil && c.Level >= 10 {
bonus = 5
}
mods.FirstAttackBonus += bonus
},
}
dndActiveAbilities["trip_attack"] = DnDAbility{
@@ -243,9 +249,14 @@ func init() {
Class: ClassFighter,
Subclass: SubclassBattleMaster,
Resource: "superiority",
Description: "Steel yourself for the fight: heal at low HP for d8 + CHA mod (consumes 1 superiority die).",
Description: "Steel yourself for the fight: heal at low HP for d8 + CHA mod (d10 at L10) (consumes 1 superiority die).",
Apply: func(c *DnDCharacter, mods *CombatModifiers) {
mods.HealItem += 4 + abilityModifier(c.CHA)
// Phase 10 SUB3a-ii — L10 Improved Combat Superiority: d8 → d10.
base := 4
if c != nil && c.Level >= 10 {
base = 5
}
mods.HealItem += base + abilityModifier(c.CHA)
},
}

View File

@@ -472,7 +472,7 @@ func TestInitSubclassResources_BattleMasterGetsSuperiority(t *testing.T) {
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if err := initSubclassResources(uid, SubclassBattleMaster); err != nil {
if err := initSubclassResources(uid, SubclassBattleMaster, 5); err != nil {
t.Fatal(err)
}
cur, max, err := getResource(uid, "superiority")
@@ -490,7 +490,7 @@ func TestInitSubclassResources_NonBattleMasterIsNoop(t *testing.T) {
if err := createAdvCharacter(uid, "bm_noop"); err != nil {
t.Fatal(err)
}
if err := initSubclassResources(uid, SubclassChampion); err != nil {
if err := initSubclassResources(uid, SubclassChampion, 5); err != nil {
t.Fatal(err)
}
cur, max, err := getResource(uid, "superiority")
@@ -546,7 +546,7 @@ func TestArmPrecisionAttack_AllowedForBattleMaster(t *testing.T) {
if err := SaveDnDCharacter(c); err != nil {
t.Fatal(err)
}
if err := initSubclassResources(uid, SubclassBattleMaster); err != nil {
if err := initSubclassResources(uid, SubclassBattleMaster, 5); err != nil {
t.Fatal(err)
}
@@ -941,3 +941,105 @@ func TestApplySubclassPassives_BerserkerL15Retaliation(t *testing.T) {
t.Errorf("L15 Berserker SporeCloud = %d, want 2 (L10 still active)", mods.SporeCloud)
}
}
// ── SUB3a-ii — Battle Master L10/L15 ────────────────────────────────────
func TestPrecisionAttack_L10ImprovedSuperiority(t *testing.T) {
// L9: d8 → +4. L10: d10 → +5.
ab := dndActiveAbilities["precision_attack"]
c9 := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 9}
mods9 := &CombatModifiers{}
ab.Apply(c9, mods9)
if mods9.FirstAttackBonus != 4 {
t.Errorf("L9 Precision Attack FirstAttackBonus = %d, want 4 (d8)", mods9.FirstAttackBonus)
}
c10 := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 10}
mods10 := &CombatModifiers{}
ab.Apply(c10, mods10)
if mods10.FirstAttackBonus != 5 {
t.Errorf("L10 Precision Attack FirstAttackBonus = %d, want 5 (d10)", mods10.FirstAttackBonus)
}
}
func TestRally_L10ImprovedSuperiority(t *testing.T) {
// CHA 14 → mod +2. L9: 4 + 2 = 6; L10: 5 + 2 = 7.
ab := dndActiveAbilities["rally"]
c9 := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 9, CHA: 14}
mods9 := &CombatModifiers{}
ab.Apply(c9, mods9)
if mods9.HealItem != 6 {
t.Errorf("L9 Rally HealItem = %d, want 6 (d8 + CHA)", mods9.HealItem)
}
c10 := &DnDCharacter{Class: ClassFighter, Subclass: SubclassBattleMaster, Level: 10, CHA: 14}
mods10 := &CombatModifiers{}
ab.Apply(c10, mods10)
if mods10.HealItem != 7 {
t.Errorf("L10 Rally HealItem = %d, want 7 (d10 + CHA)", mods10.HealItem)
}
}
func TestSubclassResourceMax_BattleMasterL15Relentless(t *testing.T) {
// Pre-L15: 4 dice. L15+: 5 dice (Relentless approximation).
if _, max := subclassResourceMax(SubclassBattleMaster, 14); max != 4 {
t.Errorf("L14 Battle Master max = %d, want 4", max)
}
if _, max := subclassResourceMax(SubclassBattleMaster, 15); max != 5 {
t.Errorf("L15 Battle Master max = %d, want 5", max)
}
if _, max := subclassResourceMax(SubclassBattleMaster, 20); max != 5 {
t.Errorf("L20 Battle Master max = %d, want 5", max)
}
}
func TestInitSubclassResources_BattleMasterL15GrowsPool(t *testing.T) {
// Seed at L5 (max=4), then re-init at L15 — pool max should grow to 5,
// and current should bump by the same delta so a freshly-rested player
// gets the new die immediately rather than after a long rest.
setupAuditTestDB(t)
uid := id.UserID("@bm_relentless:example")
if err := createAdvCharacter(uid, "bm_relentless"); err != nil {
t.Fatal(err)
}
if err := initSubclassResources(uid, SubclassBattleMaster, 5); err != nil {
t.Fatal(err)
}
cur, max, _ := getResource(uid, "superiority")
if cur != 4 || max != 4 {
t.Fatalf("L5 pool = %d/%d, want 4/4", cur, max)
}
if err := initSubclassResources(uid, SubclassBattleMaster, 15); err != nil {
t.Fatal(err)
}
cur, max, _ = getResource(uid, "superiority")
if cur != 5 || max != 5 {
t.Errorf("L15 pool after reconcile = %d/%d, want 5/5", cur, max)
}
}
func TestInitSubclassResources_BattleMasterL15PreservesSpentDice(t *testing.T) {
// Player burned through 3 dice (current=1, max=4), then levels up to 15.
// The growth delta (+1) should be added to current → cur=2, max=5. We
// don't refill to max; that's the long-rest's job.
setupAuditTestDB(t)
uid := id.UserID("@bm_partial:example")
if err := createAdvCharacter(uid, "bm_partial"); err != nil {
t.Fatal(err)
}
if err := initSubclassResources(uid, SubclassBattleMaster, 5); err != nil {
t.Fatal(err)
}
if ok, err := spendResource(uid, "superiority", 3); err != nil || !ok {
t.Fatalf("spendResource: ok=%v err=%v", ok, err)
}
if err := initSubclassResources(uid, SubclassBattleMaster, 15); err != nil {
t.Fatal(err)
}
cur, max, _ := getResource(uid, "superiority")
if cur != 2 || max != 5 {
t.Errorf("partially spent + L15 grow = %d/%d, want 2/5", cur, max)
}
}

View File

@@ -132,6 +132,13 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
}
if len(events) > 0 {
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
// growth here. Idempotent and non-fatal: a stale pool just means the
// player reaches their full cap on the next long rest at the latest.
if c.Subclass != "" {
_ = initSubclassResources(userID, c.Subclass, c.Level)
}
p.sendLevelUpDM(userID, c, events, amount)
}