diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b28c89f0..91decd1fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Achievement rank-up notifications: the client now shows rank-up popups when achievements level up, using per-character tracking of last-displayed levels ([#165](https://github.com/Mezeporta/Erupe/issues/165)) +- Database migration `0008_achievement_displayed_levels` (tracks last-displayed achievement levels) - Savedata corruption defense (tier 1): bounded decompression in nullcomp prevents OOM from crafted payloads, bounds-checked delta patching prevents buffer overflows, compressed payload size limits (512KB) and decompressed size limits (1MB) reject oversized saves, rotating savedata backups (3 slots, 30-minute interval) provide recovery points - Savedata corruption defense (tier 2): SHA-256 checksum on decompressed savedata verified on every load, atomic DB transactions wrapping character data + house data + hash + backup in a single commit, per-character save mutex preventing concurrent save races - Database migration `0007_savedata_integrity` (rotating backup table + integrity checksum column) diff --git a/cmd/protbot/main.go b/cmd/protbot/main.go index 5d658269b..87771fe39 100644 --- a/cmd/protbot/main.go +++ b/cmd/protbot/main.go @@ -15,6 +15,7 @@ import ( "os" "os/signal" "syscall" + "time" "erupe-ce/cmd/protbot/scenario" ) @@ -23,7 +24,7 @@ func main() { signAddr := flag.String("sign-addr", "127.0.0.1:53312", "Sign server address (host:port)") user := flag.String("user", "", "Username") pass := flag.String("pass", "", "Password") - action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests") + action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests, achievement") message := flag.String("message", "", "Chat message to send (used with --action chat)") flag.Parse() @@ -139,8 +140,78 @@ func main() { fmt.Printf("[quests] Received %d bytes of quest data\n", len(data)) _ = scenario.Logout(result.Channel) + case "achievement": + result, err := scenario.Login(*signAddr, *user, *pass) + if err != nil { + fmt.Fprintf(os.Stderr, "login failed: %v\n", err) + os.Exit(1) + } + charID := result.Sign.CharIDs[0] + if _, err := scenario.SetupSession(result.Channel, charID); err != nil { + fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err) + _ = result.Channel.Close() + os.Exit(1) + } + if err := scenario.EnterLobby(result.Channel); err != nil { + fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err) + _ = result.Channel.Close() + os.Exit(1) + } + + // Step 1: Get current achievements. + achs, err := scenario.GetAchievements(result.Channel, charID) + if err != nil { + fmt.Fprintf(os.Stderr, "get achievements failed: %v\n", err) + _ = scenario.Logout(result.Channel) + os.Exit(1) + } + fmt.Printf("[achievement] Total points: %d\n", achs.Points) + hasNotify := false + for _, e := range achs.Entries { + marker := "" + if e.Notify { + marker = " ** RANK UP **" + hasNotify = true + } + if e.Level > 0 || e.Notify { + fmt.Printf(" [%2d] Level %d Progress %d/%d Trophy 0x%02X%s\n", + e.ID, e.Level, e.Progress, e.Required, e.Trophy, marker) + } + } + + // Step 2: Mark as displayed if there were notifications. + if hasNotify { + fmt.Println("[achievement] Sending DISPLAYED_ACHIEVEMENT to acknowledge rank-ups...") + if err := scenario.DisplayedAchievement(result.Channel); err != nil { + fmt.Fprintf(os.Stderr, "displayed achievement failed: %v\n", err) + } + // Brief pause for fire-and-forget packet to be processed. + <-time.After(500 * time.Millisecond) + + // Step 3: Re-fetch to verify notifications are cleared. + achs2, err := scenario.GetAchievements(result.Channel, charID) + if err != nil { + fmt.Fprintf(os.Stderr, "re-fetch achievements failed: %v\n", err) + } else { + cleared := true + for _, e := range achs2.Entries { + if e.Notify { + fmt.Printf(" [%2d] STILL notifying (level %d) — not cleared!\n", e.ID, e.Level) + cleared = false + } + } + if cleared { + fmt.Println("[achievement] All rank-up notifications cleared successfully.") + } + } + } else { + fmt.Println("[achievement] No pending rank-up notifications.") + } + + _ = scenario.Logout(result.Channel) + default: - fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests)\n", *action) + fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests, achievement)\n", *action) os.Exit(1) } } diff --git a/cmd/protbot/protocol/opcodes.go b/cmd/protbot/protocol/opcodes.go index 37c57a158..2342859d4 100644 --- a/cmd/protbot/protocol/opcodes.go +++ b/cmd/protbot/protocol/opcodes.go @@ -19,5 +19,8 @@ const ( MSG_SYS_RIGHTS_RELOAD uint16 = 0x005D MSG_MHF_LOADDATA uint16 = 0x0061 MSG_MHF_ENUMERATE_QUEST uint16 = 0x009F - MSG_MHF_GET_WEEKLY_SCHED uint16 = 0x00E1 + MSG_MHF_GET_ACHIEVEMENT uint16 = 0x00D4 + MSG_MHF_ADD_ACHIEVEMENT uint16 = 0x00D6 + MSG_MHF_DISPLAYED_ACHIEVEMENT uint16 = 0x00D8 + MSG_MHF_GET_WEEKLY_SCHED uint16 = 0x00E1 ) diff --git a/cmd/protbot/protocol/packets.go b/cmd/protbot/protocol/packets.go index 58d378f07..3e673048d 100644 --- a/cmd/protbot/protocol/packets.go +++ b/cmd/protbot/protocol/packets.go @@ -215,6 +215,56 @@ func BuildEnumerateQuestPacket(ackHandle uint32, world uint8, counter, offset ui return bf.Data() } +// BuildGetAchievementPacket builds a MSG_MHF_GET_ACHIEVEMENT packet. +// Layout mirrors Erupe's MsgMhfGetAchievement.Parse: +// +// uint16 opcode +// uint32 ackHandle +// uint32 charID +// uint32 zeroed +// 0x00 0x10 terminator +func BuildGetAchievementPacket(ackHandle, charID uint32) []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint16(MSG_MHF_GET_ACHIEVEMENT) + bf.WriteUint32(ackHandle) + bf.WriteUint32(charID) + bf.WriteUint32(0) // Zeroed + bf.WriteBytes([]byte{0x00, 0x10}) + return bf.Data() +} + +// BuildAddAchievementPacket builds a MSG_MHF_ADD_ACHIEVEMENT packet (fire-and-forget, no ACK). +// Layout mirrors Erupe's MsgMhfAddAchievement.Parse: +// +// uint16 opcode +// uint8 achievementID +// uint16 unk1 +// uint16 unk2 +// 0x00 0x10 terminator +func BuildAddAchievementPacket(achievementID uint8) []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint16(MSG_MHF_ADD_ACHIEVEMENT) + bf.WriteUint8(achievementID) + bf.WriteUint16(0) // Unk1 + bf.WriteUint16(0) // Unk2 + bf.WriteBytes([]byte{0x00, 0x10}) + return bf.Data() +} + +// BuildDisplayedAchievementPacket builds a MSG_MHF_DISPLAYED_ACHIEVEMENT packet (fire-and-forget). +// Layout mirrors Erupe's MsgMhfDisplayedAchievement.Parse: +// +// uint16 opcode +// uint8 zeroed +// 0x00 0x10 terminator +func BuildDisplayedAchievementPacket() []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint16(MSG_MHF_DISPLAYED_ACHIEVEMENT) + bf.WriteUint8(0) // Zeroed + bf.WriteBytes([]byte{0x00, 0x10}) + return bf.Data() +} + // BuildGetWeeklySchedulePacket builds a MSG_MHF_GET_WEEKLY_SCHEDULE packet. // // uint16 opcode diff --git a/cmd/protbot/scenario/achievement.go b/cmd/protbot/scenario/achievement.go new file mode 100644 index 000000000..a7c9602b1 --- /dev/null +++ b/cmd/protbot/scenario/achievement.go @@ -0,0 +1,93 @@ +package scenario + +import ( + "fmt" + "time" + + "erupe-ce/cmd/protbot/protocol" + "erupe-ce/common/byteframe" +) + +// AchievementEntry holds parsed achievement data from the server response. +type AchievementEntry struct { + ID uint8 + Level uint8 + Next uint16 + Required uint32 + Notify bool + Trophy uint8 + Progress uint32 +} + +// AchievementResult holds the full parsed GET_ACHIEVEMENT response. +type AchievementResult struct { + Points uint32 + Entries []AchievementEntry +} + +// GetAchievements sends MSG_MHF_GET_ACHIEVEMENT and returns the parsed response. +func GetAchievements(ch *protocol.ChannelConn, charID uint32) (*AchievementResult, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildGetAchievementPacket(ack, charID) + fmt.Printf("[achievement] Sending GET_ACHIEVEMENT (charID=%d, ackHandle=%d)...\n", charID, ack) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("get achievement send: %w", err) + } + + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("get achievement ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("get achievement failed: error code %d", resp.ErrorCode) + } + fmt.Printf("[achievement] ACK received (%d bytes)\n", len(resp.Data)) + + return parseAchievementResponse(resp.Data) +} + +// IncrementAchievement sends MSG_MHF_ADD_ACHIEVEMENT (fire-and-forget, no ACK). +func IncrementAchievement(ch *protocol.ChannelConn, achievementID uint8) error { + pkt := protocol.BuildAddAchievementPacket(achievementID) + fmt.Printf("[achievement] Sending ADD_ACHIEVEMENT (id=%d)...\n", achievementID) + return ch.SendPacket(pkt) +} + +// DisplayedAchievement sends MSG_MHF_DISPLAYED_ACHIEVEMENT to tell the server +// the client has seen all rank-up notifications (fire-and-forget, no ACK). +func DisplayedAchievement(ch *protocol.ChannelConn) error { + pkt := protocol.BuildDisplayedAchievementPacket() + fmt.Printf("[achievement] Sending DISPLAYED_ACHIEVEMENT...\n") + return ch.SendPacket(pkt) +} + +func parseAchievementResponse(data []byte) (*AchievementResult, error) { + if len(data) < 20 { + return nil, fmt.Errorf("achievement response too short: %d bytes", len(data)) + } + + bf := byteframe.NewByteFrameFromBytes(data) + result := &AchievementResult{} + + // Header: 4x uint32 points (all same value), 3 bytes unk, 1 byte count + result.Points = bf.ReadUint32() + bf.ReadUint32() // Points repeated + bf.ReadUint32() // Points repeated + bf.ReadUint32() // Points repeated + bf.ReadBytes(3) // Unk (0x02, 0x00, 0x00) + count := bf.ReadUint8() + + for i := uint8(0); i < count; i++ { + entry := AchievementEntry{} + entry.ID = bf.ReadUint8() + entry.Level = bf.ReadUint8() + entry.Next = bf.ReadUint16() + entry.Required = bf.ReadUint32() + entry.Notify = bf.ReadBool() + entry.Trophy = bf.ReadUint8() + bf.ReadUint16() // Unk + entry.Progress = bf.ReadUint32() + result.Entries = append(result.Entries, entry) + } + return result, nil +} diff --git a/docs/technical-debt.md b/docs/technical-debt.md index 7d159a0e9..eb875bc0b 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -27,7 +27,7 @@ These TODOs represent features that are visibly broken for players. | Location | Issue | Impact | Tracker | |----------|-------|--------|---------| | ~~`model_character.go:88,101,113`~~ | ~~`TODO: fix bookshelf data pointer` for G10-ZZ, F4-F5, and S6 versions~~ | ~~Wrong pointer corrupts character save reads for three game versions.~~ **Fixed.** Corrected offsets to 103928 (G1–Z2), 71928 (F4–F5), 23928 (S6) — validated via inter-version delta analysis and Ghidra decompilation of `snj_db_get_housedata` in the ZZ DLL. | [#164](https://github.com/Mezeporta/Erupe/issues/164) | -| `handlers_achievement.go:117` | `TODO: Notify on rank increase` — always returns `false` | Achievement rank-up notifications are silently suppressed. Requires understanding what `MhfDisplayedAchievement` (currently an empty handler) sends to track "last displayed" state. | [#165](https://github.com/Mezeporta/Erupe/issues/165) | +| ~~`handlers_achievement.go:117`~~ | ~~`TODO: Notify on rank increase` — always returns `false`~~ | ~~Achievement rank-up notifications are silently suppressed.~~ **Fixed.** RE'd `putDisplayed_achievement` in ZZ DLL: sends opcode + 1 zero byte (no achievement ID). Added `displayed_levels` column to track per-character last-displayed levels; `GetAchievement` compares current vs displayed; `DisplayedAchievement` snapshots current levels. | [#165](https://github.com/Mezeporta/Erupe/issues/165) | | ~~`handlers_guild_info.go:443`~~ | ~~`TODO: Enable GuildAlliance applications` — hardcoded `true`~~ | ~~Guild alliance applications are always open regardless of setting.~~ **Fixed.** Added `recruiting` column to `guild_alliances`, wired `OperateJoint` actions `0x06`/`0x07`, reads from DB. | [#166](https://github.com/Mezeporta/Erupe/issues/166) | | ~~`handlers_session.go:410`~~ | ~~`TODO(Andoryuuta): log key index off-by-one`~~ | ~~Known off-by-one in log key indexing is unresolved~~ **Documented.** RE'd from ZZ DLL: `putRecord_log`/`putTerminal_log` don't embed the key (size 0), so the off-by-one only matters in pre-ZZ clients and is benign server-side. | [#167](https://github.com/Mezeporta/Erupe/issues/167) | | ~~`handlers_session.go:551`~~ | ~~`TODO: This case might be <=G2`~~ | ~~Uncertain version detection in switch case~~ **Documented.** RE'd ZZ per-entry parser (FUN_115868a0) confirms 40-byte padding. G2 DLL analysis inconclusive (stripped, no shared struct sizes). Kept <=G1 boundary with RE documentation. | [#167](https://github.com/Mezeporta/Erupe/issues/167) | @@ -96,7 +96,7 @@ Based on remaining impact: 1. ~~**Add tests for `handlers_commands.go`**~~ — **Done.** 62 tests covering all 12 commands (ban, timer, PSN, reload, key quest, rights, course, raviente, teleport, discord, playtime, help), disabled-command gating, op overrides, error paths, and `initCommands`. 2. ~~**Fix bookshelf data pointer** ([#164](https://github.com/Mezeporta/Erupe/issues/164))~~ — **Done.** Corrected offsets for G1–Z2, F4–F5, S6 via delta analysis + Ghidra RE -3. **Fix achievement rank-up notifications** ([#165](https://github.com/Mezeporta/Erupe/issues/165)) — needs protocol research on `MhfDisplayedAchievement` +3. ~~**Fix achievement rank-up notifications** ([#165](https://github.com/Mezeporta/Erupe/issues/165))~~ — **Done.** RE'd `putDisplayed_achievement` from ZZ DLL; added `displayed_levels` column + service methods + migration 0008 4. ~~**Add coverage threshold** to CI~~ — **Done.** 50% floor enforced via `go tool cover` in CI; Codecov removed. 5. ~~**Fix guild alliance toggle** ([#166](https://github.com/Mezeporta/Erupe/issues/166))~~ — **Done.** `recruiting` column + `OperateJoint` allow/deny actions + DB toggle 6. ~~**Fix session handler retail mismatches** ([#167](https://github.com/Mezeporta/Erupe/issues/167))~~ — **Documented.** RE'd from ZZ DLL; log key off-by-one is benign server-side, player count fixed via `QuestReserved`. diff --git a/server/channelserver/handlers_achievement.go b/server/channelserver/handlers_achievement.go index d466d58e3..3d5852c00 100644 --- a/server/channelserver/handlers_achievement.go +++ b/server/channelserver/handlers_achievement.go @@ -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) {} diff --git a/server/channelserver/repo_achievement.go b/server/channelserver/repo_achievement.go index 26e12dd79..2f2942843 100644 --- a/server/channelserver/repo_achievement.go +++ b/server/channelserver/repo_achievement.go @@ -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 +} diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 6d2d9733f..99d25a39c 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -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. diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 6617bc0ca..66c6aaab9 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -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 { diff --git a/server/channelserver/svc_achievement.go b/server/channelserver/svc_achievement.go index 01e93b003..eee1fb495 100644 --- a/server/channelserver/svc_achievement.go +++ b/server/channelserver/svc_achievement.go @@ -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 { diff --git a/server/channelserver/svc_achievement_test.go b/server/channelserver/svc_achievement_test.go index c60d6ed19..16aee2c72 100644 --- a/server/channelserver/svc_achievement_test.go +++ b/server/channelserver/svc_achievement_test.go @@ -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, diff --git a/server/migrations/sql/0008_achievement_displayed_levels.sql b/server/migrations/sql/0008_achievement_displayed_levels.sql new file mode 100644 index 000000000..ba4498291 --- /dev/null +++ b/server/migrations/sql/0008_achievement_displayed_levels.sql @@ -0,0 +1,4 @@ +-- 0008: Add displayed_levels to achievements table for rank-up notifications (#165). +-- Stores 33 bytes (one level per achievement) representing the last level +-- the client acknowledged seeing. NULL means never displayed (shows all rank-ups). +ALTER TABLE public.achievements ADD COLUMN IF NOT EXISTS displayed_levels bytea;