diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 4cc06a9..3b82f00 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -415,7 +415,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | Sub-phase | Bucket | Sizing | Notes | |---|---|---|---| -| **L5a** | Skills (8 fields) | 1 day | `SkillState` struct mirroring `PetState`/`HouseState`. Mutation surface is small: `checkAdvLevelUp(char, skill)` + the few sites that bump `*XP` directly. Reader flip unblocks dnd_sheet's legacy display row and `babysitDailyCost`. CombatLevel rejoins this struct as a transitional column — dropped at teardown after the DnD mass-backfill. | +| **L5a** | Skills (8 fields) | 1 day | `SkillState` struct mirroring `PetState`/`HouseState`. Mutation surface is small: `checkAdvLevelUp(char, skill)` + the few sites that bump `*XP` directly. Reader flip unblocks dnd_sheet's legacy display row and `babysitDailyCost`. CombatLevel rejoins this struct as a transitional column — dropped at teardown after the DnD mass-backfill. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight `combat_*` / `mining_*` / `foraging_*` / `fishing_*` columns added via columnMigration. `SkillState` struct + `HasSkills()` / `loadSkillState` / `upsertPlayerMetaSkillState` / `backfillPlayerMetaSkillState` / `skillStateFromAdvChar` helpers in `player_meta.go`. Dual-writes wired at every mutation site: `adventure_consumables.go` (craft success + craft failure XP grant), `adventure_events.go` (event XP grant across combat/mining/foraging), `adventure_arena.go::resolveArenaDeath` + `arenaCompleteSession` (combat XP + level-up). Backfill `backfillPlayerMetaSkillState()` wired into Init, idempotent (only fills rows where every skill column is still zero AND the legacy row has at least one non-zero skill/XP). Tests: `TestPlayerMetaSkillStateBackfill_Idempotent`, `TestLoadSkillState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaSkillState_RoundTrip` in `player_meta_test.go`. `go vet ./... && go test ./...` clean. **Reader flip deferred** — bundled with cutting AdvCharacter writes (post-soak), per the L4e pattern. | | **L5b** | Babysit state (5 fields) | 0.5 day | Mutation surface is `runBabysitDailyTrickle` save in scheduler + the babysit toggle handlers. Tightly scoped. | | **L5c** | NPC counters/debuffs (~13 fields) | 1 day | "Later phase" referenced in L4d note. Pack the four `*Expires` and three `*LastSeen` timestamps as nullable columns; counters as ints. The roll-targets are write-once-per-encounter so dual-write is cheap. **Hidden discovery mechanic — never surface in player-facing output.** | | **L5d** | Streak + action state + lifecycle (10 fields) | 1 day | Action state mutates every action, so the dual-write hook is hot — ensure the upsert is in the same code path that already does `saveAdvCharacter`. | diff --git a/internal/db/db.go b/internal/db/db.go index 0d244a1..7b7e67c 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -226,6 +226,21 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN house_missed_payments INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN house_autopay INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN house_current_rate REAL NOT NULL DEFAULT 0`, + // Adv 2.0 Phase L5a — Skills migration off AdvCharacter. + // CombatLevel / MiningSkill / ForagingSkill / FishingSkill and their + // XP counters move to player_meta; AdvCharacter columns keep + // dual-writing during soak (gogobee_legacy_migration.md §7.3 L5a). + // CombatLevel/CombatXP are transitional — dropped after L5g's + // DnDCharacter mass-backfill retires the dndLevelFromCombatLevel + // fallback. + `ALTER TABLE player_meta ADD COLUMN combat_level INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN combat_xp INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN mining_skill INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN mining_xp INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN foraging_skill INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN foraging_xp INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN fishing_skill INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN fishing_xp INTEGER NOT NULL DEFAULT 0`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 32d0abe..a772dd1 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -225,6 +225,11 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaHouseState(); err != nil { slog.Error("player_meta: house state backfill failed", "err", err) } + // Adv 2.0 Phase L5a — one-shot skill state backfill into player_meta. + // Idempotent (only fills rows whose skill columns are all still zero). + if err := backfillPlayerMetaSkillState(); err != nil { + slog.Error("player_meta: skill 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) diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index d82ef54..c0e13a1 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -472,6 +472,9 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c if err := upsertPlayerMetaArena(ctx.Sender, char.ArenaWins, char.ArenaLosses, char.InvasionScore); err != nil { slog.Error("player_meta: arena loss dual-write failed", "user", ctx.Sender, "err", err) } + if err := upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char)); err != nil { + slog.Error("player_meta: arena loss skill dual-write failed", "user", ctx.Sender, "err", err) + } now := time.Now().UTC() run.Status = "dead" @@ -551,6 +554,9 @@ func (p *AdventurePlugin) arenaCompleteSession(userID id.UserID, run *ArenaRun, if err := upsertPlayerMetaArena(userID, char.ArenaWins, char.ArenaLosses, char.InvasionScore); err != nil { slog.Error("player_meta: arena win dual-write failed", "user", userID, "err", err) } + if err := upsertPlayerMetaSkillState(userID, skillStateFromAdvChar(char)); err != nil { + slog.Error("player_meta: arena win skill dual-write failed", "user", userID, "err", err) + } // End run if not already ended if run.Status != "completed" && run.Status != "cashed_out" { diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 3785ad7..072a5d4 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -483,6 +483,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) char.CraftsSucceeded++ checkAdvLevelUp(char, "foraging") _ = saveAdvCharacter(char) + _ = upsertPlayerMetaSkillState(userID, skillStateFromAdvChar(char)) } } else { // Failure: all ingredients destroyed @@ -496,6 +497,7 @@ func autoCraftConsumables(userID id.UserID, items []AdvItem, foragingLevel int) char.ForagingXP += craftXPFailure[bestRecipe.Tier] checkAdvLevelUp(char, "foraging") _ = saveAdvCharacter(char) + _ = upsertPlayerMetaSkillState(userID, skillStateFromAdvChar(char)) } } } diff --git a/internal/plugin/adventure_events.go b/internal/plugin/adventure_events.go index 818f354..b555d92 100644 --- a/internal/plugin/adventure_events.go +++ b/internal/plugin/adventure_events.go @@ -215,6 +215,7 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error { } checkAdvLevelUp(char, xpSkill) _ = saveAdvCharacter(char) + _ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char)) } } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 1d42e57..6cd05cf 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -765,6 +765,168 @@ func houseStateFromAdvChar(c *AdventureCharacter) HouseState { } } +// SkillState is the in-memory mirror of player_meta's skill columns. Phase +// L5a ports CombatLevel/CombatXP and the three harvest skills + their XP +// off AdvCharacter (gogobee_legacy_migration.md §7.3 L5a). CombatLevel and +// CombatXP are transitional — they're dropped after L5g's DnDCharacter +// mass-backfill retires the legacy CombatLevel-derived fallback in +// hospital/rival/zone/sheet/onboarding. +type SkillState struct { + CombatLevel int + CombatXP int + MiningSkill int + MiningXP int + ForagingSkill int + ForagingXP int + FishingSkill int + FishingXP int +} + +// HasSkills returns true when any skill or XP field is non-zero. Used as +// the "row is migrated" marker in loadSkillState — an all-zero row is +// indistinguishable from "no row yet" so it falls through to the legacy +// table during the soak window. CombatLevel starts at 1 for any created +// character (see createAdvCharacter), so the only fully-zero state is a +// truly unmigrated row. +func (s SkillState) HasSkills() bool { + return s.CombatLevel > 0 || s.MiningSkill > 0 || s.ForagingSkill > 0 || s.FishingSkill > 0 || + s.CombatXP > 0 || s.MiningXP > 0 || s.ForagingXP > 0 || s.FishingXP > 0 +} + +// upsertPlayerMetaSkillState writes the full skill column set for a user. +// Used by the dual-write path during the L5a soak window. +func upsertPlayerMetaSkillState(userID id.UserID, s SkillState) error { + _, err := db.Get().Exec( + `INSERT INTO player_meta ( + user_id, combat_level, combat_xp, + mining_skill, mining_xp, + foraging_skill, foraging_xp, + fishing_skill, fishing_xp + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + combat_level = excluded.combat_level, + combat_xp = excluded.combat_xp, + mining_skill = excluded.mining_skill, + mining_xp = excluded.mining_xp, + foraging_skill = excluded.foraging_skill, + foraging_xp = excluded.foraging_xp, + fishing_skill = excluded.fishing_skill, + fishing_xp = excluded.fishing_xp`, + string(userID), + s.CombatLevel, s.CombatXP, + s.MiningSkill, s.MiningXP, + s.ForagingSkill, s.ForagingXP, + s.FishingSkill, s.FishingXP, + ) + return err +} + +// loadSkillState returns the player's skill state from player_meta when +// populated (HasSkills true), falling back to adventure_characters during +// the L5a soak window. +func loadSkillState(userID id.UserID) (SkillState, error) { + var s SkillState + err := db.Get().QueryRow( + `SELECT combat_level, combat_xp, + mining_skill, mining_xp, + foraging_skill, foraging_xp, + fishing_skill, fishing_xp + FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan( + &s.CombatLevel, &s.CombatXP, + &s.MiningSkill, &s.MiningXP, + &s.ForagingSkill, &s.ForagingXP, + &s.FishingSkill, &s.FishingXP, + ) + if err == nil && s.HasSkills() { + return s, nil + } + if err != nil && err != sql.ErrNoRows { + return SkillState{}, err + } + // Fallback to AdvCharacter during soak. + var legacy SkillState + err = db.Get().QueryRow( + `SELECT combat_level, combat_xp, + mining_skill, mining_xp, + foraging_skill, foraging_xp, + fishing_skill, fishing_xp + FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan( + &legacy.CombatLevel, &legacy.CombatXP, + &legacy.MiningSkill, &legacy.MiningXP, + &legacy.ForagingSkill, &legacy.ForagingXP, + &legacy.FishingSkill, &legacy.FishingXP, + ) + if err == sql.ErrNoRows { + return SkillState{}, nil + } + if err != nil { + return SkillState{}, err + } + return legacy, nil +} + +// backfillPlayerMetaSkillState copies the skill columns from +// adventure_characters into player_meta for any row whose skill columns +// are all still zero. Idempotent: only updates rows that haven't been +// populated yet (via backfill or dual-write). +func backfillPlayerMetaSkillState() 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 combat_level = (SELECT combat_level FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + combat_xp = (SELECT combat_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + mining_skill = (SELECT mining_skill FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + mining_xp = (SELECT mining_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + foraging_skill = (SELECT foraging_skill FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + foraging_xp = (SELECT foraging_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + fishing_skill = (SELECT fishing_skill FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + fishing_xp = (SELECT fishing_xp FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) + WHERE combat_level = 0 AND combat_xp = 0 + AND mining_skill = 0 AND mining_xp = 0 + AND foraging_skill = 0 AND foraging_xp = 0 + AND fishing_skill = 0 AND fishing_xp = 0 + AND EXISTS ( + SELECT 1 FROM adventure_characters ac + WHERE ac.user_id = player_meta.user_id + AND (ac.combat_level > 0 OR ac.mining_skill > 0 OR ac.foraging_skill > 0 OR ac.fishing_skill > 0 + OR ac.combat_xp > 0 OR ac.mining_xp > 0 OR ac.foraging_xp > 0 OR ac.fishing_xp > 0) + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: skill state backfilled", "rows", n) + return nil +} + +// skillStateFromAdvChar projects the skill-related fields off an +// AdventureCharacter into a SkillState. Used by the dual-write path: every +// site that mutates AdvCharacter skill or XP fields then calls +// upsertPlayerMetaSkillState with this projection so the columns move +// cleanly during the L5a soak window. +func skillStateFromAdvChar(c *AdventureCharacter) SkillState { + return SkillState{ + CombatLevel: c.CombatLevel, + CombatXP: c.CombatXP, + MiningSkill: c.MiningSkill, + MiningXP: c.MiningXP, + ForagingSkill: c.ForagingSkill, + ForagingXP: c.ForagingXP, + FishingSkill: c.FishingSkill, + FishingXP: c.FishingXP, + } +} + // 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 diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index 148079b..1fb1479 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -775,6 +775,131 @@ func TestLoadHouseState_FallsBackToAdvCharacter(t *testing.T) { } } +func TestPlayerMetaSkillStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-skill-bf:example") + if err := createAdvCharacter(uid, "Skiller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET combat_level = ?, combat_xp = ?, + mining_skill = ?, mining_xp = ?, + foraging_skill = ?, foraging_xp = ?, + fishing_skill = ?, fishing_xp = ? + WHERE user_id = ?`, + 7, 250, 4, 100, 3, 80, 2, 60, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET combat_level = 0, combat_xp = 0, + mining_skill = 0, mining_xp = 0, + foraging_skill = 0, foraging_xp = 0, + fishing_skill = 0, fishing_xp = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaSkillState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadSkillState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + want := SkillState{CombatLevel: 7, CombatXP: 250, MiningSkill: 4, MiningXP: 100, ForagingSkill: 3, ForagingXP: 80, FishingSkill: 2, FishingXP: 60} + if got != want { + t.Errorf("after backfill: got %+v want %+v", got, want) + } + + // Layer a dual-write (level-up bump combat to 8, drain xp). + got.CombatLevel = 8 + got.CombatXP = 0 + if err := upsertPlayerMetaSkillState(uid, got); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaSkillState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadSkillState(uid) + if got2.CombatLevel != 8 || got2.CombatXP != 0 { + t.Errorf("backfill clobbered dual-write: got %+v", got2) + } +} + +func TestLoadSkillState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-skill-fb:example") + if err := createAdvCharacter(uid, "Faller"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ? + WHERE user_id = ?`, + 5, 3, 2, 1, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET combat_level = 0, combat_xp = 0, + mining_skill = 0, mining_xp = 0, + foraging_skill = 0, foraging_xp = 0, + fishing_skill = 0, fishing_xp = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadSkillState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.CombatLevel != 5 || got.MiningSkill != 3 || got.ForagingSkill != 2 || got.FishingSkill != 1 { + t.Errorf("fallback: got %+v", got) + } + + // Unknown user → zero SkillState, no error. + got2, err := loadSkillState(id.UserID("@meta-skill-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got2 != (SkillState{}) { + t.Errorf("missing user: got %+v", got2) + } +} + +func TestUpsertPlayerMetaSkillState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-skill-rt:example") + in := SkillState{CombatLevel: 12, CombatXP: 450, MiningSkill: 8, MiningXP: 200, ForagingSkill: 6, ForagingXP: 150, FishingSkill: 5, FishingXP: 90} + if err := upsertPlayerMetaSkillState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadSkillState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != in { + t.Errorf("round-trip mismatch:\n got=%+v\nwant=%+v", got, in) + } + + in.CombatLevel = 13 + in.CombatXP = 0 + if err := upsertPlayerMetaSkillState(uid, in); err != nil { + t.Fatalf("upsert update: %v", err) + } + got2, _ := loadSkillState(uid) + if got2 != in { + t.Errorf("update mismatch:\n got=%+v\nwant=%+v", got2, in) + } +} + func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { setupAuditTestDB(t)