Adv 2.0 L5e: death state migration off AdvCharacter

Eight player_meta columns: alive (default 1), dead_until,
death_reprieve_last, last_pardon_used (DATETIME), last_death_date,
grudge_location, death_source, death_location (TEXT).

DeathState struct + load/upsert/backfill/projection helpers. Dual-write
strategy switches: instead of per-site upserts, the dual-write rides
saveAdvCharacter itself (after the existing display_name dual-write).
Death-state mutation surface spans ~50 save sites; the saveAdvCharacter-
internal hook catches every one without scatter.

Backfill idempotent (only fills rows where every death field is still
default). Tests cover backfill, fallback, round-trip, and the
saveAdvCharacter dual-write.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 11:33:16 -07:00
parent 8fc8941cc9
commit 45586eb697
6 changed files with 388 additions and 1 deletions

View File

@@ -283,6 +283,23 @@ func runMigrations(d *sql.DB) error {
`ALTER TABLE player_meta ADD COLUMN harvest_actions_used INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE player_meta ADD COLUMN created_at DATETIME`,
`ALTER TABLE player_meta ADD COLUMN last_active_at DATETIME`,
// Adv 2.0 Phase L5e — Death state migration off AdvCharacter.
// Alive / DeadUntil / DeathReprieveLast / LastDeathDate /
// LastPardonUsed / GrudgeLocation / DeathSource / DeathLocation move
// to player_meta. Dual-write strategy switches to inside
// saveAdvCharacter (death state mutates at ~50 save sites; per-site
// upserts would be too noisy — gogobee_legacy_migration.md §7.3
// L5e). `Alive` defaults to 1 since legacy created characters are
// alive, and a never-migrated row should fall through to the legacy
// table via loadDeathState.
`ALTER TABLE player_meta ADD COLUMN alive INTEGER NOT NULL DEFAULT 1`,
`ALTER TABLE player_meta ADD COLUMN dead_until DATETIME`,
`ALTER TABLE player_meta ADD COLUMN death_reprieve_last DATETIME`,
`ALTER TABLE player_meta ADD COLUMN last_death_date TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN last_pardon_used DATETIME`,
`ALTER TABLE player_meta ADD COLUMN grudge_location TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN death_source TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE player_meta ADD COLUMN death_location TEXT NOT NULL DEFAULT ''`,
}
for _, stmt := range columnMigrations {
if _, err := d.Exec(stmt); err != nil {

View File

@@ -245,6 +245,11 @@ func (p *AdventurePlugin) Init() error {
if err := backfillPlayerMetaLifecycleState(); err != nil {
slog.Error("player_meta: lifecycle state backfill failed", "err", err)
}
// Adv 2.0 Phase L5e — one-shot death state backfill into player_meta.
// Idempotent (only fills rows whose death fields are all default).
if err := backfillPlayerMetaDeathState(); err != nil {
slog.Error("player_meta: death 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)

View File

@@ -718,6 +718,12 @@ func saveAdvCharacter(char *AdventureCharacter) error {
if dnErr := upsertPlayerMetaDisplayName(char.UserID, char.DisplayName); dnErr != nil {
slog.Error("player_meta: display_name dual-write failed", "user", char.UserID, "err", dnErr)
}
// Adv 2.0 Phase L5e — dual-write death state into player_meta. Mutation
// surface is too wide for per-site upserts (~50 save sites), so the
// dual-write rides every saveAdvCharacter call.
if dsErr := upsertPlayerMetaDeathState(char.UserID, deathStateFromAdvChar(char)); dsErr != nil {
slog.Error("player_meta: death state dual-write failed", "user", char.UserID, "err", dsErr)
}
return nil
}

View File

@@ -1593,6 +1593,205 @@ func resetAllPlayerMetaDailyActions() error {
return err
}
// DeathState mirrors player_meta's death-state columns. Phase L5e ports
// these fields off AdvCharacter (gogobee_legacy_migration.md §7.3 L5e).
// Mutation surface is large (~50 saveAdvCharacter sites touch death
// state), so the dual-write pattern switches to inside saveAdvCharacter
// itself rather than per-site upserts.
type DeathState struct {
Alive bool
DeadUntil *time.Time
DeathReprieveLast *time.Time
LastDeathDate string
LastPardonUsed *time.Time
GrudgeLocation string
DeathSource string
DeathLocation string
}
// upsertPlayerMetaDeathState writes the full death column set for a user.
// Called from inside saveAdvCharacter during the L5e soak window.
func upsertPlayerMetaDeathState(userID id.UserID, s DeathState) error {
alive := 0
if s.Alive {
alive = 1
}
var deadUntil, reprieve, pardon interface{}
if s.DeadUntil != nil {
deadUntil = s.DeadUntil.UTC()
}
if s.DeathReprieveLast != nil {
reprieve = s.DeathReprieveLast.UTC()
}
if s.LastPardonUsed != nil {
pardon = s.LastPardonUsed.UTC()
}
_, err := db.Get().Exec(
`INSERT INTO player_meta (
user_id, alive, dead_until, death_reprieve_last,
last_death_date, last_pardon_used,
grudge_location, death_source, death_location
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
alive = excluded.alive,
dead_until = excluded.dead_until,
death_reprieve_last = excluded.death_reprieve_last,
last_death_date = excluded.last_death_date,
last_pardon_used = excluded.last_pardon_used,
grudge_location = excluded.grudge_location,
death_source = excluded.death_source,
death_location = excluded.death_location`,
string(userID), alive, deadUntil, reprieve,
s.LastDeathDate, pardon,
s.GrudgeLocation, s.DeathSource, s.DeathLocation,
)
return err
}
// loadDeathState returns death state from player_meta when populated,
// otherwise falls back to adventure_characters during the L5e soak
// window. The "row is migrated" marker is any non-default field.
func loadDeathState(userID id.UserID) (DeathState, error) {
var (
s DeathState
aliveInt int
deadUntil, reprieve, pardon sql.NullTime
)
err := db.Get().QueryRow(
`SELECT alive, dead_until, death_reprieve_last,
last_death_date, last_pardon_used,
grudge_location, death_source, death_location
FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(
&aliveInt, &deadUntil, &reprieve,
&s.LastDeathDate, &pardon,
&s.GrudgeLocation, &s.DeathSource, &s.DeathLocation,
)
migrated := false
if err == nil {
s.Alive = aliveInt == 1
if deadUntil.Valid {
t := deadUntil.Time.UTC()
s.DeadUntil = &t
migrated = true
}
if reprieve.Valid {
t := reprieve.Time.UTC()
s.DeathReprieveLast = &t
migrated = true
}
if pardon.Valid {
t := pardon.Time.UTC()
s.LastPardonUsed = &t
migrated = true
}
if s.LastDeathDate != "" || s.GrudgeLocation != "" ||
s.DeathSource != "" || s.DeathLocation != "" || !s.Alive {
migrated = true
}
if migrated {
return s, nil
}
}
if err != nil && err != sql.ErrNoRows {
return DeathState{}, err
}
// Fallback to AdvCharacter during soak.
var (
legacy DeathState
legacyAlive int
legacyDead, legacyReprieve, legacyPardon sql.NullTime
)
err = db.Get().QueryRow(
`SELECT alive, dead_until, death_reprieve_last,
last_death_date, last_pardon_used,
grudge_location, death_source, death_location
FROM adventure_characters WHERE user_id = ?`,
string(userID),
).Scan(
&legacyAlive, &legacyDead, &legacyReprieve,
&legacy.LastDeathDate, &legacyPardon,
&legacy.GrudgeLocation, &legacy.DeathSource, &legacy.DeathLocation,
)
if err == sql.ErrNoRows {
return DeathState{Alive: true}, nil
}
if err != nil {
return DeathState{}, err
}
legacy.Alive = legacyAlive == 1
if legacyDead.Valid {
t := legacyDead.Time.UTC()
legacy.DeadUntil = &t
}
if legacyReprieve.Valid {
t := legacyReprieve.Time.UTC()
legacy.DeathReprieveLast = &t
}
if legacyPardon.Valid {
t := legacyPardon.Time.UTC()
legacy.LastPardonUsed = &t
}
return legacy, nil
}
// backfillPlayerMetaDeathState copies death columns from
// adventure_characters into player_meta for any row that hasn't been
// migrated yet. Idempotent: only fills rows where every death field is
// still the default (alive=1, all timestamps NULL, all strings empty).
func backfillPlayerMetaDeathState() 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 alive = (SELECT alive FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
dead_until = (SELECT dead_until FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
death_reprieve_last = (SELECT death_reprieve_last FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
last_death_date = (SELECT last_death_date FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
last_pardon_used = (SELECT last_pardon_used FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
grudge_location = (SELECT grudge_location FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
death_source = (SELECT death_source FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id),
death_location = (SELECT death_location FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id)
WHERE alive = 1 AND dead_until IS NULL
AND death_reprieve_last IS NULL AND last_pardon_used IS NULL
AND last_death_date = '' AND grudge_location = ''
AND death_source = '' AND death_location = ''
AND EXISTS (
SELECT 1 FROM adventure_characters ac
WHERE ac.user_id = player_meta.user_id
AND (ac.alive = 0 OR ac.dead_until IS NOT NULL
OR ac.death_reprieve_last IS NOT NULL OR ac.last_pardon_used IS NOT NULL
OR ac.last_death_date <> '' OR ac.grudge_location <> ''
OR ac.death_source <> '' OR ac.death_location <> '')
)
`)
if err != nil {
return err
}
n, _ := res.RowsAffected()
slog.Info("player_meta: death state backfilled", "rows", n)
return nil
}
// deathStateFromAdvChar projects death fields off an AdventureCharacter.
func deathStateFromAdvChar(c *AdventureCharacter) DeathState {
return DeathState{
Alive: c.Alive,
DeadUntil: c.DeadUntil,
DeathReprieveLast: c.DeathReprieveLast,
LastDeathDate: c.LastDeathDate,
LastPardonUsed: c.LastPardonUsed,
GrudgeLocation: c.GrudgeLocation,
DeathSource: c.DeathSource,
DeathLocation: c.DeathLocation,
}
}
// 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

View File

@@ -1327,6 +1327,166 @@ func TestResetAllPlayerMetaDailyActions(t *testing.T) {
}
}
func TestPlayerMetaDeathStateBackfill_Idempotent(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-death-bf:example")
if err := createAdvCharacter(uid, "Mortal"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
deadUntil := time.Now().UTC().Add(2 * time.Hour).Truncate(time.Second)
if _, err := db.Get().Exec(
`UPDATE adventure_characters
SET alive = ?, dead_until = ?, last_death_date = ?,
grudge_location = ?, death_source = ?, death_location = ?
WHERE user_id = ?`,
0, deadUntil, "2026-05-09", "Dark Cave", "adventure", "Dark Cave", string(uid),
); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE player_meta SET alive = 1, dead_until = NULL,
death_reprieve_last = NULL, last_death_date = '',
last_pardon_used = NULL, grudge_location = '',
death_source = '', death_location = '' WHERE user_id = ?`,
string(uid),
); err != nil {
t.Fatalf("clear: %v", err)
}
if err := backfillPlayerMetaDeathState(); err != nil {
t.Fatalf("backfill 1: %v", err)
}
got, err := loadDeathState(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.Alive || got.LastDeathDate != "2026-05-09" || got.GrudgeLocation != "Dark Cave" ||
got.DeathSource != "adventure" || got.DeathLocation != "Dark Cave" {
t.Errorf("after backfill: got %+v", got)
}
if got.DeadUntil == nil || !got.DeadUntil.Equal(deadUntil) {
t.Errorf("dead_until: got %v want %v", got.DeadUntil, deadUntil)
}
// Layer a dual-write: revive.
got.Alive = true
got.DeadUntil = nil
if err := upsertPlayerMetaDeathState(uid, got); err != nil {
t.Fatalf("dual-write: %v", err)
}
if err := backfillPlayerMetaDeathState(); err != nil {
t.Fatalf("backfill 2: %v", err)
}
got2, _ := loadDeathState(uid)
if !got2.Alive || got2.DeadUntil != nil {
t.Errorf("backfill clobbered revive: got %+v", got2)
}
}
func TestLoadDeathState_FallsBackToAdvCharacter(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-death-fb:example")
if err := createAdvCharacter(uid, "Faller"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE adventure_characters SET alive = ?, grudge_location = ? WHERE user_id = ?`,
0, "Mine", string(uid),
); err != nil {
t.Fatalf("seed: %v", err)
}
if _, err := db.Get().Exec(
`UPDATE player_meta SET alive = 1, dead_until = NULL,
death_reprieve_last = NULL, last_death_date = '',
last_pardon_used = NULL, grudge_location = '',
death_source = '', death_location = '' WHERE user_id = ?`,
string(uid),
); err != nil {
t.Fatalf("clear: %v", err)
}
got, err := loadDeathState(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.Alive || got.GrudgeLocation != "Mine" {
t.Errorf("fallback: got %+v", got)
}
// Unknown user → zero state defaults to alive.
got2, err := loadDeathState(id.UserID("@meta-death-nobody:example"))
if err != nil {
t.Fatalf("load missing: %v", err)
}
if !got2.Alive {
t.Errorf("missing user should default to alive: got %+v", got2)
}
}
func TestUpsertPlayerMetaDeathState_RoundTrip(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-death-rt:example")
deadUntil := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
pardon := time.Now().UTC().Add(-24 * time.Hour).Truncate(time.Second)
in := DeathState{
Alive: false,
DeadUntil: &deadUntil,
LastDeathDate: "2026-05-09",
LastPardonUsed: &pardon,
GrudgeLocation: "the Abyss",
DeathSource: "arena",
DeathLocation: "the Arena",
}
if err := upsertPlayerMetaDeathState(uid, in); err != nil {
t.Fatalf("upsert insert: %v", err)
}
got, err := loadDeathState(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
if got.Alive || got.LastDeathDate != "2026-05-09" || got.GrudgeLocation != "the Abyss" ||
got.DeathSource != "arena" || got.DeathLocation != "the Arena" {
t.Errorf("round-trip mismatch: got %+v", got)
}
if got.DeadUntil == nil || !got.DeadUntil.Equal(deadUntil) {
t.Errorf("dead_until: got %v want %v", got.DeadUntil, deadUntil)
}
if got.LastPardonUsed == nil || !got.LastPardonUsed.Equal(pardon) {
t.Errorf("last_pardon_used: got %v want %v", got.LastPardonUsed, pardon)
}
}
func TestSaveAdvCharacter_DualWritesDeathState(t *testing.T) {
setupAuditTestDB(t)
uid := id.UserID("@meta-death-sw:example")
if err := createAdvCharacter(uid, "Saver"); err != nil {
t.Fatalf("createAdvCharacter: %v", err)
}
char, err := loadAdvCharacter(uid)
if err != nil {
t.Fatalf("load: %v", err)
}
deadUntil := time.Now().UTC().Add(time.Hour).Truncate(time.Second)
char.Alive = false
char.DeadUntil = &deadUntil
char.GrudgeLocation = "the Pit"
if err := saveAdvCharacter(char); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadDeathState(uid)
if err != nil {
t.Fatalf("loadDeathState: %v", err)
}
if got.Alive || got.GrudgeLocation != "the Pit" {
t.Errorf("save did not propagate to player_meta: got %+v", got)
}
}
func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) {
setupAuditTestDB(t)