mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ func (p *AdventurePlugin) handleEventRespond(ctx MessageContext) error {
|
||||
}
|
||||
checkAdvLevelUp(char, xpSkill)
|
||||
_ = saveAdvCharacter(char)
|
||||
_ = upsertPlayerMetaSkillState(ctx.Sender, skillStateFromAdvChar(char))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user