mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 02:41:09 +00:00
pets: earn XP from combat wins again
petGrantXP has been dead code since R1 deleted the legacy daily activity loop it used to ride. Nothing replaced the call, so for the whole life of Adventure 2.0 the only pet XP in the game came from a paid babysitter. Prod bears it out: the one player who never subscribed has a pet sitting at level 1 with 0 XP after months of play. That is not cosmetic. DerivePlayerStats scales PetAttackProc, PetDeflectProc and PetAttackDmg off pet level, so a frozen pet is a permanently dead combat slot that the player has no way to revive. Wire it into postCombatBookkeeping — the one seam all four combat 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. Both slots earn on the same win, matching the babysit trickle: combat only 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, because this runs on a path that does not hold the per-user lock and a full-row write could clobber a concurrent save. Verified against the sim: a level-3 pet finishes one L10 expedition at level 4 with carryover, where before it finished exactly where it started.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user