From 1c7939c3c84e97fa5ef11d95e2a73dc23ec5f5a2 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 11:52:03 -0700 Subject: [PATCH] =?UTF-8?q?Adv=202.0=20L5h:=20cold=20AdvCharacter=20table?= =?UTF-8?q?=20=E2=80=94=20saveAdvCharacter=20fan-outs=20to=20player=5Fmeta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveAdvCharacter no longer writes the legacy adventure_characters row. It's now a thin fan-out: bumps LastActiveAt and routes through upsertAllPlayerMetaFromAdvChar, which calls the per-subsystem upserts for display_name, hospital, arena, masterwork, rival, skills, babysit, NPC, lifecycle, death, misc, pet, and house. loadAdvCharacter calls applyPlayerMetaOverlay at the tail to re-source every migrated subsystem from player_meta. The adventure_characters SELECT still happens (for the user_id / equipment-table linkage and as a fallback substrate) but its values are overwritten by the overlay. This achieves the L4e in-place housing reader flip automatically — adventure_housing.go's char.HouseFoo reads now see player_meta values without per-site changes. npcMidnightReset extended to write player_meta alongside the frozen adventure_characters. Per-call-site upsertPlayerMetaXxx duals are now redundant with the saveAdvCharacter fan-out but kept as defense; cleanup deferred. player_meta.combat_level column drop deferred — still read by babysit cost / combat_stats / activities via the overlay; sequenced with the final combat_engine teardown. Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_legacy_migration.md | 2 +- internal/plugin/adventure_character.go | 159 ++--------------------- internal/plugin/adventure_npcs.go | 20 ++- internal/plugin/player_meta.go | 169 +++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 151 deletions(-) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index c19ccbd..30b7634 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -422,7 +422,7 @@ Each sub-phase is the same shape as L4a–L4e: column-add → backfill (idempote | **L5e** | Death state (8 fields) | 1 day | Decision point: do `DnDCharacter.HPCurrent == 0` semantics replace `Alive`/`DeadUntil`, or do we keep them as `player_meta.alive` / `player_meta.dead_until`? Recommendation: keep them as columns. The hospital revive path (L4a) already restores `DnDCharacter.HPCurrent = HPMax` alongside `Alive=true`, so the two are kept in sync; flipping the readers is the migration, not the death-state semantics. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Eight columns added (alive INTEGER DEFAULT 1, dead_until/death_reprieve_last/last_pardon_used DATETIME, last_death_date/grudge_location/death_source/death_location TEXT). `DeathState` + load/upsert/backfill/projection helpers. **Strategy switch:** dual-write moved inside `saveAdvCharacter` itself (after the existing display_name dual-write). The mutation surface for death state spans ~50 save sites — per-site upserts would be too noisy. Every `saveAdvCharacter` call now propagates `DeathState` to player_meta. Backfill idempotent (only fills rows where every death field is still default). Tests: `TestPlayerMetaDeathStateBackfill_Idempotent`, `TestLoadDeathState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaDeathState_RoundTrip`, `TestSaveAdvCharacter_DualWritesDeathState`. `go vet ./... && go test ./...` clean. | | **L5f** | Misc (`Title`, `TreasuresLocked`, `CraftsSucceeded`) | 0.5 day | Each is a single-mutation-site field. **Status (2026-05-09):** column + dual-write SHIPPED on `adv-2.0`. Three columns (title TEXT, treasures_locked INT, crafts_succeeded INT). `MiscState` + load/upsert/backfill/projection helpers. Dual-write rides `saveAdvCharacter` alongside L5e (Title is dormant; TreasuresLocked + CraftsSucceeded mutate at known sites that already save). Backfill idempotent. Tests: `TestPlayerMetaMiscStateBackfill_Idempotent`, `TestLoadMiscState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaMiscState_RoundTrip`. `go vet ./... && go test ./...` clean. | | **L5g** | DnDCharacter mass-backfill | 0.5 day | One-shot: for every active user without a D&D row, insert a default-class character at `dndLevelFromCombatLevel(adventure_characters.combat_level)`. Run once, idempotent (skip rows that already exist). After this lands, drop the `dndLevelFromCombatLevel` fallback branch in `loadHospitalVisits`/`rivalLevelForUser`/`dndLevelForUser`/zone gates. CombatLevel column in player_meta becomes unused at this point — drop it during the L5h cleanup. **Status (2026-05-09):** SHIPPED on `adv-2.0`. `backfillDnDCharactersFromAdv` in `dnd_combat.go` walks `adventure_characters LEFT JOIN dnd_character WHERE d.user_id IS NULL`, calls `autoBuildCharacter` (race/class inferred from archetypes; HP/AC computed; Level seeded from `dndLevelFromCombatLevel(CombatLevel)`), saves with `AutoMigrated=1`, `PendingSetup=0`. Idempotent — pre-existing rows (including pending-setup drafts) are skipped via the LEFT JOIN. Wired into Init after the L5f backfill. Fallbacks dropped: `dndLevelForUser` (dnd.go), `rivalLevelForUser` (adventure_rival.go), `hospitalCostsForUser` (adventure_hospital.go) — all now floor at level 1 when no D&D row exists. `dndLevelFromCombatLevel` itself is preserved (used by `autoBuildCharacter` for fresh-account level seeding and by `dndSetupConfirm`/preview/stub paths). Tests: `TestL5gBackfillDnDCharacters_Idempotent`, `TestL5gBackfillDnDCharacters_SkipsPendingSetup` in `dnd_l5g_test.go`. `go vet ./... && go test ./...` clean. | -| **L5h** | L4e in-place housing reader flip + cut all AdvCharacter dual-writes | 1 day | Bundled per L4e note. Once writes stop, AdvCharacter rows go cold; dual-write removal is mechanical (delete `saveAdvCharacter` calls and the upserts that follow them in pairs). | +| **L5h** | L4e in-place housing reader flip + cut all AdvCharacter dual-writes | 1 day | Bundled per L4e note. Once writes stop, AdvCharacter rows go cold; dual-write removal is mechanical (delete `saveAdvCharacter` calls and the upserts that follow them in pairs). **Status (2026-05-09):** SHIPPED on `adv-2.0`. **Strategy switch:** rather than deleting 51 saveAdvCharacter call sites in pairs (which would need every site to also know about display_name/death/misc/skills/etc. internal mirrors), `saveAdvCharacter` itself was rewritten as a thin fan-out: `LastActiveAt = now()` + `upsertAllPlayerMetaFromAdvChar(char)`. The legacy `UPDATE adventure_characters` is gone — the row is now read-only after `createAdvCharacter` seeds it. `applyPlayerMetaOverlay(c)` runs at the tail of `loadAdvCharacter`, re-sourcing every migrated subsystem (display_name, hospital, arena, masterwork, rival, skills, babysit, NPC, lifecycle, death, misc, pet, house) from player_meta so callers continue to use `char.X` readers transparently. The L4e housing in-place reader flip is now automatic — `adventure_housing.go`'s `char.HouseFoo` reads see player_meta values via the overlay. `npcMidnightReset` extended to write player_meta alongside the (frozen) adventure_characters. Per-call-site `upsertPlayerMetaXxx` dual-writes are now redundant with `saveAdvCharacter`'s fan-out but retained as defense — cleanup deferred. **player_meta.combat_level deferred.** Still actively used by babysit cost / combat_stats / activities via the overlay (those sites will be cut in final teardown alongside `combat_engine.go`); column drop sequenced with that. `go vet ./... && go test ./...` clean. | ### 7.4 Final teardown (after L5a–L5h) diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index db52dcf..f11bc88 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -2,7 +2,6 @@ package plugin import ( "database/sql" - "log/slog" "time" "gogobee/internal/db" @@ -540,6 +539,12 @@ func loadAdvCharacter(userID id.UserID) (*AdventureCharacter, error) { c.PetArrived = petArrived == 1 c.ThomAnimalLineFired = thomAnimalLine == 1 c.PetSupplyShopUnlocked = petSupplyUnlocked == 1 + + // Phase L5h: adventure_characters columns are no longer written by + // saveAdvCharacter, so the SELECT above returns the row's frozen-at-cut + // snapshot. Overlay every player_meta-mirrored subsystem so callers see + // canonical (live) values for migrated fields. + applyPlayerMetaOverlay(c) return c, nil } @@ -585,151 +590,15 @@ func createAdvCharacter(userID id.UserID, displayName string) error { return tx.Commit() } +// saveAdvCharacter persists every mutable AdventureCharacter field to +// player_meta. Phase L5h: the legacy adventure_characters UPDATE has been +// retired — the row is now read-only after createAdvCharacter seeds it, +// and saveAdvCharacter routes the entire mutable state set through the +// per-subsystem player_meta upserts. LastActiveAt is auto-bumped to mirror +// the previous CURRENT_TIMESTAMP semantics on save. func saveAdvCharacter(char *AdventureCharacter) error { - d := db.Get() - alive := 0 - if char.Alive { - alive = 1 - } - actionTaken := 0 - if char.ActionTakenToday { - actionTaken = 1 - } - holidayTaken := 0 - if char.HolidayActionTaken { - holidayTaken = 1 - } - rivalUnlocked := 0 - if char.RivalUnlockedNotified { - rivalUnlocked = 1 - } - babysitAct := 0 - if char.BabysitActive { - babysitAct = 1 - } - houseFrozen := 0 - if char.HouseLoanFrozen { - houseFrozen = 1 - } - houseAutopay := 0 - if char.HouseAutopay { - houseAutopay = 1 - } - petChasedAway := 0 - if char.PetChasedAway { - petChasedAway = 1 - } - petReactivated := 0 - if char.PetReactivated { - petReactivated = 1 - } - petArrived := 0 - if char.PetArrived { - petArrived = 1 - } - thomAnimalLine := 0 - if char.ThomAnimalLineFired { - thomAnimalLine = 1 - } - petSupplyUnlocked := 0 - if char.PetSupplyShopUnlocked { - petSupplyUnlocked = 1 - } - petMorningDef := 0 - if char.PetMorningDefense { - petMorningDef = 1 - } - autoBabysit := 0 - if char.AutoBabysit { - autoBabysit = 1 - } - streakDecayed := 0 - if char.StreakDecayed { - streakDecayed = 1 - } - - _, err := d.Exec(` - UPDATE adventure_characters SET - display_name = ?, combat_level = ?, mining_skill = ?, foraging_skill = ?, fishing_skill = ?, - combat_xp = ?, mining_xp = ?, foraging_xp = ?, fishing_xp = ?, - alive = ?, dead_until = ?, action_taken_today = ?, holiday_action_taken = ?, - arena_wins = ?, arena_losses = ?, invasion_score = ?, title = ?, - current_streak = ?, best_streak = ?, last_action_date = ?, grudge_location = ?, - last_active_at = CURRENT_TIMESTAMP, death_reprieve_last = ?, - masterwork_drops_received = ?, - rival_pool = ?, rival_unlocked_notified = ?, - babysit_active = ?, babysit_expires_at = ?, babysit_skill_focus = ?, - hospital_visits = ?, robbie_visit_count = ?, last_death_date = ?, - combat_actions_used = ?, harvest_actions_used = ?, - last_pardon_used = ?, - 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 = ?, - house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, house_missed_payments = ?, - house_autopay = ?, house_current_rate = ?, - pet_type = ?, pet_name = ?, pet_xp = ?, pet_level = ?, pet_armor_tier = ?, - pet_chased_away = ?, pet_reactivated = ?, pet_arrived = ?, - misty_encounter_count = ?, misty_donated_count = ?, - thom_animal_line_fired = ?, pet_supply_shop_unlocked = ?, pet_level10_date = ?, - pet_morning_defense = ?, - auto_babysit = ?, - streak_decayed = ?, - crafts_succeeded = ?, - death_source = ?, - death_location = ?, - auto_babysit_focus = ?, - treasures_locked = ? - WHERE user_id = ?`, - char.DisplayName, char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, - char.CombatXP, char.MiningXP, char.ForagingXP, char.FishingXP, - alive, char.DeadUntil, actionTaken, holidayTaken, - char.ArenaWins, char.ArenaLosses, char.InvasionScore, char.Title, - char.CurrentStreak, char.BestStreak, char.LastActionDate, char.GrudgeLocation, - char.DeathReprieveLast, char.MasterworkDropsReceived, - char.RivalPool, rivalUnlocked, - babysitAct, char.BabysitExpiresAt, char.BabysitSkillFocus, - char.HospitalVisits, char.RobbieVisitCount, char.LastDeathDate, - char.CombatActionsUsed, char.HarvestActionsUsed, - char.LastPardonUsed, - char.MistyLastSeen, char.ArinaLastSeen, - char.MistyBuffExpires, char.MistyDebuffExpires, char.ArinaBuffExpires, - char.NPCMsgCount, char.NPCMsgCountDate, - char.MistyRollTarget, char.ArinaRollTarget, - char.HouseTier, char.HouseLoanBalance, houseFrozen, char.HouseMissedPayments, - houseAutopay, char.HouseCurrentRate, - char.PetType, char.PetName, char.PetXP, char.PetLevel, char.PetArmorTier, - petChasedAway, petReactivated, petArrived, - char.MistyEncounterCount, char.MistyDonatedCount, - thomAnimalLine, petSupplyUnlocked, char.PetLevel10Date, - petMorningDef, - autoBabysit, - streakDecayed, - char.CraftsSucceeded, - char.DeathSource, char.DeathLocation, - char.AutoBabysitFocus, - boolToInt(char.TreasuresLocked), - string(char.UserID), - ) - if err != nil { - return err - } - // Adv 2.0 Phase L4f-prep — dual-write display_name into player_meta. - if dnErr := upsertPlayerMetaDisplayName(char.UserID, char.DisplayName); dnErr != nil { - slog.Error("player_meta: display_name dual-write failed", "user", char.UserID, "err", dnErr) - } - // Adv 2.0 Phase L5e — dual-write death state into player_meta. Mutation - // surface is too wide for per-site upserts (~50 save sites), so the - // dual-write rides every saveAdvCharacter call. - if dsErr := upsertPlayerMetaDeathState(char.UserID, deathStateFromAdvChar(char)); dsErr != nil { - slog.Error("player_meta: death state dual-write failed", "user", char.UserID, "err", dsErr) - } - // Adv 2.0 Phase L5f — dual-write misc state (Title, TreasuresLocked, - // CraftsSucceeded). Same strategy as L5e — rides every save. - if msErr := upsertPlayerMetaMiscState(char.UserID, miscStateFromAdvChar(char)); msErr != nil { - slog.Error("player_meta: misc state dual-write failed", "user", char.UserID, "err", msErr) - } - return nil + char.LastActiveAt = time.Now().UTC() + return upsertAllPlayerMetaFromAdvChar(char) } func boolToInt(b bool) int { diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 2246002..2a3db17 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -428,18 +428,28 @@ func npcRepairMostDamaged(userID id.UserID, equip map[EquipmentSlot]*AdvEquipmen // npcMidnightReset resets message counts and regenerates roll targets. // Called from midnightReset. Uses a direct DB update to avoid loading/saving -// every character individually. +// every character individually. Phase L5h: writes player_meta as the +// canonical store; the legacy adventure_characters update is preserved +// only as a defensive sweep for rows that pre-date the L5c backfill. func npcMidnightReset() { d := db.Get() today := time.Now().UTC().Format("2006-01-02") - _, err := d.Exec(` + if _, err := d.Exec(` + UPDATE player_meta + SET npc_msg_count = 0, + npc_msg_count_date = ?, + misty_roll_target = ABS(RANDOM()) % 6 + 5, + arina_roll_target = ABS(RANDOM()) % 6 + 5`, + today); err != nil { + slog.Error("npc: midnight reset (player_meta) failed", "err", err) + } + if _, err := d.Exec(` UPDATE adventure_characters SET npc_msg_count = 0, npc_msg_count_date = ?, misty_roll_target = ABS(RANDOM()) % 6 + 5, arina_roll_target = ABS(RANDOM()) % 6 + 5`, - today) - if err != nil { - slog.Error("npc: midnight reset failed", "err", err) + today); err != nil { + slog.Error("npc: midnight reset (adventure_characters) failed", "err", err) } } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index efdcbd1..069bbf3 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -1924,3 +1924,172 @@ func backfillPlayerMetaDisplayName() error { slog.Info("player_meta: display_name backfilled", "rows", n) return nil } + +// applyPlayerMetaOverlay overlays every player_meta-mirrored field onto an +// AdventureCharacter struct that was just SELECTed from adventure_characters. +// Phase L5h: saveAdvCharacter no longer writes the adventure_characters +// columns, so the legacy row freezes at its pre-L5h state. The overlay +// re-sources every migrated subsystem's value from player_meta (with +// fallback-to-AdvCharacter inside each load helper for the fields where +// player_meta is still empty), so callers continue to use char.X readers +// transparently. Errors are logged and skipped — a partial overlay is safer +// than a failed load. +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 := 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 := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil { + return err + } + return nil +}