mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
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>
424 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|