Seed player_meta on auto-migration to prevent player_meta-less stragglers

The !setup confirm path seeds the canonical player_meta row (commit
667f87f), but the auto-migration path (ensureDnDCharacterForCombat) writes
a confirmed dnd_character without touching the legacy layer. Commands that
trigger it — !rest, !cast, !abilities, !skills — derive the adventure char
via a bare loadAdvCharacter that is nil when player_meta is absent, and
autoBuildCharacter tolerates a nil char. So a brand-new player whose
first-ever adventure action is one of those gets a confirmed character with
no player_meta, which then fails every legacy-layer command (expeditions,
arena, world boss, town, duels) with "sql: no rows" — the same state
@camcast was found in.

Fix: ensurePlayerMetaSeed guarantees the seed row (+ tier-0 equipment)
exists at the fresh auto-migration point. Conditional on player_meta being
absent, so it's idempotent and never duplicates a legacy player's gear
(createAdvCharacter's equipment insert has no conflict guard).

Regression tests cover both the straggler repro (first-ever !rest seeds
player_meta + loads cleanly) and idempotency (no equipment duplication).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
This commit is contained in:
prosolis
2026-07-10 23:50:48 -07:00
parent 59319ede9d
commit fedd357a29
3 changed files with 127 additions and 0 deletions

View File

@@ -1,6 +1,9 @@
package plugin package plugin
import ( import (
"database/sql"
"errors"
"strings"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
@@ -536,6 +539,41 @@ func createAdvCharacter(userID id.UserID, displayName string) error {
return tx.Commit() 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 // saveAdvCharacter persists every mutable AdventureCharacter field to
// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been // player_meta. Phase L5h: the legacy adventure_characters UPDATE has been
// retired — the row is now read-only after createAdvCharacter seeds it, // retired — the row is now read-only after createAdvCharacter seeds it,

View File

@@ -1,6 +1,7 @@
package plugin package plugin
import ( import (
"fmt"
"log/slog" "log/slog"
"time" "time"
@@ -305,6 +306,13 @@ func ensureDnDCharacterForCombat(userID id.UserID, char *AdventureCharacter) (*D
c := autoBuildCharacter(userID, char) c := autoBuildCharacter(userID, char)
c.AutoMigrated = true c.AutoMigrated = true
c.PendingSetup = false 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 { if err := SaveDnDCharacter(c); err != nil {
return nil, false, err return nil, false, err
} }

View File

@@ -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))
}
}