Files
gogobee/internal/plugin/adventure_housing_test.go
prosolis 57a0ea90f2 N4/E1: second pet slot for the Tier-4 Estate
An Established (Tier-4) home can draw a second companion. Both pets show up on
the sheet and the town showcase, level via babysitting, and wear their own
barding (!thom pet2buy <tier>).

Combat: both pets contribute their attack/deflect/whiff procs at half weight —
DerivePlayerStats now averages over the active pets, so a pair reads as roughly
one full pet (flavor-forward, not a stat spike; difficulty-neutral). The average
reduces to identity over a single pet (x/1==x, 0.0+x==x, 3+(2L+1)/2==3+L), and
the engines still roll one proc per channel per round, so the single-pet path
and TestCombatCharacterization golden stay byte-identical. Pinned by
TestDerivePlayerStats_OnePetIsByteIdentical + the golden.

Scope kept deliberately flavor-forward: pet 2 carries identity/level/barding and
the three combat procs only. The morning-defense buff, death/ditch-recovery
save, and the level-10 supply-shop unlock stay pet-1 mechanics — no second
defensive multiplier stacks, and the one-pet path is untouched by construction.

Storage: parallel pet2_* columns on player_meta + Pet2* mirror on
AdventureCharacter (absent == no second pet, DEFAULT '' correct for every
pre-existing row, no backfill — the N2-temper / P4-party precedent). Pet-1 code
paths are untouched. Arrival reuses the chase/feed/type/name DM flow, slot-aware,
gated HouseTier>=4 with an established first pet.

Review fixes folded in (high-effort /code-review, 3 angles): pet-2 barding block
no longer hidden when pet-1 is chased away; showcase tie-break restored
(level desc, then name); petGrantXP collapsed onto the shared level-up helper;
dead-param armor-buy wrappers inlined; pet-2 barding tagged with its own euro
ledger reason.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 15:03:57 -07:00

374 lines
11 KiB
Go

package plugin
import (
"strings"
"testing"
)
// ── Housing Tier Definitions ───────────────────────────────────────────────
func TestHouseTierByTier_Valid(t *testing.T) {
for _, def := range houseTiers {
got := houseTierByTier(def.Tier)
if got == nil {
t.Errorf("houseTierByTier(%d) returned nil", def.Tier)
continue
}
if got.Tier != def.Tier {
t.Errorf("houseTierByTier(%d).Tier = %d", def.Tier, got.Tier)
}
}
}
func TestHouseTierByTier_Invalid(t *testing.T) {
if houseTierByTier(0) != nil {
t.Error("tier 0 (no house) should return nil")
}
if houseTierByTier(99) != nil {
t.Error("tier 99 should return nil")
}
}
func TestHouseNextTier(t *testing.T) {
// From no house (tier 0) → tier 1
def := houseNextTier(0)
if def == nil || def.Tier != 1 {
t.Error("next tier from 0 should be 1")
}
// From tier 1 → tier 2
def = houseNextTier(1)
if def == nil || def.Tier != 2 {
t.Error("next tier from 1 should be 2")
}
// From max tier → nil
def = houseNextTier(4)
if def != nil {
t.Error("next tier from max should be nil")
}
}
// ── Closing Costs ──────────────────────────────────────────────────────────
func TestHouseClosingCosts(t *testing.T) {
// 11% of 75000 = 8250
costs := houseClosingCosts(75000)
if costs != 8250 {
t.Errorf("closing costs on 75000: got %d, want 8250", costs)
}
}
func TestHouseTotalCost(t *testing.T) {
// 75000 + 11% = 83250
total := houseTotalCost(75000)
if total != 83250 {
t.Errorf("total cost on 75000: got %d, want 83250", total)
}
}
// ── Weekly Payment ─────────────────────────────────────────────────────────
func TestHouseWeeklyPayment_Normal(t *testing.T) {
// 100000 balance at 6.5%: interest = 100000*0.065/52 ≈ 125, principal = 100000*0.005 = 500
// Total ≈ 625
payment := houseWeeklyPayment(100000, 6.5)
if payment < 600 || payment > 650 {
t.Errorf("weekly payment on 100k at 6.5%%: got %d, want ~625", payment)
}
}
func TestHouseWeeklyPayment_ZeroBalance(t *testing.T) {
if houseWeeklyPayment(0, 6.5) != 0 {
t.Error("zero balance should produce zero payment")
}
}
func TestHouseWeeklyPayment_ZeroRate(t *testing.T) {
if houseWeeklyPayment(100000, 0) != 0 {
t.Error("zero rate should produce zero payment")
}
}
func TestHouseWeeklyPayment_MinimumOne(t *testing.T) {
// Very small balance should still yield at least 1
payment := houseWeeklyPayment(1, 0.01)
if payment < 1 {
t.Errorf("tiny balance should produce at least 1, got %d", payment)
}
}
// ── Missed Payment Penalty ─────────────────────────────────────────────────
func TestHouseMissedPenalty(t *testing.T) {
// 5% of 625 = 31
penalty := houseMissedPenalty(625)
if penalty != 31 {
t.Errorf("missed penalty on 625 weekly: got %d, want 31", penalty)
}
}
func TestHouseMissedPenalty_Minimum(t *testing.T) {
// Tiny payment should still yield at least 1
penalty := houseMissedPenalty(1)
if penalty < 1 {
t.Errorf("penalty should be at least 1, got %d", penalty)
}
}
// ── Character Helper Methods ───────────────────────────────────────────────
func TestHasHouse(t *testing.T) {
tests := []struct {
name string
tier int
loan int
expected bool
}{
{"no house", 0, 0, false},
{"base house", 1, 0, true},
{"loan only", 0, 50000, true},
{"livable with loan", 2, 100000, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
char := &AdventureCharacter{HouseTier: tt.tier, HouseLoanBalance: tt.loan}
if char.HasHouse() != tt.expected {
t.Errorf("HasHouse() = %v, want %v", char.HasHouse(), tt.expected)
}
})
}
}
func TestHouseHPBonus(t *testing.T) {
tests := []struct {
tier int
expected float64
}{
{0, 0},
{1, 0}, // Base house — no bonus
{2, 0.05}, // Livable
{3, 0.12}, // Comfortable
{4, 0.20}, // Established
}
for _, tt := range tests {
char := &AdventureCharacter{HouseTier: tt.tier}
if bonus := char.HouseHPBonus(); bonus != tt.expected {
t.Errorf("tier %d: HouseHPBonus() = %f, want %f", tt.tier, bonus, tt.expected)
}
}
}
// ── Thom Greeting ──────────────────────────────────────────────────────────
func TestThomGreeting_PreAdoption(t *testing.T) {
char := &AdventureCharacter{}
greeting := thomGreeting(char)
found := false
for _, g := range thomGreetingsPreAdoption {
if greeting == g {
found = true
break
}
}
if !found {
t.Errorf("pre-adoption greeting not from expected pool: %q", greeting)
}
}
func TestThomGreeting_PostAdoption(t *testing.T) {
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetLevel: 5}
greeting := thomGreeting(char)
found := false
for _, g := range thomGreetingsPostAdoption {
if greeting == g {
found = true
break
}
}
if !found {
t.Errorf("post-adoption greeting not from expected pool: %q", greeting)
}
}
func TestThomGreeting_PostLevel10(t *testing.T) {
char := &AdventureCharacter{PetType: "cat", PetName: "Whiskers", PetArrived: true, PetLevel: 10}
greeting := thomGreeting(char)
if !strings.Contains(greeting, "Whiskers") {
t.Errorf("post-level-10 greeting should contain pet name, got: %q", greeting)
}
}
// ── Thom Shop View ─────────────────────────────────────────────────────────
func TestThomShopView_NoHouse(t *testing.T) {
char := &AdventureCharacter{}
text := thomShopView(char, 100000)
if !strings.Contains(text, "Krooke Realty") {
t.Error("should contain shop name")
}
if !strings.Contains(text, "don't own a house") {
t.Error("should mention no house")
}
if !strings.Contains(text, "Base House") {
t.Error("should show first tier available")
}
if !strings.Contains(text, "€75000") {
t.Error("should show base price")
}
}
func TestThomShopView_WithLoan(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5}
text := thomShopView(char, 10000)
if !strings.Contains(text, "Loan balance") {
t.Error("should show loan balance")
}
if !strings.Contains(text, "Pay off") {
t.Error("should mention paying off loan")
}
}
func TestThomShopView_MaxTier(t *testing.T) {
char := &AdventureCharacter{HouseTier: 4} // max tier
text := thomShopView(char, 999999)
if !strings.Contains(text, "Max tier") {
t.Error("should mention max tier reached")
}
}
func TestThomShopView_Autopay(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseAutopay: true}
text := thomShopView(char, 10000)
if !strings.Contains(text, "autopay") {
t.Error("should show autopay status")
}
}
func TestThomShopView_Frozen(t *testing.T) {
char := &AdventureCharacter{HouseTier: 1, HouseLoanBalance: 50000, HouseCurrentRate: 6.5, HouseLoanFrozen: true}
text := thomShopView(char, 10000)
if !strings.Contains(text, "FROZEN") {
t.Error("should show frozen status")
}
}
// ── Pet Armor ──────────────────────────────────────────────────────────────
func TestPetArmorDefs_BothTypes(t *testing.T) {
dogDefs := petArmorDefs("dog")
catDefs := petArmorDefs("cat")
if len(dogDefs) != 5 {
t.Errorf("dog armor: got %d tiers, want 5", len(dogDefs))
}
if len(catDefs) != 5 {
t.Errorf("cat armor: got %d tiers, want 5", len(catDefs))
}
// Verify tiers are sequential 1-5
for i, def := range dogDefs {
if def.Tier != i+1 {
t.Errorf("dog armor[%d].Tier = %d, want %d", i, def.Tier, i+1)
}
}
// Deflect bonuses should increase with tier
for i := 1; i < len(dogDefs); i++ {
if dogDefs[i].DeflectBonus <= dogDefs[i-1].DeflectBonus {
t.Errorf("dog armor tier %d deflect bonus should exceed tier %d", dogDefs[i].Tier, dogDefs[i-1].Tier)
}
}
}
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
text := petArmorShopView("dog", 0, "petbuy")
if !strings.Contains(text, "⬆️") {
t.Error("should show upgrade indicators")
}
if !strings.Contains(text, "petbuy") {
t.Error("should show buy instructions")
}
}
func TestPetArmorShopView_MaxTier(t *testing.T) {
text := petArmorShopView("cat", 5, "petbuy")
if !strings.Contains(text, "Max pet armor") {
t.Error("should show max tier message")
}
}
// ── Flavor Pool Coverage ───────────────────────────────────────────────────
func TestThomFlavorPools_NonEmpty(t *testing.T) {
pools := map[string][]string{
"thomGreetingsPreAdoption": thomGreetingsPreAdoption,
"thomGreetingsPostAdoption": thomGreetingsPostAdoption,
"thomGreetingsPostLevel10": thomGreetingsPostLevel10,
"thomHouseSellingLines": thomHouseSellingLines,
"ThomRateIncrease": ThomRateIncrease,
"ThomRateDecrease": ThomRateDecrease,
"ThomRateIncreasePet": ThomRateIncreasePet,
"ThomRateDecreasePet": ThomRateDecreasePet,
}
for name, pool := range pools {
if len(pool) == 0 {
t.Errorf("flavor pool %s is empty", name)
}
}
}
func TestThomRateFlavor_Placeholders(t *testing.T) {
for i, line := range ThomRateIncrease {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateIncrease[%d] missing {amount} placeholder", i)
}
}
for i, line := range ThomRateDecrease {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateDecrease[%d] missing {amount} placeholder", i)
}
}
for i, line := range ThomRateIncreasePet {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateIncreasePet[%d] missing {amount} placeholder", i)
}
if !strings.Contains(line, "{pet_name}") {
t.Errorf("ThomRateIncreasePet[%d] missing {pet_name} placeholder", i)
}
}
for i, line := range ThomRateDecreasePet {
if !strings.Contains(line, "{amount}") {
t.Errorf("ThomRateDecreasePet[%d] missing {amount} placeholder", i)
}
if !strings.Contains(line, "{pet_name}") {
t.Errorf("ThomRateDecreasePet[%d] missing {pet_name} placeholder", i)
}
}
}
// ── House Tiers Sanity ─────────────────────────────────────────────────────
func TestHouseTiers_PricesIncreasing(t *testing.T) {
for i := 1; i < len(houseTiers); i++ {
if houseTiers[i].BasePrice <= houseTiers[i-1].BasePrice {
t.Errorf("tier %d price (%d) should exceed tier %d price (%d)",
houseTiers[i].Tier, houseTiers[i].BasePrice,
houseTiers[i-1].Tier, houseTiers[i-1].BasePrice)
}
}
}
func TestHouseTiers_SequentialTiers(t *testing.T) {
for i, def := range houseTiers {
if def.Tier != i+1 {
t.Errorf("houseTiers[%d].Tier = %d, want %d", i, def.Tier, i+1)
}
}
}