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:
prosolis
2026-07-10 15:03:57 -07:00
parent 27d6bfd361
commit 57a0ea90f2
12 changed files with 560 additions and 117 deletions

View File

@@ -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 {

View File

@@ -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)
} }

View File

@@ -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 {
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.HasPet2() {
leveled = advancePetLevelsFromXP(&char.Pet2XP, &char.Pet2Level, &char.Pet2Level10Date, petXPPerBabysitDay*100) || leveled
} }
outcome := "pet_care" outcome := "pet_care"
if leveled { if leveled {

View File

@@ -92,6 +92,18 @@ type AdventureCharacter struct {
PetSupplyShopUnlocked bool PetSupplyShopUnlocked bool
PetLevel10Date string PetLevel10Date string
PetMorningDefense bool PetMorningDefense bool
// 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 AutoBabysit bool
AutoBabysitFocus string // mining|fishing|foraging — preferred skill for auto-babysit; "" defaults to weakest 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 TreasuresLocked bool // when true, treasure drops at cap are refused instead of auto-swapping
@@ -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

View File

@@ -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")
if char.HasPet() {
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName)) sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char)) 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.")
} }
if slot == 2 {
char.Pet2ArmorTier = tier
} else {
char.PetArmorTier = tier 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))
} }

View File

@@ -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")

View 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)
}
}
}

View File

@@ -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 == "🚪" {
if slot == 2 {
char.Pet2ChasedAway = true
char.Pet2Reactivated = false
} else {
char.PetChasedAway = true char.PetChasedAway = true
char.PetReactivated = false 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.")
} }
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.PetType = data.PetType
char.PetName = name char.PetName = name
char.PetArrived = true char.PetArrived = true
char.PetChasedAway = false char.PetChasedAway = false
char.PetLevel = 1 char.PetLevel = 1
char.PetXP = 0 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 ─────────────────────────────────────────────────────

View File

@@ -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
petName, petType, flagsRaw string
level, armorTier int 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
} }
if e, ok := showcaseEntryFor(owner, petName, petType, level, armorTier, flagsRaw); ok {
out = append(out, e)
}
if e, ok := showcaseEntryFor(owner, pet2Name, pet2Type, pet2Level, pet2Armor, flags2Raw); ok {
out = append(out, e)
}
}
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 var flags petFlagsJSON
if flagsRaw != "" { if flagsRaw != "" {
_ = json.Unmarshal([]byte(flagsRaw), &flags) _ = json.Unmarshal([]byte(flagsRaw), &flags)
} }
if !flags.Arrived || flags.ChasedAway { if !flags.Arrived || flags.ChasedAway {
continue return petShowcaseEntry{}, false
} }
out = append(out, petShowcaseEntry{ return petShowcaseEntry{
Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")", Name: firstNonEmpty(petName, "an unnamed companion") + " (" + owner + ")",
Type: petType, Type: petType,
Level: level, Level: level,
ArmorName: petArmorName(petType, armorTier), ArmorName: petArmorName(petType, armorTier),
}) }, true
if len(out) >= limit {
break
}
}
return out, rows.Err()
} }
// 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.

View File

@@ -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

View File

@@ -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))
} }

View File

@@ -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
} }