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:
Houmgaor
2026-03-18 11:35:31 +01:00
parent 476882e1fb
commit 61d85e749f
13 changed files with 383 additions and 13 deletions

View File

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