mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
|
||||
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom %s <tier>` (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 <tier>`")
|
||||
return p.SendDM(ctx.Sender, fmt.Sprintf("Usage: `!thom %s <tier>`", 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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
180
internal/plugin/adventure_pet2_test.go
Normal file
180
internal/plugin/adventure_pet2_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user