mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
93
cmd/protbot/scenario/achievement.go
Normal file
93
cmd/protbot/scenario/achievement.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user