Adv 2.0 L4d reader flip: pet helpers off AdvCharacter to PetState

All ten helpers in adventure_pets.go now take PetState (or *PetState
for petGrantXP) instead of *AdventureCharacter. mistyHousingHint takes
raw NPC counters since Misty fields stay on AdvCharacter for a later
phase. mistyReactivatePet returns bool; caller flips both stores.

DeathTransitionParams gained Pet PetState so combat_bridge no longer
touches the DB; arena caller loads PetState. PetState.HasPet() mirrors
AdvCharacter's. petMidnightCheck loads PetState per char.

L4 grep-empty exit criterion now holds for adventure_pets.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-05-09 11:07:40 -07:00
parent 0004a2e35c
commit 81cce0169f
9 changed files with 147 additions and 148 deletions

View File

@@ -323,6 +323,8 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events,
- `go vet ./...` + `go test ./...` clean. - `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 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 ### 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`). - `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. - Mortgage scheduler tick stays — only the field source changes.

View File

@@ -452,8 +452,10 @@ func (p *AdventurePlugin) resolveArenaDeath(ctx MessageContext, run *ArenaRun, c
phaseMessages := bossFlowPhaseMessages(bossNarr) phaseMessages := bossFlowPhaseMessages(bossNarr)
arenaPet, _ := loadPetState(char.UserID)
dt := transitionDeath(DeathTransitionParams{ dt := transitionDeath(DeathTransitionParams{
Char: char, Char: char,
Pet: arenaPet,
Source: "arena", Source: "arena",
DeathLocation: "the Arena", DeathLocation: "the Arena",
}) })

View File

@@ -234,7 +234,10 @@ func (p *AdventurePlugin) resolveMisty(ctx MessageContext, char *AdventureCharac
char.MistyDonatedCount++ char.MistyDonatedCount++
// Pet reactivation: donating to Misty after chasing pet away // 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 { if err := saveAdvCharacter(char); err != nil {
slog.Error("npc: failed to save misty buff", "user", ctx.Sender, "err", err) 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) // Housing hint (fires once after 2+ encounters)
mistyHouse, _ := loadHouseState(char.UserID) mistyHouse, _ := loadHouseState(char.UserID)
hint := mistyHousingHint(char, mistyHouse) hint := mistyHousingHint(char.MistyEncounterCount, char.MistyDonatedCount, mistyHouse)
if hint != "" { if hint != "" {
reply += "\n\n_" + hint + "_" reply += "\n\n_" + hint + "_"
} }

View File

@@ -32,25 +32,25 @@ func petXPToNextLevel(level int) int {
} }
// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up. // petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up.
func petGrantXP(char *AdventureCharacter) bool { func petGrantXP(pet *PetState) bool {
if !char.HasPet() || char.PetLevel >= 10 { if !pet.HasPet() || pet.Level >= 10 {
return false return false
} }
char.PetXP += int(petXPPerAction * 100) // store as centixp for precision pet.XP += int(petXPPerAction * 100) // store as centixp for precision
leveled := false leveled := false
for char.PetLevel < 10 { for pet.Level < 10 {
needed := petXPToNextLevel(char.PetLevel) * 100 needed := petXPToNextLevel(pet.Level) * 100
if char.PetXP < needed { if pet.XP < needed {
break break
} }
char.PetXP -= needed pet.XP -= needed
char.PetLevel++ pet.Level++
leveled = true leveled = true
} }
if char.PetLevel >= 10 && char.PetLevel10Date == "" { if pet.Level >= 10 && pet.Level10Date == "" {
char.PetLevel10Date = time.Now().UTC().Format("2006-01-02") pet.Level10Date = time.Now().UTC().Format("2006-01-02")
} }
return leveled return leveled
@@ -108,19 +108,19 @@ type PetCombatResult struct {
} }
// petRollCombatActions rolls attack and deflect for a combat round. // petRollCombatActions rolls attack and deflect for a combat round.
func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombatResult { func petRollCombatActions(pet PetState, enemyName string) *PetCombatResult {
if !char.HasPet() { if !pet.HasPet() {
return nil return nil
} }
result := &PetCombatResult{} result := &PetCombatResult{}
// Attack roll // Attack roll
if rand.Float64() < petAttackChance(char.PetLevel) { if rand.Float64() < petAttackChance(pet.Level) {
result.Attacked = true 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 var pool []string
if char.PetType == "dog" { if pet.Type == "dog" {
pool = PetDogAttack pool = PetDogAttack
} else { } else {
pool = PetCatAttack pool = PetCatAttack
@@ -132,10 +132,10 @@ func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombat
} }
// Deflect roll // Deflect roll
if rand.Float64() < petDeflectChance(char.PetLevel, char.PetArmorTier) { if rand.Float64() < petDeflectChance(pet.Level, pet.ArmorTier) {
result.Deflected = true result.Deflected = true
var pool []string var pool []string
if char.PetType == "dog" { if pet.Type == "dog" {
pool = PetDogDeflect pool = PetDogDeflect
} else { } else {
pool = PetCatDeflect pool = PetCatDeflect
@@ -149,20 +149,20 @@ func petRollCombatActions(char *AdventureCharacter, enemyName string) *PetCombat
} }
// petRollDitchRecovery rolls for pet intervention on player death. // petRollDitchRecovery rolls for pet intervention on player death.
func petRollDitchRecovery(char *AdventureCharacter) bool { func petRollDitchRecovery(pet PetState) bool {
if !char.HasPet() { if !pet.HasPet() {
return false return false
} }
return rand.Float64() < petDitchRecoveryChance(char.PetLevel) return rand.Float64() < petDitchRecoveryChance(pet.Level)
} }
// petVictoryText returns a random victory reaction line. // petVictoryText returns a random victory reaction line.
func petVictoryText(char *AdventureCharacter) string { func petVictoryText(pet PetState) string {
if !char.HasPet() { if !pet.HasPet() {
return "" return ""
} }
var pool []string var pool []string
if char.PetType == "dog" { if pet.Type == "dog" {
pool = PetDogVictory pool = PetDogVictory
} else { } else {
pool = PetCatVictory pool = PetCatVictory
@@ -171,12 +171,12 @@ func petVictoryText(char *AdventureCharacter) string {
} }
// petDeathText returns a random death reaction line. // petDeathText returns a random death reaction line.
func petDeathText(char *AdventureCharacter) string { func petDeathText(pet PetState) string {
if !char.HasPet() { if !pet.HasPet() {
return "" return ""
} }
var pool []string var pool []string
if char.PetType == "dog" { if pet.Type == "dog" {
pool = PetDogDeath pool = PetDogDeath
} else { } else {
pool = PetCatDeath pool = PetCatDeath
@@ -187,11 +187,11 @@ func petDeathText(char *AdventureCharacter) string {
// ── Pet Arrival Flow ─────────────────────────────────────────────────────── // ── Pet Arrival Flow ───────────────────────────────────────────────────────
// petShouldArrive checks if pet arrival should trigger. // petShouldArrive checks if pet arrival should trigger.
// Fires randomly after Tier 1 house upgrade is complete. Housing state // Fires randomly after Tier 1 house upgrade is complete. Housing and pet
// comes from player_meta via loadHouseState (L4e reader flip); pet flags // state come from player_meta via loadHouseState / loadPetState (L4d/L4e
// still live on AdvCharacter during the soak window. // reader flip).
func petShouldArrive(char *AdventureCharacter, house HouseState) bool { func petShouldArrive(pet PetState, house HouseState) bool {
if char.PetArrived { if pet.Arrived {
return false return false
} }
// Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2 // Pet arrives after Tier 1 (Livable) upgrade = HouseTier >= 2
@@ -199,8 +199,8 @@ func petShouldArrive(char *AdventureCharacter, house HouseState) bool {
return false return false
} }
// Pet was chased away and reactivated via Misty — allow re-arrival // Pet was chased away and reactivated via Misty — allow re-arrival
if char.PetChasedAway { if pet.ChasedAway {
return char.PetReactivated return pet.Reactivated
} }
// 15% daily chance after house tier 1 // 15% daily chance after house tier 1
return rand.Float64() < 0.15 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. // petMorningEvent rolls for a morning pet event and returns flavor text.
// Returns empty string if no event fires. // Returns empty string if no event fires.
func petMorningEvent(char *AdventureCharacter) string { func petMorningEvent(pet PetState) string {
if !char.HasPet() { if !pet.HasPet() {
return "" return ""
} }
@@ -372,23 +372,23 @@ func petMorningEvent(char *AdventureCharacter) string {
return "" return ""
} }
if char.PetType == "cat" { if pet.Type == "cat" {
line := PetCatOffering[rand.IntN(len(PetCatOffering))] 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))] 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 ─────────────────────────────────────────── // ── Pet Supply Shop Unlock Check ───────────────────────────────────────────
// petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10. // petCheckSupplyShopUnlock checks if 1 week has passed since pet hit level 10.
func petCheckSupplyShopUnlock(char *AdventureCharacter) bool { func petCheckSupplyShopUnlock(pet PetState) bool {
if char.PetSupplyShopUnlocked || char.PetLevel10Date == "" { if pet.SupplyShopUnlocked || pet.Level10Date == "" {
return false return false
} }
d, err := time.Parse("2006-01-02", char.PetLevel10Date) d, err := time.Parse("2006-01-02", pet.Level10Date)
if err != nil { if err != nil {
return false return false
} }
@@ -399,12 +399,14 @@ func petCheckSupplyShopUnlock(char *AdventureCharacter) bool {
// mistyHousingHint returns a hint about housing/pets if conditions are met. // mistyHousingHint returns a hint about housing/pets if conditions are met.
// Fires once, after 2+ Misty encounters, player has no house. Housing // Fires once, after 2+ Misty encounters, player has no house. Housing
// state comes from player_meta (L4e reader flip). // state comes from player_meta (L4e reader flip). Misty NPC counters still
func mistyHousingHint(char *AdventureCharacter, house HouseState) string { // live on AdvCharacter (NPC migration is a later phase) so the caller
if house.HasHouse() || char.MistyEncounterCount < 2 { // passes them in directly.
func mistyHousingHint(mistyEncounterCount, mistyDonatedCount int, house HouseState) string {
if house.HasHouse() || mistyEncounterCount < 2 {
return "" 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 "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." return "Thank you for your time."
@@ -412,11 +414,11 @@ func mistyHousingHint(char *AdventureCharacter, house HouseState) string {
// ── Misty Pet Reactivation ───────────────────────────────────────────────── // ── Misty Pet Reactivation ─────────────────────────────────────────────────
// mistyReactivatePet marks the pet as reactivated if it was chased away. // mistyReactivatePet returns true when a chased-away pet should be marked
func mistyReactivatePet(char *AdventureCharacter) { // as reactivated. Caller is responsible for flipping the flag on both
if char.PetChasedAway && !char.PetReactivated { // AdvCharacter and player_meta.
char.PetReactivated = true func mistyReactivatePet(pet PetState) bool {
} return pet.ChasedAway && !pet.Reactivated
} }
// ── Pet Level 10 Ticker (runs daily at midnight) ─────────────────────────── // ── Pet Level 10 Ticker (runs daily at midnight) ───────────────────────────
@@ -434,7 +436,8 @@ func (p *AdventurePlugin) petMidnightCheck() {
} }
// Check supply shop unlock // Check supply shop unlock
if petCheckSupplyShopUnlock(&char) { pet, _ := loadPetState(char.UserID)
if petCheckSupplyShopUnlock(pet) {
char.PetSupplyShopUnlocked = true char.PetSupplyShopUnlocked = true
_ = saveAdvCharacter(&char) _ = saveAdvCharacter(&char)
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char)) _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(&char))

View File

@@ -41,67 +41,67 @@ func TestPetXPToNextLevel_Increasing(t *testing.T) {
} }
func TestPetGrantXP_LevelsUp(t *testing.T) { func TestPetGrantXP_LevelsUp(t *testing.T) {
char := &AdventureCharacter{ pet := &PetState{
PetType: "dog", Type: "dog",
PetName: "Rex", Name: "Rex",
PetArrived: true, Arrived: true,
PetLevel: 1, Level: 1,
PetXP: 950, // close to needing 1000 (10 * 100 centixp) XP: 950, // close to needing 1000 (10 * 100 centixp)
} }
leveled := petGrantXP(char) leveled := petGrantXP(pet)
if !leveled { if !leveled {
t.Error("should have leveled up") t.Error("should have leveled up")
} }
if char.PetLevel != 2 { if pet.Level != 2 {
t.Errorf("pet level should be 2, got %d", char.PetLevel) t.Errorf("pet level should be 2, got %d", pet.Level)
} }
} }
func TestPetGrantXP_NoPet(t *testing.T) { func TestPetGrantXP_NoPet(t *testing.T) {
char := &AdventureCharacter{} pet := &PetState{}
if petGrantXP(char) { if petGrantXP(pet) {
t.Error("should not grant XP without a pet") t.Error("should not grant XP without a pet")
} }
} }
func TestPetGrantXP_MaxLevel(t *testing.T) { func TestPetGrantXP_MaxLevel(t *testing.T) {
char := &AdventureCharacter{ pet := &PetState{
PetType: "cat", Type: "cat",
PetName: "Luna", Name: "Luna",
PetArrived: true, Arrived: true,
PetLevel: 10, Level: 10,
PetXP: 9999, XP: 9999,
} }
if petGrantXP(char) { if petGrantXP(pet) {
t.Error("should not level up past max level 10") t.Error("should not level up past max level 10")
} }
} }
func TestPetGrantXP_ChasedAway(t *testing.T) { func TestPetGrantXP_ChasedAway(t *testing.T) {
char := &AdventureCharacter{ pet := &PetState{
PetType: "dog", Type: "dog",
PetName: "Rex", Name: "Rex",
PetArrived: true, Arrived: true,
PetChasedAway: true, ChasedAway: true,
PetLevel: 1, Level: 1,
PetXP: 0, XP: 0,
} }
if petGrantXP(char) { if petGrantXP(pet) {
t.Error("should not grant XP to chased-away pet") t.Error("should not grant XP to chased-away pet")
} }
} }
func TestPetGrantXP_SetsLevel10Date(t *testing.T) { func TestPetGrantXP_SetsLevel10Date(t *testing.T) {
char := &AdventureCharacter{ pet := &PetState{
PetType: "dog", Type: "dog",
PetName: "Rex", Name: "Rex",
PetArrived: true, Arrived: true,
PetLevel: 9, Level: 9,
PetXP: 4999, // needs 5000 (50 * 100) for level 9→10 XP: 4999, // needs 5000 (50 * 100) for level 9→10
} }
petGrantXP(char) petGrantXP(pet)
if char.PetLevel == 10 && char.PetLevel10Date == "" { if pet.Level == 10 && pet.Level10Date == "" {
t.Error("reaching level 10 should set PetLevel10Date") t.Error("reaching level 10 should set Level10Date")
} }
} }
@@ -167,46 +167,42 @@ func TestPetDitchRecoveryChance_Level10(t *testing.T) {
// ── Pet Combat Results ───────────────────────────────────────────────────── // ── Pet Combat Results ─────────────────────────────────────────────────────
func TestPetRollCombatActions_NoPet(t *testing.T) { func TestPetRollCombatActions_NoPet(t *testing.T) {
char := &AdventureCharacter{} result := petRollCombatActions(PetState{}, "Goblin")
result := petRollCombatActions(char, "Goblin")
if result != nil { if result != nil {
t.Error("should return nil without a pet") t.Error("should return nil without a pet")
} }
} }
func TestPetRollDitchRecovery_NoPet(t *testing.T) { func TestPetRollDitchRecovery_NoPet(t *testing.T) {
char := &AdventureCharacter{} if petRollDitchRecovery(PetState{}) {
if petRollDitchRecovery(char) {
t.Error("should return false without a pet") t.Error("should return false without a pet")
} }
} }
func TestPetVictoryText_Dog(t *testing.T) { func TestPetVictoryText_Dog(t *testing.T) {
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true} pet := PetState{Type: "dog", Name: "Rex", Arrived: true}
text := petVictoryText(char) text := petVictoryText(pet)
if text == "" { if text == "" {
t.Error("should return victory text for dog") t.Error("should return victory text for dog")
} }
} }
func TestPetVictoryText_Cat(t *testing.T) { func TestPetVictoryText_Cat(t *testing.T) {
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true} pet := PetState{Type: "cat", Name: "Luna", Arrived: true}
text := petVictoryText(char) text := petVictoryText(pet)
if text == "" { if text == "" {
t.Error("should return victory text for cat") t.Error("should return victory text for cat")
} }
} }
func TestPetVictoryText_NoPet(t *testing.T) { func TestPetVictoryText_NoPet(t *testing.T) {
char := &AdventureCharacter{} if petVictoryText(PetState{}) != "" {
if petVictoryText(char) != "" {
t.Error("should return empty string without pet") t.Error("should return empty string without pet")
} }
} }
func TestPetDeathText_NoPet(t *testing.T) { func TestPetDeathText_NoPet(t *testing.T) {
char := &AdventureCharacter{} if petDeathText(PetState{}) != "" {
if petDeathText(char) != "" {
t.Error("should return empty string without pet") t.Error("should return empty string without pet")
} }
} }
@@ -214,37 +210,33 @@ func TestPetDeathText_NoPet(t *testing.T) {
// ── Pet Arrival Logic ────────────────────────────────────────────────────── // ── Pet Arrival Logic ──────────────────────────────────────────────────────
func TestPetShouldArrive_NoHouse(t *testing.T) { func TestPetShouldArrive_NoHouse(t *testing.T) {
char := &AdventureCharacter{} if petShouldArrive(PetState{}, HouseState{Tier: 0}) {
if petShouldArrive(char, HouseState{Tier: 0}) {
t.Error("should not arrive without house") t.Error("should not arrive without house")
} }
} }
func TestPetShouldArrive_BaseHouseOnly(t *testing.T) { func TestPetShouldArrive_BaseHouseOnly(t *testing.T) {
char := &AdventureCharacter{} if petShouldArrive(PetState{}, HouseState{Tier: 1}) { // Base house, not yet Livable
if petShouldArrive(char, HouseState{Tier: 1}) { // Base house, not yet Livable
t.Error("should not arrive with only base house (need tier 2+)") t.Error("should not arrive with only base house (need tier 2+)")
} }
} }
func TestPetShouldArrive_AlreadyArrived(t *testing.T) { func TestPetShouldArrive_AlreadyArrived(t *testing.T) {
char := &AdventureCharacter{PetArrived: true} if petShouldArrive(PetState{Arrived: true}, HouseState{Tier: 2}) {
if petShouldArrive(char, HouseState{Tier: 2}) {
t.Error("should not trigger if pet already arrived") t.Error("should not trigger if pet already arrived")
} }
} }
func TestPetShouldArrive_ChasedAway(t *testing.T) { func TestPetShouldArrive_ChasedAway(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: true} if petShouldArrive(PetState{ChasedAway: true}, HouseState{Tier: 2}) {
if petShouldArrive(char, HouseState{Tier: 2}) {
t.Error("should not trigger if pet was chased away (and not reactivated)") t.Error("should not trigger if pet was chased away (and not reactivated)")
} }
} }
func TestPetShouldArrive_Reactivated(t *testing.T) { func TestPetShouldArrive_Reactivated(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true} pet := PetState{ChasedAway: true, Reactivated: true}
// With reactivation, should always return 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") t.Error("reactivated pet should always trigger arrival")
} }
} }
@@ -274,8 +266,7 @@ func TestHasPet(t *testing.T) {
// ── Morning Pet Events ───────────────────────────────────────────────────── // ── Morning Pet Events ─────────────────────────────────────────────────────
func TestPetMorningEvent_NoPet(t *testing.T) { func TestPetMorningEvent_NoPet(t *testing.T) {
char := &AdventureCharacter{} if petMorningEvent(PetState{}) != "" {
if petMorningEvent(char) != "" {
t.Error("should return empty string without pet") t.Error("should return empty string without pet")
} }
} }
@@ -283,30 +274,26 @@ func TestPetMorningEvent_NoPet(t *testing.T) {
// ── Misty Housing Hint ───────────────────────────────────────────────────── // ── Misty Housing Hint ─────────────────────────────────────────────────────
func TestMistyHousingHint_NoEncounters(t *testing.T) { func TestMistyHousingHint_NoEncounters(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 0} if mistyHousingHint(0, 0, HouseState{}) != "" {
if mistyHousingHint(char, HouseState{}) != "" {
t.Error("should not hint with 0 encounters") t.Error("should not hint with 0 encounters")
} }
} }
func TestMistyHousingHint_HasHouse(t *testing.T) { func TestMistyHousingHint_HasHouse(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 3} if mistyHousingHint(3, 0, HouseState{Tier: 1}) != "" {
if mistyHousingHint(char, HouseState{Tier: 1}) != "" {
t.Error("should not hint if player has house") t.Error("should not hint if player has house")
} }
} }
func TestMistyHousingHint_Donated(t *testing.T) { func TestMistyHousingHint_Donated(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 1} hint := mistyHousingHint(2, 1, HouseState{})
hint := mistyHousingHint(char, HouseState{})
if !strings.Contains(hint, "house") { if !strings.Contains(hint, "house") {
t.Errorf("donated player hint should mention house, got: %q", hint) t.Errorf("donated player hint should mention house, got: %q", hint)
} }
} }
func TestMistyHousingHint_NotDonated(t *testing.T) { func TestMistyHousingHint_NotDonated(t *testing.T) {
char := &AdventureCharacter{MistyEncounterCount: 2, MistyDonatedCount: 0} hint := mistyHousingHint(2, 0, HouseState{})
hint := mistyHousingHint(char, HouseState{})
if hint != "Thank you for your time." { if hint != "Thank you for your time." {
t.Errorf("non-donor hint should be dismissive, got: %q", hint) t.Errorf("non-donor hint should be dismissive, got: %q", hint)
} }
@@ -315,56 +302,50 @@ func TestMistyHousingHint_NotDonated(t *testing.T) {
// ── Misty Pet Reactivation ───────────────────────────────────────────────── // ── Misty Pet Reactivation ─────────────────────────────────────────────────
func TestMistyReactivatePet_ChasedAway(t *testing.T) { func TestMistyReactivatePet_ChasedAway(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: false} pet := PetState{ChasedAway: true, Reactivated: false}
mistyReactivatePet(char) if !mistyReactivatePet(pet) {
if !char.PetReactivated { t.Error("should signal reactivation for chased-away pet")
t.Error("should set PetReactivated")
} }
} }
func TestMistyReactivatePet_NotChasedAway(t *testing.T) { func TestMistyReactivatePet_NotChasedAway(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: false, PetReactivated: false} pet := PetState{ChasedAway: false, Reactivated: false}
mistyReactivatePet(char) if mistyReactivatePet(pet) {
if char.PetReactivated { t.Error("should not reactivate if pet wasn't chased away")
t.Error("should not set PetReactivated if pet wasn't chased away")
} }
} }
func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) { func TestMistyReactivatePet_AlreadyReactivated(t *testing.T) {
char := &AdventureCharacter{PetChasedAway: true, PetReactivated: true} pet := PetState{ChasedAway: true, Reactivated: true}
mistyReactivatePet(char) if mistyReactivatePet(pet) {
if !char.PetReactivated { t.Error("should not re-signal once already reactivated")
t.Error("should remain reactivated")
} }
} }
// ── Pet Supply Shop Unlock ───────────────────────────────────────────────── // ── Pet Supply Shop Unlock ─────────────────────────────────────────────────
func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) { func TestPetCheckSupplyShopUnlock_NotLevel10(t *testing.T) {
char := &AdventureCharacter{PetLevel10Date: ""} if petCheckSupplyShopUnlock(PetState{Level10Date: ""}) {
if petCheckSupplyShopUnlock(char) {
t.Error("should not unlock without level 10 date") t.Error("should not unlock without level 10 date")
} }
} }
func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) { func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) {
char := &AdventureCharacter{PetSupplyShopUnlocked: true, PetLevel10Date: "2020-01-01"} pet := PetState{SupplyShopUnlocked: true, Level10Date: "2020-01-01"}
if petCheckSupplyShopUnlock(char) { if petCheckSupplyShopUnlock(pet) {
t.Error("should not re-trigger if already unlocked") t.Error("should not re-trigger if already unlocked")
} }
} }
func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) { func TestPetCheckSupplyShopUnlock_TooSoon(t *testing.T) {
// Set date to today — not 7 days ago // Set date to today — not 7 days ago
char := &AdventureCharacter{PetLevel10Date: "2099-01-01"} if petCheckSupplyShopUnlock(PetState{Level10Date: "2099-01-01"}) {
if petCheckSupplyShopUnlock(char) {
t.Error("should not unlock before 7 days have passed") t.Error("should not unlock before 7 days have passed")
} }
} }
func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) { func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) {
char := &AdventureCharacter{PetLevel10Date: "2020-01-01"} if !petCheckSupplyShopUnlock(PetState{Level10Date: "2020-01-01"}) {
if !petCheckSupplyShopUnlock(char) {
t.Error("should unlock after 7+ days") t.Error("should unlock after 7+ days")
} }
} }

View File

@@ -116,13 +116,14 @@ func (p *AdventurePlugin) sendMorningDMs() {
// Pet arrival check (fires before normal morning DM) // Pet arrival check (fires before normal morning DM)
house, _ := loadHouseState(char.UserID) house, _ := loadHouseState(char.UserID)
if petShouldArrive(&char, house) { pet, _ := loadPetState(char.UserID)
if petShouldArrive(pet, house) {
p.petArrivalDM(char.UserID) p.petArrivalDM(char.UserID)
continue continue
} }
// Morning pet event // Morning pet event
petEvent := petMorningEvent(&char) petEvent := petMorningEvent(pet)
if petEvent != "" { if petEvent != "" {
char.PetMorningDefense = true char.PetMorningDefense = true
_ = saveAdvCharacter(&char) _ = saveAdvCharacter(&char)

View File

@@ -312,6 +312,7 @@ func applyXPBonuses(p XPBonusParams) XPResult {
type DeathTransitionParams struct { type DeathTransitionParams struct {
Char *AdventureCharacter Char *AdventureCharacter
Equip map[EquipmentSlot]*AdvEquipment Equip map[EquipmentSlot]*AdvEquipment
Pet PetState // pet state for ditch-recovery roll; zero value disables
ChatLevel int ChatLevel int
Location string // set as GrudgeLocation; empty = don't set Location string // set as GrudgeLocation; empty = don't set
Source string // death source: "adventure" | "arena" — recorded on Kill() Source string // death source: "adventure" | "arena" — recorded on Kill()
@@ -374,8 +375,8 @@ func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
} }
r.Died = true r.Died = true
if petRollDitchRecovery(p.Char) && p.Char.DeadUntil != nil { if petRollDitchRecovery(p.Pet) && p.Char.DeadUntil != nil {
reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Char.PetLevel)) reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Pet.Level))
p.Char.DeadUntil = &reduced p.Char.DeadUntil = &reduced
r.PetRecovered = true r.PetRecovered = true
} }

View File

@@ -170,10 +170,9 @@ func TestTransitionDeath_SovereignReprieve(t *testing.T) {
func TestTransitionDeath_KillAndPetRecovery(t *testing.T) { func TestTransitionDeath_KillAndPetRecovery(t *testing.T) {
deaths, petRecoveries := 0, 0 deaths, petRecoveries := 0, 0
for i := 0; i < 500; i++ { for i := 0; i < 500; i++ {
char := &AdventureCharacter{ char := &AdventureCharacter{Alive: true}
Alive: true, PetType: "cat", PetArrived: true, PetLevel: 5, pet := PetState{Type: "cat", Arrived: true, Level: 5}
} r := transitionDeath(DeathTransitionParams{Char: char, Pet: pet, Location: "Mine"})
r := transitionDeath(DeathTransitionParams{Char: char, Location: "Mine"})
if !r.Died { if !r.Died {
t.Fatal("should die with no saves available") t.Fatal("should die with no saves available")
} }

View File

@@ -402,6 +402,13 @@ type PetState struct {
MorningDefense bool 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 // 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. // flag is an additive JSON field — old rows decode as false.
type petFlagsJSON struct { type petFlagsJSON struct {