mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
3 Commits
sim-real-c
...
0c4c4757d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4c4757d3 | ||
|
|
fedd357a29 | ||
|
|
59319ede9d |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
81
internal/plugin/player_meta_seed_test.go
Normal file
81
internal/plugin/player_meta_seed_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user