diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 5a49849..1beffc1 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -334,7 +334,8 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, - 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. +- **Reader flip (read-only sites) SHIPPED 2026-05-09.** `dnd_rest.go` long-rest housing eligibility, `dnd_sheet.go` housing-tier display, and `adventure_pets.go::petShouldArrive`/`mistyHousingHint` now read via `loadHouseState(userID)` (fallback to AdvCharacter inside the helper). `petShouldArrive` and `mistyHousingHint` gained a `HouseState` parameter; callers in `adventure_scheduler.go` and `adventure_npcs.go` load it. `renderDnDSheet` gained a `HouseState` parameter; test callers in `dnd_subclass_test.go` pass `HouseState{}`. Tests in `adventure_pets_test.go` updated to pass `HouseState`. `go vet ./... && go test ./...` clean. +- **Reader flip in `adventure_housing.go` itself still deferred.** Mutation paths (`handleThomBuy`, `handleThomPayoff`, `handleThomPay`, `handleThomAutopay`, `processMortgagePayments`, `sendMortgageRateChangeDMs`) read-then-write `char.House*` in place; flipping these is bundled with cutting the AdvCharacter writes (post-soak), not now. ### 6.6 L4f — TwinBee/render - `adventure_render.go`: morning DM reads from DnDCharacter + `player_meta`. Coop teaser gating moves to DnDCharacter.Level. diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 3e96c93..3035aa8 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -244,7 +244,8 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac reply := mistyAcceptLines[rand.IntN(len(mistyAcceptLines))] // Housing hint (fires once after 2+ encounters) - hint := mistyHousingHint(char) + mistyHouse, _ := loadHouseState(char.UserID) + hint := mistyHousingHint(char, mistyHouse) if hint != "" { reply += "\n\n_" + hint + "_" } diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index 00c1781..e0829a3 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -187,13 +187,15 @@ func petDeathText(char *AdventureCharacter) string { // ── Pet Arrival Flow ─────────────────────────────────────────────────────── // petShouldArrive checks if pet arrival should trigger. -// Fires randomly after Tier 1 house upgrade is complete. -func petShouldArrive(char *AdventureCharacter) bool { +// Fires randomly after Tier 1 house upgrade is complete. Housing state +// comes from player_meta via loadHouseState (L4e reader flip); pet flags +// still live on AdvCharacter during the soak window. +func petShouldArrive(char *AdventureCharacter, house HouseState) bool { if char.PetArrived { return false } // Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2 - if char.HouseTier < 2 { + if house.Tier < 2 { return false } // Pet was chased away and reactivated via Misty — allow re-arrival @@ -396,9 +398,10 @@ func petCheckSupplyShopUnlock(char *AdventureCharacter) bool { // ── Misty Housing Hint ───────────────────────────────────────────────────── // mistyHousingHint returns a hint about housing/pets if conditions are met. -// Fires once, after 2+ Misty encounters, player has no house. -func mistyHousingHint(char *AdventureCharacter) string { - if char.HasHouse() || char.MistyEncounterCount < 2 { +// Fires once, after 2+ Misty encounters, player has no house. Housing +// state comes from player_meta (L4e reader flip). +func mistyHousingHint(char *AdventureCharacter, house HouseState) string { + if house.HasHouse() || char.MistyEncounterCount < 2 { return "" } if char.MistyDonatedCount > 0 { diff --git a/internal/plugin/adventure_pets_test.go b/internal/plugin/adventure_pets_test.go index 5f618f7..054ed9f 100644 --- a/internal/plugin/adventure_pets_test.go +++ b/internal/plugin/adventure_pets_test.go @@ -214,37 +214,37 @@ func TestPetDeathText_NoPet(t *testing.T) { // ── Pet Arrival Logic ────────────────────────────────────────────────────── func TestPetShouldArrive_NoHouse(t *testing.T) { - char := &AdventureCharacter{HouseTier: 0} - if petShouldArrive(char) { + char := &AdventureCharacter{} + if petShouldArrive(char, HouseState{Tier: 0}) { t.Error("should not arrive without house") } } func TestPetShouldArrive_BaseHouseOnly(t *testing.T) { - char := &AdventureCharacter{HouseTier: 1} // Base house, not yet Livable - if petShouldArrive(char) { + char := &AdventureCharacter{} + if petShouldArrive(char, HouseState{Tier: 1}) { // Base house, not yet Livable t.Error("should not arrive with only base house (need tier 2+)") } } func TestPetShouldArrive_AlreadyArrived(t *testing.T) { - char := &AdventureCharacter{HouseTier: 2, PetArrived: true} - if petShouldArrive(char) { + char := &AdventureCharacter{PetArrived: true} + if petShouldArrive(char, HouseState{Tier: 2}) { t.Error("should not trigger if pet already arrived") } } func TestPetShouldArrive_ChasedAway(t *testing.T) { - char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true} - if petShouldArrive(char) { + char := &AdventureCharacter{PetChasedAway: true} + if petShouldArrive(char, HouseState{Tier: 2}) { t.Error("should not trigger if pet was chased away (and not reactivated)") } } func TestPetShouldArrive_Reactivated(t *testing.T) { - char := &AdventureCharacter{HouseTier: 2, PetChasedAway: true, PetReactivated: true} + char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true} // With reactivation, should always return true - if !petShouldArrive(char) { + if !petShouldArrive(char, HouseState{Tier: 2}) { t.Error("reactivated pet should always trigger arrival") } } @@ -284,21 +284,21 @@ func TestPetMorningEvent_NoPet(t *testing.T) { func TestMistyHousingHint_NoEncounters(t *testing.T) { char := &AdventureCharacter{MistyEncounterCount: 0} - if mistyHousingHint(char) != "" { + if mistyHousingHint(char, HouseState{}) != "" { t.Error("should not hint with 0 encounters") } } func TestMistyHousingHint_HasHouse(t *testing.T) { - char := &AdventureCharacter{MistyEncounterCount: 3, HouseTier: 1} - if mistyHousingHint(char) != "" { + char := &AdventureCharacter{MistyEncounterCount: 3} + if mistyHousingHint(char, HouseState{Tier: 1}) != "" { t.Error("should not hint if player has house") } } func TestMistyHousingHint_Donated(t *testing.T) { char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 1} - hint := mistyHousingHint(char) + hint := mistyHousingHint(char, HouseState{}) if !strings.Contains(hint, "house") { t.Errorf("donated player hint should mention house, got: %q", hint) } @@ -306,7 +306,7 @@ func TestMistyHousingHint_Donated(t *testing.T) { func TestMistyHousingHint_NotDonated(t *testing.T) { char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 0} - hint := mistyHousingHint(char) + hint := mistyHousingHint(char, HouseState{}) if hint != "Thank you for your time." { t.Errorf("non-donor hint should be dismissive, got: %q", hint) } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 1c4aab2..0459890 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -115,7 +115,8 @@ func (p *AdventurePlugin) sendMorningDMs() { } // Pet arrival check (fires before normal morning DM) - if petShouldArrive(&char) { + house, _ := loadHouseState(char.UserID) + if petShouldArrive(&char, house) { p.petArrivalDM(char.UserID) continue } diff --git a/internal/plugin/dnd_rest.go b/internal/plugin/dnd_rest.go index 1599ebd..0df303e 100644 --- a/internal/plugin/dnd_rest.go +++ b/internal/plugin/dnd_rest.go @@ -126,8 +126,11 @@ func (p *AdventurePlugin) handleDnDLongRest(ctx MessageContext) error { } } - // Eligibility: housing OR pay inn fee. - hasHousing := advChar.HouseTier > 0 + // Eligibility: housing OR pay inn fee. Housing read flips to + // player_meta via loadHouseState (L4e reader flip); falls back to + // adventure_characters during the soak window. + house, _ := loadHouseState(ctx.Sender) + hasHousing := house.Tier > 0 innPaid := false if !hasHousing { if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(dndInnCost), "dnd_inn_long_rest") { diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 2c785cd..10c0294 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -63,11 +63,12 @@ func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error { equip, _ := loadAdvEquipment(ctx.Sender) treasures, _ := loadAdvTreasureBonuses(ctx.Sender) meta, _ := loadPlayerMeta(ctx.Sender) + house, _ := loadHouseState(ctx.Sender) - return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, meta, equip, treasures)) + return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, meta, house, equip, treasures)) } -func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus) string { +func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, house HouseState, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus) string { ri, _ := raceInfo(c.Race) ci, _ := classInfo(c.Class) mods := c.Modifiers() @@ -174,8 +175,8 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, if adv.PetName != "" { b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel)) } - if adv.HouseTier > 0 { - b.WriteString(fmt.Sprintf(" Housing: tier %d\n", adv.HouseTier)) + if house.Tier > 0 { + b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier)) } } diff --git a/internal/plugin/dnd_subclass_test.go b/internal/plugin/dnd_subclass_test.go index 173ca3c..400ace3 100644 --- a/internal/plugin/dnd_subclass_test.go +++ b/internal/plugin/dnd_subclass_test.go @@ -308,7 +308,7 @@ func TestSheet_ShowsSubclassWhenChosen(t *testing.T) { STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, Subclass: SubclassBattleMaster, } - out := renderDnDSheet(c, nil, nil, nil, nil) + out := renderDnDSheet(c, nil, nil, HouseState{}, nil, nil) if !strings.Contains(out, "Battle Master") { t.Errorf("sheet missing subclass name:\n%s", out) } @@ -320,7 +320,7 @@ func TestSheet_PromptsWhenUnchosenAtL5(t *testing.T) { Level: 5, HPMax: 40, HPCurrent: 40, ArmorClass: 16, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, } - out := renderDnDSheet(c, nil, nil, nil, nil) + out := renderDnDSheet(c, nil, nil, HouseState{}, nil, nil) if !strings.Contains(out, "!subclass") { t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out) }