mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +00:00
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:
@@ -35,6 +35,17 @@ const (
|
|||||||
ClassMage DnDClass = "mage"
|
ClassMage DnDClass = "mage"
|
||||||
ClassCleric DnDClass = "cleric"
|
ClassCleric DnDClass = "cleric"
|
||||||
ClassRanger DnDClass = "ranger"
|
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 {
|
type DnDRaceInfo struct {
|
||||||
@@ -54,6 +65,10 @@ type DnDClassInfo struct {
|
|||||||
HPAvg int // per-level average after L1 (roundup of die/2 + 1)
|
HPAvg int // per-level average after L1 (roundup of die/2 + 1)
|
||||||
PrimaryA string // primary stat for narrative (not mechanical in Phase 1)
|
PrimaryA string // primary stat for narrative (not mechanical in Phase 1)
|
||||||
PrimaryB string
|
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{
|
var dndRaces = []DnDRaceInfo{
|
||||||
@@ -67,11 +82,17 @@ var dndRaces = []DnDRaceInfo{
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dndClasses = []DnDClassInfo{
|
var dndClasses = []DnDClassInfo{
|
||||||
{ClassFighter, "Fighter", 10, 6, "STR", "CON"},
|
{ClassFighter, "Fighter", 10, 6, "STR", "CON", true},
|
||||||
{ClassRogue, "Rogue", 8, 5, "DEX", "INT"},
|
{ClassRogue, "Rogue", 8, 5, "DEX", "INT", true},
|
||||||
{ClassMage, "Mage", 6, 4, "INT", "WIS"},
|
{ClassMage, "Mage", 6, 4, "INT", "WIS", true},
|
||||||
{ClassCleric, "Cleric", 8, 5, "WIS", "CHA"},
|
{ClassCleric, "Cleric", 8, 5, "WIS", "CHA", true},
|
||||||
{ClassRanger, "Ranger", 8, 5, "DEX", "WIS"},
|
{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) {
|
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 {
|
func computeAC(class DnDClass, dexMod int) int {
|
||||||
floor := 0
|
floor := 0
|
||||||
switch class {
|
switch class {
|
||||||
case ClassFighter:
|
case ClassFighter, ClassPaladin:
|
||||||
floor = 6 // medium-armor baseline
|
floor = 6 // heavy/medium-armor baseline
|
||||||
case ClassCleric, ClassRanger:
|
case ClassCleric, ClassRanger, ClassDruid:
|
||||||
floor = 3
|
floor = 3
|
||||||
case ClassRogue:
|
case ClassRogue, ClassBard, ClassWarlock:
|
||||||
floor = 1
|
floor = 1
|
||||||
}
|
}
|
||||||
return 10 + dexMod + floor
|
return 10 + dexMod + floor
|
||||||
|
|||||||
@@ -21,10 +21,15 @@ import (
|
|||||||
// Fighter is a martial class; Mage uses spell-attack baseline.
|
// Fighter is a martial class; Mage uses spell-attack baseline.
|
||||||
var dndClassWeaponBonus = map[DnDClass]int{
|
var dndClassWeaponBonus = map[DnDClass]int{
|
||||||
ClassFighter: 2,
|
ClassFighter: 2,
|
||||||
|
ClassPaladin: 2,
|
||||||
ClassRanger: 1,
|
ClassRanger: 1,
|
||||||
ClassRogue: 1,
|
ClassRogue: 1,
|
||||||
|
ClassDruid: 1,
|
||||||
|
ClassBard: 1,
|
||||||
ClassCleric: 0,
|
ClassCleric: 0,
|
||||||
ClassMage: 0,
|
ClassMage: 0,
|
||||||
|
ClassSorcerer: 0,
|
||||||
|
ClassWarlock: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monster AC formulas. Tuned so a typical L1 player (+5 attack bonus) hits
|
// 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.
|
// uses INT (spell attack), Cleric uses WIS.
|
||||||
func classAttackStatMod(c *DnDCharacter) int {
|
func classAttackStatMod(c *DnDCharacter) int {
|
||||||
switch c.Class {
|
switch c.Class {
|
||||||
case ClassFighter:
|
case ClassFighter, ClassPaladin:
|
||||||
return abilityModifier(c.STR)
|
return abilityModifier(c.STR)
|
||||||
case ClassRogue, ClassRanger:
|
case ClassRogue, ClassRanger:
|
||||||
return abilityModifier(c.DEX)
|
return abilityModifier(c.DEX)
|
||||||
case ClassMage:
|
case ClassMage:
|
||||||
return abilityModifier(c.INT)
|
return abilityModifier(c.INT)
|
||||||
case ClassCleric:
|
case ClassCleric, ClassDruid:
|
||||||
return abilityModifier(c.WIS)
|
return abilityModifier(c.WIS)
|
||||||
|
case ClassBard, ClassSorcerer, ClassWarlock:
|
||||||
|
return abilityModifier(c.CHA)
|
||||||
}
|
}
|
||||||
return abilityModifier(c.STR)
|
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
|
return [6]int{12, 14, 13, 8, 15, 10} // WIS, DEX, CON
|
||||||
case ClassRanger:
|
case ClassRanger:
|
||||||
return [6]int{12, 15, 13, 10, 14, 8} // DEX, WIS, CON
|
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
|
return [6]int{15, 14, 13, 12, 10, 8} // fallback: martial-ish
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -519,6 +519,11 @@ func parseClass(s string) (DnDClass, bool) {
|
|||||||
s = strings.ToLower(strings.TrimSpace(s))
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
for _, ci := range dndClasses {
|
for _, ci := range dndClasses {
|
||||||
if string(ci.Key) == s || strings.EqualFold(ci.Display, s) {
|
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
|
return ci.Key, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -565,6 +570,9 @@ func renderRaceMenu() string {
|
|||||||
func renderClassMenu() string {
|
func renderClassMenu() string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, ci := range dndClasses {
|
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))
|
b.WriteString(fmt.Sprintf(" • **%s** (d%d, %s/%s)\n", ci.Display, ci.HPDie, ci.PrimaryA, ci.PrimaryB))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
|
|||||||
@@ -138,12 +138,16 @@ func slotsForClassLevel(class DnDClass, level int) map[int]int {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
switch class {
|
switch class {
|
||||||
case ClassMage:
|
case ClassMage, ClassDruid, ClassBard, ClassSorcerer:
|
||||||
return mageSlots(level)
|
// All 5e full casters share one slot progression.
|
||||||
|
return fullCasterSlots(level)
|
||||||
case ClassCleric:
|
case ClassCleric:
|
||||||
return clericSlots(level)
|
return clericSlots(level)
|
||||||
case ClassRanger:
|
case ClassRanger, ClassPaladin:
|
||||||
|
// Half-casters share the Ranger progression: no slots until L2.
|
||||||
return rangerSlots(level)
|
return rangerSlots(level)
|
||||||
|
case ClassWarlock:
|
||||||
|
return warlockSlots(level)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -191,8 +195,10 @@ func slotsForCharacter(c *DnDCharacter) map[int]int {
|
|||||||
|
|
||||||
// Tables transcribed from gogobee_spell_system.md §1. We interpolate
|
// 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.
|
// between the doc's milestone rows so every level 1..20 has a defined pool.
|
||||||
func mageSlots(level int) map[int]int {
|
// fullCasterSlots is the standard 5e full-caster slot progression, shared by
|
||||||
// Standard 5e full caster table (mage = wizard).
|
// 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{
|
rows := [][6]int{
|
||||||
// L1, L2, L3, L4, L5
|
// L1, L2, L3, L4, L5
|
||||||
{2, 0, 0, 0, 0}, // 1
|
{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])
|
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 {
|
func packSlots(l1, l2, l3, l4, l5 int) map[int]int {
|
||||||
out := map[int]int{}
|
out := map[int]int{}
|
||||||
for i, n := range []int{l1, l2, l3, l4, l5} {
|
for i, n := range []int{l1, l2, l3, l4, l5} {
|
||||||
@@ -304,8 +345,10 @@ func spellcastingMod(c *DnDCharacter) int {
|
|||||||
switch c.Class {
|
switch c.Class {
|
||||||
case ClassMage:
|
case ClassMage:
|
||||||
return abilityModifier(c.INT)
|
return abilityModifier(c.INT)
|
||||||
case ClassCleric, ClassRanger:
|
case ClassCleric, ClassRanger, ClassDruid:
|
||||||
return abilityModifier(c.WIS)
|
return abilityModifier(c.WIS)
|
||||||
|
case ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin:
|
||||||
|
return abilityModifier(c.CHA)
|
||||||
case ClassRogue:
|
case ClassRogue:
|
||||||
// Phase 10 SUB2-AT: Arcane Trickster is INT-based.
|
// Phase 10 SUB2-AT: Arcane Trickster is INT-based.
|
||||||
if c.Subclass == SubclassArcaneTrickster {
|
if c.Subclass == SubclassArcaneTrickster {
|
||||||
@@ -334,10 +377,14 @@ func spellAttackBonus(c *DnDCharacter) int {
|
|||||||
return proficiencyBonus(c.Level) + spellcastingMod(c)
|
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 {
|
func classIsCaster(class DnDClass) bool {
|
||||||
switch class {
|
switch class {
|
||||||
case ClassMage, ClassCleric, ClassRanger:
|
case ClassMage, ClassCleric, ClassRanger,
|
||||||
|
ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user