diff --git a/internal/plugin/adventure_pet_combat_xp_test.go b/internal/plugin/adventure_pet_combat_xp_test.go new file mode 100644 index 0000000..e0d1201 --- /dev/null +++ b/internal/plugin/adventure_pet_combat_xp_test.go @@ -0,0 +1,118 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +func newPetXPTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +// TestGrantPetCombatXPPersists is the regression guard for the bug this fixes: +// petGrantXP existed but nothing called it, so an un-babysat pet sat at its +// adoption level forever. A win must move XP on disk. +func TestGrantPetCombatXPPersists(t *testing.T) { + newPetXPTestDB(t) + uid := id.UserID("@petxp:test") + + pet := PetState{Type: "dog", Name: "Rex", Arrived: true, Level: 1, XP: 0} + if err := upsertPlayerMetaPetState(uid, pet); err != nil { + t.Fatal(err) + } + + if leveled := grantPetCombatXP(uid); len(leveled) != 0 { + t.Errorf("one win should not level a fresh pet, got %v", leveled) + } + + got, err := loadPetState(uid) + if err != nil { + t.Fatal(err) + } + if got.XP != int(petXPPerAction*100) { + t.Errorf("XP = %d, want %d", got.XP, int(petXPPerAction*100)) + } + if got.Level != 1 { + t.Errorf("Level = %d, want 1", got.Level) + } +} + +// TestGrantPetCombatXPLevelsBothSlots checks the second pet earns off the same +// win, matching the babysit trickle — combat only reads the two pets' averaged +// procs, so leveling both is not a power spike. +func TestGrantPetCombatXPLevelsBothSlots(t *testing.T) { + newPetXPTestDB(t) + uid := id.UserID("@petxp2:test") + + // Both one grant short of level 2 (needs 10 XP = 1000 centi-XP). + short := 1000 - int(petXPPerAction*100) + if err := upsertPlayerMetaPetState(uid, + PetState{Type: "dog", Name: "Rex", Arrived: true, Level: 1, XP: short}); err != nil { + t.Fatal(err) + } + if err := upsertPlayerMetaPet2State(uid, + PetState{Type: "cat", Name: "Whiskers", Arrived: true, Level: 1, XP: short}); err != nil { + t.Fatal(err) + } + + leveled := grantPetCombatXP(uid) + if len(leveled) != 2 { + t.Fatalf("expected both pets to level, got %v", leveled) + } + + p1, _ := loadPetState(uid) + p2, _ := loadPet2State(uid) + if p1.Level != 2 || p2.Level != 2 { + t.Errorf("levels = %d/%d, want 2/2", p1.Level, p2.Level) + } +} + +// TestGrantPetCombatXPIgnoresChasedAway — a pet that isn't with you doesn't +// fight, so it doesn't earn. +func TestGrantPetCombatXPIgnoresChasedAway(t *testing.T) { + newPetXPTestDB(t) + uid := id.UserID("@petxp3:test") + + if err := upsertPlayerMetaPetState(uid, PetState{ + Type: "dog", Name: "Rex", Arrived: true, ChasedAway: true, Level: 3, XP: 100, + }); err != nil { + t.Fatal(err) + } + + grantPetCombatXP(uid) + + got, _ := loadPetState(uid) + if got.XP != 100 { + t.Errorf("chased-away pet gained XP: %d, want 100", got.XP) + } +} + +// TestGrantPetCombatXPCapsAtTen — a maxed pet stops earning rather than +// accumulating dead XP. +func TestGrantPetCombatXPCapsAtTen(t *testing.T) { + newPetXPTestDB(t) + uid := id.UserID("@petxp4:test") + + if err := upsertPlayerMetaPetState(uid, PetState{ + Type: "dog", Name: "Rex", Arrived: true, Level: 10, XP: 0, + }); err != nil { + t.Fatal(err) + } + + if leveled := grantPetCombatXP(uid); len(leveled) != 0 { + t.Errorf("L10 pet should not level, got %v", leveled) + } + got, _ := loadPetState(uid) + if got.XP != 0 || got.Level != 10 { + t.Errorf("L10 pet moved: level %d xp %d", got.Level, got.XP) + } +} diff --git a/internal/plugin/adventure_pets.go b/internal/plugin/adventure_pets.go index a10a91a..f661e2f 100644 --- a/internal/plugin/adventure_pets.go +++ b/internal/plugin/adventure_pets.go @@ -41,6 +41,51 @@ func petGrantXP(pet *PetState) bool { return advancePetLevelsFromXP(&pet.XP, &pet.Level, &pet.Level10Date, int(petXPPerAction*100)) } +// grantPetCombatXP pays both pet slots their per-action XP for a fight the +// player won, and returns the names of any pet that leveled so the caller can +// narrate it. +// +// This is the pet's only *earned* XP source. It used to ride the legacy daily +// activity loop, which R1 deleted — and for the whole life of Adventure 2.0 +// nothing replaced it, leaving petGrantXP orphaned and every un-babysat pet +// frozen at the level it was adopted with. Pet level is not cosmetic +// (DerivePlayerStats scales PetAttackProc / PetDeflectProc / PetAttackDmg off +// it), so a frozen pet is a permanently dead combat slot. +// +// Both slots earn on the same win, matching the babysit trickle: combat only +// ever reads the two pets' *averaged* procs, so leveling both is not a spike. +// +// Writes go through the narrow per-slot pet upserts rather than +// saveAdvCharacter: this runs on the combat close-out path, which does not +// hold the per-user lock, and a full-row write from here could clobber a +// concurrent character save. +func grantPetCombatXP(userID id.UserID) []string { + var leveled []string + slots := []struct { + n int + load func(id.UserID) (PetState, error) + upsert func(id.UserID, PetState) error + }{ + {1, loadPetState, upsertPlayerMetaPetState}, + {2, loadPet2State, upsertPlayerMetaPet2State}, + } + for _, s := range slots { + pet, err := s.load(userID) + if err != nil || !pet.HasPet() { + continue + } + didLevel := petGrantXP(&pet) + if uerr := s.upsert(userID, pet); uerr != nil { + slog.Error("adventure: pet xp persist", "user", userID, "slot", s.n, "err", uerr) + continue + } + if didLevel { + leveled = append(leveled, fmt.Sprintf("%s reached level %d", pet.Name, pet.Level)) + } + } + return leveled +} + // 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. diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index b5e3c62..e5be547 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -32,6 +32,20 @@ func (p *AdventurePlugin) postCombatBookkeeping( if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil { slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err) } + // The pet fought too. A win is its only earned XP — see grantPetCombatXP + // for why this seam and not the room-clear one: it is the single place all + // four close-outs already meet, so a pet cannot level differently depending + // on whether the fight auto-resolved or was played a round at a time. + if result.PlayerWon { + if leveled := grantPetCombatXP(userID); len(leveled) > 0 { + for _, line := range leveled { + slog.Info("adventure: pet leveled", "user", userID, "pet", line) + } + if err := p.SendDM(userID, "🐾 "+strings.Join(leveled, "\n🐾 ")); err != nil { + slog.Warn("adventure: pet level-up DM", "user", userID, "err", err) + } + } + } } // grantCombatAchievements checks combat results for achievement-worthy moments.