D&D: scaffold the five missing caster classes

Add Druid, Bard, Sorcerer, Warlock and Paladin as structural
scaffolding ahead of the Open5e spell-data import. They are wired
through the mechanical layer but gated out of !setup via the new
DnDClassInfo.Playable flag — they have no spell list yet.

- dnd.go: class constants, dndClasses rows, Playable gate, AC floors
- dnd_combat.go: classAttackStatMod, dndClassWeaponBonus,
  classStatPriority arrays for the five
- dnd_spells.go: mageSlots generalized to fullCasterSlots (shared by
  the full casters); Paladin shares rangerSlots; new warlockSlots
  simplified long-rest pool; spellcastingMod + classIsCaster extended
- dnd_setup.go: renderClassMenu and parseClass filter on Playable

Subclasses and spell lists are deferred to later sessions.
This commit is contained in:
prosolis
2026-05-14 14:43:05 -07:00
parent 0cd8fd3337
commit e6e0009253
4 changed files with 117 additions and 24 deletions

View File

@@ -35,6 +35,17 @@ const (
ClassMage DnDClass = "mage"
ClassCleric DnDClass = "cleric"
ClassRanger DnDClass = "ranger"
// Caster classes added as structural scaffolding ahead of the Open5e
// spell-data import. They are wired through the mechanical layer (HP/AC,
// slot tables, spellcasting ability) but kept Playable=false — they have
// no spell list yet, so !setup hides them. Flip Playable once Open5e
// lands their spells. Subclasses are deferred to a later pass.
ClassDruid DnDClass = "druid"
ClassBard DnDClass = "bard"
ClassSorcerer DnDClass = "sorcerer"
ClassWarlock DnDClass = "warlock"
ClassPaladin DnDClass = "paladin"
)
type DnDRaceInfo struct {
@@ -54,6 +65,10 @@ type DnDClassInfo struct {
HPAvg int // per-level average after L1 (roundup of die/2 + 1)
PrimaryA string // primary stat for narrative (not mechanical in Phase 1)
PrimaryB string
// Playable gates whether the class is offered at !setup. The Open5e
// caster classes are wired through the engine but stay false until
// their spell lists exist — see the ClassDruid… block above.
Playable bool
}
var dndRaces = []DnDRaceInfo{
@@ -67,11 +82,17 @@ var dndRaces = []DnDRaceInfo{
}
var dndClasses = []DnDClassInfo{
{ClassFighter, "Fighter", 10, 6, "STR", "CON"},
{ClassRogue, "Rogue", 8, 5, "DEX", "INT"},
{ClassMage, "Mage", 6, 4, "INT", "WIS"},
{ClassCleric, "Cleric", 8, 5, "WIS", "CHA"},
{ClassRanger, "Ranger", 8, 5, "DEX", "WIS"},
{ClassFighter, "Fighter", 10, 6, "STR", "CON", true},
{ClassRogue, "Rogue", 8, 5, "DEX", "INT", true},
{ClassMage, "Mage", 6, 4, "INT", "WIS", true},
{ClassCleric, "Cleric", 8, 5, "WIS", "CHA", true},
{ClassRanger, "Ranger", 8, 5, "DEX", "WIS", true},
// Open5e caster scaffold — Playable=false until spell lists land.
{ClassDruid, "Druid", 8, 5, "WIS", "CON", false},
{ClassBard, "Bard", 8, 5, "CHA", "DEX", false},
{ClassSorcerer, "Sorcerer", 6, 4, "CHA", "CON", false},
{ClassWarlock, "Warlock", 8, 5, "CHA", "CON", false},
{ClassPaladin, "Paladin", 10, 6, "STR", "CHA", false},
}
func raceInfo(r DnDRace) (DnDRaceInfo, bool) {
@@ -137,11 +158,11 @@ func computeMaxHP(class DnDClass, conMod, level int) int {
func computeAC(class DnDClass, dexMod int) int {
floor := 0
switch class {
case ClassFighter:
floor = 6 // medium-armor baseline
case ClassCleric, ClassRanger:
case ClassFighter, ClassPaladin:
floor = 6 // heavy/medium-armor baseline
case ClassCleric, ClassRanger, ClassDruid:
floor = 3
case ClassRogue:
case ClassRogue, ClassBard, ClassWarlock:
floor = 1
}
return 10 + dexMod + floor

View File

@@ -21,10 +21,15 @@ import (
// Fighter is a martial class; Mage uses spell-attack baseline.
var dndClassWeaponBonus = map[DnDClass]int{
ClassFighter: 2,
ClassPaladin: 2,
ClassRanger: 1,
ClassRogue: 1,
ClassDruid: 1,
ClassBard: 1,
ClassCleric: 0,
ClassMage: 0,
ClassSorcerer: 0,
ClassWarlock: 0,
}
// Monster AC formulas. Tuned so a typical L1 player (+5 attack bonus) hits
@@ -61,14 +66,16 @@ func proficiencyBonus(level int) int {
// uses INT (spell attack), Cleric uses WIS.
func classAttackStatMod(c *DnDCharacter) int {
switch c.Class {
case ClassFighter:
case ClassFighter, ClassPaladin:
return abilityModifier(c.STR)
case ClassRogue, ClassRanger:
return abilityModifier(c.DEX)
case ClassMage:
return abilityModifier(c.INT)
case ClassCleric:
case ClassCleric, ClassDruid:
return abilityModifier(c.WIS)
case ClassBard, ClassSorcerer, ClassWarlock:
return abilityModifier(c.CHA)
}
return abilityModifier(c.STR)
}
@@ -196,6 +203,16 @@ func classStatPriority(class DnDClass) [6]int {
return [6]int{12, 14, 13, 8, 15, 10} // WIS, DEX, CON
case ClassRanger:
return [6]int{12, 15, 13, 10, 14, 8} // DEX, WIS, CON
case ClassDruid:
return [6]int{8, 14, 13, 10, 15, 12} // WIS, DEX, CON
case ClassBard:
return [6]int{8, 14, 13, 10, 12, 15} // CHA, DEX, CON
case ClassSorcerer:
return [6]int{8, 14, 13, 12, 10, 15} // CHA, DEX, CON
case ClassWarlock:
return [6]int{8, 14, 13, 12, 10, 15} // CHA, DEX, CON
case ClassPaladin:
return [6]int{15, 14, 13, 8, 10, 12} // STR, DEX, CON
}
return [6]int{15, 14, 13, 12, 10, 8} // fallback: martial-ish
}

View File

@@ -519,6 +519,11 @@ func parseClass(s string) (DnDClass, bool) {
s = strings.ToLower(strings.TrimSpace(s))
for _, ci := range dndClasses {
if string(ci.Key) == s || strings.EqualFold(ci.Display, s) {
if !ci.Playable {
// Recognized class, but not yet selectable (Open5e
// caster scaffold — no spell list). Treat as no match.
return "", false
}
return ci.Key, true
}
}
@@ -565,6 +570,9 @@ func renderRaceMenu() string {
func renderClassMenu() string {
var b strings.Builder
for _, ci := range dndClasses {
if !ci.Playable {
continue // Open5e caster scaffold — hidden until spell lists land.
}
b.WriteString(fmt.Sprintf(" • **%s** (d%d, %s/%s)\n", ci.Display, ci.HPDie, ci.PrimaryA, ci.PrimaryB))
}
return b.String()

View File

@@ -138,12 +138,16 @@ func slotsForClassLevel(class DnDClass, level int) map[int]int {
return nil
}
switch class {
case ClassMage:
return mageSlots(level)
case ClassMage, ClassDruid, ClassBard, ClassSorcerer:
// All 5e full casters share one slot progression.
return fullCasterSlots(level)
case ClassCleric:
return clericSlots(level)
case ClassRanger:
case ClassRanger, ClassPaladin:
// Half-casters share the Ranger progression: no slots until L2.
return rangerSlots(level)
case ClassWarlock:
return warlockSlots(level)
}
return nil
}
@@ -191,8 +195,10 @@ func slotsForCharacter(c *DnDCharacter) map[int]int {
// Tables transcribed from gogobee_spell_system.md §1. We interpolate
// between the doc's milestone rows so every level 1..20 has a defined pool.
func mageSlots(level int) map[int]int {
// Standard 5e full caster table (mage = wizard).
// fullCasterSlots is the standard 5e full-caster slot progression, shared by
// Mage, Druid, Bard and Sorcerer. (Was mageSlots — generalized when the
// Open5e caster classes landed; output is byte-identical for Mage.)
func fullCasterSlots(level int) map[int]int {
rows := [][6]int{
// L1, L2, L3, L4, L5
{2, 0, 0, 0, 0}, // 1
@@ -284,6 +290,41 @@ func rangerSlots(level int) map[int]int {
return packSlots(r[0], r[1], r[2], r[3], r[4])
}
// warlockSlots — simplified pool. Real 5e Pact Magic gives a tiny number of
// slots that all sit at the highest level and recharge on a short rest; we
// deliberately don't model that (see the Open5e class-scaffold decision —
// "simplified pool, long-rest reset like everyone else"). Instead the Warlock
// gets a modest long-rest pool that tops out at 4th-level slots.
func warlockSlots(level int) map[int]int {
rows := [][6]int{
{1, 0, 0, 0, 0}, // 1
{2, 0, 0, 0, 0}, // 2
{2, 2, 0, 0, 0}, // 3
{2, 2, 0, 0, 0}, // 4
{2, 2, 2, 0, 0}, // 5
{2, 2, 2, 0, 0}, // 6
{2, 2, 2, 1, 0}, // 7
{2, 2, 2, 1, 0}, // 8
{2, 2, 2, 2, 0}, // 9
{2, 2, 2, 2, 0}, // 10
{3, 2, 2, 2, 0}, // 11
{3, 2, 2, 2, 0}, // 12
{3, 3, 2, 2, 0}, // 13
{3, 3, 2, 2, 0}, // 14
{3, 3, 3, 2, 0}, // 15
{3, 3, 3, 2, 0}, // 16
{4, 3, 3, 2, 0}, // 17
{4, 3, 3, 2, 0}, // 18
{4, 3, 3, 3, 0}, // 19
{4, 3, 3, 3, 0}, // 20
}
if level > len(rows) {
level = len(rows)
}
r := rows[level-1]
return packSlots(r[0], r[1], r[2], r[3], r[4])
}
func packSlots(l1, l2, l3, l4, l5 int) map[int]int {
out := map[int]int{}
for i, n := range []int{l1, l2, l3, l4, l5} {
@@ -304,8 +345,10 @@ func spellcastingMod(c *DnDCharacter) int {
switch c.Class {
case ClassMage:
return abilityModifier(c.INT)
case ClassCleric, ClassRanger:
case ClassCleric, ClassRanger, ClassDruid:
return abilityModifier(c.WIS)
case ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin:
return abilityModifier(c.CHA)
case ClassRogue:
// Phase 10 SUB2-AT: Arcane Trickster is INT-based.
if c.Subclass == SubclassArcaneTrickster {
@@ -334,10 +377,14 @@ func spellAttackBonus(c *DnDCharacter) int {
return proficiencyBonus(c.Level) + spellcastingMod(c)
}
// classIsCaster returns true for the three caster classes Phase 9 covers.
// classIsCaster returns true for every spellcasting class. The Open5e
// scaffold classes (Druid/Bard/Sorcerer/Warlock/Paladin) are casters by the
// engine's reckoning even while Playable=false — they have slot tables and a
// spellcasting ability; they just have no spell list to know yet.
func classIsCaster(class DnDClass) bool {
switch class {
case ClassMage, ClassCleric, ClassRanger:
case ClassMage, ClassCleric, ClassRanger,
ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin:
return true
}
return false