mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user