diff --git a/internal/plugin/dnd_abilities.go b/internal/plugin/dnd_abilities.go index 9b7bdbe..4b6c346 100644 --- a/internal/plugin/dnd_abilities.go +++ b/internal/plugin/dnd_abilities.go @@ -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 } diff --git a/internal/plugin/dnd_subclass_cleric_test.go b/internal/plugin/dnd_subclass_cleric_test.go index c9de847..c10bf79 100644 --- a/internal/plugin/dnd_subclass_cleric_test.go +++ b/internal/plugin/dnd_subclass_cleric_test.go @@ -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") diff --git a/internal/plugin/dnd_subclass_cmd.go b/internal/plugin/dnd_subclass_cmd.go index bed8878..c94a16f 100644 --- a/internal/plugin/dnd_subclass_cmd.go +++ b/internal/plugin/dnd_subclass_cmd.go @@ -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. diff --git a/internal/plugin/dnd_subclass_combat.go b/internal/plugin/dnd_subclass_combat.go index c0c6182..8efcfac 100644 --- a/internal/plugin/dnd_subclass_combat.go +++ b/internal/plugin/dnd_subclass_combat.go @@ -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) }, } diff --git a/internal/plugin/dnd_subclass_combat_test.go b/internal/plugin/dnd_subclass_combat_test.go index 4554c65..9bc48b9 100644 --- a/internal/plugin/dnd_subclass_combat_test.go +++ b/internal/plugin/dnd_subclass_combat_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_xp.go b/internal/plugin/dnd_xp.go index 60ec8f2..2e687bd 100644 --- a/internal/plugin/dnd_xp.go +++ b/internal/plugin/dnd_xp.go @@ -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) }