mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Creates the player_meta table (gogobee_legacy_migration.md §2.1) with the three columns L2 needs: arena_wins, arena_losses, invasion_score. Future phases ALTER in their own columns. Naming follows the §2.0 callout — no dnd_ prefix on new tables. Helpers in internal/plugin/player_meta.go: loadPlayerMeta, upsertPlayerMetaArena, backfillPlayerMetaArena (INSERT OR IGNORE, idempotent, logs row count per Phase R1 precedent). Plugin Init runs the backfill once on startup. Dual-write per §11: every char.ArenaWins++/ArenaLosses++ in adventure_arena.go also calls upsertPlayerMetaArena. AdvCharacter columns stay in place — reads will switch back to AdvCharacter trivially if a rollback is needed before L5 teardown. Read swap: handleArenaStats and renderDnDSheet now load player_meta and prefer its values (falling back to AdvCharacter if the row is missing, which only matters during the deploy window before backfill runs). renderArenaPersonalStats's signature changed from (char, stats) to (displayName, wins, losses, stats) so the render is DB-free. Tests: TestPlayerMetaArenaBackfill_Idempotent verifies double-run backfill is a no-op and doesn't clobber post-backfill dual-writes; TestUpsertPlayerMetaArena_RoundTrip covers insert/update/missing-user paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
358 lines
12 KiB
Go
358 lines
12 KiB
Go
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, 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, 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)
|
|
}
|
|
}
|