diff --git a/internal/plugin/dnd_cast.go b/internal/plugin/dnd_cast.go index 3743b18..2f76883 100644 --- a/internal/plugin/dnd_cast.go +++ b/internal/plugin/dnd_cast.go @@ -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()) } diff --git a/internal/plugin/dnd_mage_learn_test.go b/internal/plugin/dnd_mage_learn_test.go new file mode 100644 index 0000000..fb72c38 --- /dev/null +++ b/internal/plugin/dnd_mage_learn_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_prepare_test.go b/internal/plugin/dnd_prepare_test.go new file mode 100644 index 0000000..024356e --- /dev/null +++ b/internal/plugin/dnd_prepare_test.go @@ -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 ` 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") + } +} diff --git a/internal/plugin/dnd_spell_aoe_test.go b/internal/plugin/dnd_spell_aoe_test.go new file mode 100644 index 0000000..ea319d5 --- /dev/null +++ b/internal/plugin/dnd_spell_aoe_test.go @@ -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 + } + } +} diff --git a/internal/plugin/dnd_spell_combat.go b/internal/plugin/dnd_spell_combat.go index 5537fb8..189e620 100644 --- a/internal/plugin/dnd_spell_combat.go +++ b/internal/plugin/dnd_spell_combat.go @@ -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" diff --git a/internal/plugin/dnd_spell_save_rounding_test.go b/internal/plugin/dnd_spell_save_rounding_test.go new file mode 100644 index 0000000..6304dd4 --- /dev/null +++ b/internal/plugin/dnd_spell_save_rounding_test.go @@ -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) + } +} diff --git a/internal/plugin/dnd_spells.go b/internal/plugin/dnd_spells.go index 03a0378..a6fe8c2 100644 --- a/internal/plugin/dnd_spells.go +++ b/internal/plugin/dnd_spells.go @@ -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 diff --git a/internal/plugin/dnd_spells_migration_test.go b/internal/plugin/dnd_spells_migration_test.go new file mode 100644 index 0000000..8c95247 --- /dev/null +++ b/internal/plugin/dnd_spells_migration_test.go @@ -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) + } + } +} diff --git a/internal/plugin/dnd_xp.go b/internal/plugin/dnd_xp.go index 0255c1a..bd701ef 100644 --- a/internal/plugin/dnd_xp.go +++ b/internal/plugin/dnd_xp.go @@ -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 `)._\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 ─────────────────────────────────────────────────────────