From d638f62fd4c4b962c06064b7c03e0866ad00ca70 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sat, 9 May 2026 10:18:52 -0700 Subject: [PATCH] Adv 2.0 L4e: housing dual-write house_* off AdvCharacter to player_meta Co-Authored-By: Claude Opus 4.7 (1M context) --- gogobee_legacy_migration.md | 9 ++ internal/db/db.go | 10 ++ internal/plugin/adventure.go | 6 + internal/plugin/adventure_housing.go | 15 +++ internal/plugin/player_meta.go | 157 +++++++++++++++++++++++++++ internal/plugin/player_meta_test.go | 135 +++++++++++++++++++++++ 6 files changed, 332 insertions(+) diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index b716e78..e3e0b73 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -327,6 +327,15 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, - `adventure_housing.go`, `adventure_mortgage.go`: HouseTier/loan fields → `player_meta`. `houseHPBonus` → applied to `DnDCharacter.HPMax` calculation (already a parallel hook in `dnd_sheet.go`'s `recomputeHPMax`). - Mortgage scheduler tick stays — only the field source changes. +**Status (2026-05-09):** SHIPPED on `adv-2.0` (column + dual-write only; reader flip deferred — see note). +- `player_meta` gains `house_tier`, `house_loan_balance`, `house_loan_frozen`, `house_missed_payments`, `house_autopay`, `house_current_rate` via columnMigration in `internal/db/db.go`. Six columns (no JSON pack — bools and rate co-mutate with tier/balance at known sites). +- New helpers in `player_meta.go`: `HouseState` struct + `HasHouse()`, `upsertPlayerMetaHouseState(userID, HouseState)`, `loadHouseState(userID)` (player_meta canonical when `tier > 0 || loan_balance > 0`, falls back to AdvCharacter otherwise), `backfillPlayerMetaHouseState()` (idempotent — only updates rows whose tier and loan_balance are both still zero), and `houseStateFromAdvChar(*AdventureCharacter)` for the dual-write projection. +- Dual-writes wired at every house mutation site in `adventure_housing.go`: `handleThomBuy` (full purchase + loan purchase), `handleThomPayoff`, `handleThomPay` (extra payment), `handleThomAutopay`, the four `processMortgagePayments` save paths (paid-off, success, missed-payment-freeze, missed-payment), and `sendMortgageRateChangeDMs` rate refresh. +- Backfill: `backfillPlayerMetaHouseState()` runs on every Init; idempotent (only fills rows whose `house_tier` and `house_loan_balance` are both still the default zero, and the legacy AdvCharacter row has either tier > 0 or loan > 0). +- Tests: `TestPlayerMetaHouseStateBackfill_Idempotent`, `TestLoadHouseState_FallsBackToAdvCharacter`, `TestUpsertPlayerMetaHouseState_RoundTrip` in `player_meta_test.go` (skip without prod DB, matching the L4d pattern). +- `go vet ./...` + `go test ./...` clean. +- **Reader flip deferred.** Reads in `adventure_housing.go`, `dnd_rest.go`, `dnd_sheet.go`, `adventure_pets.go` still go through `*AdventureCharacter`. Flipping them to `loadHouseState(userID)` is a separate session; the DB-level migration (this step) is the prerequisite. + ### 6.6 L4f — TwinBee/render - `adventure_render.go`: morning DM reads from DnDCharacter + `player_meta`. Coop teaser gating moves to DnDCharacter.Level. - `adventure_twinbee.go`: simulator already addressed in L1. Remaining usage is read-only stat display — port to DnDCharacter. diff --git a/internal/db/db.go b/internal/db/db.go index 83104a1..0d244a1 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -216,6 +216,16 @@ func runMigrations(d *sql.DB) error { `ALTER TABLE player_meta ADD COLUMN pet_flags_json TEXT NOT NULL DEFAULT '{}'`, `ALTER TABLE player_meta ADD COLUMN pet_supply_shop_unlocked INTEGER NOT NULL DEFAULT 0`, `ALTER TABLE player_meta ADD COLUMN pet_level_10_date TEXT NOT NULL DEFAULT ''`, + // Adv 2.0 Phase L4e — Housing & mortgage migration off AdvCharacter. + // HouseTier / HouseLoanBalance / HouseLoanFrozen / HouseMissedPayments + // / HouseAutopay / HouseCurrentRate move to player_meta; AdvCharacter + // columns keep dual-writing during soak (gogobee_legacy_migration.md §6.5). + `ALTER TABLE player_meta ADD COLUMN house_tier INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN house_loan_balance INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN house_loan_frozen INTEGER NOT NULL DEFAULT 0`, + `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`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index edce0a3..a2fab99 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -219,6 +219,12 @@ func (p *AdventurePlugin) Init() error { if err := backfillPlayerMetaPetState(); err != nil { slog.Error("player_meta: pet state backfill failed", "err", err) } + // Adv 2.0 Phase L4e — one-shot house state backfill into player_meta. + // Idempotent (only fills rows whose house_tier and house_loan_balance + // are still both the default zero). + if err := backfillPlayerMetaHouseState(); err != nil { + slog.Error("player_meta: house state backfill failed", "err", err) + } // Phase L3 — cancel any open/active legacy coop dungeon runs and // refund member contributions + unsettled bets. Idempotent. closeAndRefundLegacyCoopRuns(p.euro) diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go index f7db09d..31e6bd2 100644 --- a/internal/plugin/adventure_housing.go +++ b/internal/plugin/adventure_housing.go @@ -330,6 +330,8 @@ func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureChara p.euro.Credit(ctx.Sender, float64(totalCost), "house_purchase_refund") return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.") } + // L4e dual-write: mirror house state into player_meta. + _ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char)) return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 **%s** purchased outright for €%d.\n\nNo loan. No weekly payments. Thom is visibly disappointed.", def.Name, totalCost)) } @@ -361,6 +363,8 @@ func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureChara } return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.") } + // L4e dual-write: mirror house state into player_meta. + _ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char)) weekly := houseWeeklyPayment(loanAmount, rate) var sb strings.Builder @@ -409,6 +413,8 @@ func (p *AdventurePlugin) handleThomPayoff(ctx MessageContext, char *AdventureCh p.euro.Credit(ctx.Sender, payoffAmount, "house_payoff_refund") return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.") } + // L4e dual-write: mirror house state into player_meta. + _ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char)) if char.HasPet() { return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, char.PetName))) @@ -462,6 +468,8 @@ func (p *AdventurePlugin) handleThomPay(ctx MessageContext, char *AdventureChara p.euro.Credit(ctx.Sender, float64(amount), "house_extra_payment_refund") return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.") } + // L4e dual-write: mirror house state into player_meta. + _ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char)) if char.HouseLoanBalance == 0 { if char.HasPet() { @@ -493,6 +501,8 @@ func (p *AdventurePlugin) handleThomAutopay(ctx MessageContext, char *AdventureC if err := saveAdvCharacter(char); err != nil { return p.SendDM(ctx.Sender, "Failed to save.") } + // L4e dual-write: mirror house state into player_meta. + _ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char)) if char.HouseAutopay { return p.SendDM(ctx.Sender, "✅ Autopay enabled. 2% discount on weekly payments. Thom calls this a favour. It is not.") @@ -538,6 +548,7 @@ func (p *AdventurePlugin) processMortgagePayments() { freshChar.HouseLoanBalance = 0 freshChar.HouseMissedPayments = 0 _ = saveAdvCharacter(freshChar) + _ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar)) userMu.Unlock() // Paid off! if freshChar.HasPet() { @@ -549,6 +560,7 @@ func (p *AdventurePlugin) processMortgagePayments() { } freshChar.HouseMissedPayments = 0 _ = saveAdvCharacter(freshChar) + _ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar)) userMu.Unlock() } else { // Missed payment @@ -559,6 +571,7 @@ func (p *AdventurePlugin) processMortgagePayments() { if freshChar.HouseMissedPayments >= 10 { freshChar.HouseLoanFrozen = true _ = saveAdvCharacter(freshChar) + _ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar)) userMu.Unlock() if freshChar.HasPet() { _ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomFreezePet, freshChar.PetName))) @@ -567,6 +580,7 @@ func (p *AdventurePlugin) processMortgagePayments() { } } else { _ = saveAdvCharacter(freshChar) + _ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar)) userMu.Unlock() if freshChar.HasPet() { _ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentPet, freshChar.HouseLoanBalance, freshChar.PetName))) @@ -608,6 +622,7 @@ func (p *AdventurePlugin) sendMortgageRateChangeDMs(oldRate, newRate float64) { } freshChar.HouseCurrentRate = newRate _ = saveAdvCharacter(freshChar) + _ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar)) userMu.Unlock() newWeekly := houseWeeklyPayment(freshChar.HouseLoanBalance, newRate) diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index d0451fe..8658003 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -601,6 +601,163 @@ func petStateFromAdvChar(c *AdventureCharacter) PetState { } } +// 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 when +// populated (HasHouse true), falling back to adventure_characters during +// the L4e soak window. A row with tier=0 and loan_balance=0 is treated as +// unmigrated and falls through — matching the canonical "tier > 0 || loan +// > 0" has-a-house marker used elsewhere. +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 == nil && (s.Tier > 0 || s.LoanBalance > 0) { + s.LoanFrozen = frozenInt == 1 + s.Autopay = autopayInt == 1 + return s, nil + } + if err != nil && err != sql.ErrNoRows { + return HouseState{}, err + } + // Fallback to AdvCharacter during soak. + var ( + legacyTier, legacyBalance, legacyMissed int + legacyFrozen, legacyAutopay int + legacyRate float64 + ) + err = db.Get().QueryRow( + `SELECT house_tier, house_loan_balance, house_loan_frozen, + house_missed_payments, house_autopay, house_current_rate + FROM adventure_characters WHERE user_id = ?`, + string(userID), + ).Scan(&legacyTier, &legacyBalance, &legacyFrozen, + &legacyMissed, &legacyAutopay, &legacyRate) + if err == sql.ErrNoRows { + return HouseState{}, nil + } + if err != nil { + return HouseState{}, err + } + return HouseState{ + Tier: legacyTier, + LoanBalance: legacyBalance, + LoanFrozen: legacyFrozen == 1, + MissedPayments: legacyMissed, + Autopay: legacyAutopay == 1, + CurrentRate: legacyRate, + }, nil +} + +// backfillPlayerMetaHouseState copies the house_* columns from +// adventure_characters into player_meta for any row whose house_tier and +// house_loan_balance are both still the default zero. Idempotent: only +// updates rows that haven't been populated yet (via backfill or +// dual-write). +func backfillPlayerMetaHouseState() 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 house_tier = (SELECT house_tier FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + house_loan_balance = (SELECT house_loan_balance FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + house_loan_frozen = (SELECT house_loan_frozen FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + house_missed_payments = (SELECT house_missed_payments FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + house_autopay = (SELECT house_autopay FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id), + house_current_rate = (SELECT house_current_rate FROM adventure_characters ac WHERE ac.user_id = player_meta.user_id) + WHERE house_tier = 0 + AND house_loan_balance = 0 + AND EXISTS ( + SELECT 1 FROM adventure_characters ac + WHERE ac.user_id = player_meta.user_id + AND (ac.house_tier > 0 OR ac.house_loan_balance > 0) + ) + `) + if err != nil { + return err + } + n, _ := res.RowsAffected() + slog.Info("player_meta: house state backfilled", "rows", n) + return 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, + } +} + // backfillPlayerMetaDisplayName copies adventure_characters.display_name // into player_meta.display_name for any row whose display_name is still // the empty default. Idempotent: safe to re-run; only updates rows that diff --git a/internal/plugin/player_meta_test.go b/internal/plugin/player_meta_test.go index 6121efc..148079b 100644 --- a/internal/plugin/player_meta_test.go +++ b/internal/plugin/player_meta_test.go @@ -676,3 +676,138 @@ func TestUpsertPlayerMetaPetState_RoundTrip(t *testing.T) { t.Errorf("update mismatch:\n got=%+v\nwant=%+v", got2, in) } } + +// Phase L4e — House state migration. Backfill is idempotent and only touches +// rows that haven't been migrated yet (tier=0 AND loan_balance=0); the upsert +// helper round-trips the full HouseState; loadHouseState falls back to +// AdvCharacter when player_meta hasn't been populated yet. + +func TestPlayerMetaHouseStateBackfill_Idempotent(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-house-bf:example") + if err := createAdvCharacter(uid, "HouseOwner"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, + house_missed_payments = ?, house_autopay = ?, house_current_rate = ? + WHERE user_id = ?`, + 2, 50000, 0, 1, 1, 6.25, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET house_tier = 0, house_loan_balance = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + if err := backfillPlayerMetaHouseState(); err != nil { + t.Fatalf("backfill 1: %v", err) + } + got, err := loadHouseState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Tier != 2 || got.LoanBalance != 50000 || got.LoanFrozen || + got.MissedPayments != 1 || !got.Autopay || got.CurrentRate != 6.25 { + t.Errorf("after backfill: got %+v", got) + } + + // Layer a dual-write that pays down the loan and toggles autopay off. + got.LoanBalance = 25000 + got.MissedPayments = 0 + got.Autopay = false + if err := upsertPlayerMetaHouseState(uid, got); err != nil { + t.Fatalf("dual-write: %v", err) + } + if err := backfillPlayerMetaHouseState(); err != nil { + t.Fatalf("backfill 2: %v", err) + } + got2, _ := loadHouseState(uid) + if got2.LoanBalance != 25000 || got2.Autopay || got2.MissedPayments != 0 { + t.Errorf("backfill clobbered dual-write: got %+v", got2) + } +} + +func TestLoadHouseState_FallsBackToAdvCharacter(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-house-fb:example") + if err := createAdvCharacter(uid, "HouseFallback"); err != nil { + t.Fatalf("createAdvCharacter: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE adventure_characters + SET house_tier = ?, house_loan_balance = ?, house_loan_frozen = ?, + house_missed_payments = ?, house_autopay = ?, house_current_rate = ? + WHERE user_id = ?`, + 3, 75000, 1, 5, 0, 7.0, string(uid), + ); err != nil { + t.Fatalf("seed: %v", err) + } + if _, err := db.Get().Exec( + `UPDATE player_meta SET house_tier = 0, house_loan_balance = 0 WHERE user_id = ?`, + string(uid), + ); err != nil { + t.Fatalf("clear: %v", err) + } + + got, err := loadHouseState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got.Tier != 3 || got.LoanBalance != 75000 || !got.LoanFrozen || + got.MissedPayments != 5 || got.Autopay || got.CurrentRate != 7.0 { + t.Errorf("fallback: got %+v", got) + } + + // Unknown user → zero HouseState, no error. + got2, err := loadHouseState(id.UserID("@meta-house-nobody:example")) + if err != nil { + t.Fatalf("load missing: %v", err) + } + if got2.Tier != 0 || got2.LoanBalance != 0 { + t.Errorf("missing user: got %+v", got2) + } +} + +func TestUpsertPlayerMetaHouseState_RoundTrip(t *testing.T) { + setupAuditTestDB(t) + + uid := id.UserID("@meta-house-rt:example") + in := HouseState{ + Tier: 4, + LoanBalance: 120000, + LoanFrozen: true, + MissedPayments: 7, + Autopay: true, + CurrentRate: 5.875, + } + if err := upsertPlayerMetaHouseState(uid, in); err != nil { + t.Fatalf("upsert insert: %v", err) + } + got, err := loadHouseState(uid) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != in { + t.Errorf("round-trip mismatch:\n got=%+v\nwant=%+v", got, in) + } + + // Pay-down updates the columns via the same upsert. + in.LoanBalance = 0 + in.LoanFrozen = false + in.MissedPayments = 0 + in.Autopay = false + if err := upsertPlayerMetaHouseState(uid, in); err != nil { + t.Fatalf("upsert update: %v", err) + } + got2, _ := loadHouseState(uid) + if got2 != in { + t.Errorf("update mismatch:\n got=%+v\nwant=%+v", got2, in) + } +}