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
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,