mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
feat(achievement): add rank-up notifications (#165)
RE'd putDisplayed_achievement from ZZ client DLL via Ghidra: the packet sends opcode + 1 zero byte with no achievement ID, acting as a blanket "I saw everything" signal. Server changes: - Track per-character last-displayed levels in new displayed_levels column (migration 0008) - GetAchievement compares current vs displayed levels per entry - DisplayedAchievement snapshots current levels to clear notifications - Repo, service, mock, and 3 new service tests Protbot changes: - New --action achievement: fetches achievements, shows rank-up markers, sends DISPLAYED_ACHIEVEMENT, re-fetches to verify notifications clear - Packet builders for GET/ADD/DISPLAYED_ACHIEVEMENT
This commit is contained in:
@@ -114,7 +114,7 @@ func handleMsgMhfGetAchievement(s *Session, p mhfpacket.MHFPacket) {
|
||||
resp.WriteUint8(ach.Level)
|
||||
resp.WriteUint16(ach.NextValue)
|
||||
resp.WriteUint32(ach.Required)
|
||||
resp.WriteBool(false) // TODO: Notify on rank increase since last checked, see MhfDisplayedAchievement
|
||||
resp.WriteBool(summary.Notify[id])
|
||||
resp.WriteUint8(ach.Trophy)
|
||||
/* Trophy bitfield
|
||||
0000 0000
|
||||
@@ -152,7 +152,9 @@ func handleMsgMhfAddAchievement(s *Session, p mhfpacket.MHFPacket) {
|
||||
func handleMsgMhfPaymentAchievement(s *Session, p mhfpacket.MHFPacket) {}
|
||||
|
||||
func handleMsgMhfDisplayedAchievement(s *Session, p mhfpacket.MHFPacket) {
|
||||
// This is how you would figure out if the rank-up notification needs to occur
|
||||
if err := s.server.achievementService.MarkDisplayed(s.charID); err != nil {
|
||||
s.logger.Warn("Failed to mark achievements as displayed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func handleMsgMhfGetCaAchievementHist(s *Session, p mhfpacket.MHFPacket) {}
|
||||
|
||||
@@ -42,3 +42,18 @@ func (r *AchievementRepository) IncrementScore(charID uint32, achievementID uint
|
||||
_, err := r.db.Exec(fmt.Sprintf("UPDATE achievements SET ach%d=ach%d+1 WHERE id=$1", achievementID, achievementID), charID)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDisplayedLevels returns the last-displayed achievement levels for a character.
|
||||
// Returns nil if never displayed (all rank-ups should trigger notifications).
|
||||
func (r *AchievementRepository) GetDisplayedLevels(charID uint32) ([]byte, error) {
|
||||
var levels []byte
|
||||
err := r.db.QueryRow("SELECT displayed_levels FROM achievements WHERE id=$1", charID).Scan(&levels)
|
||||
return levels, err
|
||||
}
|
||||
|
||||
// SaveDisplayedLevels stores the current achievement levels as "displayed",
|
||||
// so future GET_ACHIEVEMENT responses only notify on new rank-ups.
|
||||
func (r *AchievementRepository) SaveDisplayedLevels(charID uint32, levels []byte) error {
|
||||
_, err := r.db.Exec("UPDATE achievements SET displayed_levels=$1 WHERE id=$2", levels, charID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -290,6 +290,8 @@ type AchievementRepo interface {
|
||||
EnsureExists(charID uint32) error
|
||||
GetAllScores(charID uint32) ([33]int32, error)
|
||||
IncrementScore(charID uint32, achievementID uint8) error
|
||||
GetDisplayedLevels(charID uint32) ([]byte, error)
|
||||
SaveDisplayedLevels(charID uint32, levels []byte) error
|
||||
}
|
||||
|
||||
// ShopRepo defines the contract for shop data access.
|
||||
|
||||
@@ -11,12 +11,16 @@ var errNotFound = errors.New("not found")
|
||||
// --- mockAchievementRepo ---
|
||||
|
||||
type mockAchievementRepo struct {
|
||||
scores [33]int32
|
||||
ensureCalled bool
|
||||
ensureErr error
|
||||
getScoresErr error
|
||||
incrementErr error
|
||||
incrementedID uint8
|
||||
scores [33]int32
|
||||
ensureCalled bool
|
||||
ensureErr error
|
||||
getScoresErr error
|
||||
incrementErr error
|
||||
incrementedID uint8
|
||||
displayedLevels []byte
|
||||
displayedErr error
|
||||
savedLevels []byte
|
||||
saveLevelsErr error
|
||||
}
|
||||
|
||||
func (m *mockAchievementRepo) EnsureExists(_ uint32) error {
|
||||
@@ -33,6 +37,15 @@ func (m *mockAchievementRepo) IncrementScore(_ uint32, id uint8) error {
|
||||
return m.incrementErr
|
||||
}
|
||||
|
||||
func (m *mockAchievementRepo) GetDisplayedLevels(_ uint32) ([]byte, error) {
|
||||
return m.displayedLevels, m.displayedErr
|
||||
}
|
||||
|
||||
func (m *mockAchievementRepo) SaveDisplayedLevels(_ uint32, levels []byte) error {
|
||||
m.savedLevels = levels
|
||||
return m.saveLevelsErr
|
||||
}
|
||||
|
||||
// --- mockMailRepo ---
|
||||
|
||||
type mockMailRepo struct {
|
||||
|
||||
@@ -23,6 +23,7 @@ const achievementEntryCount = uint8(33)
|
||||
type AchievementSummary struct {
|
||||
Points uint32
|
||||
Achievements [33]Achievement
|
||||
Notify [33]bool
|
||||
}
|
||||
|
||||
// GetAll ensures the achievement record exists, fetches all scores, and computes
|
||||
@@ -38,15 +39,49 @@ func (svc *AchievementService) GetAll(charID uint32) (*AchievementSummary, error
|
||||
return nil, err
|
||||
}
|
||||
|
||||
displayed, err := svc.achievementRepo.GetDisplayedLevels(charID)
|
||||
if err != nil {
|
||||
svc.logger.Debug("No displayed levels found, all rank-ups will notify", zap.Error(err))
|
||||
}
|
||||
|
||||
var summary AchievementSummary
|
||||
for id := uint8(0); id < achievementEntryCount; id++ {
|
||||
ach := GetAchData(id, scores[id])
|
||||
summary.Points += ach.Value
|
||||
summary.Achievements[id] = ach
|
||||
|
||||
// Notify if current level exceeds the last-displayed level.
|
||||
if ach.Level > 0 {
|
||||
if displayed == nil || int(id) >= len(displayed) {
|
||||
summary.Notify[id] = true
|
||||
} else if ach.Level > displayed[id] {
|
||||
summary.Notify[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return &summary, nil
|
||||
}
|
||||
|
||||
// MarkDisplayed snapshots the current achievement levels so that future
|
||||
// GET_ACHIEVEMENT responses only notify on new rank-ups since this point.
|
||||
func (svc *AchievementService) MarkDisplayed(charID uint32) error {
|
||||
if err := svc.achievementRepo.EnsureExists(charID); err != nil {
|
||||
svc.logger.Error("Failed to ensure achievements record", zap.Error(err))
|
||||
}
|
||||
|
||||
scores, err := svc.achievementRepo.GetAllScores(charID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
levels := make([]byte, achievementEntryCount)
|
||||
for id := uint8(0); id < achievementEntryCount; id++ {
|
||||
ach := GetAchData(id, scores[id])
|
||||
levels[id] = ach.Level
|
||||
}
|
||||
return svc.achievementRepo.SaveDisplayedLevels(charID, levels)
|
||||
}
|
||||
|
||||
// Increment validates the achievement ID, ensures the record exists, and bumps
|
||||
// the score for the given achievement category.
|
||||
func (svc *AchievementService) Increment(charID uint32, achievementID uint8) error {
|
||||
|
||||
@@ -65,6 +65,86 @@ func TestAchievementService_GetAll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementService_GetAll_NotifyOnNewRankUp(t *testing.T) {
|
||||
// Scores: ach0=5 (level 1), ach1=0 (level 0), ach2=20 (level 2)
|
||||
// Displayed: ach0 was level 0, ach1 was level 0, ach2 was level 2
|
||||
// Expected: ach0 notifies (1 > 0), ach1 does not (level 0), ach2 does not (2 == 2)
|
||||
mock := &mockAchievementRepo{
|
||||
scores: [33]int32{5, 0, 20},
|
||||
displayedLevels: make([]byte, 33), // all zeros
|
||||
}
|
||||
mock.displayedLevels[2] = 2 // ach2 was already displayed at level 2
|
||||
|
||||
svc := newTestAchievementService(mock)
|
||||
summary, err := svc.GetAll(1)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !summary.Notify[0] {
|
||||
t.Error("ach0: expected notify=true (level 1, displayed 0)")
|
||||
}
|
||||
if summary.Notify[1] {
|
||||
t.Error("ach1: expected notify=false (level 0)")
|
||||
}
|
||||
if summary.Notify[2] {
|
||||
t.Error("ach2: expected notify=false (level 2, displayed 2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementService_GetAll_NotifyAllWhenNeverDisplayed(t *testing.T) {
|
||||
// No displayed levels (nil) — all achievements with level > 0 should notify.
|
||||
mock := &mockAchievementRepo{
|
||||
scores: [33]int32{5, 15},
|
||||
displayedErr: errNotFound,
|
||||
}
|
||||
svc := newTestAchievementService(mock)
|
||||
summary, err := svc.GetAll(1)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !summary.Notify[0] {
|
||||
t.Error("ach0: expected notify=true (never displayed)")
|
||||
}
|
||||
if !summary.Notify[1] {
|
||||
t.Error("ach1: expected notify=true (never displayed)")
|
||||
}
|
||||
if summary.Notify[2] {
|
||||
t.Error("ach2: expected notify=false (level 0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementService_MarkDisplayed(t *testing.T) {
|
||||
mock := &mockAchievementRepo{
|
||||
scores: [33]int32{5, 0, 20}, // ach0=level1, ach1=level0, ach2=level2
|
||||
}
|
||||
svc := newTestAchievementService(mock)
|
||||
|
||||
err := svc.MarkDisplayed(1)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
if !mock.ensureCalled {
|
||||
t.Error("EnsureExists should have been called")
|
||||
}
|
||||
if mock.savedLevels == nil {
|
||||
t.Fatal("SaveDisplayedLevels should have been called")
|
||||
}
|
||||
if len(mock.savedLevels) != 33 {
|
||||
t.Fatalf("Expected 33 bytes, got %d", len(mock.savedLevels))
|
||||
}
|
||||
if mock.savedLevels[0] != 1 {
|
||||
t.Errorf("ach0 level: got %d, want 1", mock.savedLevels[0])
|
||||
}
|
||||
if mock.savedLevels[1] != 0 {
|
||||
t.Errorf("ach1 level: got %d, want 0", mock.savedLevels[1])
|
||||
}
|
||||
if mock.savedLevels[2] != 2 {
|
||||
t.Errorf("ach2 level: got %d, want 2", mock.savedLevels[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAchievementService_GetAll_EnsureErrorNonFatal(t *testing.T) {
|
||||
mock := &mockAchievementRepo{
|
||||
ensureErr: errNotFound,
|
||||
|
||||
Reference in New Issue
Block a user