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
}