mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +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"
|
||||
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
|
||||
|
||||
@@ -20,11 +20,16 @@ import (
|
||||
// Class-derived "weapon proficiency" bonus baked into AttackBonus.
|
||||
// Fighter is a martial class; Mage uses spell-attack baseline.
|
||||
var dndClassWeaponBonus = map[DnDClass]int{
|
||||
ClassFighter: 2,
|
||||
ClassRanger: 1,
|
||||
ClassRogue: 1,
|
||||
ClassCleric: 0,
|
||||
ClassMage: 0,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user