diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index 1beffc1..cc48ec1 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -323,6 +323,8 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, - `go vet ./...` + `go test ./...` clean. - **Reader flip deferred.** The L4 exit criterion (`grep 'AdventureCharacter\|CombatLevel' adventure_pets.go` empty) is NOT met yet: `adventure_pets.go`'s helpers (`petGrantXP`, `petRollCombatActions`, `petRollDitchRecovery`, `petVictoryText`, `petDeathText`, `petShouldArrive`, `petMorningEvent`, `petCheckSupplyShopUnlock`, `mistyHousingHint`, `mistyReactivatePet`) still take `*AdventureCharacter` because they're called from many external files (`adventure_babysit.go`, `adventure_scheduler.go`, `adventure_npcs.go`, `combat_bridge.go`, `combat_stats.go`, `dnd_sheet.go`, `adventure_arena.go`, `adventure_character.go`). Flipping these signatures is a cross-file refactor that can land after the soak window — at which point the helpers can take `userID` (and call `loadPetState`) and the call sites drop their `*AdventureCharacter` access. The DB-level migration (this step) is the prerequisite that unblocks that refactor. +**Reader flip (cross-file signature port) SHIPPED 2026-05-09.** All ten pet helpers in `adventure_pets.go` now take `PetState` (or `*PetState` for the test-only mutator `petGrantXP`) instead of `*AdventureCharacter`. `mistyHousingHint` takes `(mistyEncounterCount, mistyDonatedCount int, house HouseState)` — Misty NPC counters still live on AdvCharacter pending a later NPC phase. `mistyReactivatePet` returns `bool` (caller flips both AdvCharacter and `PetState`). `PetState` gained a `HasPet()` method mirroring `AdventureCharacter.HasPet()`. Call sites updated: `adventure_scheduler.go` (load PetState alongside HouseState in morning loop), `adventure_npcs.go::resolveMisty` (load PetState before reactivation check; pass NPC counters by value to the housing hint), `combat_bridge.go::transitionDeath` (now takes `Pet PetState` in `DeathTransitionParams`; arena caller in `adventure_arena.go::resolveArenaDeath` loads it; bridge no longer touches the DB so unit tests don't need DB init), and `petMidnightCheck` (loads PetState per char). Tests in `adventure_pets_test.go` and `combat_bridge_test.go` ported to construct `PetState` literals directly. Exit criterion now holds: `grep 'AdventureCharacter\|CombatLevel' internal/plugin/adventure_pets.go` is empty. `go vet ./... && go test ./...` clean. + ### 6.5 L4e — Housing & mortgage - `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. diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 4617dc5..d82ef54 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -452,8 +452,10 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c phaseMessages := bossFlowPhaseMessages(bossNarr) + arenaPet, _ := loadPetState(char.UserID) dt := transitionDeath(DeathTransitionParams{ Char: char, + Pet: arenaPet, Source: "arena", DeathLocation: "the Arena", }) diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 3035aa8..1f25ed3 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -234,7 +234,10 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac char.MistyDonatedCount++ // Pet reactivation: donating to Misty after chasing pet away - mistyReactivatePet(char) + mistyPet, _ := loadPetState(char.UserID) + if mistyReactivatePet(mistyPet) { + char.PetReactivated = true + } if err := saveAdvCharacter(char); err != nil { slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err) @@ -245,7 +248,7 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac // Housing hint (fires once after 2+ encounters) mistyHouse, _ := loadHouseState(char.UserID) - hint := mistyHousingHint(char, mistyHouse) + hint := mistyHousingHint(char.MistyEncounterCount, char.MistyDonatedCount, mistyHouse) if hint != "" { reply += "\n\n_" + hint + "_" } diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index e0829a3..1c11f9a 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -32,25 +32,25 @@ func petXPToNextLevel(level int) int { } // petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up. -func petGrantXP(char *AdventureCharacter) bool { - if !char.HasPet() || char.PetLevel >= 10 { +func petGrantXP(pet *PetState) bool { + if !pet.HasPet() || pet.Level >= 10 { return false } - char.PetXP += int(petXPPerAction * 100) // store as centixp for precision + pet.XP += int(petXPPerAction * 100) // store as centixp for precision leveled := false - for char.PetLevel < 10 { - needed := petXPToNextLevel(char.PetLevel) * 100 - if char.PetXP < needed { + for pet.Level < 10 { + needed := petXPToNextLevel(pet.Level) * 100 + if pet.XP < needed { break } - char.PetXP -= needed - char.PetLevel++ + pet.XP -= needed + pet.Level++ leveled = true } - if char.PetLevel >= 10 && char.PetLevel10Date == "" { - char.PetLevel10Date = time.Now().UTC().Format("2006-01-02") + if pet.Level >= 10 && pet.Level10Date == "" { + pet.Level10Date = time.Now().UTC().Format("2006-01-02") } return leveled @@ -108,19 +108,19 @@ type PetCombatResult struct { } // petRollCombatActions rolls attack and deflect for a combat round. -func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombatResult { - if !char.HasPet() { +func petRollCombatActions(pet PetState, enemyName string) *PetCombatResult { + if !pet.HasPet() { return nil } result := &PetCombatResult{} // Attack roll - if rand.Float64() < petAttackChance(char.PetLevel) { + if rand.Float64() < petAttackChance(pet.Level) { result.Attacked = true - result.AttackDamage = 3 + rand.IntN(5) + char.PetLevel // 3-7 + level + result.AttackDamage = 3 + rand.IntN(5) + pet.Level // 3-7 + level var pool []string - if char.PetType == "dog" { + if pet.Type == "dog" { pool = PetDogAttack } else { pool = PetCatAttack @@ -132,10 +132,10 @@ func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombat } // Deflect roll - if rand.Float64() < petDeflectChance(char.PetLevel, char.PetArmorTier) { + if rand.Float64() < petDeflectChance(pet.Level, pet.ArmorTier) { result.Deflected = true var pool []string - if char.PetType == "dog" { + if pet.Type == "dog" { pool = PetDogDeflect } else { pool = PetCatDeflect @@ -149,20 +149,20 @@ func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombat } // petRollDitchRecovery rolls for pet intervention on player death. -func petRollDitchRecovery(char *AdventureCharacter) bool { - if !char.HasPet() { +func petRollDitchRecovery(pet PetState) bool { + if !pet.HasPet() { return false } - return rand.Float64() < petDitchRecoveryChance(char.PetLevel) + return rand.Float64() < petDitchRecoveryChance(pet.Level) } // petVictoryText returns a random victory reaction line. -func petVictoryText(char *AdventureCharacter) string { - if !char.HasPet() { +func petVictoryText(pet PetState) string { + if !pet.HasPet() { return "" } var pool []string - if char.PetType == "dog" { + if pet.Type == "dog" { pool = PetDogVictory } else { pool = PetCatVictory @@ -171,12 +171,12 @@ func petVictoryText(char *AdventureCharacter) string { } // petDeathText returns a random death reaction line. -func petDeathText(char *AdventureCharacter) string { - if !char.HasPet() { +func petDeathText(pet PetState) string { + if !pet.HasPet() { return "" } var pool []string - if char.PetType == "dog" { + if pet.Type == "dog" { pool = PetDogDeath } else { pool = PetCatDeath @@ -187,11 +187,11 @@ func petDeathText(char *AdventureCharacter) string { // ── Pet Arrival Flow ─────────────────────────────────────────────────────── // petShouldArrive checks if pet arrival should trigger. -// 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 { +// Fires randomly after Tier 1 house upgrade is complete. Housing and pet +// state come from player_meta via loadHouseState / loadPetState (L4d/L4e +// reader flip). +func petShouldArrive(pet PetState, house HouseState) bool { + if pet.Arrived { return false } // Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2 @@ -199,8 +199,8 @@ func petShouldArrive(char *AdventureCharacter, house HouseState) bool { return false } // Pet was chased away and reactivated via Misty — allow re-arrival - if char.PetChasedAway { - return char.PetReactivated + if pet.ChasedAway { + return pet.Reactivated } // 15% daily chance after house tier 1 return rand.Float64() < 0.15 @@ -362,8 +362,8 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext) error { // petMorningEvent rolls for a morning pet event and returns flavor text. // Returns empty string if no event fires. -func petMorningEvent(char *AdventureCharacter) string { - if !char.HasPet() { +func petMorningEvent(pet PetState) string { + if !pet.HasPet() { return "" } @@ -372,23 +372,23 @@ func petMorningEvent(char *AdventureCharacter) string { return "" } - if char.PetType == "cat" { + if pet.Type == "cat" { line := PetCatOffering[rand.IntN(len(PetCatOffering))] - return strings.ReplaceAll(line, "{pet_name}", char.PetName) + return strings.ReplaceAll(line, "{pet_name}", pet.Name) } line := PetDogSmothering[rand.IntN(len(PetDogSmothering))] - return strings.ReplaceAll(line, "{pet_name}", char.PetName) + return strings.ReplaceAll(line, "{pet_name}", pet.Name) } // ── Pet Supply Shop Unlock Check ─────────────────────────────────────────── // petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10. -func petCheckSupplyShopUnlock(char *AdventureCharacter) bool { - if char.PetSupplyShopUnlocked || char.PetLevel10Date == "" { +func petCheckSupplyShopUnlock(pet PetState) bool { + if pet.SupplyShopUnlocked || pet.Level10Date == "" { return false } - d, err := time.Parse("2006-01-02", char.PetLevel10Date) + d, err := time.Parse("2006-01-02", pet.Level10Date) if err != nil { return false } @@ -399,12 +399,14 @@ func petCheckSupplyShopUnlock(char *AdventureCharacter) bool { // mistyHousingHint returns a hint about housing/pets if conditions are met. // 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 { +// state comes from player_meta (L4e reader flip). Misty NPC counters still +// live on AdvCharacter (NPC migration is a later phase) so the caller +// passes them in directly. +func mistyHousingHint(mistyEncounterCount, mistyDonatedCount int, house HouseState) string { + if house.HasHouse() || mistyEncounterCount < 2 { return "" } - if char.MistyDonatedCount > 0 { + if mistyDonatedCount > 0 { return "You seem like a good person. I can imagine you sitting in a house caring for a pet someday." } return "Thank you for your time." @@ -412,11 +414,11 @@ func mistyHousingHint(char *AdventureCharacter, house HouseState) string { // ── Misty Pet Reactivation ───────────────────────────────────────────────── -// mistyReactivatePet marks the pet as reactivated if it was chased away. -func mistyReactivatePet(char *AdventureCharacter) { - if char.PetChasedAway && !char.PetReactivated { - char.PetReactivated = true - } +// mistyReactivatePet returns true when a chased-away pet should be marked +// as reactivated. Caller is responsible for flipping the flag on both +// AdvCharacter and player_meta. +func mistyReactivatePet(pet PetState) bool { + return pet.ChasedAway && !pet.Reactivated } // ── Pet Level 10 Ticker (runs daily at midnight) ─────────────────────────── @@ -434,7 +436,8 @@ func (p *AdventurePlugin) petMidnightCheck() { } // Check supply shop unlock - if petCheckSupplyShopUnlock(&char) { + pet, _ := loadPetState(char.UserID) + if petCheckSupplyShopUnlock(pet) { char.PetSupplyShopUnlocked = true _ = saveAdvCharacter(&char) _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char)) diff --git a/internal/plugin/adventure_pets_test.go b/internal/plugin/adventure_pets_test.go index 054ed9f..60317b1 100644 --- a/internal/plugin/adventure_pets_test.go +++ b/internal/plugin/adventure_pets_test.go @@ -41,67 +41,67 @@ func TestPetXPToNextLevel_Increasing(t *testing.T) { } func TestPetGrantXP_LevelsUp(t *testing.T) { - char := &AdventureCharacter{ - PetType: "dog", - PetName: "Rex", - PetArrived: true, - PetLevel: 1, - PetXP: 950, // close to needing 1000 (10 * 100 centixp) + pet := &PetState{ + Type: "dog", + Name: "Rex", + Arrived: true, + Level: 1, + XP: 950, // close to needing 1000 (10 * 100 centixp) } - leveled := petGrantXP(char) + leveled := petGrantXP(pet) if !leveled { t.Error("should have leveled up") } - if char.PetLevel != 2 { - t.Errorf("pet level should be 2, got %d", char.PetLevel) + if pet.Level != 2 { + t.Errorf("pet level should be 2, got %d", pet.Level) } } func TestPetGrantXP_NoPet(t *testing.T) { - char := &AdventureCharacter{} - if petGrantXP(char) { + pet := &PetState{} + if petGrantXP(pet) { t.Error("should not grant XP without a pet") } } func TestPetGrantXP_MaxLevel(t *testing.T) { - char := &AdventureCharacter{ - PetType: "cat", - PetName: "Luna", - PetArrived: true, - PetLevel: 10, - PetXP: 9999, + pet := &PetState{ + Type: "cat", + Name: "Luna", + Arrived: true, + Level: 10, + XP: 9999, } - if petGrantXP(char) { + if petGrantXP(pet) { t.Error("should not level up past max level 10") } } func TestPetGrantXP_ChasedAway(t *testing.T) { - char := &AdventureCharacter{ - PetType: "dog", - PetName: "Rex", - PetArrived: true, - PetChasedAway: true, - PetLevel: 1, - PetXP: 0, + pet := &PetState{ + Type: "dog", + Name: "Rex", + Arrived: true, + ChasedAway: true, + Level: 1, + XP: 0, } - if petGrantXP(char) { + if petGrantXP(pet) { t.Error("should not grant XP to chased-away pet") } } func TestPetGrantXP_SetsLevel10Date(t *testing.T) { - char := &AdventureCharacter{ - PetType: "dog", - PetName: "Rex", - PetArrived: true, - PetLevel: 9, - PetXP: 4999, // needs 5000 (50 * 100) for level 9→10 + pet := &PetState{ + Type: "dog", + Name: "Rex", + Arrived: true, + Level: 9, + XP: 4999, // needs 5000 (50 * 100) for level 9→10 } - petGrantXP(char) - if char.PetLevel == 10 && char.PetLevel10Date == "" { - t.Error("reaching level 10 should set PetLevel10Date") + petGrantXP(pet) + if pet.Level == 10 && pet.Level10Date == "" { + t.Error("reaching level 10 should set Level10Date") } } @@ -167,46 +167,42 @@ func TestPetDitchRecoveryChance_Level10(t *testing.T) { // ── Pet Combat Results ───────────────────────────────────────────────────── func TestPetRollCombatActions_NoPet(t *testing.T) { - char := &AdventureCharacter{} - result := petRollCombatActions(char, "Goblin") + result := petRollCombatActions(PetState{}, "Goblin") if result != nil { t.Error("should return nil without a pet") } } func TestPetRollDitchRecovery_NoPet(t *testing.T) { - char := &AdventureCharacter{} - if petRollDitchRecovery(char) { + if petRollDitchRecovery(PetState{}) { t.Error("should return false without a pet") } } func TestPetVictoryText_Dog(t *testing.T) { - char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true} - text := petVictoryText(char) + pet := PetState{Type: "dog", Name: "Rex", Arrived: true} + text := petVictoryText(pet) if text == "" { t.Error("should return victory text for dog") } } func TestPetVictoryText_Cat(t *testing.T) { - char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true} - text := petVictoryText(char) + pet := PetState{Type: "cat", Name: "Luna", Arrived: true} + text := petVictoryText(pet) if text == "" { t.Error("should return victory text for cat") } } func TestPetVictoryText_NoPet(t *testing.T) { - char := &AdventureCharacter{} - if petVictoryText(char) != "" { + if petVictoryText(PetState{}) != "" { t.Error("should return empty string without pet") } } func TestPetDeathText_NoPet(t *testing.T) { - char := &AdventureCharacter{} - if petDeathText(char) != "" { + if petDeathText(PetState{}) != "" { t.Error("should return empty string without pet") } } @@ -214,37 +210,33 @@ func TestPetDeathText_NoPet(t *testing.T) { // ── Pet Arrival Logic ────────────────────────────────────────────────────── func TestPetShouldArrive_NoHouse(t *testing.T) { - char := &AdventureCharacter{} - if petShouldArrive(char, HouseState{Tier: 0}) { + if petShouldArrive(PetState{}, HouseState{Tier: 0}) { t.Error("should not arrive without house") } } func TestPetShouldArrive_BaseHouseOnly(t *testing.T) { - char := &AdventureCharacter{} - if petShouldArrive(char, HouseState{Tier: 1}) { // Base house, not yet Livable + if petShouldArrive(PetState{}, 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{PetArrived: true} - if petShouldArrive(char, HouseState{Tier: 2}) { + if petShouldArrive(PetState{Arrived: true}, HouseState{Tier: 2}) { t.Error("should not trigger if pet already arrived") } } func TestPetShouldArrive_ChasedAway(t *testing.T) { - char := &AdventureCharacter{PetChasedAway: true} - if petShouldArrive(char, HouseState{Tier: 2}) { + if petShouldArrive(PetState{ChasedAway: true}, HouseState{Tier: 2}) { t.Error("should not trigger if pet was chased away (and not reactivated)") } } func TestPetShouldArrive_Reactivated(t *testing.T) { - char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true} + pet := PetState{ChasedAway: true, Reactivated: true} // With reactivation, should always return true - if !petShouldArrive(char, HouseState{Tier: 2}) { + if !petShouldArrive(pet, HouseState{Tier: 2}) { t.Error("reactivated pet should always trigger arrival") } } @@ -274,8 +266,7 @@ func TestHasPet(t *testing.T) { // ── Morning Pet Events ───────────────────────────────────────────────────── func TestPetMorningEvent_NoPet(t *testing.T) { - char := &AdventureCharacter{} - if petMorningEvent(char) != "" { + if petMorningEvent(PetState{}) != "" { t.Error("should return empty string without pet") } } @@ -283,30 +274,26 @@ func TestPetMorningEvent_NoPet(t *testing.T) { // ── Misty Housing Hint ───────────────────────────────────────────────────── func TestMistyHousingHint_NoEncounters(t *testing.T) { - char := &AdventureCharacter{MistyEncounterCount: 0} - if mistyHousingHint(char, HouseState{}) != "" { + if mistyHousingHint(0, 0, HouseState{}) != "" { t.Error("should not hint with 0 encounters") } } func TestMistyHousingHint_HasHouse(t *testing.T) { - char := &AdventureCharacter{MistyEncounterCount: 3} - if mistyHousingHint(char, HouseState{Tier: 1}) != "" { + if mistyHousingHint(3, 0, 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, HouseState{}) + hint := mistyHousingHint(2, 1, HouseState{}) if !strings.Contains(hint, "house") { t.Errorf("donated player hint should mention house, got: %q", hint) } } func TestMistyHousingHint_NotDonated(t *testing.T) { - char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 0} - hint := mistyHousingHint(char, HouseState{}) + hint := mistyHousingHint(2, 0, HouseState{}) if hint != "Thank you for your time." { t.Errorf("non-donor hint should be dismissive, got: %q", hint) } @@ -315,56 +302,50 @@ func TestMistyHousingHint_NotDonated(t *testing.T) { // ── Misty Pet Reactivation ───────────────────────────────────────────────── func TestMistyReactivatePet_ChasedAway(t *testing.T) { - char := &AdventureCharacter{PetChasedAway: true, PetReactivated: false} - mistyReactivatePet(char) - if !char.PetReactivated { - t.Error("should set PetReactivated") + pet := PetState{ChasedAway: true, Reactivated: false} + if !mistyReactivatePet(pet) { + t.Error("should signal reactivation for chased-away pet") } } func TestMistyReactivatePet_NotChasedAway(t *testing.T) { - char := &AdventureCharacter{PetChasedAway: false, PetReactivated: false} - mistyReactivatePet(char) - if char.PetReactivated { - t.Error("should not set PetReactivated if pet wasn't chased away") + pet := PetState{ChasedAway: false, Reactivated: false} + if mistyReactivatePet(pet) { + t.Error("should not reactivate if pet wasn't chased away") } } func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) { - char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true} - mistyReactivatePet(char) - if !char.PetReactivated { - t.Error("should remain reactivated") + pet := PetState{ChasedAway: true, Reactivated: true} + if mistyReactivatePet(pet) { + t.Error("should not re-signal once already reactivated") } } // ── Pet Supply Shop Unlock ───────────────────────────────────────────────── func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) { - char := &AdventureCharacter{PetLevel10Date: ""} - if petCheckSupplyShopUnlock(char) { + if petCheckSupplyShopUnlock(PetState{Level10Date: ""}) { t.Error("should not unlock without level 10 date") } } func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) { - char := &AdventureCharacter{PetSupplyShopUnlocked: true, PetLevel10Date: "2020-01-01"} - if petCheckSupplyShopUnlock(char) { + pet := PetState{SupplyShopUnlocked: true, Level10Date: "2020-01-01"} + if petCheckSupplyShopUnlock(pet) { t.Error("should not re-trigger if already unlocked") } } func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) { // Set date to today — not 7 days ago - char := &AdventureCharacter{PetLevel10Date: "2099-01-01"} - if petCheckSupplyShopUnlock(char) { + if petCheckSupplyShopUnlock(PetState{Level10Date: "2099-01-01"}) { t.Error("should not unlock before 7 days have passed") } } func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) { - char := &AdventureCharacter{PetLevel10Date: "2020-01-01"} - if !petCheckSupplyShopUnlock(char) { + if !petCheckSupplyShopUnlock(PetState{Level10Date: "2020-01-01"}) { t.Error("should unlock after 7+ days") } } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 0459890..c9971df 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -116,13 +116,14 @@ func (p *AdventurePlugin) sendMorningDMs() { // Pet arrival check (fires before normal morning DM) house, _ := loadHouseState(char.UserID) - if petShouldArrive(&char, house) { + pet, _ := loadPetState(char.UserID) + if petShouldArrive(pet, house) { p.petArrivalDM(char.UserID) continue } // Morning pet event - petEvent := petMorningEvent(&char) + petEvent := petMorningEvent(pet) if petEvent != "" { char.PetMorningDefense = true _ = saveAdvCharacter(&char) diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 553e643..2b08dcf 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -312,6 +312,7 @@ func applyXPBonuses(p XPBonusParams) XPResult { type DeathTransitionParams struct { Char *AdventureCharacter Equip map[EquipmentSlot]*AdvEquipment + Pet PetState // pet state for ditch-recovery roll; zero value disables ChatLevel int Location string // set as GrudgeLocation; empty = don't set Source string // death source: "adventure" | "arena" — recorded on Kill() @@ -374,8 +375,8 @@ func transitionDeath(p DeathTransitionParams) DeathTransitionResult { } r.Died = true - if petRollDitchRecovery(p.Char) && p.Char.DeadUntil != nil { - reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Char.PetLevel)) + if petRollDitchRecovery(p.Pet) && p.Char.DeadUntil != nil { + reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Pet.Level)) p.Char.DeadUntil = &reduced r.PetRecovered = true } diff --git a/internal/plugin/combat_bridge_test.go b/internal/plugin/combat_bridge_test.go index a6131a0..62f6f3f 100644 --- a/internal/plugin/combat_bridge_test.go +++ b/internal/plugin/combat_bridge_test.go @@ -170,10 +170,9 @@ func TestTransitionDeath_SovereignReprieve(t *testing.T) { func TestTransitionDeath_KillAndPetRecovery(t *testing.T) { deaths, petRecoveries := 0, 0 for i := 0; i < 500; i++ { - char := &AdventureCharacter{ - Alive: true, PetType: "cat", PetArrived: true, PetLevel: 5, - } - r := transitionDeath(DeathTransitionParams{Char: char, Location: "Mine"}) + char := &AdventureCharacter{Alive: true} + pet := PetState{Type: "cat", Arrived: true, Level: 5} + r := transitionDeath(DeathTransitionParams{Char: char, Pet: pet, Location: "Mine"}) if !r.Died { t.Fatal("should die with no saves available") } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 8658003..1d42e57 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -402,6 +402,13 @@ type PetState struct { MorningDefense bool } +// HasPet returns true if the player has an active pet (arrived, not chased +// away). Mirrors AdventureCharacter.HasPet() for the cross-file pet-helper +// flip in L4d. +func (s PetState) HasPet() bool { + return s.Type != "" && s.Arrived && !s.ChasedAway +} + // petFlagsJSON is the on-disk encoding of the four pet bools. Adding a new // flag is an additive JSON field — old rows decode as false. type petFlagsJSON struct {