mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 D&D Phase 10 SUB1: subclass selection system
Implements the identity layer of gogobee_subclass_system.md: 15 subclasses (3 per class) selectable at L5 via !subclass. - Schema: new subclass + last_subclass_respec_at columns on dnd_character. - Registry (dnd_subclass.go): 15 entries with class, display, tagline, and TwinBee flavor line; helpers for prompt rendering and input resolution (number, id, or display-name; case/space/hyphen-insensitive). - !subclass command: bare form prints prompt or current; !subclass <name> selects (free first time) or changes (500 euros + 30-day cooldown via separate timestamp from the full !respec wipe). Save-then-debit ordering mirrors !respec audit fix B. - Level-up DM at L5+ now embeds the real selection prompt instead of the Phase 9 placeholder; suppressed once a subclass is set. - Sheet shows the subclass below the class line, or nudges !subclass when unchosen at L5+. - Existing !respec full-wipe also clears subclass + cooldown timestamp. Mechanical effects of L5/7/10/15 subclass abilities deferred to SUB2/SUB3.
This commit is contained in:
@@ -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 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_spell TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE dnd_character ADD COLUMN concentration_expires_at DATETIME`,
|
`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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func (p *AdventurePlugin) Commands() []CommandDef {
|
|||||||
{Name: "sheet", Description: "View your Adv 2.0 character sheet", Usage: "!sheet", Category: "Games"},
|
{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: "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: "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 <skill> [dc]", Category: "Games"},
|
{Name: "check", Description: "Roll an Adv 2.0 skill check (d20 + ability modifier vs DC)", Usage: "!check <skill> [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: "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 <ability>", Category: "Games"},
|
{Name: "arm", Description: "Pre-arm an active Adv 2.0 ability for next combat (consumes resource)", Usage: "!arm <ability>", Category: "Games"},
|
||||||
@@ -216,6 +217,9 @@ func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
|
|||||||
if p.IsCommand(ctx.Body, "respec") {
|
if p.IsCommand(ctx.Body, "respec") {
|
||||||
return p.handleDnDRespecCmd(ctx)
|
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") {
|
if p.IsCommand(ctx.Body, "check") {
|
||||||
return p.handleDnDCheckCmd(ctx, p.GetArgs(ctx.Body, "check"))
|
return p.handleDnDCheckCmd(ctx, p.GetArgs(ctx.Body, "check"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,12 @@ type DnDCharacter struct {
|
|||||||
PendingCast string
|
PendingCast string
|
||||||
ConcentrationSpell string
|
ConcentrationSpell string
|
||||||
ConcentrationExpiresAt *time.Time
|
ConcentrationExpiresAt *time.Time
|
||||||
|
// Phase 10 — subclass.
|
||||||
|
// Subclass: empty until the player runs !subclass at L5+. Once set,
|
||||||
|
// changing requires `!subclass <name>` again subject to a 30-day
|
||||||
|
// cooldown (LastSubclassRespecAt) and 500-coin cost.
|
||||||
|
Subclass DnDSubclass
|
||||||
|
LastSubclassRespecAt *time.Time
|
||||||
LastRespecAt *time.Time
|
LastRespecAt *time.Time
|
||||||
LastShortRestAt *time.Time
|
LastShortRestAt *time.Time
|
||||||
LastLongRestAt *time.Time
|
LastLongRestAt *time.Time
|
||||||
@@ -204,18 +210,20 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
|||||||
hp_current, hp_max, temp_hp, armor_class,
|
hp_current, hp_max, temp_hp, armor_class,
|
||||||
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
||||||
pending_cast, concentration_spell, concentration_expires_at,
|
pending_cast, concentration_spell, concentration_expires_at,
|
||||||
|
subclass, last_subclass_respec_at,
|
||||||
last_respec_at, last_short_rest_at, last_long_rest_at,
|
last_respec_at, last_short_rest_at, last_long_rest_at,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
FROM dnd_character WHERE user_id = ?`, string(userID))
|
FROM dnd_character WHERE user_id = ?`, string(userID))
|
||||||
c := &DnDCharacter{}
|
c := &DnDCharacter{}
|
||||||
var pending, autoMig, onboard int
|
var pending, autoMig, onboard int
|
||||||
var raceStr, classStr string
|
var raceStr, classStr, subclassStr string
|
||||||
var uidStr string
|
var uidStr string
|
||||||
err := row.Scan(&uidStr, &raceStr, &classStr, &c.Level, &c.XP,
|
err := row.Scan(&uidStr, &raceStr, &classStr, &c.Level, &c.XP,
|
||||||
&c.STR, &c.DEX, &c.CON, &c.INT, &c.WIS, &c.CHA,
|
&c.STR, &c.DEX, &c.CON, &c.INT, &c.WIS, &c.CHA,
|
||||||
&c.HPCurrent, &c.HPMax, &c.TempHP, &c.ArmorClass,
|
&c.HPCurrent, &c.HPMax, &c.TempHP, &c.ArmorClass,
|
||||||
&pending, &autoMig, &onboard, &c.ArmedAbility,
|
&pending, &autoMig, &onboard, &c.ArmedAbility,
|
||||||
&c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt,
|
&c.PendingCast, &c.ConcentrationSpell, &c.ConcentrationExpiresAt,
|
||||||
|
&subclassStr, &c.LastSubclassRespecAt,
|
||||||
&c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt,
|
&c.LastRespecAt, &c.LastShortRestAt, &c.LastLongRestAt,
|
||||||
&c.CreatedAt, &c.UpdatedAt)
|
&c.CreatedAt, &c.UpdatedAt)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -227,6 +235,7 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
|||||||
c.UserID = id.UserID(uidStr)
|
c.UserID = id.UserID(uidStr)
|
||||||
c.Race = DnDRace(raceStr)
|
c.Race = DnDRace(raceStr)
|
||||||
c.Class = DnDClass(classStr)
|
c.Class = DnDClass(classStr)
|
||||||
|
c.Subclass = DnDSubclass(subclassStr)
|
||||||
c.PendingSetup = pending == 1
|
c.PendingSetup = pending == 1
|
||||||
c.AutoMigrated = autoMig == 1
|
c.AutoMigrated = autoMig == 1
|
||||||
c.OnboardingSent = onboard == 1
|
c.OnboardingSent = onboard == 1
|
||||||
@@ -253,8 +262,9 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
|||||||
hp_current, hp_max, temp_hp, armor_class,
|
hp_current, hp_max, temp_hp, armor_class,
|
||||||
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
pending_setup, auto_migrated, onboarding_sent, armed_ability,
|
||||||
pending_cast, concentration_spell, concentration_expires_at,
|
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)
|
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
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
race=excluded.race, class=excluded.class,
|
race=excluded.race, class=excluded.class,
|
||||||
dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp,
|
dnd_level=excluded.dnd_level, dnd_xp=excluded.dnd_xp,
|
||||||
@@ -270,6 +280,8 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
|||||||
pending_cast=excluded.pending_cast,
|
pending_cast=excluded.pending_cast,
|
||||||
concentration_spell=excluded.concentration_spell,
|
concentration_spell=excluded.concentration_spell,
|
||||||
concentration_expires_at=excluded.concentration_expires_at,
|
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_respec_at=excluded.last_respec_at,
|
||||||
last_short_rest_at=excluded.last_short_rest_at,
|
last_short_rest_at=excluded.last_short_rest_at,
|
||||||
last_long_rest_at=excluded.last_long_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,
|
c.HPCurrent, c.HPMax, c.TempHP, c.ArmorClass,
|
||||||
pending, autoMig, onboard, c.ArmedAbility,
|
pending, autoMig, onboard, c.ArmedAbility,
|
||||||
c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt,
|
c.PendingCast, c.ConcentrationSpell, c.ConcentrationExpiresAt,
|
||||||
|
string(c.Subclass), c.LastSubclassRespecAt,
|
||||||
c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt)
|
c.LastRespecAt, c.LastShortRestAt, c.LastLongRestAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("save dnd_character: %w", err)
|
return fmt.Errorf("save dnd_character: %w", err)
|
||||||
|
|||||||
@@ -383,6 +383,10 @@ func (p *AdventurePlugin) handleDnDRespecCmd(ctx MessageContext) error {
|
|||||||
c.PendingSetup = true
|
c.PendingSetup = true
|
||||||
c.AutoMigrated = false
|
c.AutoMigrated = false
|
||||||
c.LastRespecAt = &now
|
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
|
// 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
|
// player's old state survives and they keep their euros — better than
|
||||||
// a destructive debit-without-wipe.
|
// a destructive debit-without-wipe.
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, equip map[Equipmen
|
|||||||
name = adv.DisplayName
|
name = adv.DisplayName
|
||||||
}
|
}
|
||||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
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")
|
b.WriteString(strings.Repeat("─", 36) + "\n")
|
||||||
|
|
||||||
// Vitals
|
// Vitals
|
||||||
|
|||||||
209
internal/plugin/dnd_subclass.go
Normal file
209
internal/plugin/dnd_subclass.go
Normal file
@@ -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 <name or number>` (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
|
||||||
|
}
|
||||||
145
internal/plugin/dnd_subclass_cmd.go
Normal file
145
internal/plugin/dnd_subclass_cmd.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// !subclass — Phase 10 SUB1.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
//
|
||||||
|
// !subclass → show selection prompt for player's class
|
||||||
|
// !subclass <name|number> → 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 <name>` — costs **%d euros**, **%d-day** cooldown.\n",
|
||||||
|
dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour))))
|
||||||
|
return p.SendDM(ctx.Sender, b.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// `!subclass <name|number>` → 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())
|
||||||
|
}
|
||||||
357
internal/plugin/dnd_subclass_test.go
Normal file
357
internal/plugin/dnd_subclass_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,15 +189,20 @@ func buildLevelUpMessage(c *DnDCharacter, events []LevelUpEvent, flavor string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subclass selection cue: design doc specs a prompt at L5. Mechanics
|
// Phase 10 — subclass selection cue. Fires when the player crosses L5
|
||||||
// arrive in a future phase; for now the level-up DM mentions it so the
|
// without already having a subclass (rare path: a multi-level grant
|
||||||
// player isn't surprised when it lands.
|
// 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 {
|
for _, ev := range events {
|
||||||
if ev.NewLevel == 5 {
|
if ev.NewLevel >= dndSubclassMinLevel {
|
||||||
b.WriteString("\n🎯 _Subclass selection unlocks at L5 — coming in a future update._")
|
b.WriteString("\n🎯 ")
|
||||||
|
b.WriteString(renderSubclassPrompt(c.Class))
|
||||||
|
b.WriteString("\n")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user