mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Compare commits
2 Commits
59319ede9d
...
0c4c4757d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c4c4757d3 | ||
|
|
fedd357a29 |
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
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