package plugin import ( "database/sql" "encoding/json" "time" "gogobee/internal/db" "maunium.net/go/mautrix/id" ) // player_meta is the Adv 2.0 holding pen for non-stat per-user state // migrating off adventure_characters (gogobee_legacy_migration.md §2.1). // Phase L2 step 5 lands the table + arena counters; later phases extend // the schema and add their own helpers here. // PlayerMeta is the in-memory mirror of the player_meta row. Only the // fields a phase has migrated are meaningful; unmigrated fields stay // zero-valued and are sourced from AdvCharacter. type PlayerMeta struct { UserID id.UserID ArenaWins int ArenaLosses int InvasionScore int DisplayName string HospitalVisits int MasterworkDropsReceived int } // loadPlayerMeta reads the player_meta row for a user. Returns a // zero-valued struct (with UserID set) when no row exists — callers // should treat absence as "all fields zero", matching the column // defaults. func loadPlayerMeta(userID id.UserID) (*PlayerMeta, error) { m := &PlayerMeta{UserID: userID} err := db.Get().QueryRow( `SELECT arena_wins, arena_losses, invasion_score, display_name, hospital_visits, masterwork_drops_received FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&m.ArenaWins, &m.ArenaLosses, &m.InvasionScore, &m.DisplayName, &m.HospitalVisits, &m.MasterworkDropsReceived) if err == sql.ErrNoRows { return m, nil } if err != nil { return nil, err } return m, nil } // upsertPlayerMetaArena writes arena_wins / arena_losses / invasion_score // for a user. Creates the row if missing. Used by the dual-write path // during the L1–L4 migration (§11): every arena counter mutation goes // through both AdvCharacter and player_meta until soak completes. func upsertPlayerMetaArena(userID id.UserID, wins, losses, invasionScore int) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, arena_wins, arena_losses, invasion_score) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET arena_wins = excluded.arena_wins, arena_losses = excluded.arena_losses, invasion_score = excluded.invasion_score`, string(userID), wins, losses, invasionScore, ) return err } // upsertPlayerMetaDisplayName writes display_name for a user, leaving other // columns untouched. Used by the dual-write path during the L4f-prep // DisplayName migration: every place that mutates AdvCharacter.DisplayName // also calls this so player_meta.display_name stays in sync until soak // completes and readers flip over (gogobee_legacy_migration.md §7). func upsertPlayerMetaDisplayName(userID id.UserID, displayName string) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, display_name) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`, string(userID), displayName, ) return err } // loadDisplayName returns the player's display name from player_meta. // Empty string if the user has no row. func loadDisplayName(userID id.UserID) (string, error) { var name string err := db.Get().QueryRow( `SELECT display_name FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&name) if err == sql.ErrNoRows { return "", nil } return name, err } // upsertPlayerMetaHospitalVisits writes hospital_visits for a user, leaving // other columns untouched. Used by the dual-write path during the L4a // Hospital migration: every increment on AdvCharacter.HospitalVisits also // calls this so player_meta.hospital_visits stays in sync until soak // completes (gogobee_legacy_migration.md §6.1, §11). func upsertPlayerMetaHospitalVisits(userID id.UserID, visits int) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, hospital_visits) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET hospital_visits = excluded.hospital_visits`, string(userID), visits, ) return err } // loadHospitalVisits returns the player's hospital visit count from // player_meta. Zero if the user has no row. func loadHospitalVisits(userID id.UserID) (int, error) { var visits int err := db.Get().QueryRow( `SELECT hospital_visits FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&visits) if err == sql.ErrNoRows { return 0, nil } return visits, err } // upsertPlayerMetaRivalState writes rival_pool / rival_unlocked_notified for // a user, leaving other columns untouched. Used by the dual-write path during // the L4b Rival migration: every place that mutates AdvCharacter.RivalPool / // RivalUnlockedNotified also calls this so player_meta stays in sync until // soak completes (gogobee_legacy_migration.md §6.2, §11). func upsertPlayerMetaRivalState(userID id.UserID, pool int, notified bool) error { notifiedInt := 0 if notified { notifiedInt = 1 } _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, rival_pool, rival_unlocked_notified) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET rival_pool = excluded.rival_pool, rival_unlocked_notified = excluded.rival_unlocked_notified`, string(userID), pool, notifiedInt, ) return err } // loadRivalState returns rival_pool / rival_unlocked_notified for a user // from player_meta. (0, false) if the user has no row. func loadRivalState(userID id.UserID) (pool int, notified bool, err error) { var notifiedInt int err = db.Get().QueryRow( `SELECT rival_pool, rival_unlocked_notified FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&pool, ¬ifiedInt) if err == sql.ErrNoRows { return 0, false, nil } if err != nil { return 0, false, err } return pool, notifiedInt == 1, nil } // upsertPlayerMetaMasterworkDrops writes masterwork_drops_received for a user, // leaving other columns untouched. Used by the dual-write path during the // L4c Masterwork migration: every increment on AdvCharacter.MasterworkDropsReceived // also calls this so player_meta stays in sync until soak completes // (gogobee_legacy_migration.md §6.3, §11). func upsertPlayerMetaMasterworkDrops(userID id.UserID, drops int) error { _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, masterwork_drops_received) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET masterwork_drops_received = excluded.masterwork_drops_received`, string(userID), drops, ) return err } // loadMasterworkDrops returns the player's masterwork drop count from // player_meta. Zero if the user has no row. func loadMasterworkDrops(userID id.UserID) (int, error) { var drops int err := db.Get().QueryRow( `SELECT masterwork_drops_received FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&drops) if err == sql.ErrNoRows { return 0, nil } return drops, err } // PetState is the in-memory mirror of player_meta's pet_* columns. Phase L4d // ports pet ownership and progression off AdvCharacter (gogobee_legacy_migration.md // §6.4). The four flag bools serialize as a single pet_flags_json column to // avoid an explosion of boolean ALTERs when future pet behaviors land. type PetState struct { Type string Name string XP int Level int ArmorTier int SupplyShopUnlocked bool Level10Date string Arrived bool ChasedAway bool Reactivated bool MorningDefense bool } // HasPet returns true if the player has an active pet (arrived, not chased // away). Mirrors AdventureCharacter.HasPet() for the cross-file pet-helper // flip in L4d. func (s PetState) HasPet() bool { return s.Type != "" && s.Arrived && !s.ChasedAway } // petFlagsJSON is the on-disk encoding of the four pet bools. Adding a new // flag is an additive JSON field — old rows decode as false. type petFlagsJSON struct { Arrived bool `json:"arrived"` ChasedAway bool `json:"chased_away"` Reactivated bool `json:"reactivated"` MorningDefense bool `json:"morning_defense"` } // upsertPlayerMetaPetState writes the full pet column set for a user. Pet // state mutates as a unit (arrival sets type/name/level/flags together; // level-ups touch xp/level/level10date), so this helper takes the whole // PetState rather than splitting per-field. Used by the dual-write path // during the L4d soak window. func upsertPlayerMetaPetState(userID id.UserID, s PetState) error { flags, err := json.Marshal(petFlagsJSON{ Arrived: s.Arrived, ChasedAway: s.ChasedAway, Reactivated: s.Reactivated, MorningDefense: s.MorningDefense, }) if err != nil { return err } supplyInt := 0 if s.SupplyShopUnlocked { supplyInt = 1 } _, err = db.Get().Exec( `INSERT INTO player_meta ( user_id, pet_type, pet_name, pet_xp, pet_level, pet_armor_tier, pet_flags_json, pet_supply_shop_unlocked, pet_level_10_date ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET pet_type = excluded.pet_type, pet_name = excluded.pet_name, pet_xp = excluded.pet_xp, pet_level = excluded.pet_level, pet_armor_tier = excluded.pet_armor_tier, pet_flags_json = excluded.pet_flags_json, pet_supply_shop_unlocked = excluded.pet_supply_shop_unlocked, pet_level_10_date = excluded.pet_level_10_date`, string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier, string(flags), supplyInt, s.Level10Date, ) return err } // loadPetState returns the player's pet state from player_meta. Empty // PetState{} if the user has no row. func loadPetState(userID id.UserID) (PetState, error) { var ( s PetState flagsRaw string supplyInt int ) err := db.Get().QueryRow( `SELECT pet_type, pet_name, pet_xp, pet_level, pet_armor_tier, pet_flags_json, pet_supply_shop_unlocked, pet_level_10_date FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier, &flagsRaw, &supplyInt, &s.Level10Date) if err == sql.ErrNoRows { return PetState{}, nil } if err != nil { return PetState{}, err } s.SupplyShopUnlocked = supplyInt == 1 var f petFlagsJSON if flagsRaw != "" { _ = json.Unmarshal([]byte(flagsRaw), &f) } s.Arrived = f.Arrived s.ChasedAway = f.ChasedAway s.Reactivated = f.Reactivated s.MorningDefense = f.MorningDefense return s, nil } // petStateFromAdvChar projects the pet-related fields off an AdventureCharacter // into a PetState. Used by the dual-write path: every site that mutates // AdvCharacter pet fields then calls upsertPlayerMetaPetState with this // projection so the column moves cleanly during the L4d soak window. func petStateFromAdvChar(c *AdventureCharacter) PetState { return PetState{ Type: c.PetType, Name: c.PetName, XP: c.PetXP, Level: c.PetLevel, ArmorTier: c.PetArmorTier, SupplyShopUnlocked: c.PetSupplyShopUnlocked, Level10Date: c.PetLevel10Date, Arrived: c.PetArrived, ChasedAway: c.PetChasedAway, Reactivated: c.PetReactivated, MorningDefense: c.PetMorningDefense, } } // pet2StateFromAdvChar projects the second pet's fields off an // AdventureCharacter. SupplyShopUnlocked / MorningDefense are always zero for // slot 2 — those mechanics stay pet-1-only (see AdventureCharacter.Pet2*). func pet2StateFromAdvChar(c *AdventureCharacter) PetState { return PetState{ Type: c.Pet2Type, Name: c.Pet2Name, XP: c.Pet2XP, Level: c.Pet2Level, ArmorTier: c.Pet2ArmorTier, Level10Date: c.Pet2Level10Date, Arrived: c.Pet2Arrived, ChasedAway: c.Pet2ChasedAway, Reactivated: c.Pet2Reactivated, } } // loadPet2State returns the player's second pet from the pet2_* columns. Empty // PetState{} if the user has no row or no second pet. func loadPet2State(userID id.UserID) (PetState, error) { var ( s PetState flagsRaw string ) err := db.Get().QueryRow( `SELECT pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier, pet2_flags_json, pet2_level_10_date FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier, &flagsRaw, &s.Level10Date) if err == sql.ErrNoRows { return PetState{}, nil } if err != nil { return PetState{}, err } var f petFlagsJSON if flagsRaw != "" { _ = json.Unmarshal([]byte(flagsRaw), &f) } s.Arrived = f.Arrived s.ChasedAway = f.ChasedAway s.Reactivated = f.Reactivated return s, nil } // upsertPlayerMetaPet2State writes the second pet's column set. func upsertPlayerMetaPet2State(userID id.UserID, s PetState) error { flags, err := json.Marshal(petFlagsJSON{ Arrived: s.Arrived, ChasedAway: s.ChasedAway, Reactivated: s.Reactivated, }) if err != nil { return err } _, err = db.Get().Exec( `INSERT INTO player_meta ( user_id, pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier, pet2_flags_json, pet2_level_10_date ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET pet2_type = excluded.pet2_type, pet2_name = excluded.pet2_name, pet2_xp = excluded.pet2_xp, pet2_level = excluded.pet2_level, pet2_armor_tier = excluded.pet2_armor_tier, pet2_flags_json = excluded.pet2_flags_json, pet2_level_10_date = excluded.pet2_level_10_date`, string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier, string(flags), s.Level10Date, ) return err } // loadJournalPages reads the Hollow King campaign page bitmask (N5/D1). A // missing row means no pages found yet. func loadJournalPages(userID id.UserID) (int64, error) { var mask int64 err := db.Get().QueryRow( `SELECT journal_pages FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&mask) if err == sql.ErrNoRows { return 0, nil } if err != nil { return 0, err } return mask, nil } // grantJournalPageDB sets the bit for one page atomically. The OR against the // stored value (not a read-modify-write of the in-memory character) means a // page granted mid-expedition survives a concurrent character save, and the // call is idempotent — re-granting a found page is a no-op. func grantJournalPageDB(userID id.UserID, page int) error { if page < 1 { return nil } bit := int64(1) << (page - 1) _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, journal_pages) VALUES (?, ?) ON CONFLICT(user_id) DO UPDATE SET journal_pages = journal_pages | excluded.journal_pages`, string(userID), bit, ) return err } // HouseState is the in-memory mirror of player_meta's house_* columns. Phase // L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md // §6.5). All six fields mutate together at known sites (purchase, payoff, // payment, autopay toggle, weekly tick, rate refresh), so the helper takes // the whole struct rather than splitting per-field. type HouseState struct { Tier int LoanBalance int LoanFrozen bool MissedPayments int Autopay bool CurrentRate float64 } // HasHouse mirrors AdventureCharacter.HasHouse: a player counts as housed // if they own any tier or carry an outstanding loan. Used as the "row is // migrated" marker in loadHouseState — a zero/zero/zero state is // indistinguishable from "no row yet" so it falls through to the legacy // table during the soak window. func (s HouseState) HasHouse() bool { return s.Tier > 0 || s.LoanBalance > 0 } // upsertPlayerMetaHouseState writes the full house column set for a user. // Used by the dual-write path during the L4e soak window. func upsertPlayerMetaHouseState(userID id.UserID, s HouseState) error { frozen := 0 if s.LoanFrozen { frozen = 1 } autopay := 0 if s.Autopay { autopay = 1 } _, err := db.Get().Exec( `INSERT INTO player_meta ( user_id, house_tier, house_loan_balance, house_loan_frozen, house_missed_payments, house_autopay, house_current_rate ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET house_tier = excluded.house_tier, house_loan_balance = excluded.house_loan_balance, house_loan_frozen = excluded.house_loan_frozen, house_missed_payments = excluded.house_missed_payments, house_autopay = excluded.house_autopay, house_current_rate = excluded.house_current_rate`, string(userID), s.Tier, s.LoanBalance, frozen, s.MissedPayments, autopay, s.CurrentRate, ) return err } // loadHouseState returns the player's house state from player_meta. Empty // HouseState{} if the user has no row. func loadHouseState(userID id.UserID) (HouseState, error) { var ( s HouseState frozenInt int autopayInt int ) err := db.Get().QueryRow( `SELECT house_tier, house_loan_balance, house_loan_frozen, house_missed_payments, house_autopay, house_current_rate FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&s.Tier, &s.LoanBalance, &frozenInt, &s.MissedPayments, &autopayInt, &s.CurrentRate) if err == sql.ErrNoRows { return HouseState{}, nil } if err != nil { return HouseState{}, err } s.LoanFrozen = frozenInt == 1 s.Autopay = autopayInt == 1 return s, nil } // houseStateFromAdvChar projects the housing-related fields off an // AdventureCharacter into a HouseState. Used by the dual-write path: // every site that mutates AdvCharacter house fields then calls // upsertPlayerMetaHouseState with this projection so the columns move // cleanly during the L4e soak window. func houseStateFromAdvChar(c *AdventureCharacter) HouseState { return HouseState{ Tier: c.HouseTier, LoanBalance: c.HouseLoanBalance, LoanFrozen: c.HouseLoanFrozen, MissedPayments: c.HouseMissedPayments, Autopay: c.HouseAutopay, CurrentRate: c.HouseCurrentRate, } } // 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. 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. 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 == sql.ErrNoRows { return SkillState{}, nil } return s, err } // skillStateFromAdvChar projects the skill-related fields off an // AdventureCharacter into a SkillState. Used by saveAdvCharacter's fan-out // to mirror skill columns into player_meta. 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, } } // 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. // Empty BabysitState{} if the user has no row. 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 == sql.ErrNoRows { return BabysitState{}, nil } if err != nil { return BabysitState{}, err } s.Active = activeInt == 1 s.AutoBabysit = autoInt == 1 if expires.Valid { t := expires.Time.UTC() s.ExpiresAt = &t } return s, 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, } } // NPCState mirrors player_meta's NPC counter/timestamp columns. Phase // L5c ports Misty/Arina/Robbie/Thom counters + buff/debuff expiries off // AdvCharacter (gogobee_legacy_migration.md §7.3 L5c). Hidden discovery // mechanics — never surface in player-facing output. type NPCState struct { MistyLastSeen *time.Time ArinaLastSeen *time.Time MistyBuffExpires *time.Time MistyDebuffExpires *time.Time ArinaBuffExpires *time.Time NPCMsgCount int NPCMsgCountDate string MistyRollTarget int ArinaRollTarget int MistyEncounterCount int MistyDonatedCount int ThomAnimalLineFired bool RobbieVisitCount int } // HasNPCActivity returns true when any counter or timestamp is non-zero — // the "row is migrated" marker for the loadNPCState fallback path. func (s NPCState) HasNPCActivity() bool { return s.MistyLastSeen != nil || s.ArinaLastSeen != nil || s.MistyBuffExpires != nil || s.MistyDebuffExpires != nil || s.ArinaBuffExpires != nil || s.NPCMsgCount > 0 || s.NPCMsgCountDate != "" || s.MistyRollTarget > 0 || s.ArinaRollTarget > 0 || s.MistyEncounterCount > 0 || s.MistyDonatedCount > 0 || s.ThomAnimalLineFired || s.RobbieVisitCount > 0 } // upsertPlayerMetaNPCState writes the full NPC column set for a user. // Used by the dual-write path during the L5c soak window. func upsertPlayerMetaNPCState(userID id.UserID, s NPCState) error { thom := 0 if s.ThomAnimalLineFired { thom = 1 } var ( mistyLast, arinaLast, mistyBuff, mistyDebuff, arinaBuff interface{} ) if s.MistyLastSeen != nil { mistyLast = s.MistyLastSeen.UTC() } if s.ArinaLastSeen != nil { arinaLast = s.ArinaLastSeen.UTC() } if s.MistyBuffExpires != nil { mistyBuff = s.MistyBuffExpires.UTC() } if s.MistyDebuffExpires != nil { mistyDebuff = s.MistyDebuffExpires.UTC() } if s.ArinaBuffExpires != nil { arinaBuff = s.ArinaBuffExpires.UTC() } _, err := db.Get().Exec( `INSERT INTO player_meta ( user_id, misty_last_seen, arina_last_seen, misty_buff_expires, misty_debuff_expires, arina_buff_expires, npc_msg_count, npc_msg_count_date, misty_roll_target, arina_roll_target, misty_encounter_count, misty_donated_count, thom_animal_line_fired, robbie_visit_count ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET misty_last_seen = excluded.misty_last_seen, arina_last_seen = excluded.arina_last_seen, misty_buff_expires = excluded.misty_buff_expires, misty_debuff_expires = excluded.misty_debuff_expires, arina_buff_expires = excluded.arina_buff_expires, npc_msg_count = excluded.npc_msg_count, npc_msg_count_date = excluded.npc_msg_count_date, misty_roll_target = excluded.misty_roll_target, arina_roll_target = excluded.arina_roll_target, misty_encounter_count = excluded.misty_encounter_count, misty_donated_count = excluded.misty_donated_count, thom_animal_line_fired = excluded.thom_animal_line_fired, robbie_visit_count = excluded.robbie_visit_count`, string(userID), mistyLast, arinaLast, mistyBuff, mistyDebuff, arinaBuff, s.NPCMsgCount, s.NPCMsgCountDate, s.MistyRollTarget, s.ArinaRollTarget, s.MistyEncounterCount, s.MistyDonatedCount, thom, s.RobbieVisitCount, ) return err } // loadNPCState returns the NPC state from player_meta. func loadNPCState(userID id.UserID) (NPCState, error) { var ( s NPCState mistyLast, arinaLast, mistyBuff, mistyDebuff, arinaBuff sql.NullTime thomInt int ) err := db.Get().QueryRow( `SELECT misty_last_seen, arina_last_seen, misty_buff_expires, misty_debuff_expires, arina_buff_expires, npc_msg_count, npc_msg_count_date, misty_roll_target, arina_roll_target, misty_encounter_count, misty_donated_count, thom_animal_line_fired, robbie_visit_count FROM player_meta WHERE user_id = ?`, string(userID), ).Scan( &mistyLast, &arinaLast, &mistyBuff, &mistyDebuff, &arinaBuff, &s.NPCMsgCount, &s.NPCMsgCountDate, &s.MistyRollTarget, &s.ArinaRollTarget, &s.MistyEncounterCount, &s.MistyDonatedCount, &thomInt, &s.RobbieVisitCount, ) if err == sql.ErrNoRows { return NPCState{}, nil } if err != nil { return NPCState{}, err } s.ThomAnimalLineFired = thomInt == 1 if mistyLast.Valid { t := mistyLast.Time.UTC() s.MistyLastSeen = &t } if arinaLast.Valid { t := arinaLast.Time.UTC() s.ArinaLastSeen = &t } if mistyBuff.Valid { t := mistyBuff.Time.UTC() s.MistyBuffExpires = &t } if mistyDebuff.Valid { t := mistyDebuff.Time.UTC() s.MistyDebuffExpires = &t } if arinaBuff.Valid { t := arinaBuff.Time.UTC() s.ArinaBuffExpires = &t } return s, nil } // npcStateFromAdvChar projects NPC fields off an AdventureCharacter into // an NPCState. Used by the dual-write path during L5c soak. func npcStateFromAdvChar(c *AdventureCharacter) NPCState { return NPCState{ MistyLastSeen: c.MistyLastSeen, ArinaLastSeen: c.ArinaLastSeen, MistyBuffExpires: c.MistyBuffExpires, MistyDebuffExpires: c.MistyDebuffExpires, ArinaBuffExpires: c.ArinaBuffExpires, NPCMsgCount: c.NPCMsgCount, NPCMsgCountDate: c.NPCMsgCountDate, MistyRollTarget: c.MistyRollTarget, ArinaRollTarget: c.ArinaRollTarget, MistyEncounterCount: c.MistyEncounterCount, MistyDonatedCount: c.MistyDonatedCount, ThomAnimalLineFired: c.ThomAnimalLineFired, RobbieVisitCount: c.RobbieVisitCount, } } // LifecycleState mirrors player_meta's streak/action/lifecycle columns. // Phase L5d ports these fields off AdvCharacter (gogobee_legacy_migration.md // §7.3 L5d). Streak fields update on the daily morning DM tick; action // flags reset by `resetAllAdvDailyActions` and only flip true when the // player rests; lifecycle timestamps are set at character creation and // auto-bumped by every saveAdvCharacter via CURRENT_TIMESTAMP. type LifecycleState struct { CurrentStreak int BestStreak int LastActionDate string StreakDecayed bool ActionTakenToday bool HolidayActionTaken bool CombatActionsUsed int HarvestActionsUsed int CreatedAt *time.Time LastActiveAt *time.Time } // HasLifecycle returns true when any lifecycle field is non-zero — used // as the "row is migrated" marker in loadLifecycleState. CreatedAt is set // at character creation, so any migrated row will have it. func (s LifecycleState) HasLifecycle() bool { return s.CreatedAt != nil || s.LastActiveAt != nil || s.CurrentStreak > 0 || s.BestStreak > 0 || s.LastActionDate != "" || s.StreakDecayed || s.ActionTakenToday || s.HolidayActionTaken || s.CombatActionsUsed > 0 || s.HarvestActionsUsed > 0 } // upsertPlayerMetaLifecycleState writes the full lifecycle column set for // a user. Used by the dual-write path during the L5d soak window. // CreatedAt and LastActiveAt are written when non-nil; a nil value leaves // the column NULL. func upsertPlayerMetaLifecycleState(userID id.UserID, s LifecycleState) error { streakDecayed := 0 if s.StreakDecayed { streakDecayed = 1 } actionTaken := 0 if s.ActionTakenToday { actionTaken = 1 } holidayTaken := 0 if s.HolidayActionTaken { holidayTaken = 1 } var createdAt, lastActiveAt interface{} if s.CreatedAt != nil { createdAt = s.CreatedAt.UTC() } if s.LastActiveAt != nil { lastActiveAt = s.LastActiveAt.UTC() } _, err := db.Get().Exec( `INSERT INTO player_meta ( user_id, current_streak, best_streak, last_action_date, streak_decayed, action_taken_today, holiday_action_taken, combat_actions_used, harvest_actions_used, created_at, last_active_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET current_streak = excluded.current_streak, best_streak = excluded.best_streak, last_action_date = excluded.last_action_date, streak_decayed = excluded.streak_decayed, action_taken_today = excluded.action_taken_today, holiday_action_taken = excluded.holiday_action_taken, combat_actions_used = excluded.combat_actions_used, harvest_actions_used = excluded.harvest_actions_used, created_at = excluded.created_at, last_active_at = excluded.last_active_at`, string(userID), s.CurrentStreak, s.BestStreak, s.LastActionDate, streakDecayed, actionTaken, holidayTaken, s.CombatActionsUsed, s.HarvestActionsUsed, createdAt, lastActiveAt, ) return err } // loadLifecycleState returns lifecycle state from player_meta. func loadLifecycleState(userID id.UserID) (LifecycleState, error) { var ( s LifecycleState streakDecayed, actionTaken, holidayTaken int createdAt, lastActiveAt sql.NullTime ) err := db.Get().QueryRow( `SELECT current_streak, best_streak, last_action_date, streak_decayed, action_taken_today, holiday_action_taken, combat_actions_used, harvest_actions_used, created_at, last_active_at FROM player_meta WHERE user_id = ?`, string(userID), ).Scan( &s.CurrentStreak, &s.BestStreak, &s.LastActionDate, &streakDecayed, &actionTaken, &holidayTaken, &s.CombatActionsUsed, &s.HarvestActionsUsed, &createdAt, &lastActiveAt, ) if err == sql.ErrNoRows { return LifecycleState{}, nil } if err != nil { return LifecycleState{}, err } s.StreakDecayed = streakDecayed == 1 s.ActionTakenToday = actionTaken == 1 s.HolidayActionTaken = holidayTaken == 1 if createdAt.Valid { t := createdAt.Time.UTC() s.CreatedAt = &t } if lastActiveAt.Valid { t := lastActiveAt.Time.UTC() s.LastActiveAt = &t } return s, nil } // lifecycleStateFromAdvChar projects lifecycle fields off an // AdventureCharacter into a LifecycleState. Used by the dual-write path // during the L5d soak window. func lifecycleStateFromAdvChar(c *AdventureCharacter) LifecycleState { out := LifecycleState{ CurrentStreak: c.CurrentStreak, BestStreak: c.BestStreak, LastActionDate: c.LastActionDate, StreakDecayed: c.StreakDecayed, ActionTakenToday: c.ActionTakenToday, HolidayActionTaken: c.HolidayActionTaken, CombatActionsUsed: c.CombatActionsUsed, HarvestActionsUsed: c.HarvestActionsUsed, } if !c.CreatedAt.IsZero() { t := c.CreatedAt.UTC() out.CreatedAt = &t } if !c.LastActiveAt.IsZero() { t := c.LastActiveAt.UTC() out.LastActiveAt = &t } return out } // resetAllPlayerMetaDailyActions parallels resetAllAdvDailyActions: // resets the action_taken_today / holiday_action_taken / // combat_actions_used / harvest_actions_used columns in player_meta for // any row whose last_action_date is older than today (or NULL/empty). // Used by the daily reset tick during the L5d soak window. func resetAllPlayerMetaDailyActions() error { today := time.Now().UTC().Format("2006-01-02") _, err := db.Get().Exec(` UPDATE player_meta SET action_taken_today = 0, holiday_action_taken = 0, combat_actions_used = 0, harvest_actions_used = 0 WHERE last_action_date < ? OR last_action_date = ''`, today) return err } // resetAllPetMorningDefense clears the one-day pet morning-defense buff for // every player. The buff is a fresh morning grant (25% roll in the briefing / // overworld DM), so it must not survive past the midnight rollover. The flag // lives inside pet_flags_json (see petFlagsJSON), so patch that key in place; // only rows where it's currently set are touched. func resetAllPetMorningDefense() error { _, err := db.Get().Exec(` UPDATE player_meta SET pet_flags_json = json_set(pet_flags_json, '$.morning_defense', json('false')) WHERE json_extract(pet_flags_json, '$.morning_defense') = 1`) 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. Default // DeathState{Alive: true} if the user has no row. 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, ) if err == sql.ErrNoRows { return DeathState{Alive: true}, nil } if err != nil { return DeathState{}, err } s.Alive = aliveInt == 1 if deadUntil.Valid { t := deadUntil.Time.UTC() s.DeadUntil = &t } if reprieve.Valid { t := reprieve.Time.UTC() s.DeathReprieveLast = &t } if pardon.Valid { t := pardon.Time.UTC() s.LastPardonUsed = &t } return s, 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, } } // MiscState mirrors player_meta's miscellaneous columns: Title, // TreasuresLocked, CraftsSucceeded. Phase L5f ports these fields off // AdvCharacter (gogobee_legacy_migration.md §7.3 L5f). Dual-write rides // `saveAdvCharacter` alongside the L5e death state, since these fields // either rarely mutate or already mutate at every save site. type MiscState struct { Title string TreasuresLocked bool CraftsSucceeded int } func upsertPlayerMetaMiscState(userID id.UserID, s MiscState) error { locked := 0 if s.TreasuresLocked { locked = 1 } _, err := db.Get().Exec( `INSERT INTO player_meta (user_id, title, treasures_locked, crafts_succeeded) VALUES (?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET title = excluded.title, treasures_locked = excluded.treasures_locked, crafts_succeeded = excluded.crafts_succeeded`, string(userID), s.Title, locked, s.CraftsSucceeded, ) return err } func loadMiscState(userID id.UserID) (MiscState, error) { var ( s MiscState lockedInt int ) err := db.Get().QueryRow( `SELECT title, treasures_locked, crafts_succeeded FROM player_meta WHERE user_id = ?`, string(userID), ).Scan(&s.Title, &lockedInt, &s.CraftsSucceeded) if err == sql.ErrNoRows { return MiscState{}, nil } if err != nil { return MiscState{}, err } s.TreasuresLocked = lockedInt == 1 return s, nil } func miscStateFromAdvChar(c *AdventureCharacter) MiscState { return MiscState{ Title: c.Title, TreasuresLocked: c.TreasuresLocked, CraftsSucceeded: c.CraftsSucceeded, } } // applyPlayerMetaOverlay populates an AdventureCharacter from player_meta. // Post-L5 close-out: loadAdvCharacter no longer SELECTs from // adventure_characters — it default-inits the struct and overlays every // migrated subsystem's value from player_meta here. Errors are skipped — a // partial overlay is safer than a failed load (each field falls back to its // Go zero value, except Alive which is pre-set true). func applyPlayerMetaOverlay(c *AdventureCharacter) { if c == nil { return } uid := c.UserID if name, err := loadDisplayName(uid); err == nil && name != "" { c.DisplayName = name } if visits, err := loadHospitalVisits(uid); err == nil { c.HospitalVisits = visits } if pm, err := loadPlayerMeta(uid); err == nil && pm != nil { c.ArenaWins = pm.ArenaWins c.ArenaLosses = pm.ArenaLosses c.InvasionScore = pm.InvasionScore } if drops, err := loadMasterworkDrops(uid); err == nil { c.MasterworkDropsReceived = drops } if pool, notified, err := loadRivalState(uid); err == nil { c.RivalPool = pool c.RivalUnlockedNotified = notified } if s, err := loadSkillState(uid); err == nil { c.CombatLevel = s.CombatLevel c.CombatXP = s.CombatXP c.MiningSkill = s.MiningSkill c.MiningXP = s.MiningXP c.ForagingSkill = s.ForagingSkill c.ForagingXP = s.ForagingXP c.FishingSkill = s.FishingSkill c.FishingXP = s.FishingXP } if s, err := loadBabysitState(uid); err == nil { c.BabysitActive = s.Active c.BabysitExpiresAt = s.ExpiresAt c.BabysitSkillFocus = s.SkillFocus c.AutoBabysit = s.AutoBabysit c.AutoBabysitFocus = s.AutoBabysitFocus } if s, err := loadNPCState(uid); err == nil { c.MistyLastSeen = s.MistyLastSeen c.ArinaLastSeen = s.ArinaLastSeen c.MistyBuffExpires = s.MistyBuffExpires c.MistyDebuffExpires = s.MistyDebuffExpires c.ArinaBuffExpires = s.ArinaBuffExpires c.NPCMsgCount = s.NPCMsgCount c.NPCMsgCountDate = s.NPCMsgCountDate c.MistyRollTarget = s.MistyRollTarget c.ArinaRollTarget = s.ArinaRollTarget c.MistyEncounterCount = s.MistyEncounterCount c.MistyDonatedCount = s.MistyDonatedCount c.ThomAnimalLineFired = s.ThomAnimalLineFired c.RobbieVisitCount = s.RobbieVisitCount } if s, err := loadLifecycleState(uid); err == nil { c.CurrentStreak = s.CurrentStreak c.BestStreak = s.BestStreak c.LastActionDate = s.LastActionDate c.StreakDecayed = s.StreakDecayed c.ActionTakenToday = s.ActionTakenToday c.HolidayActionTaken = s.HolidayActionTaken c.CombatActionsUsed = s.CombatActionsUsed c.HarvestActionsUsed = s.HarvestActionsUsed if s.CreatedAt != nil { c.CreatedAt = *s.CreatedAt } if s.LastActiveAt != nil { c.LastActiveAt = *s.LastActiveAt } } if s, err := loadDeathState(uid); err == nil { c.Alive = s.Alive c.DeadUntil = s.DeadUntil c.DeathReprieveLast = s.DeathReprieveLast c.LastDeathDate = s.LastDeathDate c.LastPardonUsed = s.LastPardonUsed c.GrudgeLocation = s.GrudgeLocation c.DeathSource = s.DeathSource c.DeathLocation = s.DeathLocation } if s, err := loadMiscState(uid); err == nil { c.Title = s.Title c.TreasuresLocked = s.TreasuresLocked c.CraftsSucceeded = s.CraftsSucceeded } if s, err := loadPetState(uid); err == nil { c.PetType = s.Type c.PetName = s.Name c.PetXP = s.XP c.PetLevel = s.Level c.PetArmorTier = s.ArmorTier c.PetSupplyShopUnlocked = s.SupplyShopUnlocked c.PetLevel10Date = s.Level10Date c.PetArrived = s.Arrived c.PetChasedAway = s.ChasedAway c.PetReactivated = s.Reactivated c.PetMorningDefense = s.MorningDefense } if s, err := loadPet2State(uid); err == nil { c.Pet2Type = s.Type c.Pet2Name = s.Name c.Pet2XP = s.XP c.Pet2Level = s.Level c.Pet2ArmorTier = s.ArmorTier c.Pet2Level10Date = s.Level10Date c.Pet2Arrived = s.Arrived c.Pet2ChasedAway = s.ChasedAway c.Pet2Reactivated = s.Reactivated } if mask, err := loadJournalPages(uid); err == nil { c.JournalPages = mask } if s, err := loadHouseState(uid); err == nil { c.HouseTier = s.Tier c.HouseLoanBalance = s.LoanBalance c.HouseLoanFrozen = s.LoanFrozen c.HouseMissedPayments = s.MissedPayments c.HouseAutopay = s.Autopay c.HouseCurrentRate = s.CurrentRate } } // upsertAllPlayerMetaFromAdvChar writes every player_meta-mirrored subsystem's // state in one shot from an AdventureCharacter. Phase L5h: saveAdvCharacter // stops writing adventure_characters and instead routes through this fan-out, // keeping every load helper's read path canonical against player_meta. Returns // the first error encountered; partial writes are possible and acceptable // since each upsert is its own atomic transaction. func upsertAllPlayerMetaFromAdvChar(c *AdventureCharacter) error { uid := c.UserID if err := upsertPlayerMetaDisplayName(uid, c.DisplayName); err != nil { return err } if err := upsertPlayerMetaHospitalVisits(uid, c.HospitalVisits); err != nil { return err } if err := upsertPlayerMetaArena(uid, c.ArenaWins, c.ArenaLosses, c.InvasionScore); err != nil { return err } if err := upsertPlayerMetaMasterworkDrops(uid, c.MasterworkDropsReceived); err != nil { return err } if err := upsertPlayerMetaRivalState(uid, c.RivalPool, c.RivalUnlockedNotified); err != nil { return err } if err := upsertPlayerMetaSkillState(uid, skillStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaBabysitState(uid, babysitStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaNPCState(uid, npcStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaLifecycleState(uid, lifecycleStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaDeathState(uid, deathStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaMiscState(uid, miscStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaPet2State(uid, pet2StateFromAdvChar(c)); err != nil { return err } if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil { return err } return nil }