diff --git a/internal/db/db.go b/internal/db/db.go index 39b17e7..c915106 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -364,6 +364,20 @@ func runMigrations(d *sql.DB) error { // item drops out of sell/craft/combat readers as one flag change. DEFAULT 0 // = "in play", correct for every pre-existing row, so no bootstrap backfill. `ALTER TABLE adventure_inventory ADD COLUMN in_vault INTEGER NOT NULL DEFAULT 0`, + // N4/E1 second pet slot (gogobee_engagement_plan.md §E1). A Tier-4 Estate + // unlocks a second companion. It lives in a parallel pet2_* column set + // rather than a rows table so the single-pet path (and its combat golden) + // is untouched: absent pet2_type == "no second pet", DEFAULT '' is correct + // for every pre-existing row, so no bootstrap backfill. Pet 2 carries only + // what it needs — identity, level, barding — and deliberately skips the + // supply-shop unlock (a pet-1 mechanic) and morning-defense flag. + `ALTER TABLE player_meta ADD COLUMN pet2_type TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN pet2_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE player_meta ADD COLUMN pet2_xp INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN pet2_level INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN pet2_armor_tier INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE player_meta ADD COLUMN pet2_flags_json TEXT NOT NULL DEFAULT '{}'`, + `ALTER TABLE player_meta ADD COLUMN pet2_level_10_date TEXT NOT NULL DEFAULT ''`, } for _, stmt := range columnMigrations { if _, err := d.Exec(stmt); err != nil { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 56e1f45..ce97840 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -915,9 +915,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact case "npc_encounter": return p.resolveNPCEncounter(ctx, interaction) case "pet_arrival": - return p.resolvePetArrival(ctx) + return p.resolvePetArrival(ctx, interaction) case "pet_type": - return p.resolvePetType(ctx) + return p.resolvePetType(ctx, interaction) case "pet_name": return p.resolvePetName(ctx, interaction) } diff --git a/internal/plugin/adventure_babysit.go b/internal/plugin/adventure_babysit.go index 32d1e5f..83e7047 100644 --- a/internal/plugin/adventure_babysit.go +++ b/internal/plugin/adventure_babysit.go @@ -36,22 +36,15 @@ func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) { if !char.BabysitActive { return } + // Both companions share the sitter's attention and gain the flat trickle. + // (Combat only ever reads their *averaged* procs, so leveling both is not a + // power spike.) leveled := false - if char.HasPet() && char.PetLevel < 10 { - // Bypass petGrantXP's per-action constant — we want a flat trickle. - char.PetXP += petXPPerBabysitDay * 100 - for char.PetLevel < 10 { - needed := petXPToNextLevel(char.PetLevel) * 100 - if char.PetXP < needed { - break - } - char.PetXP -= needed - char.PetLevel++ - leveled = true - } - if char.PetLevel >= 10 && char.PetLevel10Date == "" { - char.PetLevel10Date = time.Now().UTC().Format("2006-01-02") - } + if char.HasPet() { + leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled + } + if char.HasPet2() { + leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled } outcome := "pet_care" if leveled { diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index fcd3db1..fcb38f2 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -92,13 +92,25 @@ type AdventureCharacter struct { PetSupplyShopUnlocked bool PetLevel10Date string PetMorningDefense bool - AutoBabysit bool - AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest - TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping - StreakDecayed bool - CraftsSucceeded int - DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure) - DeathLocation string // human-readable location of last death; cleared on revive is not required + // Second pet (N4/E1, Tier-4 Estate). A parallel slot, not a rewrite of the + // pet-1 fields above — see HasPet2. Carries only identity/level/barding; the + // morning-defense, ditch-recovery and supply-shop mechanics stay pet-1-only. + Pet2Type string + Pet2Name string + Pet2XP int + Pet2Level int + Pet2ArmorTier int + Pet2ChasedAway bool + Pet2Reactivated bool + Pet2Arrived bool + Pet2Level10Date string + AutoBabysit bool + AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest + TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping + StreakDecayed bool + CraftsSucceeded int + DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure) + DeathLocation string // human-readable location of last death; cleared on revive is not required } type AdvEquipment struct { @@ -270,6 +282,11 @@ func (c *AdventureCharacter) HasPet() bool { return c.PetType != "" && c.PetArrived && !c.PetChasedAway } +// HasPet2 returns true if the player has an active second pet. +func (c *AdventureCharacter) HasPet2() bool { + return c.Pet2Type != "" && c.Pet2Arrived && !c.Pet2ChasedAway +} + // HasHouse returns true if the player has purchased at least a base house. func (c *AdventureCharacter) HasHouse() bool { return c.HouseTier > 0 || c.HouseLoanBalance > 0 diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go index f7db09d..4d95177 100644 --- a/internal/plugin/adventure_housing.go +++ b/internal/plugin/adventure_housing.go @@ -99,12 +99,16 @@ type advPendingHouseDownPayment struct { type advPendingHouseAutopay struct{} -type advPendingPetArrival struct{} +// Slot is the pet slot the arrival conversation is filling: 1 (the first pet) +// or 2 (the Tier-4 Estate's second companion). Zero-value 1 keeps every +// existing single-pet interaction unchanged. +type advPendingPetArrival struct{ Slot int } -type advPendingPetType struct{} +type advPendingPetType struct{ Slot int } type advPendingPetName struct { PetType string + Slot int } // ── Thom Krooke Greeting ─────────────────────────────────────────────────── @@ -221,11 +225,19 @@ func thomShopView(char *AdventureCharacter, balance float64) string { } } - // Pet supply shop (unlocks 1 week after pet level 10) - if char.PetSupplyShopUnlocked && char.HasPet() { + // Pet supply shop (unlocks 1 week after pet level 10). The unlock is a + // pet-1 mechanic but covers barding for both companions. Each pet's block is + // gated on its own presence so a chased-away pet-1 doesn't hide pet-2's shop. + if char.PetSupplyShopUnlocked && (char.HasPet() || char.HasPet2()) { sb.WriteString("\n---\n") - sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName)) - sb.WriteString(petArmorShopView(char)) + if char.HasPet() { + sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName)) + sb.WriteString(petArmorShopView(char.PetType, char.PetArmorTier, "petbuy")) + } + if char.HasPet2() { + sb.WriteString(fmt.Sprintf("\n🐾 **Pet Supplies** — for %s\n\n", char.Pet2Name)) + sb.WriteString(petArmorShopView(char.Pet2Type, char.Pet2ArmorTier, "pet2buy")) + } } return sb.String() @@ -269,8 +281,11 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error { case lower == "buy" || strings.HasPrefix(lower, "buy "): return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy"))) + case strings.HasPrefix(lower, "pet2buy "): + return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[8:]), 2) + case strings.HasPrefix(lower, "petbuy "): - return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:])) + return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1) default: return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.") @@ -673,18 +688,18 @@ func petArmorDefs(petType string) []PetArmorDef { return petCatArmor } -func petArmorShopView(char *AdventureCharacter) string { +func petArmorShopView(petType string, armorTier int, buyCmd string) string { var sb strings.Builder - defs := petArmorDefs(char.PetType) + defs := petArmorDefs(petType) - if char.PetArmorTier >= 5 { + if armorTier >= 5 { sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n") return sb.String() } for _, def := range defs { - if def.Tier <= char.PetArmorTier { - if def.Tier == char.PetArmorTier { + if def.Tier <= armorTier { + if def.Tier == armorTier { sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier)) } continue @@ -692,11 +707,14 @@ func petArmorShopView(char *AdventureCharacter) string { indicator := "⬆️" sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100)) } - sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy ` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1)) + sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s ` (e.g., `!thom %s %d`)", buyCmd, buyCmd, armorTier+1)) return sb.String() } -func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error { +// petArmorBuyForSlot buys the next barding tier for the given pet slot. The +// supply-shop unlock is shared (a pet-1 mechanic), but each pet's armor tier is +// tracked and purchased independently. +func (p *AdventurePlugin) petArmorBuyForSlot(ctx MessageContext, tierStr string, slot int) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() @@ -706,8 +724,16 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC return p.SendDM(ctx.Sender, "Failed to load your character.") } - if !char.HasPet() { - return p.SendDM(ctx.Sender, "You don't have a pet.") + // Slot accessors keep the pet-1 and pet-2 field sets isolated. + hasPet, petType, petName, curTier := char.HasPet(), char.PetType, char.PetName, char.PetArmorTier + buyCmd := "petbuy" + if slot == 2 { + hasPet, petType, petName, curTier = char.HasPet2(), char.Pet2Type, char.Pet2Name, char.Pet2ArmorTier + buyCmd = "pet2buy" + } + + if !hasPet { + return p.SendDM(ctx.Sender, "You don't have that pet.") } if !char.PetSupplyShopUnlocked { return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.") @@ -716,19 +742,19 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC tier := 0 for _, c := range tierStr { if c < '0' || c > '9' { - return p.SendDM(ctx.Sender, "Usage: `!thom petbuy `") + return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s `", buyCmd)) } tier = tier*10 + int(c-'0') } - if tier != char.PetArmorTier+1 { - if tier <= char.PetArmorTier { + if tier != curTier+1 { + if tier <= curTier { return p.SendDM(ctx.Sender, "That's not an upgrade.") } - return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1)) + return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", curTier+1)) } - defs := petArmorDefs(char.PetType) + defs := petArmorDefs(petType) var def *PetArmorDef for i := range defs { if defs[i].Tier == tier { @@ -742,18 +768,26 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC balance := p.euro.GetBalance(ctx.Sender) if balance < float64(def.Price) { - return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance)) + return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", petName, def.Price, balance)) } - if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) { + reason := "pet_armor_" + tierStr + if slot == 2 { + reason = "pet2_armor_" + tierStr + } + if !p.euro.Debit(ctx.Sender, float64(def.Price), reason) { return p.SendDM(ctx.Sender, "Transaction failed.") } - char.PetArmorTier = tier + if slot == 2 { + char.Pet2ArmorTier = tier + } else { + char.PetArmorTier = tier + } if err := saveAdvCharacter(char); err != nil { p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund") return p.SendDM(ctx.Sender, "Failed to save. Refunded.") } - return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName)) + return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, petName, def.DeflectBonus*100, petName)) } diff --git a/internal/plugin/adventure_housing_test.go b/internal/plugin/adventure_housing_test.go index f3a5d49..10ac8c8 100644 --- a/internal/plugin/adventure_housing_test.go +++ b/internal/plugin/adventure_housing_test.go @@ -285,8 +285,7 @@ func TestPetArmorDefs_BothTypes(t *testing.T) { } func TestPetArmorShopView_HasUpgrades(t *testing.T) { - char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0} - text := petArmorShopView(char) + text := petArmorShopView("dog", 0, "petbuy") if !strings.Contains(text, "⬆️") { t.Error("should show upgrade indicators") @@ -297,8 +296,7 @@ func TestPetArmorShopView_HasUpgrades(t *testing.T) { } func TestPetArmorShopView_MaxTier(t *testing.T) { - char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5} - text := petArmorShopView(char) + text := petArmorShopView("cat", 5, "petbuy") if !strings.Contains(text, "Max pet armor") { t.Error("should show max tier message") diff --git a/internal/plugin/adventure_pet2_test.go b/internal/plugin/adventure_pet2_test.go new file mode 100644 index 0000000..e109c2e --- /dev/null +++ b/internal/plugin/adventure_pet2_test.go @@ -0,0 +1,180 @@ +package plugin + +import ( + "strings" + "testing" + + "maunium.net/go/mautrix/id" +) + +// TestDerivePlayerStats_OnePetIsByteIdentical pins that a single pet still +// produces exactly the pre-two-pet mod values (the averaging code must reduce +// to identity over one element, or the combat golden moves). +func TestDerivePlayerStats_OnePetIsByteIdentical(t *testing.T) { + char := testChar(10) + char.PetType = "dog" + char.PetName = "Rex" + char.PetArrived = true + char.PetLevel = 6 + char.PetArmorTier = 2 + + _, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false) + + if stats.PetAttackProc != petAttackChance(6) { + t.Errorf("PetAttackProc = %v, want %v (identity over one pet)", stats.PetAttackProc, petAttackChance(6)) + } + if stats.PetDeflectProc != petDeflectChance(6, 2) { + t.Errorf("PetDeflectProc = %v, want %v", stats.PetDeflectProc, petDeflectChance(6, 2)) + } + if want := 0.01 + 6*0.005; stats.PetWhiffProc != want { + t.Errorf("PetWhiffProc = %v, want %v", stats.PetWhiffProc, want) + } + if stats.PetAttackDmg != 3+6 { + t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+6) + } +} + +// TestDerivePlayerStats_TwoEqualPetsMatchOne: two identical pets average to the +// same procs as one — a pair is not a stat spike. +func TestDerivePlayerStats_TwoEqualPetsMatchOne(t *testing.T) { + char := testChar(10) + char.PetType, char.PetName, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", "Rex", true, 8, 3 + char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 8, 3 + + _, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false) + + if stats.PetAttackProc != petAttackChance(8) { + t.Errorf("two L8 pets PetAttackProc = %v, want one-pet %v", stats.PetAttackProc, petAttackChance(8)) + } + if stats.PetAttackDmg != 3+8 { + t.Errorf("two L8 pets PetAttackDmg = %d, want %d", stats.PetAttackDmg, 3+8) + } +} + +// TestDerivePlayerStats_TwoUnequalPetsAverage: mixed levels average, never +// exceeding the stronger pet's solo contribution. +func TestDerivePlayerStats_TwoUnequalPetsAverage(t *testing.T) { + char := testChar(10) + char.PetType, char.PetArrived, char.PetLevel, char.PetArmorTier = "dog", true, 10, 0 + char.Pet2Type, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", true, 2, 0 + + _, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false) + + wantAtk := (petAttackChance(10) + petAttackChance(2)) / 2 + if stats.PetAttackProc != wantAtk { + t.Errorf("PetAttackProc = %v, want avg %v", stats.PetAttackProc, wantAtk) + } + // Averaged proc must sit strictly between the two pets' solo values. + if !(stats.PetAttackProc < petAttackChance(10) && stats.PetAttackProc > petAttackChance(2)) { + t.Errorf("averaged proc %v not between %v and %v", stats.PetAttackProc, petAttackChance(2), petAttackChance(10)) + } + if want := 3 + (10+2+1)/2; stats.PetAttackDmg != want { // rounded avg level = 6 + t.Errorf("PetAttackDmg = %d, want %d", stats.PetAttackDmg, want) + } +} + +// TestDerivePlayerStats_Pet2OnlyStillContributes: if the first pet is chased +// away but the second remains, combat still sees the second pet. +func TestDerivePlayerStats_Pet2OnlyStillContributes(t *testing.T) { + char := testChar(10) + char.PetType, char.PetArrived, char.PetChasedAway, char.PetLevel = "dog", true, true, 5 + char.Pet2Type, char.Pet2Arrived, char.Pet2Level = "cat", true, 4 + + _, stats := DerivePlayerStats(char, testEquip(0), zeroBonuses(), 0, 0, false) + + if !char.HasPet() && stats.PetAttackProc != petAttackChance(4) { + t.Errorf("pet2-only PetAttackProc = %v, want %v", stats.PetAttackProc, petAttackChance(4)) + } +} + +// TestDerivePlayerStats_NoPetsZero: the golden's own case — no pets, no procs. +func TestDerivePlayerStats_NoPetsZero(t *testing.T) { + _, stats := DerivePlayerStats(testChar(10), testEquip(0), zeroBonuses(), 0, 0, false) + if stats.PetAttackProc != 0 || stats.PetDeflectProc != 0 || stats.PetWhiffProc != 0 || stats.PetAttackDmg != 0 { + t.Errorf("petless character got pet mods: %+v", stats) + } +} + +func TestPetShouldArrive2_Gating(t *testing.T) { + pet1 := PetState{Type: "dog", Arrived: true} + tier4 := HouseState{Tier: 4} + tier3 := HouseState{Tier: 3} + + if petShouldArrive2(pet1, PetState{}, tier3) { + t.Error("second pet must not arrive below Tier 4") + } + if petShouldArrive2(PetState{}, PetState{}, tier4) { + t.Error("second pet needs an established first pet") + } + if petShouldArrive2(pet1, PetState{Arrived: true}, tier4) { + t.Error("already-arrived second pet must not re-roll") + } + if petShouldArrive2(pet1, PetState{ChasedAway: true}, tier4) { + t.Error("chased-away second pet does not re-arrive") + } +} + +// TestPet2StoreRoundTrip exercises the pet2_* columns and the AdvChar mirror. +func TestPet2StoreRoundTrip(t *testing.T) { + townTestDB(t) + user := id.UserID("@pet2:test.invalid") + + char := &AdventureCharacter{UserID: user} + char.PetType, char.PetName, char.PetArrived, char.PetLevel = "dog", "Rex", true, 3 + char.Pet2Type, char.Pet2Name, char.Pet2Arrived, char.Pet2Level, char.Pet2ArmorTier = "cat", "Luna", true, 5, 2 + if err := saveAdvCharacter(char); err != nil { + t.Fatal(err) + } + + got, err := loadAdvCharacter(user) + if err != nil || got == nil { + t.Fatalf("reload: %v", err) + } + if !got.HasPet2() || got.Pet2Name != "Luna" || got.Pet2Level != 5 || got.Pet2ArmorTier != 2 || got.Pet2Type != "cat" { + t.Fatalf("pet2 did not round-trip: %+v", got) + } + if !got.HasPet() || got.PetName != "Rex" { + t.Fatalf("pet1 clobbered by pet2 write: %+v", got) + } + + // Direct slot loader agrees. + p2, _ := loadPet2State(user) + if p2.Name != "Luna" || p2.Level != 5 { + t.Fatalf("loadPet2State mismatch: %+v", p2) + } +} + +// TestPetShowcase_IncludesBothSlots verifies the town showcase surfaces second +// pets and hides a chased-away one. +func TestPetShowcase_IncludesBothSlots(t *testing.T) { + townTestDB(t) + + a := &AdventureCharacter{UserID: id.UserID("@a:test.invalid")} + a.PetType, a.PetName, a.PetArrived, a.PetLevel = "dog", "Rex", true, 4 + a.Pet2Type, a.Pet2Name, a.Pet2Arrived, a.Pet2Level = "cat", "Luna", true, 9 + if err := saveAdvCharacter(a); err != nil { + t.Fatal(err) + } + b := &AdventureCharacter{UserID: id.UserID("@b:test.invalid")} + b.Pet2Type, b.Pet2Name, b.Pet2Arrived, b.Pet2Level, b.Pet2ChasedAway = "dog", "Ghost", true, 7, true + if err := saveAdvCharacter(b); err != nil { + t.Fatal(err) + } + + pets, err := loadPetShowcase(townListCap) + if err != nil { + t.Fatal(err) + } + if len(pets) != 2 { + t.Fatalf("want 2 active pets (Rex + Luna), got %d: %+v", len(pets), pets) + } + // Sorted by level desc → Luna (9) before Rex (4). + if !strings.Contains(pets[0].Name, "Luna") || !strings.Contains(pets[1].Name, "Rex") { + t.Fatalf("showcase not level-ordered across slots: %+v", pets) + } + for _, p := range pets { + if strings.Contains(p.Name, "Ghost") { + t.Errorf("chased-away second pet should be hidden: %+v", p) + } + } +} diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index e1bf26b..a10a91a 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -31,28 +31,37 @@ func petXPToNextLevel(level int) int { } } -// petGrantXP adds XP to the pet and handles level-ups. Returns true if leveled up. +// petGrantXP adds a per-action XP grant to the pet and handles level-ups. +// Returns true if leveled up. Shares the level-up loop with the babysit trickle +// via advancePetLevelsFromXP. func petGrantXP(pet *PetState) bool { - if !pet.HasPet() || pet.Level >= 10 { + if !pet.HasPet() { return false } + return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100)) +} - pet.XP += int(petXPPerAction * 100) // store as centixp for precision +// advancePetLevelsFromXP adds centi-XP to a pet and applies any level-ups, up +// to the level-10 cap, stamping the level-10 date on first reaching it. Shared +// by both pet slots (the babysit trickle). Returns true if the pet leveled. +func advancePetLevelsFromXP(xp, level *int, level10Date *string, addCentiXP int) bool { + if *level >= 10 { + return false + } + *xp += addCentiXP leveled := false - for pet.Level < 10 { - needed := petXPToNextLevel(pet.Level) * 100 - if pet.XP < needed { + for *level < 10 { + needed := petXPToNextLevel(*level) * 100 + if *xp < needed { break } - pet.XP -= needed - pet.Level++ + *xp -= needed + *level++ leveled = true } - - if pet.Level >= 10 && pet.Level10Date == "" { - pet.Level10Date = time.Now().UTC().Format("2006-01-02") + if *level >= 10 && *level10Date == "" { + *level10Date = time.Now().UTC().Format("2006-01-02") } - return leveled } @@ -206,6 +215,23 @@ func petShouldArrive(pet PetState, house HouseState) bool { return rand.Float64() < 0.30 } +// petShouldArrive2 gates the SECOND companion (N4/E1). It needs a Tier-4 Estate +// and an established first pet — the second animal wanders in to join the first, +// not an empty house. A chased-away second pet does not re-arrive (Misty's +// reactivation is a pet-1 mechanic). +func petShouldArrive2(pet1, pet2 PetState, house HouseState) bool { + if pet2.Arrived || pet2.ChasedAway { + return false + } + if house.Tier < 4 { + return false + } + if !pet1.HasPet() { + return false + } + return rand.Float64() < 0.30 +} + // maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player // surfaces from an expedition (voluntary extract, abandon, or a survived // forced extraction) or revives after death. The arrival roll lives on the @@ -221,32 +247,52 @@ func (p *AdventurePlugin) maybeRollPetArrivalOnEmerge(userID id.UserID) { pet, _ := loadPetState(userID) house, _ := loadHouseState(userID) if petShouldArrive(pet, house) { - p.petArrivalDM(userID) + p.petArrivalDM(userID, 1) + return + } + // A Tier-4 Estate can draw a second companion once the first is settled. + pet2, _ := loadPet2State(userID) + if petShouldArrive2(pet, pet2, house) { + p.petArrivalDM(userID, 2) } } -// petArrivalDM sends the initial "there's an animal in your house" DM. -func (p *AdventurePlugin) petArrivalDM(userID id.UserID) { +// petArrivalDM sends the initial "there's an animal in your house" DM for the +// given slot (1 or 2). +func (p *AdventurePlugin) petArrivalDM(userID id.UserID, slot int) { // Don't overwrite an existing pending interaction if _, exists := p.pending.Load(string(userID)); exists { return } - text := "There's an animal in your house. It looks like a...\n\n" + + intro := "There's an animal in your house. It looks like a..." + if slot == 2 { + intro = "There's *another* animal in your house. Your first pet seems unbothered. It looks like a..." + } + text := intro + "\n\n" + "🚪 Chase it away\n" + "🍖 Feed it\n\n" + "Reply with `chase` or `feed`." p.pending.Store(string(userID), &advPendingInteraction{ Type: "pet_arrival", - Data: &advPendingPetArrival{}, + Data: &advPendingPetArrival{Slot: slot}, ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) _ = p.SendDM(userID, text) } +// petSlotFromData reads a Slot off any pending-pet payload, defaulting to slot 1 +// for the zero value so older/first-pet interactions are unaffected. +func petSlotFromData(slot int) int { + if slot == 2 { + return 2 + } + return 1 +} + // resolvePetArrival handles the chase/feed response. -func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { +func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext, interaction *advPendingInteraction) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() @@ -256,13 +302,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Failed to load your character.") } + slot := petSlotFromData(interaction.Data.(*advPendingPetArrival).Slot) reply := strings.ToLower(strings.TrimSpace(ctx.Body)) if reply == "chase" || reply == "🚪" { - char.PetChasedAway = true - char.PetReactivated = false + if slot == 2 { + char.Pet2ChasedAway = true + char.Pet2Reactivated = false + } else { + char.PetChasedAway = true + char.PetReactivated = false + } _ = saveAdvCharacter(char) - _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)) return p.SendDM(ctx.Sender, "You chased it away. It disappeared around the corner and didn't come back.\n\nThe house is quiet again.") } @@ -276,7 +327,7 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_type", - Data: &advPendingPetType{}, + Data: &advPendingPetType{Slot: slot}, ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) return p.SendDM(ctx.Sender, text) @@ -285,18 +336,19 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error { // Invalid response — re-prompt p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_arrival", - Data: &advPendingPetArrival{}, + Data: &advPendingPetArrival{Slot: slot}, ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.") } // resolvePetType handles dog/cat selection. -func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error { +func (p *AdventurePlugin) resolvePetType(ctx MessageContext, interaction *advPendingInteraction) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() + slot := petSlotFromData(interaction.Data.(*advPendingPetType).Slot) reply := strings.ToLower(strings.TrimSpace(ctx.Body)) petType := "" @@ -309,7 +361,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error { if petType == "" { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_type", - Data: &advPendingPetType{}, + Data: &advPendingPetType{Slot: slot}, ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.") @@ -317,7 +369,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "pet_name", - Data: &advPendingPetName{PetType: petType}, + Data: &advPendingPetName{PetType: petType, Slot: slot}, ExpiresAt: time.Now().Add(advDMResponseWindowLow), }) @@ -355,24 +407,36 @@ func (p *AdventurePlugin) resolvePetName(ctx MessageContext, interaction *advPen return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.") } - char.PetType = data.PetType - char.PetName = name - char.PetArrived = true - char.PetChasedAway = false - char.PetLevel = 1 - char.PetXP = 0 + if petSlotFromData(data.Slot) == 2 { + char.Pet2Type = data.PetType + char.Pet2Name = name + char.Pet2Arrived = true + char.Pet2ChasedAway = false + char.Pet2Level = 1 + char.Pet2XP = 0 + } else { + char.PetType = data.PetType + char.PetName = name + char.PetArrived = true + char.PetChasedAway = false + char.PetLevel = 1 + char.PetXP = 0 + } if err := saveAdvCharacter(char); err != nil { return p.SendDM(ctx.Sender, "Failed to save.") } - _ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char)) emoji := "🐶" if data.PetType == "cat" { emoji = "🐱" } - return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s will join you in combat. %s will not explain their decisions.", - emoji, name, titleCase(data.PetType), name, name)) + tail := fmt.Sprintf("%s will join you in combat. %s will not explain their decisions.", name, name) + if petSlotFromData(data.Slot) == 2 { + tail = fmt.Sprintf("%s fights alongside your first companion — the two share the spotlight, so together they're about as much help in a scrap as one seasoned pet. %s will not explain their decisions.", name, name) + } + return p.SendDM(ctx.Sender, fmt.Sprintf("%s **%s** has moved in.\n\nBreed: Massive %s.\nLevel: 1\n\n%s", + emoji, name, titleCase(data.PetType), tail)) } // ── Morning Pet Events ───────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_town.go b/internal/plugin/adventure_town.go index a2c26fd..55d4e6d 100644 --- a/internal/plugin/adventure_town.go +++ b/internal/plugin/adventure_town.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "sort" "strings" "time" @@ -105,16 +106,16 @@ type petShowcaseEntry struct { ArmorName string } -// loadPetShowcase returns every active pet (arrived, not chased away). The -// arrived/chased flags live in pet_flags_json, so they're decoded in Go rather -// than in SQL. +// loadPetShowcase returns every active pet — both slots — across all players, +// highest level first. The arrived/chased flags live in the *_flags_json +// columns, so they're decoded in Go rather than in SQL. func loadPetShowcase(limit int) ([]petShowcaseEntry, error) { rows, err := db.Get().Query( `SELECT COALESCE(NULLIF(display_name, ''), user_id), - pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json + pet_name, pet_type, pet_level, pet_armor_tier, pet_flags_json, + pet2_name, pet2_type, pet2_level, pet2_armor_tier, pet2_flags_json FROM player_meta - WHERE pet_type != '' - ORDER BY pet_level DESC, pet_name`) + WHERE pet_type != '' OR pet2_type != ''`) if err != nil { return nil, err } @@ -123,30 +124,58 @@ func loadPetShowcase(limit int) ([]petShowcaseEntry, error) { var out []petShowcaseEntry for rows.Next() { var ( - owner, petName, petType, flagsRaw string - level, armorTier int + owner string + petName, petType, flagsRaw string + level, armorTier int + pet2Name, pet2Type, flags2Raw string + pet2Level, pet2Armor int ) - if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw); err != nil { + if err := rows.Scan(&owner, &petName, &petType, &level, &armorTier, &flagsRaw, + &pet2Name, &pet2Type, &pet2Level, &pet2Armor, &flags2Raw); err != nil { return nil, err } - var flags petFlagsJSON - if flagsRaw != "" { - _ = json.Unmarshal([]byte(flagsRaw), &flags) + if e, ok := showcaseEntryFor(owner, petName, petType, level, armorTier, flagsRaw); ok { + out = append(out, e) } - if !flags.Arrived || flags.ChasedAway { - continue - } - out = append(out, petShowcaseEntry{ - Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")", - Type: petType, - Level: level, - ArmorName: petArmorName(petType, armorTier), - }) - if len(out) >= limit { - break + if e, ok := showcaseEntryFor(owner, pet2Name, pet2Type, pet2Level, pet2Armor, flags2Raw); ok { + out = append(out, e) } } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + + sort.SliceStable(out, func(i, j int) bool { + if out[i].Level != out[j].Level { + return out[i].Level > out[j].Level + } + return out[i].Name < out[j].Name + }) + if len(out) > limit { + out = out[:limit] + } + return out, nil +} + +// showcaseEntryFor builds a showcase entry for one pet slot, returning ok=false +// if the slot is empty or its pet was chased away. +func showcaseEntryFor(owner, petName, petType string, level, armorTier int, flagsRaw string) (petShowcaseEntry, bool) { + if petType == "" { + return petShowcaseEntry{}, false + } + var flags petFlagsJSON + if flagsRaw != "" { + _ = json.Unmarshal([]byte(flagsRaw), &flags) + } + if !flags.Arrived || flags.ChasedAway { + return petShowcaseEntry{}, false + } + return petShowcaseEntry{ + Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")", + Type: petType, + Level: level, + ArmorName: petArmorName(petType, armorTier), + }, true } // petArmorName maps a pet's armor tier to its barding name, or "" for none. diff --git a/internal/plugin/combat_stats.go b/internal/plugin/combat_stats.go index 7a141fb..d47cc5c 100644 --- a/internal/plugin/combat_stats.go +++ b/internal/plugin/combat_stats.go @@ -157,12 +157,33 @@ func DerivePlayerStats( } } - // Pet modifiers + // Pet modifiers. Two pets each contribute at half weight — the combat mods + // are an average over the active pets, so a pair reads as roughly one full + // pet (flavor-forward, not a stat spike). A lone pet averages over one + // element, so x/1==x and 0.0+x==x reproduce the former values exactly, + // keeping the single-pet combat golden byte-identical. The engines roll one + // attack/deflect/whiff off these mods per round regardless of pet count, so + // the RNG draw order is unchanged too. + pets := make([]struct{ level, armor int }, 0, 2) if char.HasPet() { - mods.PetAttackProc = petAttackChance(char.PetLevel) - mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine - mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier) - mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005 + pets = append(pets, struct{ level, armor int }{char.PetLevel, char.PetArmorTier}) + } + if char.HasPet2() { + pets = append(pets, struct{ level, armor int }{char.Pet2Level, char.Pet2ArmorTier}) + } + if n := len(pets); n > 0 { + var atk, defl, whiff float64 + levelSum := 0 + for _, pt := range pets { + atk += petAttackChance(pt.level) + defl += petDeflectChance(pt.level, pt.armor) + whiff += 0.01 + float64(pt.level)*0.005 + levelSum += pt.level + } + mods.PetAttackProc = atk / float64(n) + mods.PetDeflectProc = defl / float64(n) + mods.PetWhiffProc = whiff / float64(n) + mods.PetAttackDmg = 3 + (levelSum*2+n)/(2*n) // 3 + rounded average level } if char.PetMorningDefense { mods.DamageReduct *= 0.95 // 5% less damage diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 032e28c..52fe135 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -212,6 +212,9 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, if adv.PetName != "" { b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel)) } + if adv.Pet2Name != "" { + b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.Pet2Name, adv.Pet2Type, adv.Pet2Level)) + } if house.Tier > 0 { b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier)) } diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index 35ca210..4c57c8e 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -310,6 +310,82 @@ func petStateFromAdvChar(c *AdventureCharacter) PetState { } } +// pet2StateFromAdvChar projects the second pet's fields off an +// AdventureCharacter. SupplyShopUnlocked / MorningDefense are always zero for +// slot 2 — those mechanics stay pet-1-only (see AdventureCharacter.Pet2*). +func pet2StateFromAdvChar(c *AdventureCharacter) PetState { + return PetState{ + Type: c.Pet2Type, + Name: c.Pet2Name, + XP: c.Pet2XP, + Level: c.Pet2Level, + ArmorTier: c.Pet2ArmorTier, + Level10Date: c.Pet2Level10Date, + Arrived: c.Pet2Arrived, + ChasedAway: c.Pet2ChasedAway, + Reactivated: c.Pet2Reactivated, + } +} + +// loadPet2State returns the player's second pet from the pet2_* columns. Empty +// PetState{} if the user has no row or no second pet. +func loadPet2State(userID id.UserID) (PetState, error) { + var ( + s PetState + flagsRaw string + ) + err := db.Get().QueryRow( + `SELECT pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier, + pet2_flags_json, pet2_level_10_date + FROM player_meta WHERE user_id = ?`, + string(userID), + ).Scan(&s.Type, &s.Name, &s.XP, &s.Level, &s.ArmorTier, + &flagsRaw, &s.Level10Date) + if err == sql.ErrNoRows { + return PetState{}, nil + } + if err != nil { + return PetState{}, err + } + var f petFlagsJSON + if flagsRaw != "" { + _ = json.Unmarshal([]byte(flagsRaw), &f) + } + s.Arrived = f.Arrived + s.ChasedAway = f.ChasedAway + s.Reactivated = f.Reactivated + return s, nil +} + +// upsertPlayerMetaPet2State writes the second pet's column set. +func upsertPlayerMetaPet2State(userID id.UserID, s PetState) error { + flags, err := json.Marshal(petFlagsJSON{ + Arrived: s.Arrived, + ChasedAway: s.ChasedAway, + Reactivated: s.Reactivated, + }) + if err != nil { + return err + } + _, err = db.Get().Exec( + `INSERT INTO player_meta ( + user_id, pet2_type, pet2_name, pet2_xp, pet2_level, pet2_armor_tier, + pet2_flags_json, pet2_level_10_date + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + pet2_type = excluded.pet2_type, + pet2_name = excluded.pet2_name, + pet2_xp = excluded.pet2_xp, + pet2_level = excluded.pet2_level, + pet2_armor_tier = excluded.pet2_armor_tier, + pet2_flags_json = excluded.pet2_flags_json, + pet2_level_10_date = excluded.pet2_level_10_date`, + string(userID), s.Type, s.Name, s.XP, s.Level, s.ArmorTier, + string(flags), s.Level10Date, + ) + return err +} + // HouseState is the in-memory mirror of player_meta's house_* columns. Phase // L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md // §6.5). All six fields mutate together at known sites (purchase, payoff, @@ -1194,6 +1270,17 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) { c.PetReactivated = s.Reactivated c.PetMorningDefense = s.MorningDefense } + if s, err := loadPet2State(uid); err == nil { + c.Pet2Type = s.Type + c.Pet2Name = s.Name + c.Pet2XP = s.XP + c.Pet2Level = s.Level + c.Pet2ArmorTier = s.ArmorTier + c.Pet2Level10Date = s.Level10Date + c.Pet2Arrived = s.Arrived + c.Pet2ChasedAway = s.ChasedAway + c.Pet2Reactivated = s.Reactivated + } if s, err := loadHouseState(uid); err == nil { c.HouseTier = s.Tier c.HouseLoanBalance = s.LoanBalance @@ -1248,6 +1335,9 @@ func upsertAllPlayerMetaFromAdvChar(c *AdventureCharacter) error { if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil { return err } + if err := upsertPlayerMetaPet2State(uid, pet2StateFromAdvChar(c)); err != nil { + return err + } if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil { return err }