mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 08:52:42 +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
|
// 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.
|
// = "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`,
|
`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 {
|
for _, stmt := range columnMigrations {
|
||||||
if _, err := d.Exec(stmt); err != nil {
|
if _, err := d.Exec(stmt); err != nil {
|
||||||
|
|||||||
@@ -915,9 +915,9 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
case "npc_encounter":
|
case "npc_encounter":
|
||||||
return p.resolveNPCEncounter(ctx, interaction)
|
return p.resolveNPCEncounter(ctx, interaction)
|
||||||
case "pet_arrival":
|
case "pet_arrival":
|
||||||
return p.resolvePetArrival(ctx)
|
return p.resolvePetArrival(ctx, interaction)
|
||||||
case "pet_type":
|
case "pet_type":
|
||||||
return p.resolvePetType(ctx)
|
return p.resolvePetType(ctx, interaction)
|
||||||
case "pet_name":
|
case "pet_name":
|
||||||
return p.resolvePetName(ctx, interaction)
|
return p.resolvePetName(ctx, interaction)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,22 +36,15 @@ func (p *AdventurePlugin) runBabysitDailyTrickle(char *AdventureCharacter) {
|
|||||||
if !char.BabysitActive {
|
if !char.BabysitActive {
|
||||||
return
|
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
|
leveled := false
|
||||||
if char.HasPet() && char.PetLevel < 10 {
|
if char.HasPet() {
|
||||||
// Bypass petGrantXP's per-action constant — we want a flat trickle.
|
leveled = advancePetLevelsFromXP(&char.PetXP, &char.PetLevel, &char.PetLevel10Date, petXPPerBabysitDay*100) || leveled
|
||||||
char.PetXP += petXPPerBabysitDay * 100
|
}
|
||||||
for char.PetLevel < 10 {
|
if char.HasPet2() {
|
||||||
needed := petXPToNextLevel(char.PetLevel) * 100
|
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
outcome := "pet_care"
|
outcome := "pet_care"
|
||||||
if leveled {
|
if leveled {
|
||||||
|
|||||||
@@ -92,13 +92,25 @@ type AdventureCharacter struct {
|
|||||||
PetSupplyShopUnlocked bool
|
PetSupplyShopUnlocked bool
|
||||||
PetLevel10Date string
|
PetLevel10Date string
|
||||||
PetMorningDefense bool
|
PetMorningDefense bool
|
||||||
AutoBabysit bool
|
// Second pet (N4/E1, Tier-4 Estate). A parallel slot, not a rewrite of the
|
||||||
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest
|
// pet-1 fields above — see HasPet2. Carries only identity/level/barding; the
|
||||||
TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
|
// morning-defense, ditch-recovery and supply-shop mechanics stay pet-1-only.
|
||||||
StreakDecayed bool
|
Pet2Type string
|
||||||
CraftsSucceeded int
|
Pet2Name string
|
||||||
DeathSource string // "adventure" | "arena" | "" (legacy/unknown — treated as adventure)
|
Pet2XP int
|
||||||
DeathLocation string // human-readable location of last death; cleared on revive is not required
|
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 {
|
type AdvEquipment struct {
|
||||||
@@ -270,6 +282,11 @@ func (c *AdventureCharacter) HasPet() bool {
|
|||||||
return c.PetType != "" && c.PetArrived && !c.PetChasedAway
|
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.
|
// HasHouse returns true if the player has purchased at least a base house.
|
||||||
func (c *AdventureCharacter) HasHouse() bool {
|
func (c *AdventureCharacter) HasHouse() bool {
|
||||||
return c.HouseTier > 0 || c.HouseLoanBalance > 0
|
return c.HouseTier > 0 || c.HouseLoanBalance > 0
|
||||||
|
|||||||
@@ -99,12 +99,16 @@ type advPendingHouseDownPayment struct {
|
|||||||
|
|
||||||
type advPendingHouseAutopay 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 {
|
type advPendingPetName struct {
|
||||||
PetType string
|
PetType string
|
||||||
|
Slot int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
|
||||||
@@ -221,11 +225,19 @@ func thomShopView(char *AdventureCharacter, balance float64) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pet supply shop (unlocks 1 week after pet level 10)
|
// Pet supply shop (unlocks 1 week after pet level 10). The unlock is a
|
||||||
if char.PetSupplyShopUnlocked && char.HasPet() {
|
// 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("\n---\n")
|
||||||
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
|
if char.HasPet() {
|
||||||
sb.WriteString(petArmorShopView(char))
|
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()
|
return sb.String()
|
||||||
@@ -269,8 +281,11 @@ func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
|
|||||||
case lower == "buy" || strings.HasPrefix(lower, "buy "):
|
case lower == "buy" || strings.HasPrefix(lower, "buy "):
|
||||||
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(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 "):
|
case strings.HasPrefix(lower, "petbuy "):
|
||||||
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
|
return p.petArmorBuyForSlot(ctx, strings.TrimSpace(args[7:]), 1)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
|
||||||
@@ -673,18 +688,18 @@ func petArmorDefs(petType string) []PetArmorDef {
|
|||||||
return petCatArmor
|
return petCatArmor
|
||||||
}
|
}
|
||||||
|
|
||||||
func petArmorShopView(char *AdventureCharacter) string {
|
func petArmorShopView(petType string, armorTier int, buyCmd string) string {
|
||||||
var sb strings.Builder
|
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")
|
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, def := range defs {
|
for _, def := range defs {
|
||||||
if def.Tier <= char.PetArmorTier {
|
if def.Tier <= armorTier {
|
||||||
if def.Tier == char.PetArmorTier {
|
if def.Tier == armorTier {
|
||||||
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -692,11 +707,14 @@ func petArmorShopView(char *AdventureCharacter) string {
|
|||||||
indicator := "⬆️"
|
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("%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()
|
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 := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
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.")
|
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !char.HasPet() {
|
// Slot accessors keep the pet-1 and pet-2 field sets isolated.
|
||||||
return p.SendDM(ctx.Sender, "You don't have a pet.")
|
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 {
|
if !char.PetSupplyShopUnlocked {
|
||||||
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
|
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
|
tier := 0
|
||||||
for _, c := range tierStr {
|
for _, c := range tierStr {
|
||||||
if c < '0' || c > '9' {
|
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')
|
tier = tier*10 + int(c-'0')
|
||||||
}
|
}
|
||||||
|
|
||||||
if tier != char.PetArmorTier+1 {
|
if tier != curTier+1 {
|
||||||
if tier <= char.PetArmorTier {
|
if tier <= curTier {
|
||||||
return p.SendDM(ctx.Sender, "That's not an upgrade.")
|
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
|
var def *PetArmorDef
|
||||||
for i := range defs {
|
for i := range defs {
|
||||||
if defs[i].Tier == tier {
|
if defs[i].Tier == tier {
|
||||||
@@ -742,18 +768,26 @@ func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureC
|
|||||||
|
|
||||||
balance := p.euro.GetBalance(ctx.Sender)
|
balance := p.euro.GetBalance(ctx.Sender)
|
||||||
if balance < float64(def.Price) {
|
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.")
|
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 {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
|
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, "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) {
|
func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
||||||
char := &AdventureCharacter{PetType: "dog", PetName: "Rex", PetArrived: true, PetArmorTier: 0}
|
text := petArmorShopView("dog", 0, "petbuy")
|
||||||
text := petArmorShopView(char)
|
|
||||||
|
|
||||||
if !strings.Contains(text, "⬆️") {
|
if !strings.Contains(text, "⬆️") {
|
||||||
t.Error("should show upgrade indicators")
|
t.Error("should show upgrade indicators")
|
||||||
@@ -297,8 +296,7 @@ func TestPetArmorShopView_HasUpgrades(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
func TestPetArmorShopView_MaxTier(t *testing.T) {
|
||||||
char := &AdventureCharacter{PetType: "cat", PetName: "Luna", PetArrived: true, PetArmorTier: 5}
|
text := petArmorShopView("cat", 5, "petbuy")
|
||||||
text := petArmorShopView(char)
|
|
||||||
|
|
||||||
if !strings.Contains(text, "Max pet armor") {
|
if !strings.Contains(text, "Max pet armor") {
|
||||||
t.Error("should show max tier message")
|
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 {
|
func petGrantXP(pet *PetState) bool {
|
||||||
if !pet.HasPet() || pet.Level >= 10 {
|
if !pet.HasPet() {
|
||||||
return false
|
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
|
leveled := false
|
||||||
for pet.Level < 10 {
|
for *level < 10 {
|
||||||
needed := petXPToNextLevel(pet.Level) * 100
|
needed := petXPToNextLevel(*level) * 100
|
||||||
if pet.XP < needed {
|
if *xp < needed {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
pet.XP -= needed
|
*xp -= needed
|
||||||
pet.Level++
|
*level++
|
||||||
leveled = true
|
leveled = true
|
||||||
}
|
}
|
||||||
|
if *level >= 10 && *level10Date == "" {
|
||||||
if pet.Level >= 10 && pet.Level10Date == "" {
|
*level10Date = time.Now().UTC().Format("2006-01-02")
|
||||||
pet.Level10Date = time.Now().UTC().Format("2006-01-02")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return leveled
|
return leveled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +215,23 @@ func petShouldArrive(pet PetState, house HouseState) bool {
|
|||||||
return rand.Float64() < 0.30
|
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
|
// maybeRollPetArrivalOnEmerge rolls the pet-arrival check when a player
|
||||||
// surfaces from an expedition (voluntary extract, abandon, or a survived
|
// surfaces from an expedition (voluntary extract, abandon, or a survived
|
||||||
// forced extraction) or revives after death. The arrival roll lives on the
|
// 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)
|
pet, _ := loadPetState(userID)
|
||||||
house, _ := loadHouseState(userID)
|
house, _ := loadHouseState(userID)
|
||||||
if petShouldArrive(pet, house) {
|
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.
|
// petArrivalDM sends the initial "there's an animal in your house" DM for the
|
||||||
func (p *AdventurePlugin) petArrivalDM(userID id.UserID) {
|
// given slot (1 or 2).
|
||||||
|
func (p *AdventurePlugin) petArrivalDM(userID id.UserID, slot int) {
|
||||||
// Don't overwrite an existing pending interaction
|
// Don't overwrite an existing pending interaction
|
||||||
if _, exists := p.pending.Load(string(userID)); exists {
|
if _, exists := p.pending.Load(string(userID)); exists {
|
||||||
return
|
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" +
|
"🚪 Chase it away\n" +
|
||||||
"🍖 Feed it\n\n" +
|
"🍖 Feed it\n\n" +
|
||||||
"Reply with `chase` or `feed`."
|
"Reply with `chase` or `feed`."
|
||||||
|
|
||||||
p.pending.Store(string(userID), &advPendingInteraction{
|
p.pending.Store(string(userID), &advPendingInteraction{
|
||||||
Type: "pet_arrival",
|
Type: "pet_arrival",
|
||||||
Data: &advPendingPetArrival{},
|
Data: &advPendingPetArrival{Slot: slot},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
_ = p.SendDM(userID, text)
|
_ = 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.
|
// 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 := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
@@ -256,13 +302,18 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
|||||||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slot := petSlotFromData(interaction.Data.(*advPendingPetArrival).Slot)
|
||||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||||
|
|
||||||
if reply == "chase" || reply == "🚪" {
|
if reply == "chase" || reply == "🚪" {
|
||||||
char.PetChasedAway = true
|
if slot == 2 {
|
||||||
char.PetReactivated = false
|
char.Pet2ChasedAway = true
|
||||||
|
char.Pet2Reactivated = false
|
||||||
|
} else {
|
||||||
|
char.PetChasedAway = true
|
||||||
|
char.PetReactivated = false
|
||||||
|
}
|
||||||
_ = saveAdvCharacter(char)
|
_ = 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.")
|
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{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_type",
|
Type: "pet_type",
|
||||||
Data: &advPendingPetType{},
|
Data: &advPendingPetType{Slot: slot},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
@@ -285,18 +336,19 @@ func (p *AdventurePlugin) resolvePetArrival(ctx MessageContext) error {
|
|||||||
// Invalid response — re-prompt
|
// Invalid response — re-prompt
|
||||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_arrival",
|
Type: "pet_arrival",
|
||||||
Data: &advPendingPetArrival{},
|
Data: &advPendingPetArrival{Slot: slot},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
return p.SendDM(ctx.Sender, "Reply with `chase` or `feed`.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePetType handles dog/cat selection.
|
// 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 := p.advUserLock(ctx.Sender)
|
||||||
userMu.Lock()
|
userMu.Lock()
|
||||||
defer userMu.Unlock()
|
defer userMu.Unlock()
|
||||||
|
|
||||||
|
slot := petSlotFromData(interaction.Data.(*advPendingPetType).Slot)
|
||||||
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
reply := strings.ToLower(strings.TrimSpace(ctx.Body))
|
||||||
|
|
||||||
petType := ""
|
petType := ""
|
||||||
@@ -309,7 +361,7 @@ func (p *AdventurePlugin) resolvePetType(ctx MessageContext) error {
|
|||||||
if petType == "" {
|
if petType == "" {
|
||||||
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_type",
|
Type: "pet_type",
|
||||||
Data: &advPendingPetType{},
|
Data: &advPendingPetType{Slot: slot},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
||||||
})
|
})
|
||||||
return p.SendDM(ctx.Sender, "Reply with `dog` or `cat`.")
|
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{
|
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
|
||||||
Type: "pet_name",
|
Type: "pet_name",
|
||||||
Data: &advPendingPetName{PetType: petType},
|
Data: &advPendingPetName{PetType: petType, Slot: slot},
|
||||||
ExpiresAt: time.Now().Add(advDMResponseWindowLow),
|
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.")
|
return p.SendDM(ctx.Sender, "Name can only contain letters, numbers, spaces, hyphens, and apostrophes. Try again.")
|
||||||
}
|
}
|
||||||
|
|
||||||
char.PetType = data.PetType
|
if petSlotFromData(data.Slot) == 2 {
|
||||||
char.PetName = name
|
char.Pet2Type = data.PetType
|
||||||
char.PetArrived = true
|
char.Pet2Name = name
|
||||||
char.PetChasedAway = false
|
char.Pet2Arrived = true
|
||||||
char.PetLevel = 1
|
char.Pet2ChasedAway = false
|
||||||
char.PetXP = 0
|
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 {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
return p.SendDM(ctx.Sender, "Failed to save.")
|
return p.SendDM(ctx.Sender, "Failed to save.")
|
||||||
}
|
}
|
||||||
_ = upsertPlayerMetaPetState(char.UserID, petStateFromAdvChar(char))
|
|
||||||
|
|
||||||
emoji := "🐶"
|
emoji := "🐶"
|
||||||
if data.PetType == "cat" {
|
if data.PetType == "cat" {
|
||||||
emoji = "🐱"
|
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.",
|
tail := fmt.Sprintf("%s will join you in combat. %s will not explain their decisions.", name, name)
|
||||||
emoji, name, titleCase(data.PetType), 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 ─────────────────────────────────────────────────────
|
// ── Morning Pet Events ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -105,16 +106,16 @@ type petShowcaseEntry struct {
|
|||||||
ArmorName string
|
ArmorName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadPetShowcase returns every active pet (arrived, not chased away). The
|
// loadPetShowcase returns every active pet — both slots — across all players,
|
||||||
// arrived/chased flags live in pet_flags_json, so they're decoded in Go rather
|
// highest level first. The arrived/chased flags live in the *_flags_json
|
||||||
// than in SQL.
|
// columns, so they're decoded in Go rather than in SQL.
|
||||||
func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
|
func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
|
||||||
rows, err := db.Get().Query(
|
rows, err := db.Get().Query(
|
||||||
`SELECT COALESCE(NULLIF(display_name, ''), user_id),
|
`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
|
FROM player_meta
|
||||||
WHERE pet_type != ''
|
WHERE pet_type != '' OR pet2_type != ''`)
|
||||||
ORDER BY pet_level DESC, pet_name`)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -123,30 +124,58 @@ func loadPetShowcase(limit int) ([]petShowcaseEntry, error) {
|
|||||||
var out []petShowcaseEntry
|
var out []petShowcaseEntry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var (
|
var (
|
||||||
owner, petName, petType, flagsRaw string
|
owner string
|
||||||
level, armorTier int
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
var flags petFlagsJSON
|
if e, ok := showcaseEntryFor(owner, petName, petType, level, armorTier, flagsRaw); ok {
|
||||||
if flagsRaw != "" {
|
out = append(out, e)
|
||||||
_ = json.Unmarshal([]byte(flagsRaw), &flags)
|
|
||||||
}
|
}
|
||||||
if !flags.Arrived || flags.ChasedAway {
|
if e, ok := showcaseEntryFor(owner, pet2Name, pet2Type, pet2Level, pet2Armor, flags2Raw); ok {
|
||||||
continue
|
out = append(out, e)
|
||||||
}
|
|
||||||
out = append(out, petShowcaseEntry{
|
|
||||||
Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")",
|
|
||||||
Type: petType,
|
|
||||||
Level: level,
|
|
||||||
ArmorName: petArmorName(petType, armorTier),
|
|
||||||
})
|
|
||||||
if len(out) >= limit {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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.
|
// 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() {
|
if char.HasPet() {
|
||||||
mods.PetAttackProc = petAttackChance(char.PetLevel)
|
pets = append(pets, struct{ level, armor int }{char.PetLevel, char.PetArmorTier})
|
||||||
mods.PetAttackDmg = 3 + char.PetLevel // per-attack variance added in engine
|
}
|
||||||
mods.PetDeflectProc = petDeflectChance(char.PetLevel, char.PetArmorTier)
|
if char.HasPet2() {
|
||||||
mods.PetWhiffProc = 0.01 + float64(char.PetLevel)*0.005
|
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 {
|
if char.PetMorningDefense {
|
||||||
mods.DamageReduct *= 0.95 // 5% less damage
|
mods.DamageReduct *= 0.95 // 5% less damage
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
|||||||
if adv.PetName != "" {
|
if adv.PetName != "" {
|
||||||
b.WriteString(fmt.Sprintf(" Pet: %s the %s (lv %d)\n", adv.PetName, adv.PetType, adv.PetLevel))
|
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 {
|
if house.Tier > 0 {
|
||||||
b.WriteString(fmt.Sprintf(" Housing: tier %d\n", house.Tier))
|
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
|
// HouseState is the in-memory mirror of player_meta's house_* columns. Phase
|
||||||
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
|
// L4e ports housing/mortgage off AdvCharacter (gogobee_legacy_migration.md
|
||||||
// §6.5). All six fields mutate together at known sites (purchase, payoff,
|
// §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.PetReactivated = s.Reactivated
|
||||||
c.PetMorningDefense = s.MorningDefense
|
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 {
|
if s, err := loadHouseState(uid); err == nil {
|
||||||
c.HouseTier = s.Tier
|
c.HouseTier = s.Tier
|
||||||
c.HouseLoanBalance = s.LoanBalance
|
c.HouseLoanBalance = s.LoanBalance
|
||||||
@@ -1248,6 +1335,9 @@ func upsertAllPlayerMetaFromAdvChar(c *AdventureCharacter) error {
|
|||||||
if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil {
|
if err := upsertPlayerMetaPetState(uid, petStateFromAdvChar(c)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := upsertPlayerMetaPet2State(uid, pet2StateFromAdvChar(c)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil {
|
if err := upsertPlayerMetaHouseState(uid, houseStateFromAdvChar(c)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user