mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 9 SP4: mage spellbook cap + level-up nudge
- Enforce per-level Mage known-spell cap in handleSpellsLearn; surface spellbook budget in renderSpellsList. - Mage level-up DM now nudges with "Spells available to learn: N" via extracted buildLevelUpMessage. - Extract halveSavedDamage and enemySpellSaveMod for clarity; document single-target AoE limitation in applySpellDamageSave. - Add tests: mage learn cap, prepare flow, AoE behavior, spell save rounding, spells migration.
This commit is contained in:
@@ -430,6 +430,16 @@ func renderSpellsList(c *DnDCharacter) string {
|
||||
b.WriteString(fmt.Sprintf("**Spell DC:** %d **Spell Atk:** +%d\n",
|
||||
spellSaveDC(c), spellAttackBonus(c)))
|
||||
|
||||
if c.Class == ClassMage {
|
||||
count, _ := mageLeveledKnownCount(c.UserID)
|
||||
cap := mageKnownSpellsCap(c.Level)
|
||||
b.WriteString(fmt.Sprintf("**Spellbook:** %d/%d leveled spells", count, cap))
|
||||
if avail := cap - count; avail > 0 {
|
||||
b.WriteString(fmt.Sprintf(" _(can learn %d more)_", avail))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||||
b.WriteString(fmt.Sprintf("\n_Queued: **%s**", displaySpellName(pc.SpellID)))
|
||||
if pc.SlotLevel > 0 {
|
||||
@@ -475,6 +485,19 @@ func (p *AdventurePlugin) handleSpellsLearn(ctx MessageContext, c *DnDCharacter,
|
||||
if err == nil && known {
|
||||
return p.SendDM(ctx.Sender, "You already know "+spell.Name+".")
|
||||
}
|
||||
// Cap applies to leveled spells only — cantrips are unbounded.
|
||||
if spell.Level > 0 {
|
||||
count, err := mageLeveledKnownCount(ctx.Sender)
|
||||
if err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't check your spellbook.")
|
||||
}
|
||||
cap := mageKnownSpellsCap(c.Level)
|
||||
if count >= cap {
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||||
"Spellbook is full (%d/%d). Level up to learn more leveled spells.",
|
||||
count, cap))
|
||||
}
|
||||
}
|
||||
if err := addKnownSpell(ctx.Sender, spell.ID, "class", true); err != nil {
|
||||
return p.SendDM(ctx.Sender, "Couldn't learn: "+err.Error())
|
||||
}
|
||||
|
||||
185
internal/plugin/dnd_mage_learn_test.go
Normal file
185
internal/plugin/dnd_mage_learn_test.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 9 SP4 — Mage `!spells learn` cap + level-up nudge.
|
||||
|
||||
// TestMageKnownSpellsCapPerLevel — formula: 6 + 2*(level-1).
|
||||
func TestMageKnownSpellsCapPerLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
level, cap int
|
||||
}{
|
||||
{1, 6},
|
||||
{2, 8},
|
||||
{3, 10},
|
||||
{5, 14},
|
||||
{9, 22},
|
||||
{20, 44},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := mageKnownSpellsCap(tc.level); got != tc.cap {
|
||||
t.Errorf("mageKnownSpellsCap(L%d)=%d, want %d", tc.level, got, tc.cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupMageForLearn(t *testing.T, uid id.UserID, level int) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "mage_learn"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceElf, Class: ClassMage, Level: level,
|
||||
STR: 8, DEX: 14, CON: 12, INT: 17, WIS: 12, CHA: 10,
|
||||
HPMax: 20, HPCurrent: 20, ArmorClass: 12,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setSpellSlotsForLevel(uid, ClassMage, level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// TestMageLearnCapEnforced — at L1 the cap is 6 leveled spells. Pre-fill 6;
|
||||
// the 7th attempt is refused without writing to the spellbook.
|
||||
func TestMageLearnCapEnforced(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@mage_cap:example")
|
||||
c := setupMageForLearn(t, uid, 1)
|
||||
|
||||
// Six leveled mage spells (all L1-eligible).
|
||||
pre := []string{
|
||||
"magic_missile", "thunderwave", "mage_armor", "burning_hands",
|
||||
"grease", "sleep",
|
||||
}
|
||||
for _, sid := range pre {
|
||||
if err := addKnownSpell(uid, sid, "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Cantrips: don't count.
|
||||
if err := addKnownSpell(uid, "fire_bolt", "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
count, _ := mageLeveledKnownCount(uid)
|
||||
if count != 6 {
|
||||
t.Fatalf("setup: leveled count=%d, want 6", count)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
// 7th leveled spell — must be refused.
|
||||
if err := p.handleSpellsLearn(MessageContext{Sender: uid}, c, "chromatic_orb"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known, _, _ := playerKnowsSpell(uid, "chromatic_orb"); known {
|
||||
t.Errorf("over-cap learn slipped through")
|
||||
}
|
||||
|
||||
// A cantrip should still be learnable beyond the cap.
|
||||
if err := p.handleSpellsLearn(MessageContext{Sender: uid}, c, "minor_illusion"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known, _, _ := playerKnowsSpell(uid, "minor_illusion"); !known {
|
||||
t.Errorf("cantrip blocked by leveled-spell cap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMageLearnAcceptsUpToCap — fresh Mage at L1 can learn 6 leveled spells
|
||||
// before the cap kicks in.
|
||||
func TestMageLearnAcceptsUpToCap(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@mage_fill:example")
|
||||
c := setupMageForLearn(t, uid, 1)
|
||||
|
||||
wantList := []string{
|
||||
"magic_missile", "thunderwave", "mage_armor", "burning_hands",
|
||||
"grease", "sleep",
|
||||
}
|
||||
p := &AdventurePlugin{}
|
||||
for _, sid := range wantList {
|
||||
if err := p.handleSpellsLearn(MessageContext{Sender: uid}, c, sid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known, _, _ := playerKnowsSpell(uid, sid); !known {
|
||||
t.Errorf("under-cap learn rejected: %s", sid)
|
||||
}
|
||||
}
|
||||
if got, _ := mageLeveledKnownCount(uid); got != 6 {
|
||||
t.Errorf("after 6 learns, count=%d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMageLevelUpDMNudge — buildLevelUpMessage surfaces the spellbook headroom
|
||||
// after a Mage levels up.
|
||||
func TestMageLevelUpDMNudge(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@mage_nudge:example")
|
||||
c := setupMageForLearn(t, uid, 2) // post-level-up state
|
||||
|
||||
// 5 leveled known + cantrips. At L2 cap = 8 → headroom = 3.
|
||||
for _, sid := range []string{
|
||||
"magic_missile", "mage_armor", "shield", "burning_hands", "grease",
|
||||
} {
|
||||
_ = addKnownSpell(uid, sid, "class", true)
|
||||
}
|
||||
_ = addKnownSpell(uid, "fire_bolt", "class", true)
|
||||
|
||||
events := []LevelUpEvent{{NewLevel: 2, HPGain: 5}}
|
||||
msg := buildLevelUpMessage(c, events, "")
|
||||
|
||||
if !strings.Contains(msg, "Spells available to learn: **3**") {
|
||||
t.Errorf("nudge missing or wrong count; msg=\n%s", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "!spells learn") {
|
||||
t.Errorf("nudge missing command hint; msg=\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonMageLevelUpNoSpellNudge — Fighter levelling never sees the nudge.
|
||||
func TestNonMageLevelUpNoSpellNudge(t *testing.T) {
|
||||
c := &DnDCharacter{
|
||||
Race: RaceHuman, Class: ClassFighter, Level: 5,
|
||||
HPMax: 50, HPCurrent: 50,
|
||||
}
|
||||
msg := buildLevelUpMessage(c, []LevelUpEvent{{NewLevel: 5, HPGain: 8}}, "")
|
||||
if strings.Contains(msg, "Spells available to learn") {
|
||||
t.Errorf("Fighter level-up shouldn't show spell nudge; msg=\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMageLevelUpDeltaIsTwo — leveling from L1 → L2 increases the cap by 2.
|
||||
func TestMageLevelUpDeltaIsTwo(t *testing.T) {
|
||||
for lvl := 1; lvl < 20; lvl++ {
|
||||
got := mageKnownSpellsCap(lvl+1) - mageKnownSpellsCap(lvl)
|
||||
if got != 2 {
|
||||
t.Errorf("delta L%d→L%d = %d, want 2", lvl, lvl+1, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMageSpellbookLineInRender — renderSpellsList shows the budget for Mage.
|
||||
func TestMageSpellbookLineInRender(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@mage_render:example")
|
||||
c := setupMageForLearn(t, uid, 3)
|
||||
for _, sid := range []string{"magic_missile", "mage_armor"} {
|
||||
_ = addKnownSpell(uid, sid, "class", true)
|
||||
}
|
||||
|
||||
out := renderSpellsList(c)
|
||||
// L3 cap = 10, count = 2.
|
||||
if !strings.Contains(out, "Spellbook:") || !strings.Contains(out, "2/10") {
|
||||
t.Errorf("renderSpellsList missing/wrong Spellbook line; got\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "can learn 8 more") {
|
||||
t.Errorf("renderSpellsList missing headroom hint; got\n%s", out)
|
||||
}
|
||||
}
|
||||
232
internal/plugin/dnd_prepare_test.go
Normal file
232
internal/plugin/dnd_prepare_test.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 9 SP4 — !prepare command tests for Cleric.
|
||||
|
||||
func setupClericForPrepare(t *testing.T, uid id.UserID, wis, level int) *DnDCharacter {
|
||||
t.Helper()
|
||||
if err := createAdvCharacter(uid, "prep_test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassCleric, Level: level,
|
||||
STR: 12, DEX: 12, CON: 14, INT: 10, WIS: wis, CHA: 12,
|
||||
HPMax: 30, HPCurrent: 30, ArmorClass: 16,
|
||||
}
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setSpellSlotsForLevel(uid, ClassCleric, level); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func countPrepared(t *testing.T, uid id.UserID) int {
|
||||
t.Helper()
|
||||
rows, err := listKnownSpells(uid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
s, ok := lookupSpell(r.SpellID)
|
||||
if !ok || s.Level == 0 {
|
||||
continue
|
||||
}
|
||||
if r.Prepared {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func isPrepared(t *testing.T, uid id.UserID, spellID string) bool {
|
||||
t.Helper()
|
||||
_, prepared, err := playerKnowsSpell(uid, spellID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return prepared
|
||||
}
|
||||
|
||||
// TestPrepareCap — Cleric L3 with WIS 16 (mod +3): cap = 3 + 3 = 6.
|
||||
// Adding a 7th leveled prep is rejected; cantrips don't count toward the cap.
|
||||
func TestPrepareCap(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@prep_cap:example")
|
||||
setupClericForPrepare(t, uid, 16 /*WIS*/, 3 /*level*/)
|
||||
|
||||
// Cantrips: not counted by the cap. Pre-grant some Cleric cantrips
|
||||
// already-prepared — they must not block leveled-spell prep.
|
||||
for _, sid := range []string{"sacred_flame", "guidance", "mending"} {
|
||||
if err := addKnownSpell(uid, sid, "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add 7 known leveled spells, all unprepared.
|
||||
leveled := []string{
|
||||
"cure_wounds", "healing_word_spell", "bless", "guiding_bolt",
|
||||
"shield_of_faith", "spiritual_weapon", "aid",
|
||||
}
|
||||
for _, sid := range leveled {
|
||||
if err := addKnownSpell(uid, sid, "class", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
// Prepare 6 — all should succeed.
|
||||
for i := 0; i < 6; i++ {
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, leveled[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isPrepared(t, uid, leveled[i]) {
|
||||
t.Fatalf("expected %s to be prepared after handleDnDPrepareCmd", leveled[i])
|
||||
}
|
||||
}
|
||||
if got := countPrepared(t, uid); got != 6 {
|
||||
t.Fatalf("after 6 preps, count=%d, want 6", got)
|
||||
}
|
||||
|
||||
// 7th must be rejected — handler returns nil but state should not change.
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, leveled[6]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isPrepared(t, uid, leveled[6]) {
|
||||
t.Errorf("7th spell prepared past cap of 6")
|
||||
}
|
||||
if got := countPrepared(t, uid); got != 6 {
|
||||
t.Errorf("over-cap prep changed count: got %d, want 6", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareCapFloor — Cleric L1, WIS 1 (mod -5): raw cap = -4, floored to 1.
|
||||
func TestPrepareCapFloor(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@prep_floor:example")
|
||||
setupClericForPrepare(t, uid, 1 /*WIS — mod -5*/, 1)
|
||||
|
||||
for _, sid := range []string{"cure_wounds", "bless"} {
|
||||
if err := addKnownSpell(uid, sid, "class", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, "cure_wounds"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isPrepared(t, uid, "cure_wounds") {
|
||||
t.Fatal("first prep should succeed at floored cap of 1")
|
||||
}
|
||||
// Second must be rejected.
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, "bless"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isPrepared(t, uid, "bless") {
|
||||
t.Errorf("second prep slipped past floor cap of 1")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareClearAllowsCast — `!prepare clear <spell>` removes the prep flag,
|
||||
// and `!cast` then refuses the unprepared spell (slot is preserved).
|
||||
func TestPrepareClearAllowsCast(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@prep_clear:example")
|
||||
setupClericForPrepare(t, uid, 14 /*+2*/, 3)
|
||||
|
||||
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !isPrepared(t, uid, "cure_wounds") {
|
||||
t.Fatal("setup: cure_wounds not prepared")
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, "clear cure_wounds"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isPrepared(t, uid, "cure_wounds") {
|
||||
t.Errorf("prep clear failed to remove flag")
|
||||
}
|
||||
|
||||
// Slot count before cast attempt.
|
||||
beforeSlots, _ := getSpellSlots(uid)
|
||||
beforeUsed := beforeSlots[1][1]
|
||||
|
||||
// !cast cure_wounds while unprepared — handler returns nil (DM error),
|
||||
// but no slot consumed, no pending_cast.
|
||||
if err := p.handleDnDCastCmd(MessageContext{Sender: uid}, "cure_wounds"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterSlots, _ := getSpellSlots(uid)
|
||||
afterUsed := afterSlots[1][1]
|
||||
if afterUsed != beforeUsed {
|
||||
t.Errorf("unprepared cast consumed a slot: used %d → %d", beforeUsed, afterUsed)
|
||||
}
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.PendingCast != "" {
|
||||
t.Errorf("unprepared cast queued pending_cast=%q", got.PendingCast)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareCantripsSkipCap — preparing a cantrip is a no-op (cantrips are
|
||||
// always prepared). Cap is not consulted; counts don't shift.
|
||||
func TestPrepareCantripsSkipCap(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@prep_cantrip:example")
|
||||
setupClericForPrepare(t, uid, 10 /*+0, level 1: cap floored to 1*/, 1)
|
||||
|
||||
if err := addKnownSpell(uid, "sacred_flame", "class", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fill the leveled cap so a leveled !prepare would be refused.
|
||||
if err := addKnownSpell(uid, "cure_wounds", "class", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, "sacred_flame"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Cantrip-prep doesn't toggle the prepared flag (handler short-circuits
|
||||
// with "Cantrips are always prepared." before calling setSpellPrepared).
|
||||
// What matters is that it didn't push a leveled-spell off and didn't
|
||||
// change the leveled prep count.
|
||||
if got := countPrepared(t, uid); got != 1 {
|
||||
t.Errorf("cantrip prep changed leveled count: got %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareUnknownSpellErrors — !prepare on garbage/unknown spell DMs an
|
||||
// error and never touches the DB.
|
||||
func TestPrepareUnknownSpellErrors(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@prep_unknown:example")
|
||||
setupClericForPrepare(t, uid, 14, 1)
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
// Unknown spell name.
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, "totally not a spell"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _ := listKnownSpells(uid)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("unknown spell prep populated known list: %+v", rows)
|
||||
}
|
||||
|
||||
// Spell exists but isn't on the player's known list.
|
||||
if err := p.handleDnDPrepareCmd(MessageContext{Sender: uid}, "fireball"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if isPrepared(t, uid, "fireball") {
|
||||
t.Errorf("fireball prepared without being known")
|
||||
}
|
||||
}
|
||||
93
internal/plugin/dnd_spell_aoe_test.go
Normal file
93
internal/plugin/dnd_spell_aoe_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Phase 9 SP4 — AoE flag spells must still resolve correctly through
|
||||
// applySpellDamageSave even though combat has only one enemy. Verify Fireball,
|
||||
// Burning Hands, and Cone of Cold land legal damage in both save-fail and
|
||||
// save-success branches.
|
||||
|
||||
func TestApplySpellDamageSave_FireballRange(t *testing.T) {
|
||||
spell, ok := lookupSpell("fireball")
|
||||
if !ok {
|
||||
t.Fatal("fireball missing from registry")
|
||||
}
|
||||
if !spell.AOE {
|
||||
t.Errorf("fireball should be AOE")
|
||||
}
|
||||
|
||||
c := &DnDCharacter{Class: ClassMage, Level: 5, INT: 18}
|
||||
enemy := &CombatStats{AttackBonus: 0, AC: 12}
|
||||
|
||||
// DC 99 → save virtually always fails → full damage. 8d6 = 8..48.
|
||||
for i := 0; i < 30; i++ {
|
||||
mods := &CombatModifiers{}
|
||||
applySpellDamageSave(spell, 99, c, mods, enemy, 3)
|
||||
if mods.SpellPreDamage < 8 || mods.SpellPreDamage > 48 {
|
||||
t.Errorf("fireball DC99 dmg=%d outside 8..48", mods.SpellPreDamage)
|
||||
break
|
||||
}
|
||||
}
|
||||
// DC 1 → save virtually always succeeds → half damage (floor 1).
|
||||
// 8d6/2 = 4..24.
|
||||
for i := 0; i < 30; i++ {
|
||||
mods := &CombatModifiers{}
|
||||
applySpellDamageSave(spell, 1, c, mods, enemy, 3)
|
||||
if mods.SpellPreDamage < 1 || mods.SpellPreDamage > 24 {
|
||||
t.Errorf("fireball DC1 (saved) dmg=%d outside 1..24", mods.SpellPreDamage)
|
||||
break
|
||||
}
|
||||
if !strings.Contains(mods.SpellPreDamageDesc, "saved") {
|
||||
t.Errorf("fireball DC1 desc=%q, want 'saved'", mods.SpellPreDamageDesc)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySpellDamageSave_BurningHands(t *testing.T) {
|
||||
spell, _ := lookupSpell("burning_hands")
|
||||
if !spell.AOE {
|
||||
t.Errorf("burning_hands should be AOE")
|
||||
}
|
||||
c := &DnDCharacter{Class: ClassMage, Level: 1, INT: 16}
|
||||
enemy := &CombatStats{AttackBonus: 0}
|
||||
// L1 cast, 3d6 → 3..18 on full damage.
|
||||
for i := 0; i < 30; i++ {
|
||||
mods := &CombatModifiers{}
|
||||
applySpellDamageSave(spell, 99, c, mods, enemy, 1)
|
||||
if mods.SpellPreDamage < 3 || mods.SpellPreDamage > 18 {
|
||||
t.Errorf("burning_hands dmg=%d outside 3..18", mods.SpellPreDamage)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Upcast to L3: rollSpellDamageDice adds (3-1)=2 dice → 5d6 = 5..30.
|
||||
for i := 0; i < 30; i++ {
|
||||
mods := &CombatModifiers{}
|
||||
applySpellDamageSave(spell, 99, c, mods, enemy, 3)
|
||||
if mods.SpellPreDamage < 5 || mods.SpellPreDamage > 30 {
|
||||
t.Errorf("burning_hands upcast L3 dmg=%d outside 5..30", mods.SpellPreDamage)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySpellDamageSave_ConeOfCold(t *testing.T) {
|
||||
spell, _ := lookupSpell("cone_of_cold")
|
||||
if !spell.AOE {
|
||||
t.Errorf("cone_of_cold should be AOE")
|
||||
}
|
||||
c := &DnDCharacter{Class: ClassMage, Level: 9, INT: 18}
|
||||
enemy := &CombatStats{AttackBonus: 0}
|
||||
// 8d8 = 8..64.
|
||||
for i := 0; i < 30; i++ {
|
||||
mods := &CombatModifiers{}
|
||||
applySpellDamageSave(spell, 99, c, mods, enemy, 5)
|
||||
if mods.SpellPreDamage < 8 || mods.SpellPreDamage > 64 {
|
||||
t.Errorf("cone_of_cold dmg=%d outside 8..64", mods.SpellPreDamage)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,17 +95,20 @@ func applySpellDamageAttack(spell SpellDefinition, atk int, mods *CombatModifier
|
||||
|
||||
// applySpellDamageSave — Burning Hands, Fireball, Sacred Flame, etc.
|
||||
// Enemy rolls a save (heuristic mod = enemy.AttackBonus / 2) vs spell DC.
|
||||
// Half damage on success, full on fail. AoE flag is moot — we have one enemy.
|
||||
// Half damage on success, full on fail.
|
||||
//
|
||||
// Single-target limitation: Phase 9 combat features one enemy, so AoE-flagged
|
||||
// spells (Fireball, Burning Hands, Cone of Cold, Flame Strike, etc.) collapse
|
||||
// to a single-target damage roll. The damage formula is the same as the
|
||||
// single-target case — the AoE flag is preserved on SpellDefinition for
|
||||
// future multi-enemy combat (Phase 11+) but is not consulted here.
|
||||
func applySpellDamageSave(spell SpellDefinition, dc int, c *DnDCharacter, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||
saveMod := enemy.AttackBonus / 2
|
||||
saveMod := enemySpellSaveMod(enemy)
|
||||
saveRoll := 1 + rand.IntN(20)
|
||||
saved := saveRoll+saveMod >= dc
|
||||
dmg := rollSpellDamageDice(spell, slot, c.Level)
|
||||
if saved {
|
||||
dmg /= 2
|
||||
if dmg < 1 {
|
||||
dmg = 1
|
||||
}
|
||||
dmg = halveSavedDamage(dmg)
|
||||
}
|
||||
mods.SpellPreDamage += dmg
|
||||
if saved {
|
||||
@@ -137,12 +140,35 @@ func applySpellDamageAuto(spell SpellDefinition, mods *CombatModifiers, slot, ch
|
||||
mods.SpellPreDamageDesc = fmt.Sprintf("%s — %d dmg", spell.Name, dmg)
|
||||
}
|
||||
|
||||
// halveSavedDamage applies the save-half rule. Integer division floors,
|
||||
// then a 1-damage floor ensures even a successful save against any spell
|
||||
// chips at least 1 HP. This matches the original applySpellDamageSave
|
||||
// inline branch behavior; pulled out so rounding is unit-testable.
|
||||
func halveSavedDamage(dmg int) int {
|
||||
halved := dmg / 2
|
||||
if halved < 1 {
|
||||
return 1
|
||||
}
|
||||
return halved
|
||||
}
|
||||
|
||||
// enemySpellSaveMod is the heuristic save modifier for an arena/dungeon
|
||||
// enemy. We don't track per-stat saves on monsters, so we approximate with
|
||||
// half their attack bonus — that scales linearly with threat tier (T1 ≈ +2,
|
||||
// T5 ≈ +5..7) and keeps the save vs. caster DC math meaningful at both ends.
|
||||
func enemySpellSaveMod(enemy *CombatStats) int {
|
||||
if enemy == nil {
|
||||
return 0
|
||||
}
|
||||
return enemy.AttackBonus / 2
|
||||
}
|
||||
|
||||
// applySpellControl — Hold Person, Sleep, Command, Hypnotic Pattern.
|
||||
// Enemy save vs DC; failure → skip first attack. Hold-family also primes
|
||||
// the engine's auto-crit-on-first-hit path so melee strikes connect for
|
||||
// double damage (5e: paralyzed creatures auto-crit on melee hits).
|
||||
func applySpellControl(spell SpellDefinition, dc int, mods *CombatModifiers, enemy *CombatStats, slot int) {
|
||||
saveMod := enemy.AttackBonus / 2
|
||||
saveMod := enemySpellSaveMod(enemy)
|
||||
saveRoll := 1 + rand.IntN(20)
|
||||
if saveRoll+saveMod >= dc {
|
||||
mods.SpellPreDamageDesc = spell.Name + " — resisted"
|
||||
|
||||
65
internal/plugin/dnd_spell_save_rounding_test.go
Normal file
65
internal/plugin/dnd_spell_save_rounding_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package plugin
|
||||
|
||||
import "testing"
|
||||
|
||||
// Phase 9 SP4 — save-half rounding edge cases.
|
||||
|
||||
func TestHalveSavedDamage_Boundaries(t *testing.T) {
|
||||
cases := []struct{ in, want int }{
|
||||
{0, 1}, // 0 dmg saved → 1 (floor kicks in even on no-damage spells)
|
||||
{1, 1}, // 1 / 2 = 0 → floor 1
|
||||
{2, 1}, // 2 / 2 = 1
|
||||
{3, 1}, // 3 / 2 = 1 (rounds down)
|
||||
{10, 5}, // even
|
||||
{11, 5}, // odd rounds down
|
||||
{99, 49}, // large odd
|
||||
{100, 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := halveSavedDamage(c.in); got != c.want {
|
||||
t.Errorf("halveSavedDamage(%d) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnemySpellSaveMod_Scales — heuristic save mod is enemy.AttackBonus / 2.
|
||||
// Sanity-check that T1-ish (+2..+4) → +1..+2 and T5-ish (+10..+12) → +5..+6.
|
||||
func TestEnemySpellSaveMod_Scales(t *testing.T) {
|
||||
cases := []struct {
|
||||
atk int
|
||||
want int
|
||||
}{
|
||||
{0, 0}, // unstatted enemy
|
||||
{2, 1}, // T1
|
||||
{4, 2}, // T2
|
||||
{6, 3}, // T3
|
||||
{8, 4}, // T4
|
||||
{10, 5}, // T5 floor
|
||||
{12, 6}, // T5 boss
|
||||
{20, 10}, // outsized boss
|
||||
}
|
||||
for _, c := range cases {
|
||||
enemy := &CombatStats{AttackBonus: c.atk}
|
||||
if got := enemySpellSaveMod(enemy); got != c.want {
|
||||
t.Errorf("enemySpellSaveMod(atk=%d) = %d, want %d", c.atk, got, c.want)
|
||||
}
|
||||
}
|
||||
// Monotonic non-decreasing across the range: a stronger enemy should never
|
||||
// have a worse save mod than a weaker one.
|
||||
prev := -1
|
||||
for atk := 0; atk <= 20; atk++ {
|
||||
got := enemySpellSaveMod(&CombatStats{AttackBonus: atk})
|
||||
if got < prev {
|
||||
t.Errorf("save mod regressed at atk=%d: %d < prev %d", atk, got, prev)
|
||||
}
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnemySpellSaveMod_NilSafe — defensive: nil enemy yields 0 (callers
|
||||
// should never pass nil, but the helper shouldn't panic).
|
||||
func TestEnemySpellSaveMod_NilSafe(t *testing.T) {
|
||||
if got := enemySpellSaveMod(nil); got != 0 {
|
||||
t.Errorf("nil enemy save mod = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
@@ -575,6 +575,35 @@ func defaultKnownSpells(class DnDClass, level int) []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
// mageKnownSpellsCap returns the maximum number of *leveled* spells a Mage
|
||||
// of the given level may have in their spellbook. 5e standard: 6 starting
|
||||
// spells + 2 per level after L1 (so 6 at L1, 8 at L2, …, 44 at L20).
|
||||
// Cantrips don't count against this cap.
|
||||
func mageKnownSpellsCap(level int) int {
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
return 6 + 2*(level-1)
|
||||
}
|
||||
|
||||
// mageLeveledKnownCount counts leveled (Level ≥ 1) spells in the player's
|
||||
// spellbook. Cantrips and unknown registry entries are skipped.
|
||||
func mageLeveledKnownCount(userID id.UserID) (int, error) {
|
||||
rows, err := listKnownSpells(userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n := 0
|
||||
for _, r := range rows {
|
||||
s, ok := lookupSpell(r.SpellID)
|
||||
if !ok || s.Level == 0 {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func highestAvailableSlot(class DnDClass, level int) int {
|
||||
pool := slotsForClassLevel(class, level)
|
||||
high := 0
|
||||
|
||||
188
internal/plugin/dnd_spells_migration_test.go
Normal file
188
internal/plugin/dnd_spells_migration_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Phase 9 SP4 — ensureSpellsForCharacter migration / idempotency tests.
|
||||
|
||||
func mageCharacter(uid id.UserID, level int) *DnDCharacter {
|
||||
return &DnDCharacter{
|
||||
UserID: uid, Race: RaceElf, Class: ClassMage, Level: level,
|
||||
STR: 8, DEX: 14, CON: 12, INT: 17, WIS: 12, CHA: 10,
|
||||
HPMax: 10, HPCurrent: 10, ArmorClass: 12,
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSpells_Idempotent — second call leaves the known list unchanged
|
||||
// and does not duplicate rows.
|
||||
func TestEnsureSpells_Idempotent(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@migrate_idem:example")
|
||||
c := mageCharacter(uid, 5)
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, _ := listKnownSpells(uid)
|
||||
if len(first) == 0 {
|
||||
t.Fatal("first ensureSpells: no known spells granted")
|
||||
}
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _ := listKnownSpells(uid)
|
||||
if len(second) != len(first) {
|
||||
t.Errorf("idempotency: first=%d, second=%d", len(first), len(second))
|
||||
}
|
||||
// Spell IDs must match exactly (set equality).
|
||||
wantIDs := map[string]bool{}
|
||||
for _, r := range first {
|
||||
wantIDs[r.SpellID] = true
|
||||
}
|
||||
for _, r := range second {
|
||||
if !wantIDs[r.SpellID] {
|
||||
t.Errorf("second pass introduced new spell: %s", r.SpellID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSpells_RefreshesSlotPoolOnLevelChange — when level shifts between
|
||||
// sessions, the slot pool re-syncs to the new level even if spells already
|
||||
// exist (the early-exit branch still re-runs setSpellSlotsForLevel).
|
||||
func TestEnsureSpells_RefreshesSlotPoolOnLevelChange(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@migrate_level:example")
|
||||
|
||||
c := mageCharacter(uid, 1)
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := getSpellSlots(uid)
|
||||
if got[1][0] != 2 || got[2][0] != 0 {
|
||||
t.Errorf("L1 Mage slot pool: %v, want only L1=2", got)
|
||||
}
|
||||
|
||||
// Level up between sessions.
|
||||
c.Level = 5
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = getSpellSlots(uid)
|
||||
want := slotsForClassLevel(ClassMage, 5)
|
||||
for lvl, total := range want {
|
||||
if got[lvl][0] != total {
|
||||
t.Errorf("post-level-up L%d slots: got total=%d, want %d", lvl, got[lvl][0], total)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSpells_NonCasterIsNoOp — Fighter call writes nothing.
|
||||
func TestEnsureSpells_NonCasterIsNoOp(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@migrate_fighter:example")
|
||||
c := &DnDCharacter{UserID: uid, Class: ClassFighter, Race: RaceHuman, Level: 5}
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _ := listKnownSpells(uid)
|
||||
if len(rows) != 0 {
|
||||
t.Errorf("non-caster got known spells: %+v", rows)
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
if len(slots) != 0 {
|
||||
t.Errorf("non-caster got slot pool: %+v", slots)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSpells_RangerL1CantripsOnly — Ranger L1 has no slots, and the
|
||||
// granted spells are all cantrips (Level 0).
|
||||
func TestEnsureSpells_RangerL1CantripsOnly(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@migrate_ranger:example")
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceElf, Class: ClassRanger, Level: 1,
|
||||
STR: 12, DEX: 16, CON: 13, INT: 10, WIS: 14, CHA: 8,
|
||||
}
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _ := listKnownSpells(uid)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("ranger L1 got nothing — expected at least cantrips")
|
||||
}
|
||||
for _, r := range rows {
|
||||
s, ok := lookupSpell(r.SpellID)
|
||||
if !ok {
|
||||
t.Errorf("unknown spell %q in known list", r.SpellID)
|
||||
continue
|
||||
}
|
||||
if s.Level != 0 {
|
||||
t.Errorf("ranger L1 got non-cantrip %s (L%d); expected cantrips only",
|
||||
s.ID, s.Level)
|
||||
}
|
||||
}
|
||||
slots, _ := getSpellSlots(uid)
|
||||
if len(slots) != 0 {
|
||||
t.Errorf("ranger L1 has slot pool: %+v, want empty", slots)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSpells_LevelAppropriateDefaultsExist — every default spell at
|
||||
// representative levels is present in the registry and within the player's
|
||||
// max slot level (or a cantrip).
|
||||
func TestEnsureSpells_LevelAppropriateDefaultsExist(t *testing.T) {
|
||||
for _, class := range []DnDClass{ClassMage, ClassCleric, ClassRanger} {
|
||||
for _, lvl := range []int{1, 2, 5, 9, 13, 20} {
|
||||
maxSlot := highestAvailableSlot(class, lvl)
|
||||
for _, sid := range defaultKnownSpells(class, lvl) {
|
||||
s, ok := lookupSpell(sid)
|
||||
if !ok {
|
||||
t.Errorf("%s L%d default %q missing from registry", class, lvl, sid)
|
||||
continue
|
||||
}
|
||||
if s.Level > maxSlot && s.Level > 0 {
|
||||
t.Errorf("%s L%d default %s requires L%d slot but max is L%d",
|
||||
class, lvl, sid, s.Level, maxSlot)
|
||||
}
|
||||
// Class-list sanity.
|
||||
ok = false
|
||||
for _, cl := range s.Classes {
|
||||
if cl == class {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("%s L%d default %s is not on the %s class list",
|
||||
class, lvl, sid, class)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnsureSpells_ClericDefaultsAutoPrepared — auto-migrated Cleric defaults
|
||||
// are flagged prepared so !cast works immediately. (Pre-SP4 contract; SP4
|
||||
// keeps it for migration backward-compat — players can later !prepare clear.)
|
||||
func TestEnsureSpells_ClericDefaultsAutoPrepared(t *testing.T) {
|
||||
setupAbilitiesTestDB(t)
|
||||
uid := id.UserID("@migrate_cleric:example")
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassCleric, Level: 5,
|
||||
STR: 12, DEX: 12, CON: 14, INT: 10, WIS: 16, CHA: 12,
|
||||
}
|
||||
if err := ensureSpellsForCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, _ := listKnownSpells(uid)
|
||||
if len(rows) == 0 {
|
||||
t.Fatal("cleric got no defaults")
|
||||
}
|
||||
for _, r := range rows {
|
||||
if !r.Prepared {
|
||||
t.Errorf("cleric default %s not prepared after migration", r.SpellID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,12 +142,20 @@ func (p *AdventurePlugin) sendLevelUpDM(userID id.UserID, c *DnDCharacter, event
|
||||
if p == nil || p.Client == nil {
|
||||
return // tests construct AdventurePlugin{} without a Matrix client
|
||||
}
|
||||
if err := p.SendDM(userID, buildLevelUpMessage(c, events, dndLevelUpFlavorLine())); err != nil {
|
||||
slog.Error("dnd: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildLevelUpMessage formats the level-up DM. flavor is the optional flavor
|
||||
// line (pass "" to skip). Pure function — exposed for tests.
|
||||
func buildLevelUpMessage(c *DnDCharacter, events []LevelUpEvent, flavor string) string {
|
||||
ri, _ := raceInfo(c.Race)
|
||||
ci, _ := classInfo(c.Class)
|
||||
var b strings.Builder
|
||||
b.WriteString("✨ **LEVEL UP** ✨\n\n")
|
||||
if line := dndLevelUpFlavorLine(); line != "" {
|
||||
b.WriteString("_" + line + "_\n\n")
|
||||
if flavor != "" {
|
||||
b.WriteString("_" + flavor + "_\n\n")
|
||||
}
|
||||
|
||||
if len(events) == 1 {
|
||||
@@ -170,6 +178,17 @@ func (p *AdventurePlugin) sendLevelUpDM(userID id.UserID, c *DnDCharacter, event
|
||||
b.WriteString(fmt.Sprintf("\nNext level: %d / %d XP.\n", c.XP, next))
|
||||
}
|
||||
|
||||
// Mage spellbook budget: every level-up grants +2 leveled spells. Surface
|
||||
// the headroom so the player knows to run `!spells learn`.
|
||||
if c.Class == ClassMage {
|
||||
count, _ := mageLeveledKnownCount(c.UserID)
|
||||
cap := mageKnownSpellsCap(c.Level)
|
||||
if avail := cap - count; avail > 0 {
|
||||
b.WriteString(fmt.Sprintf("\n📖 _Spells available to learn: **%d** (run `!spells learn <name>`)._\n",
|
||||
avail))
|
||||
}
|
||||
}
|
||||
|
||||
// Subclass selection cue: design doc specs a prompt at L5. Mechanics
|
||||
// arrive in a future phase; for now the level-up DM mentions it so the
|
||||
// player isn't surprised when it lands.
|
||||
@@ -179,10 +198,7 @@ func (p *AdventurePlugin) sendLevelUpDM(userID id.UserID, c *DnDCharacter, event
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if err := p.SendDM(userID, b.String()); err != nil {
|
||||
slog.Error("dnd: level-up DM failed", "user", userID, "err", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── XP grant amounts ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user