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:
prosolis
2026-05-08 09:10:05 -07:00
parent 01c70f5297
commit b53e516bf0
9 changed files with 870 additions and 13 deletions

View File

@@ -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())
}

View 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)
}
}

View 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")
}
}

View 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
}
}
}

View File

@@ -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"

View 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)
}
}

View File

@@ -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

View 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)
}
}
}

View File

@@ -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 ─────────────────────────────────────────────────────────