Files
gogobee/internal/plugin/adventure_pets_test.go
prosolis 81cce0169f 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>
2026-05-09 14:25:22 -07:00

424 lines
13 KiB
Go

package plugin
import (
"strings"
"testing"
)
// ── Pet XP & Leveling ──────────────────────────────────────────────────────
func TestPetXPToNextLevel(t *testing.T) {
tests := []struct {
level int
expected int
}{
{0, 10},
{1, 10},
{2, 10},
{4, 20},
{5, 35},
{7, 35},
{8, 50},
{9, 50},
}
for _, tt := range tests {
if got := petXPToNextLevel(tt.level); got != tt.expected {
t.Errorf("petXPToNextLevel(%d) = %d, want %d", tt.level, got, tt.expected)
}
}
}
func TestPetXPToNextLevel_Increasing(t *testing.T) {
// XP requirements should never decrease as level increases
prev := petXPToNextLevel(0)
for level := 1; level < 10; level++ {
curr := petXPToNextLevel(level)
if curr < prev {
t.Errorf("XP for level %d (%d) should not be less than level %d (%d)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetGrantXP_LevelsUp(t *testing.T) {
pet := &PetState{
Type: "dog",
Name: "Rex",
Arrived: true,
Level: 1,
XP: 950, // close to needing 1000 (10 * 100 centixp)
}
leveled := petGrantXP(pet)
if !leveled {
t.Error("should have leveled up")
}
if pet.Level != 2 {
t.Errorf("pet level should be 2, got %d", pet.Level)
}
}
func TestPetGrantXP_NoPet(t *testing.T) {
pet := &PetState{}
if petGrantXP(pet) {
t.Error("should not grant XP without a pet")
}
}
func TestPetGrantXP_MaxLevel(t *testing.T) {
pet := &PetState{
Type: "cat",
Name: "Luna",
Arrived: true,
Level: 10,
XP: 9999,
}
if petGrantXP(pet) {
t.Error("should not level up past max level 10")
}
}
func TestPetGrantXP_ChasedAway(t *testing.T) {
pet := &PetState{
Type: "dog",
Name: "Rex",
Arrived: true,
ChasedAway: true,
Level: 1,
XP: 0,
}
if petGrantXP(pet) {
t.Error("should not grant XP to chased-away pet")
}
}
func TestPetGrantXP_SetsLevel10Date(t *testing.T) {
pet := &PetState{
Type: "dog",
Name: "Rex",
Arrived: true,
Level: 9,
XP: 4999, // needs 5000 (50 * 100) for level 9→10
}
petGrantXP(pet)
if pet.Level == 10 && pet.Level10Date == "" {
t.Error("reaching level 10 should set Level10Date")
}
}
// ── Pet Combat Probabilities ───────────────────────────────────────────────
func TestPetAttackChance_ScalesWithLevel(t *testing.T) {
prev := petAttackChance(0)
for level := 1; level <= 10; level++ {
curr := petAttackChance(level)
if curr <= prev {
t.Errorf("attack chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetAttackChance_Level10(t *testing.T) {
chance := petAttackChance(10)
// 3% + 10*1.5% = 18%
if chance < 0.179 || chance > 0.181 {
t.Errorf("level 10 attack chance: got %.3f, want 0.18", chance)
}
}
func TestPetDeflectChance_ScalesWithLevel(t *testing.T) {
prev := petDeflectChance(0, 0)
for level := 1; level <= 10; level++ {
curr := petDeflectChance(level, 0)
if curr <= prev {
t.Errorf("deflect chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetDeflectChance_ArmorBonus(t *testing.T) {
baseChance := petDeflectChance(5, 0)
armoredChance := petDeflectChance(5, 3)
if armoredChance <= baseChance {
t.Errorf("armored deflect (%.3f) should exceed base (%.3f)", armoredChance, baseChance)
}
}
func TestPetDitchRecoveryChance_ScalesWithLevel(t *testing.T) {
prev := petDitchRecoveryChance(0)
for level := 1; level <= 10; level++ {
curr := petDitchRecoveryChance(level)
if curr <= prev {
t.Errorf("ditch chance at level %d (%.3f) should exceed level %d (%.3f)", level, curr, level-1, prev)
}
prev = curr
}
}
func TestPetDitchRecoveryChance_Level10(t *testing.T) {
chance := petDitchRecoveryChance(10)
// 5% + 10*2% = 25%
if chance < 0.249 || chance > 0.251 {
t.Errorf("level 10 ditch chance: got %.3f, want 0.25", chance)
}
}
// ── Pet Combat Results ─────────────────────────────────────────────────────
func TestPetRollCombatActions_NoPet(t *testing.T) {
result := petRollCombatActions(PetState{}, "Goblin")
if result != nil {
t.Error("should return nil without a pet")
}
}
func TestPetRollDitchRecovery_NoPet(t *testing.T) {
if petRollDitchRecovery(PetState{}) {
t.Error("should return false without a pet")
}
}
func TestPetVictoryText_Dog(t *testing.T) {
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) {
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) {
if petVictoryText(PetState{}) != "" {
t.Error("should return empty string without pet")
}
}
func TestPetDeathText_NoPet(t *testing.T) {
if petDeathText(PetState{}) != "" {
t.Error("should return empty string without pet")
}
}
// ── Pet Arrival Logic ──────────────────────────────────────────────────────
func TestPetShouldArrive_NoHouse(t *testing.T) {
if petShouldArrive(PetState{}, HouseState{Tier: 0}) {
t.Error("should not arrive without house")
}
}
func TestPetShouldArrive_BaseHouseOnly(t *testing.T) {
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) {
if petShouldArrive(PetState{Arrived: true}, HouseState{Tier: 2}) {
t.Error("should not trigger if pet already arrived")
}
}
func TestPetShouldArrive_ChasedAway(t *testing.T) {
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) {
pet := PetState{ChasedAway: true, Reactivated: true}
// With reactivation, should always return true
if !petShouldArrive(pet, HouseState{Tier: 2}) {
t.Error("reactivated pet should always trigger arrival")
}
}
// ── HasPet ─────────────────────────────────────────────────────────────────
func TestHasPet(t *testing.T) {
tests := []struct {
name string
char AdventureCharacter
expected bool
}{
{"no pet", AdventureCharacter{}, false},
{"has pet", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true}, true},
{"chased away", AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetChasedAway: true}, false},
{"not arrived", AdventureCharacter{PetType: "dog", PetName: "Rex"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.char.HasPet() != tt.expected {
t.Errorf("HasPet() = %v, want %v", tt.char.HasPet(), tt.expected)
}
})
}
}
// ── Morning Pet Events ─────────────────────────────────────────────────────
func TestPetMorningEvent_NoPet(t *testing.T) {
if petMorningEvent(PetState{}) != "" {
t.Error("should return empty string without pet")
}
}
// ── Misty Housing Hint ─────────────────────────────────────────────────────
func TestMistyHousingHint_NoEncounters(t *testing.T) {
if mistyHousingHint(0, 0, HouseState{}) != "" {
t.Error("should not hint with 0 encounters")
}
}
func TestMistyHousingHint_HasHouse(t *testing.T) {
if mistyHousingHint(3, 0, HouseState{Tier: 1}) != "" {
t.Error("should not hint if player has house")
}
}
func TestMistyHousingHint_Donated(t *testing.T) {
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) {
hint := mistyHousingHint(2, 0, HouseState{})
if hint != "Thank you for your time." {
t.Errorf("non-donor hint should be dismissive, got: %q", hint)
}
}
// ── Misty Pet Reactivation ─────────────────────────────────────────────────
func TestMistyReactivatePet_ChasedAway(t *testing.T) {
pet := PetState{ChasedAway: true, Reactivated: false}
if !mistyReactivatePet(pet) {
t.Error("should signal reactivation for chased-away pet")
}
}
func TestMistyReactivatePet_NotChasedAway(t *testing.T) {
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) {
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) {
if petCheckSupplyShopUnlock(PetState{Level10Date: ""}) {
t.Error("should not unlock without level 10 date")
}
}
func TestPetCheckSupplyShopUnlock_AlreadyUnlocked(t *testing.T) {
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
if petCheckSupplyShopUnlock(PetState{Level10Date: "2099-01-01"}) {
t.Error("should not unlock before 7 days have passed")
}
}
func TestPetCheckSupplyShopUnlock_Ready(t *testing.T) {
if !petCheckSupplyShopUnlock(PetState{Level10Date: "2020-01-01"}) {
t.Error("should unlock after 7+ days")
}
}
// ── Ditch Recovery Text ────────────────────────────────────────────────────
func TestPetDitchRecoveryGameRoom_NoPet(t *testing.T) {
text := petDitchRecoveryGameRoom("Player1", "", false)
if !strings.Contains(text, "Player1") {
t.Error("should contain player name")
}
if !strings.Contains(text, "stretcher") {
t.Error("should mention stretcher for non-pet version")
}
}
func TestPetDitchRecoveryGameRoom_WithPet(t *testing.T) {
text := petDitchRecoveryGameRoom("Player1", "Rex", true)
if !strings.Contains(text, "Rex") {
t.Error("should contain pet name")
}
if !strings.Contains(text, "intervened") {
t.Error("should mention pet intervention")
}
}
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
func TestPetFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"PetDogAttack": PetDogAttack,
"PetCatAttack": PetCatAttack,
"PetDogDeath": PetDogDeath,
"PetCatDeath": PetCatDeath,
"PetDogVictory": PetDogVictory,
"PetCatVictory": PetCatVictory,
"PetDogDeflect": PetDogDeflect,
"PetCatDeflect": PetCatDeflect,
"PetCatOffering": PetCatOffering,
"PetDogSmothering": PetDogSmothering,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty", name)
}
}
}
func TestPetAttackFlavor_Placeholders(t *testing.T) {
// All attack lines must have {damage}. {enemy} is optional — some lines
// intentionally omit the enemy name for voice/style reasons (especially cat).
for i, line := range PetDogAttack {
if !strings.Contains(line, "{damage}") {
t.Errorf("PetDogAttack[%d] missing {damage} placeholder", i)
}
}
for i, line := range PetCatAttack {
if !strings.Contains(line, "{damage}") {
t.Errorf("PetCatAttack[%d] missing {damage} placeholder", i)
}
}
}
func TestPetDeflectFlavor_Placeholders(t *testing.T) {
for i, line := range PetDogDeflect {
if !strings.Contains(line, "{enemy}") {
t.Errorf("PetDogDeflect[%d] missing {enemy} placeholder", i)
}
}
for i, line := range PetCatDeflect {
if !strings.Contains(line, "{enemy}") {
t.Errorf("PetCatDeflect[%d] missing {enemy} placeholder", i)
}
}
}