Adv 2.0 L5a: skills migration off AdvCharacter to player_meta

Eight player_meta columns (combat_level, combat_xp, mining_skill,
mining_xp, foraging_skill, foraging_xp, fishing_skill, fishing_xp).
SkillState struct with HasSkills() marker mirrors PetState/HouseState
shape. loadSkillState / upsertPlayerMetaSkillState /
backfillPlayerMetaSkillState / skillStateFromAdvChar helpers.

Dual-writes wired at every mutation site: consumables craft XP (both
success and failure branches), events.go XP grant across all skills,
arena death + session-complete combat XP. Backfill is idempotent (only
fills rows where every skill column is still zero AND the legacy row
has any non-zero value).

CombatLevel/CombatXP are transitional — dropped at L5g after the DnD
mass-backfill retires the legacy CombatLevel-derived fallback.

Tests: TestPlayerMetaSkillStateBackfill_Idempotent,
TestLoadSkillState_FallsBackToAdvCharacter,
TestUpsertPlayerMetaSkillState_RoundTrip.

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

View File

@@ -415,7 +415,7 @@ Each sub-phase is the same shape as L4aL4e: 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`. |

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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" {

View File

@@ -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))
}
}
}

View File

@@ -215,6 +215,7 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
}
checkAdvLevelUp(char, xpSkill)
_ = saveAdvCharacter(char)
_ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char))
}
}

View File

@@ -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

View File

@@ -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)