Adv 2.0 L4e: housing dual-write house_* off AdvCharacter to player_meta

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 10:18:52 -07:00
parent 690817f2ed
commit d638f62fd4
6 changed files with 332 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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