mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Adv 2.0 L5b: babysit state migration off AdvCharacter to player_meta
Five player_meta columns (babysit_active, babysit_expires_at, babysit_skill_focus, auto_babysit, auto_babysit_focus). BabysitState struct with IsActive() marker mirrors SkillState/HouseState shape. loadBabysitState / upsertPlayerMetaBabysitState / backfillPlayerMetaBabysitState / babysitStateFromAdvChar helpers. Dual-writes wired at handleBabysitStart, handleBabysitCancel, checkBabysitExpiry. Backfill is idempotent — only fills inactive rows whose legacy counterpart is active. AutoBabysit / AutoBabysitFocus / BabysitSkillFocus are dormant in current code but preserved for the schema migration. Tests: TestPlayerMetaBabysitStateBackfill_Idempotent, TestLoadBabysitState_FallsBackToAdvCharacter, TestUpsertPlayerMetaBabysitState_RoundTrip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -230,6 +230,11 @@ func (p *AdventurePlugin) Init() error {
|
||||
if err := backfillPlayerMetaSkillState(); err != nil {
|
||||
slog.Error("player_meta: skill state backfill failed", "err", err)
|
||||
}
|
||||
// Adv 2.0 Phase L5b — one-shot babysit state backfill into player_meta.
|
||||
// Idempotent (only fills inactive rows whose legacy counterpart is active).
|
||||
if err := backfillPlayerMetaBabysitState(); err != nil {
|
||||
slog.Error("player_meta: babysit state backfill failed", "err", err)
|
||||
}
|
||||
// Phase L3 — cancel any open/active legacy coop dungeon runs and
|
||||
// refund member contributions + unsettled bets. Idempotent.
|
||||
closeAndRefundLegacyCoopRuns(p.euro)
|
||||
|
||||
@@ -143,6 +143,9 @@ func (p *AdventurePlugin) handleBabysitPurchase(ctx MessageContext, days int) er
|
||||
p.euro.Credit(char.UserID, float64(totalCost), "babysit_refund")
|
||||
return p.SendDM(ctx.Sender, "Something went wrong activating the service. Your gold has been refunded.")
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: babysit start dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
confirm := pickBabysitFlavor(babysitConfirmLines)
|
||||
durLabel := "1 week"
|
||||
@@ -234,6 +237,9 @@ func (p *AdventurePlugin) handleBabysitCancel(ctx MessageContext) error {
|
||||
if err := saveAdvCharacter(char); err != nil {
|
||||
slog.Error("babysit: failed to save character on cancel", "user", char.UserID, "err", err)
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(char)); err != nil {
|
||||
slog.Error("player_meta: babysit cancel dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
return p.SendDM(ctx.Sender, "🍼 Service cancelled. No refund. The babysitter was already there.\n\n"+summary)
|
||||
}
|
||||
@@ -263,6 +269,9 @@ func (p *AdventurePlugin) checkBabysitExpiry(chars []AdventureCharacter) {
|
||||
slog.Error("babysit: failed to save character on expiry", "user", char.UserID, "err", err)
|
||||
continue
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(char.UserID, babysitStateFromAdvChar(&char)); err != nil {
|
||||
slog.Error("player_meta: babysit expiry dual-write failed", "user", char.UserID, "err", err)
|
||||
}
|
||||
|
||||
if err := p.SendDM(char.UserID, summary); err != nil {
|
||||
slog.Error("babysit: failed to send expiry summary DM", "user", char.UserID, "err", err)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -927,6 +928,165 @@ func skillStateFromAdvChar(c *AdventureCharacter) SkillState {
|
||||
}
|
||||
}
|
||||
|
||||
// BabysitState is the in-memory mirror of player_meta's babysit columns.
|
||||
// Phase L5b ports babysit state off AdvCharacter (gogobee_legacy_migration.md
|
||||
// §7.3 L5b). Five fields: the active service (Active + ExpiresAt +
|
||||
// SkillFocus — legacy slot, no longer mutated) and the auto-babysit
|
||||
// preference (AutoBabysit + AutoBabysitFocus, dormant in current code but
|
||||
// preserved for the schema migration).
|
||||
type BabysitState struct {
|
||||
Active bool
|
||||
ExpiresAt *time.Time
|
||||
SkillFocus string
|
||||
AutoBabysit bool
|
||||
AutoBabysitFocus string
|
||||
}
|
||||
|
||||
// IsActive returns true when the babysit service is on. Used as the "row
|
||||
// is migrated" marker in loadBabysitState; an idle row is
|
||||
// indistinguishable from a never-populated row, so it falls through to the
|
||||
// legacy table during the soak window.
|
||||
func (s BabysitState) IsActive() bool { return s.Active }
|
||||
|
||||
// upsertPlayerMetaBabysitState writes the full babysit column set for a
|
||||
// user. Used by the dual-write path during the L5b soak window.
|
||||
func upsertPlayerMetaBabysitState(userID id.UserID, s BabysitState) error {
|
||||
active := 0
|
||||
if s.Active {
|
||||
active = 1
|
||||
}
|
||||
auto := 0
|
||||
if s.AutoBabysit {
|
||||
auto = 1
|
||||
}
|
||||
var expires interface{}
|
||||
if s.ExpiresAt != nil {
|
||||
expires = s.ExpiresAt.UTC()
|
||||
}
|
||||
_, err := db.Get().Exec(
|
||||
`INSERT INTO player_meta (
|
||||
user_id, babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||
auto_babysit, auto_babysit_focus
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
babysit_active = excluded.babysit_active,
|
||||
babysit_expires_at = excluded.babysit_expires_at,
|
||||
babysit_skill_focus = excluded.babysit_skill_focus,
|
||||
auto_babysit = excluded.auto_babysit,
|
||||
auto_babysit_focus = excluded.auto_babysit_focus`,
|
||||
string(userID), active, expires, s.SkillFocus, auto, s.AutoBabysitFocus,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// loadBabysitState returns the player's babysit state from player_meta
|
||||
// when active, falling back to adventure_characters during the L5b soak
|
||||
// window. An inactive row falls through to the legacy table since an
|
||||
// inactive player_meta row is indistinguishable from an unmigrated one.
|
||||
func loadBabysitState(userID id.UserID) (BabysitState, error) {
|
||||
var (
|
||||
s BabysitState
|
||||
activeInt int
|
||||
autoInt int
|
||||
expires sql.NullTime
|
||||
)
|
||||
err := db.Get().QueryRow(
|
||||
`SELECT babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||
auto_babysit, auto_babysit_focus
|
||||
FROM player_meta WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&activeInt, &expires, &s.SkillFocus, &autoInt, &s.AutoBabysitFocus)
|
||||
if err == nil && activeInt == 1 {
|
||||
s.Active = true
|
||||
s.AutoBabysit = autoInt == 1
|
||||
if expires.Valid {
|
||||
t := expires.Time.UTC()
|
||||
s.ExpiresAt = &t
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return BabysitState{}, err
|
||||
}
|
||||
// Fallback to AdvCharacter during soak.
|
||||
var (
|
||||
legacyActive int
|
||||
legacyExpires sql.NullTime
|
||||
legacyFocus string
|
||||
legacyAuto int
|
||||
legacyAutoFoc string
|
||||
)
|
||||
err = db.Get().QueryRow(
|
||||
`SELECT babysit_active, babysit_expires_at, babysit_skill_focus,
|
||||
auto_babysit, auto_babysit_focus
|
||||
FROM adventure_characters WHERE user_id = ?`,
|
||||
string(userID),
|
||||
).Scan(&legacyActive, &legacyExpires, &legacyFocus, &legacyAuto, &legacyAutoFoc)
|
||||
if err == sql.ErrNoRows {
|
||||
return BabysitState{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return BabysitState{}, err
|
||||
}
|
||||
out := BabysitState{
|
||||
Active: legacyActive == 1,
|
||||
SkillFocus: legacyFocus,
|
||||
AutoBabysit: legacyAuto == 1,
|
||||
AutoBabysitFocus: legacyAutoFoc,
|
||||
}
|
||||
if legacyExpires.Valid {
|
||||
t := legacyExpires.Time.UTC()
|
||||
out.ExpiresAt = &t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// backfillPlayerMetaBabysitState copies the babysit columns from
|
||||
// adventure_characters into player_meta for any row whose babysit_active
|
||||
// is still 0 AND the legacy row has babysit_active = 1. Idempotent: only
|
||||
// fills inactive rows whose legacy counterpart is active.
|
||||
func backfillPlayerMetaBabysitState() error {
|
||||
if _, err := db.Get().Exec(`
|
||||
INSERT OR IGNORE INTO player_meta (user_id)
|
||||
SELECT user_id FROM adventure_characters
|
||||
`); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := db.Get().Exec(`
|
||||
UPDATE player_meta
|
||||
SET babysit_active = (SELECT babysit_active FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
|
||||
babysit_expires_at = (SELECT babysit_expires_at FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
|
||||
babysit_skill_focus = (SELECT babysit_skill_focus FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
|
||||
auto_babysit = (SELECT auto_babysit FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
|
||||
auto_babysit_focus = (SELECT auto_babysit_focus FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id)
|
||||
WHERE babysit_active = 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM adventure_characters ac
|
||||
WHERE ac.user_id = player_meta.user_id
|
||||
AND ac.babysit_active = 1
|
||||
)
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
slog.Info("player_meta: babysit state backfilled", "rows", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// babysitStateFromAdvChar projects the babysit-related fields off an
|
||||
// AdventureCharacter into a BabysitState. Used by the dual-write path
|
||||
// during the L5b soak window.
|
||||
func babysitStateFromAdvChar(c *AdventureCharacter) BabysitState {
|
||||
return BabysitState{
|
||||
Active: c.BabysitActive,
|
||||
ExpiresAt: c.BabysitExpiresAt,
|
||||
SkillFocus: c.BabysitSkillFocus,
|
||||
AutoBabysit: c.AutoBabysit,
|
||||
AutoBabysitFocus: c.AutoBabysitFocus,
|
||||
}
|
||||
}
|
||||
|
||||
// backfillPlayerMetaDisplayName copies adventure_characters.display_name
|
||||
// into player_meta.display_name for any row whose display_name is still
|
||||
// the empty default. Idempotent: safe to re-run; only updates rows that
|
||||
|
||||
@@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gogobee/internal/db"
|
||||
"maunium.net/go/mautrix/id"
|
||||
@@ -900,6 +901,146 @@ func TestUpsertPlayerMetaSkillState_RoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerMetaBabysitStateBackfill_Idempotent(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-baby-bf:example")
|
||||
if err := createAdvCharacter(uid, "Sitter"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
expires := time.Now().UTC().Add(7 * 24 * time.Hour).Truncate(time.Second)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE adventure_characters
|
||||
SET babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?,
|
||||
auto_babysit = ?, auto_babysit_focus = ?
|
||||
WHERE user_id = ?`,
|
||||
1, expires, "mining", 1, "fishing", string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET babysit_active = 0, babysit_expires_at = NULL,
|
||||
babysit_skill_focus = '', auto_babysit = 0, auto_babysit_focus = '' WHERE user_id = ?`,
|
||||
string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
if err := backfillPlayerMetaBabysitState(); err != nil {
|
||||
t.Fatalf("backfill 1: %v", err)
|
||||
}
|
||||
got, err := loadBabysitState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !got.Active || got.SkillFocus != "mining" || !got.AutoBabysit || got.AutoBabysitFocus != "fishing" {
|
||||
t.Errorf("after backfill: got %+v", got)
|
||||
}
|
||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) {
|
||||
t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires)
|
||||
}
|
||||
|
||||
// Layer a dual-write: cancel.
|
||||
got.Active = false
|
||||
got.ExpiresAt = nil
|
||||
got.SkillFocus = ""
|
||||
if err := upsertPlayerMetaBabysitState(uid, got); err != nil {
|
||||
t.Fatalf("dual-write: %v", err)
|
||||
}
|
||||
if err := backfillPlayerMetaBabysitState(); err != nil {
|
||||
t.Fatalf("backfill 2: %v", err)
|
||||
}
|
||||
got2, _ := loadBabysitState(uid)
|
||||
if got2.Active {
|
||||
t.Errorf("backfill clobbered cancel: got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBabysitState_FallsBackToAdvCharacter(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-baby-fb:example")
|
||||
if err := createAdvCharacter(uid, "Faller"); err != nil {
|
||||
t.Fatalf("createAdvCharacter: %v", err)
|
||||
}
|
||||
expires := time.Now().UTC().Add(30 * 24 * time.Hour).Truncate(time.Second)
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE adventure_characters
|
||||
SET babysit_active = ?, babysit_expires_at = ?
|
||||
WHERE user_id = ?`,
|
||||
1, expires, string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if _, err := db.Get().Exec(
|
||||
`UPDATE player_meta SET babysit_active = 0, babysit_expires_at = NULL WHERE user_id = ?`,
|
||||
string(uid),
|
||||
); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
|
||||
got, err := loadBabysitState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !got.Active {
|
||||
t.Errorf("fallback should be active: got %+v", got)
|
||||
}
|
||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) {
|
||||
t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires)
|
||||
}
|
||||
|
||||
// Unknown user → zero state, no error.
|
||||
got2, err := loadBabysitState(id.UserID("@meta-baby-nobody:example"))
|
||||
if err != nil {
|
||||
t.Fatalf("load missing: %v", err)
|
||||
}
|
||||
if got2.Active {
|
||||
t.Errorf("missing user: got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPlayerMetaBabysitState_RoundTrip(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
uid := id.UserID("@meta-baby-rt:example")
|
||||
expires := time.Now().UTC().Add(14 * 24 * time.Hour).Truncate(time.Second)
|
||||
in := BabysitState{
|
||||
Active: true,
|
||||
ExpiresAt: &expires,
|
||||
SkillFocus: "foraging",
|
||||
AutoBabysit: true,
|
||||
AutoBabysitFocus: "mining",
|
||||
}
|
||||
if err := upsertPlayerMetaBabysitState(uid, in); err != nil {
|
||||
t.Fatalf("upsert insert: %v", err)
|
||||
}
|
||||
got, err := loadBabysitState(uid)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !got.Active || got.SkillFocus != "foraging" || !got.AutoBabysit || got.AutoBabysitFocus != "mining" {
|
||||
t.Errorf("round-trip mismatch: got %+v", got)
|
||||
}
|
||||
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(expires) {
|
||||
t.Errorf("expires_at: got %v want %v", got.ExpiresAt, expires)
|
||||
}
|
||||
|
||||
// Cancel → inactive falls through, but verify upsert wrote 0.
|
||||
in.Active = false
|
||||
in.ExpiresAt = nil
|
||||
if err := upsertPlayerMetaBabysitState(uid, in); err != nil {
|
||||
t.Fatalf("upsert cancel: %v", err)
|
||||
}
|
||||
var activeInt int
|
||||
if err := db.Get().QueryRow(`SELECT babysit_active FROM player_meta WHERE user_id = ?`, string(uid)).Scan(&activeInt); err != nil {
|
||||
t.Fatalf("verify cancel: %v", err)
|
||||
}
|
||||
if activeInt != 0 {
|
||||
t.Errorf("cancel should write 0: got %d", activeInt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) {
|
||||
setupAuditTestDB(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user