Files
gogobee/internal/plugin/dnd_flavor_test.go
prosolis 9e1a1f606c Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2
Combines all uncommitted D&D work into one checkpoint:

Phases 1-8 (per gogobee_dnd_session_summary.md):
- Race/class/stats system, !setup flow, !sheet, !respec
- d20-vs-AC combat with race/class passives, auto-migration
- XP/level-up, skill checks, NPC refund hooks
- Equipment slot mapping, rarity, !rest short/long
- Pre-arm active abilities, resource pools
- Bestiary, !roll/!stats/!level, onboarding DM
- Audit fixes A-I, flavor wiring under internal/flavor/
- Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix)

Phase 9 SP1 — spell system foundations:
- 79 SpellDefinitions (76 in-scope + 3 reaction-deferred)
- dnd_known_spells, dnd_spell_slots tables
- pending_cast / concentration_* columns on dnd_character
- Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math
- Long-rest refreshes slots; respec wipes spell state
- Auto-grant default known list on !setup confirm and auto-migration

Phase 9 SP2 — !cast / !spells / !prepare commands:
- Out-of-combat HEAL and UTILITY resolve immediately
- Damage/control/buff queue as pending_cast for next combat
- Audit-style save-then-debit, slot refund on save failure
- !cast --drop, concentration supersession, prep-cap enforcement
- Reaction spells refused with Phase 11 note

SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn
tests) are next. Build clean, full test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

104 lines
3.8 KiB
Go

package plugin
import (
"testing"
"gogobee/internal/flavor"
)
// TestDnDFlavorPoolsNonEmpty: every pool we wired into the D&D layer must
// have at least one entry. Catches accidental deletions in the protected
// flavor files that would silently produce empty narrative.
func TestDnDFlavorPoolsNonEmpty(t *testing.T) {
pools := map[string][]string{
"CombatStart": flavor.CombatStart,
"CombatVictory": flavor.CombatVictory,
"BossDeath": flavor.BossDeath,
"PlayerDeath": flavor.PlayerDeath,
"ZoneComplete": flavor.ZoneComplete,
"Nat20": flavor.Nat20,
"Nat1": flavor.Nat1,
"LevelUp": flavor.LevelUp,
"ItemFound": flavor.ItemFound,
"RestShort": flavor.RestShort,
"RestLong": flavor.RestLong,
"HomeLongRest": flavor.HomeLongRest,
"MistyGreeting": flavor.MistyGreeting,
"MistyInsightSuccess": flavor.MistyInsightSuccess,
"MistySkillFail": flavor.MistySkillFail,
"ArinaGreeting": flavor.ArinaGreeting,
"ArinaArcanaSuccess": flavor.ArinaArcanaSuccess,
"ArinaSkillFail": flavor.ArinaSkillFail,
"ExpeditionStart": flavor.ExpeditionStart,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty — protected file deletion?", name)
}
for i, s := range pool {
if s == "" {
t.Errorf("flavor.%s[%d] is empty string", name, i)
}
}
}
}
// TestDnDFlavorHelpersReturnNonEmpty: every helper must produce a non-empty
// string when its underlying pool has entries.
func TestDnDFlavorHelpersReturnNonEmpty(t *testing.T) {
cases := map[string]func() string{
"dndCombatOpeningLine": dndCombatOpeningLine,
"dndZoneCompleteLine": dndZoneCompleteLine,
"dndNat20Line": dndNat20Line,
"dndNat1Line": dndNat1Line,
"dndLevelUpFlavorLine": dndLevelUpFlavorLine,
"dndItemFoundLine": dndItemFoundLine,
"dndRestShortFlavorLine": dndRestShortFlavorLine,
"dndCombatClosingLine_Win": func() string { return dndCombatClosingLine(true, false) },
"dndCombatClosingLine_Boss": func() string { return dndCombatClosingLine(true, true) },
"dndCombatClosingLine_Death": func() string { return dndCombatClosingLine(false, false) },
"dndRestLongFlavorLine_Home": func() string { return dndRestLongFlavorLine(true) },
"dndRestLongFlavorLine_Inn": func() string { return dndRestLongFlavorLine(false) },
}
for name, fn := range cases {
// Run several times — flavor pulls a random pool member, so a single
// call can flake if the pool happens to have an empty string. The
// pool-non-empty test above guards against that, but be belt-and-suspenders.
for i := 0; i < 10; i++ {
if got := fn(); got == "" {
t.Errorf("%s returned empty (call #%d)", name, i)
break
}
}
}
}
// TestDnDGreetingPoolUnion: combined Misty/Arina greeting pools include both
// legacy openings and the D&D-flavored entries.
func TestDnDGreetingPoolUnion(t *testing.T) {
mistyPool := dndMistyGreetingPool()
if len(mistyPool) != len(flavor.MistyGreeting)+len(mistyOpenings) {
t.Errorf("Misty pool union size = %d; expected %d (flavor %d + legacy %d)",
len(mistyPool), len(flavor.MistyGreeting)+len(mistyOpenings),
len(flavor.MistyGreeting), len(mistyOpenings))
}
arinaPool := dndArinaGreetingPool()
if len(arinaPool) != len(flavor.ArinaGreeting)+len(arinaOpenings) {
t.Errorf("Arina pool union size = %d; expected %d", len(arinaPool),
len(flavor.ArinaGreeting)+len(arinaOpenings))
}
}
func TestDnDItalicize(t *testing.T) {
if got := dndItalicize(""); got != "" {
t.Errorf("italicize(empty) = %q, want empty", got)
}
if got := dndItalicize(" "); got != "" {
t.Errorf("italicize(whitespace) = %q, want empty", got)
}
got := dndItalicize("hello")
if got != "\n\n_hello_" {
t.Errorf("italicize = %q, want \\n\\n_hello_", got)
}
}