diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index f95d8c6..7cb3231 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -1,6 +1,9 @@ package plugin import ( + "database/sql" + "errors" + "strings" "time" "gogobee/internal/db" @@ -536,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error { return tx.Commit() } +// ensurePlayerMetaSeed guarantees the canonical player_meta seed row (and tier-0 +// equipment) exists for userID, creating it only when absent. The auto-migration +// path (ensureDnDCharacterForCombat) writes a confirmed dnd_character without +// touching the legacy layer; without this, a brand-new player whose first-ever +// action auto-migrates — e.g. !rest or !cast before !setup — ends up with a +// player_meta-less character that fails every legacy-layer command with +// "sql: no rows" (the camcast straggler). Conditional on absence and thus +// idempotent: legacy players who already have player_meta keep their equipment +// untouched — createAdvCharacter's tier-0 equipment insert has no conflict guard +// and would otherwise duplicate their gear. +func ensurePlayerMetaSeed(userID id.UserID) error { + d := db.Get() + var one int + err := d.QueryRow(`SELECT 1 FROM player_meta WHERE user_id = ?`, string(userID)).Scan(&one) + if err == nil { + return nil // already seeded + } + if !errors.Is(err, sql.ErrNoRows) { + return err + } + return createAdvCharacter(userID, localpartOf(userID)) +} + +// localpartOf returns the mxid localpart (between @ and :) as a display-name +// fallback — matches Base.DisplayName's offline behavior. The seed's display +// name is overlaid by later player_meta upserts; this is just a sane default +// for a character born without a Matrix client in reach. +func localpartOf(userID id.UserID) string { + s := string(userID) + if i := strings.Index(s, ":"); i > 0 { + return s[1:i] + } + return strings.TrimPrefix(s, "@") +} + // saveAdvCharacter persists every mutable AdventureCharacter field to // player_meta. Phase L5h: the legacy adventure_characters UPDATE has been // retired — the row is now read-only after createAdvCharacter seeds it, diff --git a/internal/plugin/dnd_combat.go b/internal/plugin/dnd_combat.go index 4c92336..792ca7f 100644 --- a/internal/plugin/dnd_combat.go +++ b/internal/plugin/dnd_combat.go @@ -1,6 +1,7 @@ package plugin import ( + "fmt" "log/slog" "time" @@ -305,6 +306,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D c := autoBuildCharacter(userID, char) c.AutoMigrated = true c.PendingSetup = false + // Seed the canonical player_meta row before handing back a confirmed + // character. Auto-migration otherwise mints a player_meta-less character + // (the camcast straggler) that fails every legacy-layer command with + // "sql: no rows". No-op for legacy players who already have the row. + if err := ensurePlayerMetaSeed(userID); err != nil { + return nil, false, fmt.Errorf("seed player_meta: %w", err) + } if err := SaveDnDCharacter(c); err != nil { return nil, false, err } diff --git a/internal/plugin/player_meta_seed_test.go b/internal/plugin/player_meta_seed_test.go new file mode 100644 index 0000000..76f3351 --- /dev/null +++ b/internal/plugin/player_meta_seed_test.go @@ -0,0 +1,81 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// TestAutoMigrationSeedsPlayerMeta guards the camcast straggler regression: a +// brand-new player whose first-ever adventure action auto-migrates a character +// (e.g. !rest before !setup) must come out with a canonical player_meta seed, +// or every legacy-layer command (expeditions, arena, world boss, town…) fails +// with "sql: no rows". Before the ensurePlayerMetaSeed guard this produced a +// confirmed dnd_character with zero player_meta rows. +func TestAutoMigrationSeedsPlayerMeta(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@fresh-automigrant:example.org") + + p := &AdventurePlugin{euro: &EuroPlugin{}} + p.Sink = &captureSink{} + + // First-ever adventure action for a player with no character at all. + if err := p.handleDnDShortRest(MessageContext{Sender: uid}); err != nil { + t.Fatalf("handleDnDShortRest: %v", err) + } + + d := db.Get() + var dndRows, pmRows, equipRows, pending int + d.QueryRow(`SELECT COUNT(*) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&dndRows) + d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows) + d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows) + d.QueryRow(`SELECT COALESCE(MAX(pending_setup),-1) FROM dnd_character WHERE user_id=?`, string(uid)).Scan(&pending) + + if dndRows != 1 { + t.Fatalf("expected 1 auto-migrated dnd_character, got %d", dndRows) + } + if pending != 0 { + t.Errorf("auto-migrated character should be confirmed (pending_setup=0), got %d", pending) + } + if pmRows != 1 { + t.Errorf("player_meta not seeded for auto-migrant: got %d rows, want 1 (camcast straggler regression)", pmRows) + } + // createAdvCharacter seeds tier-0 gear in all five slots. + if equipRows != len(allSlots) { + t.Errorf("tier-0 equipment not seeded: got %d rows, want %d", equipRows, len(allSlots)) + } + + // The character must now load through the legacy layer that camcast choked on. + if _, err := loadAdvCharacter(uid); err != nil { + t.Errorf("loadAdvCharacter after auto-migration: %v (this is the exact failure camcast hit)", err) + } +} + +// TestEnsurePlayerMetaSeedIdempotent proves the guard never duplicates a legacy +// player's equipment — createAdvCharacter's tier-0 insert has no conflict guard, +// so the seed must be conditional on player_meta being absent. +func TestEnsurePlayerMetaSeedIdempotent(t *testing.T) { + setupEmptyTestDB(t) + uid := id.UserID("@legacy:example.org") + + // Seed once (fresh), then again (should be a no-op). + if err := ensurePlayerMetaSeed(uid); err != nil { + t.Fatalf("first seed: %v", err) + } + if err := ensurePlayerMetaSeed(uid); err != nil { + t.Fatalf("second seed: %v", err) + } + + d := db.Get() + var pmRows, equipRows int + d.QueryRow(`SELECT COUNT(*) FROM player_meta WHERE user_id=?`, string(uid)).Scan(&pmRows) + d.QueryRow(`SELECT COUNT(*) FROM adventure_equipment WHERE user_id=?`, string(uid)).Scan(&equipRows) + if pmRows != 1 { + t.Errorf("player_meta rows = %d, want 1 (no duplication)", pmRows) + } + if equipRows != len(allSlots) { + t.Errorf("equipment rows = %d, want %d (guard must not re-insert gear)", equipRows, len(allSlots)) + } +}