diff --git a/CHANGELOG.md b/CHANGELOG.md index 91decd1fd..380c74e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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) +- Diva Defense point accumulation: `MsgMhfAddUdPoint` now stores per-character quest and bonus points in a dedicated `diva_points` table, RE'd from the ZZ client DLL ([#168](https://github.com/Mezeporta/Erupe/issues/168)) +- Database migration `0009_diva_points` (per-character per-event point tracking) - 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/docs/technical-debt.md b/docs/technical-debt.md index eb875bc0b..eab287964 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -32,7 +32,7 @@ These TODOs represent features that are visibly broken for players. | ~~`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) | | ~~`handlers_session.go:714`~~ | ~~`TODO: Retail returned the number of clients in quests`~~ | ~~Player count reported to clients does not match retail behavior~~ **Fixed.** Added `QuestReserved` field to `StageSnapshot` that counts only clients in "Qs" stages, pre-collected under server lock to respect lock ordering. | [#167](https://github.com/Mezeporta/Erupe/issues/167) | -| `msg_mhf_add_ud_point.go:28` | `TODO: Parse is a stub` — field meanings unknown | UD point packet fields unnamed, `Build` not implemented | [#168](https://github.com/Mezeporta/Erupe/issues/168) | +| ~~`msg_mhf_add_ud_point.go:28`~~ | ~~`TODO: Parse is a stub` — field meanings unknown~~ | ~~UD point packet fields unnamed, `Build` not implemented~~ **Fixed.** RE'd `putAdd_ud_point` from ZZ DLL: two uint32 fields are QuestPoints (sum of 11 category accumulators) and BonusPoints (kiju prayer multiplier extra). Added `diva_points` table, repo methods, handler accumulation, and migration 0009. | [#168](https://github.com/Mezeporta/Erupe/issues/168) | ### 2. Test gaps on critical paths @@ -100,4 +100,4 @@ Based on remaining impact: 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`. -7. **Reverse-engineer MhfAddUdPoint fields** ([#168](https://github.com/Mezeporta/Erupe/issues/168)) — needs packet captures +7. ~~**Reverse-engineer MhfAddUdPoint fields** ([#168](https://github.com/Mezeporta/Erupe/issues/168))~~ — **Done.** RE'd from ZZ DLL: QuestPoints + BonusPoints, added `diva_points` table + migration 0009 diff --git a/network/mhfpacket/msg_mhf_add_ud_point.go b/network/mhfpacket/msg_mhf_add_ud_point.go index a2be14d7f..4b576d42b 100644 --- a/network/mhfpacket/msg_mhf_add_ud_point.go +++ b/network/mhfpacket/msg_mhf_add_ud_point.go @@ -1,18 +1,21 @@ package mhfpacket import ( - "errors" - "erupe-ce/common/byteframe" "erupe-ce/network" "erupe-ce/network/clientctx" ) // MsgMhfAddUdPoint represents the MSG_MHF_ADD_UD_POINT +// +// Sent by the client after completing a Diva Defense quest to report earned points. +// RE'd from ZZ DLL putAdd_ud_point (FUN_114fd490): the client sums 11 point +// category accumulators into QuestPoints, and computes BonusPoints from the +// kiju prayer song multiplier applied to the base categories. type MsgMhfAddUdPoint struct { - AckHandle uint32 - Unk1 uint32 - Unk2 uint32 + AckHandle uint32 + QuestPoints uint32 // Total points earned from the quest (sum of all categories) + BonusPoints uint32 // Extra points from kiju/prayer song multiplier } // Opcode returns the ID associated with this packet type. @@ -23,13 +26,15 @@ func (m *MsgMhfAddUdPoint) Opcode() network.PacketID { // Parse parses the packet from binary func (m *MsgMhfAddUdPoint) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { m.AckHandle = bf.ReadUint32() - m.Unk1 = bf.ReadUint32() - m.Unk2 = bf.ReadUint32() - // TODO: Parse is a stub — field meanings unknown + m.QuestPoints = bf.ReadUint32() + m.BonusPoints = bf.ReadUint32() return nil } // Build builds a binary packet from the current data. func (m *MsgMhfAddUdPoint) Build(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + bf.WriteUint32(m.AckHandle) + bf.WriteUint32(m.QuestPoints) + bf.WriteUint32(m.BonusPoints) + return nil } diff --git a/network/mhfpacket/msg_mhf_add_ud_tactics_point.go b/network/mhfpacket/msg_mhf_add_ud_tactics_point.go index 809ff0e84..4d33b19c6 100644 --- a/network/mhfpacket/msg_mhf_add_ud_tactics_point.go +++ b/network/mhfpacket/msg_mhf_add_ud_tactics_point.go @@ -7,10 +7,14 @@ import ( ) // MsgMhfAddUdTacticsPoint represents the MSG_MHF_ADD_UD_TACTICS_POINT +// +// Sent during Diva Defense interception phase to report tactics points. +// RE'd from ZZ DLL putAdd_ud_tactics_point (FUN_114fe9c0): QuestID is read +// from a character data field, TacticsPoints is the accumulated tactics value. type MsgMhfAddUdTacticsPoint struct { - AckHandle uint32 - Unk0 uint16 - Unk1 uint32 + AckHandle uint32 + QuestID uint16 // Quest/character identifier from savedata + TacticsPoints uint32 // Accumulated tactics interception points } // Opcode returns the ID associated with this packet type. @@ -21,15 +25,15 @@ func (m *MsgMhfAddUdTacticsPoint) Opcode() network.PacketID { // Parse parses the packet from binary func (m *MsgMhfAddUdTacticsPoint) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { m.AckHandle = bf.ReadUint32() - m.Unk0 = bf.ReadUint16() - m.Unk1 = bf.ReadUint32() + m.QuestID = bf.ReadUint16() + m.TacticsPoints = bf.ReadUint32() return nil } // Build builds a binary packet from the current data. func (m *MsgMhfAddUdTacticsPoint) Build(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { bf.WriteUint32(m.AckHandle) - bf.WriteUint16(m.Unk0) - bf.WriteUint32(m.Unk1) + bf.WriteUint16(m.QuestID) + bf.WriteUint32(m.TacticsPoints) return nil } diff --git a/server/channelserver/handlers_diva.go b/server/channelserver/handlers_diva.go index a35d8c0b1..b79979d8e 100644 --- a/server/channelserver/handlers_diva.go +++ b/server/channelserver/handlers_diva.go @@ -151,6 +151,26 @@ func handleMsgMhfSetKiju(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfAddUdPoint(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAddUdPoint) + + // Find the current diva event to associate points with. + eventID := uint32(0) + if s.server.divaRepo != nil { + events, err := s.server.divaRepo.GetEvents() + if err == nil && len(events) > 0 { + eventID = events[len(events)-1].ID + } + } + + if eventID != 0 && s.charID != 0 && (pkt.QuestPoints > 0 || pkt.BonusPoints > 0) { + if err := s.server.divaRepo.AddPoints(s.charID, eventID, pkt.QuestPoints, pkt.BonusPoints); err != nil { + s.logger.Warn("Failed to add diva points", + zap.Uint32("charID", s.charID), + zap.Uint32("questPoints", pkt.QuestPoints), + zap.Uint32("bonusPoints", pkt.BonusPoints), + zap.Error(err)) + } + } + doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) } diff --git a/server/channelserver/handlers_diva_test.go b/server/channelserver/handlers_diva_test.go index 64f6501f5..743ab78a3 100644 --- a/server/channelserver/handlers_diva_test.go +++ b/server/channelserver/handlers_diva_test.go @@ -88,6 +88,78 @@ func TestHandleMsgMhfAddUdPoint(t *testing.T) { } } +func TestHandleMsgMhfAddUdPoint_AccumulatesPoints(t *testing.T) { + srv := createMockServer() + repo := &mockDivaRepo{ + events: []DivaEvent{{ID: 42, StartTime: uint32(time.Now().Unix())}}, + } + srv.divaRepo = repo + s := createMockSession(1, srv) + s.charID = 100 + + // First quest: 500 quest points + 50 bonus + pkt := &mhfpacket.MsgMhfAddUdPoint{AckHandle: 1, QuestPoints: 500, BonusPoints: 50} + handleMsgMhfAddUdPoint(s, pkt) + <-s.sendPackets + + qp, bp, err := repo.GetPoints(100, 42) + if err != nil { + t.Fatal(err) + } + if qp != 500 || bp != 50 { + t.Errorf("After first quest: quest=%d bonus=%d, want 500/50", qp, bp) + } + + // Second quest: 300 quest points + 30 bonus + pkt2 := &mhfpacket.MsgMhfAddUdPoint{AckHandle: 2, QuestPoints: 300, BonusPoints: 30} + handleMsgMhfAddUdPoint(s, pkt2) + <-s.sendPackets + + qp, bp, err = repo.GetPoints(100, 42) + if err != nil { + t.Fatal(err) + } + if qp != 800 || bp != 80 { + t.Errorf("After second quest: quest=%d bonus=%d, want 800/80", qp, bp) + } +} + +func TestHandleMsgMhfAddUdPoint_NoEvent(t *testing.T) { + srv := createMockServer() + repo := &mockDivaRepo{} // no events + srv.divaRepo = repo + s := createMockSession(1, srv) + s.charID = 100 + + pkt := &mhfpacket.MsgMhfAddUdPoint{AckHandle: 1, QuestPoints: 500, BonusPoints: 50} + handleMsgMhfAddUdPoint(s, pkt) + <-s.sendPackets + + // Should still ACK successfully even with no event + if len(repo.points) != 0 { + t.Error("Should not store points when no event is active") + } +} + +func TestHandleMsgMhfAddUdPoint_ZeroPoints(t *testing.T) { + srv := createMockServer() + repo := &mockDivaRepo{ + events: []DivaEvent{{ID: 1, StartTime: uint32(time.Now().Unix())}}, + } + srv.divaRepo = repo + s := createMockSession(1, srv) + s.charID = 100 + + pkt := &mhfpacket.MsgMhfAddUdPoint{AckHandle: 1, QuestPoints: 0, BonusPoints: 0} + handleMsgMhfAddUdPoint(s, pkt) + <-s.sendPackets + + // Should not create a row for zero points + if len(repo.points) != 0 { + t.Error("Should not store zero points") + } +} + func TestHandleMsgMhfGetUdMyPoint(t *testing.T) { server := createMockServer() session := createMockSession(1, server) diff --git a/server/channelserver/repo_diva.go b/server/channelserver/repo_diva.go index 90b53e201..69fe3c658 100644 --- a/server/channelserver/repo_diva.go +++ b/server/channelserver/repo_diva.go @@ -38,3 +38,40 @@ func (r *DivaRepository) GetEvents() ([]DivaEvent, error) { err := r.db.Select(&result, "SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type='diva'") return result, err } + +// AddPoints atomically adds quest and bonus points for a character in a diva event. +func (r *DivaRepository) AddPoints(charID, eventID, questPoints, bonusPoints uint32) error { + _, err := r.db.Exec(` + INSERT INTO diva_points (char_id, event_id, quest_points, bonus_points, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (char_id, event_id) DO UPDATE + SET quest_points = diva_points.quest_points + EXCLUDED.quest_points, + bonus_points = diva_points.bonus_points + EXCLUDED.bonus_points, + updated_at = now()`, + charID, eventID, questPoints, bonusPoints) + return err +} + +// GetPoints returns the accumulated quest and bonus points for a character in an event. +func (r *DivaRepository) GetPoints(charID, eventID uint32) (int64, int64, error) { + var qp, bp int64 + err := r.db.QueryRow( + "SELECT quest_points, bonus_points FROM diva_points WHERE char_id=$1 AND event_id=$2", + charID, eventID).Scan(&qp, &bp) + if err != nil { + return 0, 0, err + } + return qp, bp, nil +} + +// GetTotalPoints returns the sum of all players' quest and bonus points for an event. +func (r *DivaRepository) GetTotalPoints(eventID uint32) (int64, int64, error) { + var qp, bp int64 + err := r.db.QueryRow( + "SELECT COALESCE(SUM(quest_points),0), COALESCE(SUM(bonus_points),0) FROM diva_points WHERE event_id=$1", + eventID).Scan(&qp, &bp) + if err != nil { + return 0, 0, err + } + return qp, bp, nil +} diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 99d25a39c..7d630a0c6 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -324,6 +324,9 @@ type DivaRepo interface { DeleteEvents() error InsertEvent(startEpoch uint32) error GetEvents() ([]DivaEvent, error) + AddPoints(charID uint32, eventID uint32, questPoints, bonusPoints uint32) error + GetPoints(charID uint32, eventID uint32) (questPoints, bonusPoints int64, err error) + GetTotalPoints(eventID uint32) (questPoints, bonusPoints int64, err error) } // MiscRepo defines the contract for miscellaneous data access. diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 66c6aaab9..dbd527669 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -1094,12 +1094,58 @@ func (m *mockRengokuRepo) GetRanking(_ uint32, _ uint32) ([]RengokuScore, error) type mockDivaRepo struct { events []DivaEvent eventsErr error + + // Point tracking for tests + points map[[2]uint32][2]int64 // [charID, eventID] -> [questPoints, bonusPoints] + addErr error + getErr error + totalErr error } func (m *mockDivaRepo) DeleteEvents() error { return nil } func (m *mockDivaRepo) InsertEvent(_ uint32) error { return nil } func (m *mockDivaRepo) GetEvents() ([]DivaEvent, error) { return m.events, m.eventsErr } +func (m *mockDivaRepo) AddPoints(charID, eventID, questPoints, bonusPoints uint32) error { + if m.addErr != nil { + return m.addErr + } + if m.points == nil { + m.points = make(map[[2]uint32][2]int64) + } + key := [2]uint32{charID, eventID} + cur := m.points[key] + cur[0] += int64(questPoints) + cur[1] += int64(bonusPoints) + m.points[key] = cur + return nil +} + +func (m *mockDivaRepo) GetPoints(charID, eventID uint32) (int64, int64, error) { + if m.getErr != nil { + return 0, 0, m.getErr + } + if m.points == nil { + return 0, 0, nil + } + p := m.points[[2]uint32{charID, eventID}] + return p[0], p[1], nil +} + +func (m *mockDivaRepo) GetTotalPoints(eventID uint32) (int64, int64, error) { + if m.totalErr != nil { + return 0, 0, m.totalErr + } + var tq, tb int64 + for k, v := range m.points { + if k[1] == eventID { + tq += v[0] + tb += v[1] + } + } + return tq, tb, nil +} + // --- mockEventRepo --- type mockEventRepo struct { diff --git a/server/migrations/sql/0009_diva_points.sql b/server/migrations/sql/0009_diva_points.sql new file mode 100644 index 000000000..c6363abee --- /dev/null +++ b/server/migrations/sql/0009_diva_points.sql @@ -0,0 +1,10 @@ +-- Track per-character Diva Defense (UD) point accumulation per event. +-- Each row records a character's total quest + bonus points for one event. +CREATE TABLE IF NOT EXISTS public.diva_points ( + char_id integer NOT NULL REFERENCES public.characters(id) ON DELETE CASCADE, + event_id integer NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + quest_points bigint NOT NULL DEFAULT 0, + bonus_points bigint NOT NULL DEFAULT 0, + updated_at timestamp with time zone NOT NULL DEFAULT now(), + PRIMARY KEY (char_id, event_id) +);