diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index d02d16b..d61e70a 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -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 diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index ba76cbf..15a8871 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -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 } diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index ae766a5..286eca7 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -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() diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index 92088df..bc425a5 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -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