diff --git a/internal/db/db.go b/internal/db/db.go index 29870a4..087debb 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -173,6 +173,11 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE dnd_character ADD COLUMN pending_cast TEXT NOT NULL DEFAULT ''`, `ALTER TABLE dnd_character ADD COLUMN concentration_spell TEXT NOT NULL DEFAULT ''`, `ALTER TABLE dnd_character ADD COLUMN concentration_expires_at DATETIME`, + // Phase 10 — subclass system. Subclass id is set at L5 via !subclass. + // last_subclass_respec_at gates the 30-day cooldown for changing it + // (separate from last_respec_at, which gates the full character wipe). + `ALTER TABLE dnd_character ADD COLUMN subclass TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE dnd_character ADD COLUMN last_subclass_respec_at DATETIME`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index f79f3e7..436e972 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -124,6 +124,7 @@ func (p *AdventurePlugin) Commands() []CommandDef { {Name: "sheet", Description: "View your Adv 2.0 character sheet", Usage: "!sheet", Category: "Games"}, {Name: "abilities", Description: "List your Adv 2.0 class and race abilities", Usage: "!abilities", Category: "Games"}, {Name: "respec", Description: "Wipe and rebuild your Adv 2.0 character (5000 euros, 7-day cooldown)", Usage: "!respec", Category: "Games"}, + {Name: "subclass", Description: "View or choose your Adv 2.0 subclass (unlocks at L5; change costs 500 euros, 30-day cooldown)", Usage: "!subclass [name|number]", Category: "Games"}, {Name: "check", Description: "Roll an Adv 2.0 skill check (d20 + ability modifier vs DC)", Usage: "!check [dc]", Category: "Games"}, {Name: "rest", Description: "Take an Adv 2.0 rest (`short`: 1h cooldown, partial heal — `long`: 24h, full heal, requires housing or inn)", Usage: "!rest short|long", Category: "Games"}, {Name: "arm", Description: "Pre-arm an active Adv 2.0 ability for next combat (consumes resource)", Usage: "!arm ", Category: "Games"}, @@ -216,6 +217,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error { if p.IsCommand(ctx.Body, "respec") { return p.handleDnDRespecCmd(ctx) } + if p.IsCommand(ctx.Body, "subclass") { + return p.handleDnDSubclassCmd(ctx, p.GetArgs(ctx.Body, "subclass")) + } if p.IsCommand(ctx.Body, "check") { return p.handleDnDCheckCmd(ctx, p.GetArgs(ctx.Body, "check")) } diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index d6c6a6d..881cef4 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -176,6 +176,12 @@ type DnDCharacter struct { PendingCast string ConcentrationSpell string ConcentrationExpiresAt *time.Time + // Phase 10 — subclass. + // Subclass: empty until the player runs !subclass at L5+. Once set, + // changing requires `!subclass ` again subject to a 30-day + // cooldown (LastSubclassRespecAt) and 500-coin cost. + Subclass DnDSubclass + LastSubclassRespecAt *time.Time LastRespecAt *time.Time LastShortRestAt *time.Time LastLongRestAt *time.Time @@ -204,18 +210,20 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) { hp_current, hp_max, temp_hp, armor_class, pending_setup, auto_migrated, onboarding_sent, armed_ability, pending_cast, concentration_spell, concentration_expires_at, + subclass, last_subclass_respec_at, last_respec_at, last_short_rest_at, last_long_rest_at, created_at, updated_at FROM dnd_character WHERE user_id = ?`, string(userID)) c := &DnDCharacter{} var pending, autoMig, onboard int - var raceStr, classStr string + var raceStr, classStr, subclassStr string var uidStr string err := row.Scan(&uidStr, &raceStr, &classStr, &c.Level, &c.XP, &c.STR, &c.DEX, &c.CON, &c.INT, &c.WIS, &c.CHA, &c.HPCurrent, &c.HPMax, &c.TempHP, &c.ArmorClass, &pending, &autoMig, &onboard, &c.ArmedAbility, &c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt, + &subclassStr, &c.LastSubclassRespecAt, &c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { @@ -227,6 +235,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) { c.UserID = id.UserID(uidStr) c.Race = DnDRace(raceStr) c.Class = DnDClass(classStr) + c.Subclass = DnDSubclass(subclassStr) c.PendingSetup = pending == 1 c.AutoMigrated = autoMig == 1 c.OnboardingSent = onboard == 1 @@ -253,8 +262,9 @@ func SaveDnDCharacter(c *DnDCharacter) error { hp_current, hp_max, temp_hp, armor_class, pending_setup, auto_migrated, onboarding_sent, armed_ability, pending_cast, concentration_spell, concentration_expires_at, + subclass, last_subclass_respec_at, last_respec_at, last_short_rest_at, last_long_rest_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(user_id) DO UPDATE SET race=excluded.race, class=excluded.class, dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp, @@ -270,6 +280,8 @@ func SaveDnDCharacter(c *DnDCharacter) error { pending_cast=excluded.pending_cast, concentration_spell=excluded.concentration_spell, concentration_expires_at=excluded.concentration_expires_at, + subclass=excluded.subclass, + last_subclass_respec_at=excluded.last_subclass_respec_at, last_respec_at=excluded.last_respec_at, last_short_rest_at=excluded.last_short_rest_at, last_long_rest_at=excluded.last_long_rest_at, @@ -279,6 +291,7 @@ func SaveDnDCharacter(c *DnDCharacter) error { c.HPCurrent, c.HPMax, c.TempHP, c.ArmorClass, pending, autoMig, onboard, c.ArmedAbility, c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt, + string(c.Subclass), c.LastSubclassRespecAt, c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt) if err != nil { return fmt.Errorf("save dnd_character: %w", err) diff --git a/internal/plugin/dnd_setup.go b/internal/plugin/dnd_setup.go index 7bfa3b3..300755f 100644 --- a/internal/plugin/dnd_setup.go +++ b/internal/plugin/dnd_setup.go @@ -383,6 +383,10 @@ func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error { c.PendingSetup = true c.AutoMigrated = false c.LastRespecAt = &now + // Phase 10: full wipe also resets subclass state. Subclass-respec + // cooldown is dropped — at L1 with no class there's nothing to gate. + c.Subclass = "" + c.LastSubclassRespecAt = nil // Save the wipe BEFORE debiting euros (audit fix B). If save fails, the // player's old state survives and they keep their euros — better than // a destructive debit-without-wipe. diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 07006e0..239e656 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -77,6 +77,13 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[Equipmen name = adv.DisplayName } b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display)) + if c.Subclass != "" { + if si, ok := subclassInfo(c.Subclass); ok { + b.WriteString(fmt.Sprintf(" _%s_\n", si.Display)) + } + } else if c.Level >= dndSubclassMinLevel { + b.WriteString(" _Subclass: unchosen — run `!subclass`_\n") + } b.WriteString(strings.Repeat("─", 36) + "\n") // Vitals diff --git a/internal/plugin/dnd_subclass.go b/internal/plugin/dnd_subclass.go new file mode 100644 index 0000000..4822f3c --- /dev/null +++ b/internal/plugin/dnd_subclass.go @@ -0,0 +1,209 @@ +package plugin + +import ( + "sort" + "strings" +) + +// Phase 10 — subclass registry. Implements `gogobee_subclass_system.md`: +// 15 subclasses (3 per class × 5 classes), unlocked at L5 via !subclass. +// +// SUB1 (this file + dnd_subclass_cmd.go) covers identity only — selection, +// storage, !respec, sheet display. Mechanical effects of L5/7/10/15 +// abilities arrive in SUB2/SUB3. +// +// Forward-deps that aren't built yet are deliberately stubbed: Beast +// Master picks the subclass even though the Phase 15 pet rework hasn't +// landed; Necromancer Animate Dead waits on an undead-summon engine. + +type DnDSubclass string + +const ( + // Fighter + SubclassChampion DnDSubclass = "champion" + SubclassBattleMaster DnDSubclass = "battle_master" + SubclassBerserker DnDSubclass = "berserker" + // Rogue + SubclassThief DnDSubclass = "thief" + SubclassAssassin DnDSubclass = "assassin" + SubclassArcaneTrickster DnDSubclass = "arcane_trickster" + // Mage + SubclassEvocation DnDSubclass = "evocation" + SubclassAbjuration DnDSubclass = "abjuration" + SubclassNecromancy DnDSubclass = "necromancy" + // Cleric + SubclassLifeDomain DnDSubclass = "life_domain" + SubclassWarDomain DnDSubclass = "war_domain" + SubclassTrickeryDomain DnDSubclass = "trickery_domain" + // Ranger + SubclassHunter DnDSubclass = "hunter" + SubclassBeastMaster DnDSubclass = "beast_master" + SubclassGloomStalker DnDSubclass = "gloom_stalker" +) + +// DnDSubclassInfo — registry row. Tagline summarizes identity in one line +// (used in selection prompt). Flavor is the TwinBee narration line shown +// on selection (per design doc §7). +type DnDSubclassInfo struct { + ID DnDSubclass + Class DnDClass + Display string + Tagline string + Flavor string +} + +// dndSubclassRegistry — 15 entries, ordered first by class (Fighter, +// Rogue, Mage, Cleric, Ranger) then by the design doc's listed order +// within each class. The selection prompt presents them in this order. +var dndSubclassRegistry = []DnDSubclassInfo{ + // Fighter + {SubclassChampion, ClassFighter, "Champion", + "Raw power, brutal efficiency.", + "You've chosen the old way. No tricks, no systems. Just you, and the blade, and the next hit. TwinBee approves of this directness."}, + {SubclassBattleMaster, ClassFighter, "Battle Master", + "Tactical superiority, maneuver control.", + "Maneuvers, dice, control of the engagement. TwinBee notes that the smartest fighter in the room rarely throws the first punch."}, + {SubclassBerserker, ClassFighter, "Berserker", + "Rage-fueled destruction, high risk/reward.", + "Rage as a tool, exhaustion as a tax. TwinBee files this under 'load-bearing recklessness'."}, + + // Rogue + {SubclassThief, ClassRogue, "Thief", + "Speed, opportunism, fast hands.", + "Quick fingers, quicker exits. TwinBee suspects the locks of the world have been put on notice."}, + {SubclassAssassin, ClassRogue, "Assassin", + "Setup and execution; devastating openers.", + "Patience, then violence, then nothing. TwinBee respects the economy of motion."}, + {SubclassArcaneTrickster, ClassRogue, "Arcane Trickster", + "Stealth plus a slice of Mage spellcasting.", + "Magic in the hands of a Rogue. TwinBee considers this the universe's way of apologizing for giving you low HP."}, + + // Mage + {SubclassEvocation, ClassMage, "Evocation", + "Pure damage output. The glass cannon perfected.", + "You picked the path of the louder spell. TwinBee finds this aesthetically pleasing and structurally alarming."}, + {SubclassAbjuration, ClassMage, "Abjuration", + "Wards, shields, counterspells.", + "You'd rather not get hit, and you've made it your specialty. TwinBee approves of this kind of foresight."}, + {SubclassNecromancy, ClassMage, "Necromancy", + "Control undead and drain life.", + "Undeath as a tool rather than a state. TwinBee has opinions about this. TwinBee is keeping them to itself."}, + + // Cleric + {SubclassLifeDomain, ClassCleric, "Life Domain", + "Maximum healing output. Party support anchor.", + "Healing as discipline, not afterthought. TwinBee notes the party will live longer because of you, whether they thank you or not."}, + {SubclassWarDomain, ClassCleric, "War Domain", + "Combat Cleric — extra attacks, martial weapons.", + "You came to fight, not to mediate. TwinBee finds this clarifying."}, + {SubclassTrickeryDomain, ClassCleric, "Trickery Domain", + "Illusion, misdirection, chaos.", + "A Cleric who lies for a living. TwinBee delights in the implied theological complications."}, + + // Ranger + {SubclassHunter, ClassRanger, "Hunter", + "Zone specialist; bonuses against enemy types.", + "You read the terrain before you draw the bow. TwinBee finds this professionalism rare and welcome."}, + {SubclassBeastMaster, ClassRanger, "Beast Master", + "Deep Ranger–pet bond. Pet as combat partner.", + "You and your pet, all the way down. TwinBee finds this genuinely moving and declines to be embarrassed about that."}, + {SubclassGloomStalker, ClassRanger, "Gloom Stalker", + "Darkness specialist. Exceptional underground.", + "You were already good in the dark. Now you're better. TwinBee notes: the Underdark is waiting."}, +} + +// subclassInfo returns the registry row for id, or (zero, false). +func subclassInfo(id DnDSubclass) (DnDSubclassInfo, bool) { + for _, s := range dndSubclassRegistry { + if s.ID == id { + return s, true + } + } + return DnDSubclassInfo{}, false +} + +// subclassesForClass returns the 3 subclasses available to a given class, +// in registry/design-doc order. +func subclassesForClass(class DnDClass) []DnDSubclassInfo { + out := make([]DnDSubclassInfo, 0, 3) + for _, s := range dndSubclassRegistry { + if s.Class == class { + out = append(out, s) + } + } + return out +} + +// resolveSubclassInput maps user-supplied input ("1", "champion", +// "battle master", "BattleMaster") to a subclass id valid for the given +// class. Returns ("", false) if no match. +func resolveSubclassInput(class DnDClass, input string) (DnDSubclass, bool) { + subs := subclassesForClass(class) + if len(subs) == 0 { + return "", false + } + trimmed := strings.TrimSpace(input) + if trimmed == "" { + return "", false + } + // Numeric: "1", "2", "3". + if len(trimmed) == 1 && trimmed[0] >= '1' && trimmed[0] <= '9' { + idx := int(trimmed[0] - '1') + if idx >= 0 && idx < len(subs) { + return subs[idx].ID, true + } + } + // Name match: case-insensitive, ignore spaces/underscores. + norm := normalizeSubclassName(trimmed) + for _, s := range subs { + if normalizeSubclassName(string(s.ID)) == norm || + normalizeSubclassName(s.Display) == norm { + return s.ID, true + } + } + return "", false +} + +func normalizeSubclassName(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "") + s = strings.ReplaceAll(s, "_", "") + s = strings.ReplaceAll(s, "-", "") + return s +} + +// renderSubclassPrompt builds the "Choose your path" message for the +// player's class. Used by !subclass with no args and by the L5 level-up +// DM cue. +func renderSubclassPrompt(class DnDClass) string { + subs := subclassesForClass(class) + if len(subs) == 0 { + return "" // unreachable for any registered class + } + ci, _ := classInfo(class) + var b strings.Builder + b.WriteString("⚔️ **Choose your path** — ") + b.WriteString(ci.Display) + b.WriteString(" subclass\n\n") + for i, s := range subs { + b.WriteString(" ") + b.WriteString(string(rune('1' + i))) + b.WriteString(". **") + b.WriteString(s.Display) + b.WriteString("** — ") + b.WriteString(s.Tagline) + b.WriteString("\n") + } + b.WriteString("\nReply: `!subclass ` (e.g. `!subclass 1` or `!subclass champion`).") + return b.String() +} + +// allSubclassIDs — used by tests/audits to walk every subclass. +func allSubclassIDs() []DnDSubclass { + ids := make([]DnDSubclass, 0, len(dndSubclassRegistry)) + for _, s := range dndSubclassRegistry { + ids = append(ids, s.ID) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} diff --git a/internal/plugin/dnd_subclass_cmd.go b/internal/plugin/dnd_subclass_cmd.go new file mode 100644 index 0000000..11d4703 --- /dev/null +++ b/internal/plugin/dnd_subclass_cmd.go @@ -0,0 +1,145 @@ +package plugin + +import ( + "fmt" + "strings" + "time" +) + +// !subclass — Phase 10 SUB1. +// +// Usage: +// +// !subclass → show selection prompt for player's class +// !subclass → choose / change subclass +// +// Rules: +// - L<5: blocked with hint message. +// - First selection (Subclass == ""): free, no cooldown. +// - Subsequent change: 30-day cooldown + 500-coin cost. Same surface +// command — no separate `respec` subcommand. The full-character-wipe +// `!respec` command is unchanged. +// +// Mechanical effects of L5/7/10/15 subclass abilities arrive in SUB2/SUB3. + +const ( + dndSubclassMinLevel = 5 + dndSubclassRespecCost = 500 + dndSubclassRespecCooldown = 30 * 24 * time.Hour +) + +func (p *AdventurePlugin) handleDnDSubclassCmd(ctx MessageContext, args string) error { + userMu := p.advUserLock(ctx.Sender) + userMu.Lock() + defer userMu.Unlock() + + c, err := LoadDnDCharacter(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error()) + } + if c == nil || c.PendingSetup { + return p.SendDM(ctx.Sender, + "No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).") + } + if c.Level < dndSubclassMinLevel { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Subclass selection unlocks at **Level %d**. You're currently L%d.", + dndSubclassMinLevel, c.Level)) + } + if len(subclassesForClass(c.Class)) == 0 { + return p.SendDM(ctx.Sender, "Your class has no subclasses registered yet.") + } + + args = strings.TrimSpace(args) + + // Bare `!subclass` → prompt or current. + if args == "" { + if c.Subclass == "" { + return p.SendDM(ctx.Sender, renderSubclassPrompt(c.Class)) + } + info, _ := subclassInfo(c.Subclass) + var b strings.Builder + b.WriteString(fmt.Sprintf("**Current subclass:** %s — _%s_\n\n", info.Display, info.Tagline)) + b.WriteString(fmt.Sprintf( + "To change: `!subclass ` — costs **%d euros**, **%d-day** cooldown.\n", + dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour)))) + return p.SendDM(ctx.Sender, b.String()) + } + + // `!subclass ` → set or change. + chosen, ok := resolveSubclassInput(c.Class, args) + if !ok { + var b strings.Builder + b.WriteString("Unknown subclass for your class. Options:\n\n") + b.WriteString(renderSubclassPrompt(c.Class)) + return p.SendDM(ctx.Sender, b.String()) + } + + // First selection — free. + if c.Subclass == "" { + return p.applySubclassChoice(ctx, c, chosen, false) + } + + // Same subclass already chosen — no-op. + if c.Subclass == chosen { + info, _ := subclassInfo(chosen) + return p.SendDM(ctx.Sender, fmt.Sprintf( + "You're already a **%s**. No change made.", info.Display)) + } + + // Change — gated by cooldown + cost. + if c.LastSubclassRespecAt != nil { + elapsed := time.Since(*c.LastSubclassRespecAt) + if elapsed < dndSubclassRespecCooldown { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Subclass change is on cooldown — %s remaining.", + formatRespecDuration(dndSubclassRespecCooldown-elapsed))) + } + } + if p.euro == nil || p.euro.GetBalance(ctx.Sender) < float64(dndSubclassRespecCost) { + return p.SendDM(ctx.Sender, fmt.Sprintf( + "Changing subclass costs **%d euros** — you don't have enough.", + dndSubclassRespecCost)) + } + return p.applySubclassChoice(ctx, c, chosen, true) +} + +// applySubclassChoice writes the new subclass to the character and (if +// charged) debits the cost. Saves before debiting (audit pattern from +// !respec): if the save fails the player keeps their euros. +func (p *AdventurePlugin) applySubclassChoice( + ctx MessageContext, c *DnDCharacter, chosen DnDSubclass, charged bool, +) error { + prev := c.Subclass + c.Subclass = chosen + if charged { + now := time.Now().UTC() + c.LastSubclassRespecAt = &now + } + if err := SaveDnDCharacter(c); err != nil { + return p.SendDM(ctx.Sender, "Couldn't save subclass choice: "+err.Error()) + } + if charged { + // Debit last; race vs. balance change is rare and strictly safer + // to favor the player than risk taking euros without applying the + // change (matches the !respec ordering). + // Race vs. balance change is rare; if Debit fails the save + // already landed. Mirrors the !respec ordering in dnd_setup.go. + _ = p.euro.Debit(ctx.Sender, float64(dndSubclassRespecCost), "dnd subclass change") + } + + info, _ := subclassInfo(chosen) + var b strings.Builder + if prev == "" { + b.WriteString(fmt.Sprintf("🎯 **Subclass selected: %s**\n\n", info.Display)) + } else { + prevInfo, _ := subclassInfo(prev) + b.WriteString(fmt.Sprintf("🎯 **Subclass changed: %s → %s**\n\n", + prevInfo.Display, info.Display)) + b.WriteString(fmt.Sprintf("_%d euros spent. Cooldown: %dd before next change._\n\n", + dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour)))) + } + b.WriteString("_" + info.Flavor + "_\n\n") + b.WriteString("_(Mechanical L5/7/10/15 subclass abilities land in upcoming phases.)_") + return p.SendDM(ctx.Sender, b.String()) +} diff --git a/internal/plugin/dnd_subclass_test.go b/internal/plugin/dnd_subclass_test.go new file mode 100644 index 0000000..4d50800 --- /dev/null +++ b/internal/plugin/dnd_subclass_test.go @@ -0,0 +1,357 @@ +package plugin + +import ( + "strings" + "testing" + "time" + + "maunium.net/go/mautrix/id" +) + +// Phase 10 SUB1 — subclass selection. + +// ── Registry sanity ────────────────────────────────────────────────────── + +func TestSubclassRegistry_FifteenEntriesThreePerClass(t *testing.T) { + if got := len(dndSubclassRegistry); got != 15 { + t.Fatalf("registry size: got %d, want 15", got) + } + perClass := map[DnDClass]int{} + for _, s := range dndSubclassRegistry { + perClass[s.Class]++ + if s.ID == "" || s.Display == "" || s.Tagline == "" || s.Flavor == "" { + t.Errorf("incomplete entry: %+v", s) + } + } + for _, cls := range []DnDClass{ClassFighter, ClassRogue, ClassMage, ClassCleric, ClassRanger} { + if perClass[cls] != 3 { + t.Errorf("%s: %d subclasses, want 3", cls, perClass[cls]) + } + } +} + +func TestSubclassRegistry_UniqueIDs(t *testing.T) { + seen := map[DnDSubclass]bool{} + for _, s := range dndSubclassRegistry { + if seen[s.ID] { + t.Errorf("duplicate subclass id: %s", s.ID) + } + seen[s.ID] = true + } +} + +// ── resolveSubclassInput ───────────────────────────────────────────────── + +func TestResolveSubclassInput_ByNumber(t *testing.T) { + got, ok := resolveSubclassInput(ClassFighter, "1") + if !ok || got != SubclassChampion { + t.Errorf("Fighter '1' = (%q, %v), want (champion, true)", got, ok) + } + got, ok = resolveSubclassInput(ClassFighter, "3") + if !ok || got != SubclassBerserker { + t.Errorf("Fighter '3' = (%q, %v), want (berserker, true)", got, ok) + } + if _, ok := resolveSubclassInput(ClassFighter, "4"); ok { + t.Error("Fighter '4' should be invalid") + } +} + +func TestResolveSubclassInput_ByNameAndDisplay(t *testing.T) { + cases := []struct { + class DnDClass + input string + want DnDSubclass + }{ + {ClassFighter, "champion", SubclassChampion}, + {ClassFighter, "Champion", SubclassChampion}, + {ClassFighter, "Battle Master", SubclassBattleMaster}, + {ClassFighter, "battlemaster", SubclassBattleMaster}, + {ClassFighter, "battle_master", SubclassBattleMaster}, + {ClassRogue, "Arcane Trickster", SubclassArcaneTrickster}, + {ClassMage, "necromancy", SubclassNecromancy}, + {ClassCleric, "life domain", SubclassLifeDomain}, + {ClassRanger, "Gloom-Stalker", SubclassGloomStalker}, + } + for _, tc := range cases { + got, ok := resolveSubclassInput(tc.class, tc.input) + if !ok || got != tc.want { + t.Errorf("%s/%q: got (%q,%v), want (%q,true)", tc.class, tc.input, got, ok, tc.want) + } + } +} + +func TestResolveSubclassInput_WrongClass(t *testing.T) { + // "champion" belongs to Fighter — should not resolve for Rogue. + if _, ok := resolveSubclassInput(ClassRogue, "champion"); ok { + t.Error("champion should not resolve under Rogue") + } +} + +// ── renderSubclassPrompt ───────────────────────────────────────────────── + +func TestRenderSubclassPrompt_ContainsAllThree(t *testing.T) { + got := renderSubclassPrompt(ClassMage) + for _, want := range []string{"Evocation", "Abjuration", "Necromancy", "Mage"} { + if !strings.Contains(got, want) { + t.Errorf("prompt missing %q:\n%s", want, got) + } + } +} + +// ── !subclass command flow ─────────────────────────────────────────────── + +func subclassTestCharacter(t *testing.T, uid id.UserID, level int) *DnDCharacter { + t.Helper() + if err := createAdvCharacter(uid, "subclass"); err != nil { + t.Fatal(err) + } + c := &DnDCharacter{ + UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: level, + STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 16, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + return c +} + +func TestSubclass_BlockedBelowLevel5(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_low:example") + subclassTestCharacter(t, uid, 4) + p := &AdventurePlugin{euro: &EuroPlugin{}} + // Should not error, just send a "level" message; we rely on the row + // staying empty. + if err := p.handleDnDSubclassCmd(MessageContext{Sender: uid}, "champion"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Subclass != "" { + t.Errorf("subclass set despite L4: %q", got.Subclass) + } +} + +func TestSubclass_FirstSelectionFreeAndPersists(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_first:example") + subclassTestCharacter(t, uid, 5) + euro := &EuroPlugin{} + euro.ensureBalance(uid) + bal := euro.GetBalance(uid) + p := &AdventurePlugin{euro: euro} + + if err := p.handleDnDSubclassCmd(MessageContext{Sender: uid}, "champion"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Subclass != SubclassChampion { + t.Errorf("subclass = %q, want champion", got.Subclass) + } + if got.LastSubclassRespecAt != nil { + t.Errorf("first selection should not set cooldown timestamp") + } + if euro.GetBalance(uid) != bal { + t.Errorf("first selection should be free; balance %.0f → %.0f", bal, euro.GetBalance(uid)) + } +} + +func TestSubclass_SameChoiceIsNoop(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_same:example") + c := subclassTestCharacter(t, uid, 5) + c.Subclass = SubclassChampion + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + euro := &EuroPlugin{} + euro.ensureBalance(uid) + euro.Credit(uid, 10000, "test") + bal := euro.GetBalance(uid) + p := &AdventurePlugin{euro: euro} + + if err := p.handleDnDSubclassCmd(MessageContext{Sender: uid}, "champion"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.LastSubclassRespecAt != nil { + t.Error("same-choice no-op should not set cooldown") + } + if euro.GetBalance(uid) != bal { + t.Error("same-choice no-op should not debit") + } +} + +func TestSubclass_ChangeChargesAndSetsCooldown(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_change:example") + c := subclassTestCharacter(t, uid, 6) + c.Subclass = SubclassChampion + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + euro := &EuroPlugin{} + euro.ensureBalance(uid) + euro.Credit(uid, 10000, "test") + startBal := euro.GetBalance(uid) + p := &AdventurePlugin{euro: euro} + + if err := p.handleDnDSubclassCmd(MessageContext{Sender: uid}, "berserker"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Subclass != SubclassBerserker { + t.Errorf("subclass = %q, want berserker", got.Subclass) + } + if got.LastSubclassRespecAt == nil { + t.Error("cooldown timestamp not set after charged change") + } + if delta := startBal - euro.GetBalance(uid); delta < float64(dndSubclassRespecCost) { + t.Errorf("debited %.0f, want at least %d", delta, dndSubclassRespecCost) + } +} + +func TestSubclass_ChangeBlockedByCooldown(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_cd:example") + c := subclassTestCharacter(t, uid, 6) + c.Subclass = SubclassChampion + now := time.Now().UTC().Add(-1 * time.Hour) // 1h ago — well inside 30d + c.LastSubclassRespecAt = &now + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + euro := &EuroPlugin{} + euro.ensureBalance(uid) + euro.Credit(uid, 10000, "test") + bal := euro.GetBalance(uid) + p := &AdventurePlugin{euro: euro} + + if err := p.handleDnDSubclassCmd(MessageContext{Sender: uid}, "berserker"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Subclass != SubclassChampion { + t.Errorf("subclass changed despite cooldown: %q", got.Subclass) + } + if euro.GetBalance(uid) != bal { + t.Error("cooldown-blocked change should not debit") + } +} + +func TestSubclass_ChangeBlockedByInsufficientFunds(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_broke:example") + c := subclassTestCharacter(t, uid, 6) + c.Subclass = SubclassChampion + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + euro := &EuroPlugin{} + euro.ensureBalance(uid) + // No credit — balance ~0. + p := &AdventurePlugin{euro: euro} + + if err := p.handleDnDSubclassCmd(MessageContext{Sender: uid}, "berserker"); err != nil { + t.Fatal(err) + } + got, _ := LoadDnDCharacter(uid) + if got.Subclass != SubclassChampion { + t.Errorf("subclass changed despite insufficient funds: %q", got.Subclass) + } +} + +// ── level-up DM cue ────────────────────────────────────────────────────── + +func TestBuildLevelUpMessage_PromptsAtL5WhenUnchosen(t *testing.T) { + c := &DnDCharacter{ + UserID: "@x:example", Race: RaceHuman, Class: ClassFighter, + Level: 5, HPMax: 40, + } + msg := buildLevelUpMessage(c, []LevelUpEvent{{NewLevel: 5, HPGain: 7}}, "") + if !strings.Contains(msg, "Choose your path") { + t.Errorf("L5 level-up missing subclass prompt:\n%s", msg) + } + if !strings.Contains(msg, "Champion") { + t.Errorf("L5 prompt should list Champion:\n%s", msg) + } +} + +func TestBuildLevelUpMessage_NoPromptIfAlreadyChosen(t *testing.T) { + c := &DnDCharacter{ + UserID: "@x:example", Race: RaceHuman, Class: ClassFighter, + Level: 6, HPMax: 47, Subclass: SubclassChampion, + } + msg := buildLevelUpMessage(c, []LevelUpEvent{{NewLevel: 6, HPGain: 7}}, "") + if strings.Contains(msg, "Choose your path") { + t.Errorf("L6 level-up should not re-prompt:\n%s", msg) + } +} + +func TestBuildLevelUpMessage_NoPromptBelowL5(t *testing.T) { + c := &DnDCharacter{ + UserID: "@x:example", Race: RaceHuman, Class: ClassFighter, + Level: 4, HPMax: 33, + } + msg := buildLevelUpMessage(c, []LevelUpEvent{{NewLevel: 4, HPGain: 7}}, "") + if strings.Contains(msg, "Choose your path") { + t.Errorf("L4 should not prompt:\n%s", msg) + } +} + +// ── sheet display ──────────────────────────────────────────────────────── + +func TestSheet_ShowsSubclassWhenChosen(t *testing.T) { + c := &DnDCharacter{ + UserID: "@x:example", Race: RaceHuman, Class: ClassFighter, + Level: 7, HPMax: 50, HPCurrent: 50, ArmorClass: 16, + STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + Subclass: SubclassBattleMaster, + } + out := renderDnDSheet(c, nil, nil, nil) + if !strings.Contains(out, "Battle Master") { + t.Errorf("sheet missing subclass name:\n%s", out) + } +} + +func TestSheet_PromptsWhenUnchosenAtL5(t *testing.T) { + c := &DnDCharacter{ + UserID: "@x:example", Race: RaceHuman, Class: ClassFighter, + Level: 5, HPMax: 40, HPCurrent: 40, ArmorClass: 16, + STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, + } + out := renderDnDSheet(c, nil, nil, nil) + if !strings.Contains(out, "!subclass") { + t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out) + } +} + +// ── schema migration round-trip ────────────────────────────────────────── + +func TestSubclass_SchemaRoundTrip(t *testing.T) { + setupAuditTestDB(t) + uid := id.UserID("@subclass_rt:example") + if err := createAdvCharacter(uid, "rt"); err != nil { + t.Fatal(err) + } + now := time.Now().UTC().Truncate(time.Second) + c := &DnDCharacter{ + UserID: uid, Race: RaceElf, Class: ClassMage, Level: 7, + STR: 8, DEX: 14, CON: 12, INT: 17, WIS: 12, CHA: 10, + HPMax: 30, HPCurrent: 30, ArmorClass: 12, + Subclass: SubclassEvocation, LastSubclassRespecAt: &now, + } + if err := SaveDnDCharacter(c); err != nil { + t.Fatal(err) + } + got, err := LoadDnDCharacter(uid) + if err != nil || got == nil { + t.Fatalf("load: %v %v", got, err) + } + if got.Subclass != SubclassEvocation { + t.Errorf("subclass roundtrip: got %q, want evocation", got.Subclass) + } + if got.LastSubclassRespecAt == nil || !got.LastSubclassRespecAt.Equal(now) { + t.Errorf("LastSubclassRespecAt roundtrip: got %v, want %v", got.LastSubclassRespecAt, now) + } +} diff --git a/internal/plugin/dnd_xp.go b/internal/plugin/dnd_xp.go index bd701ef..60ec8f2 100644 --- a/internal/plugin/dnd_xp.go +++ b/internal/plugin/dnd_xp.go @@ -189,13 +189,18 @@ func buildLevelUpMessage(c *DnDCharacter, events []LevelUpEvent, flavor string) } } - // 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. - for _, ev := range events { - if ev.NewLevel == 5 { - b.WriteString("\n🎯 _Subclass selection unlocks at L5 — coming in a future update._") - break + // Phase 10 — subclass selection cue. Fires when the player crosses L5 + // without already having a subclass (rare path: a multi-level grant + // that crosses L5 keeps prompting until they pick). Existing-subclass + // cases stay quiet on later level-ups. + if c.Subclass == "" && c.Level >= dndSubclassMinLevel { + for _, ev := range events { + if ev.NewLevel >= dndSubclassMinLevel { + b.WriteString("\n🎯 ") + b.WriteString(renderSubclassPrompt(c.Class)) + b.WriteString("\n") + break + } } } return b.String()