mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
feat(diva): implement Diva Defense point accumulation (#168)
RE'd putAdd_ud_point (FUN_114fd490) and putAdd_ud_tactics_point (FUN_114fe9c0) from the ZZ client DLL via Ghidra decompilation. MsgMhfAddUdPoint fields: QuestPoints (sum of 11 category accumulators earned per quest) and BonusPoints (kiju prayer song multiplier extra). MsgMhfAddUdTacticsPoint fields: QuestID and TacticsPoints. Adds diva_points table (migration 0009) for per-character per-event point tracking, with UPSERT-based atomic accumulation in the handler.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
10
server/migrations/sql/0009_diva_points.sql
Normal file
10
server/migrations/sql/0009_diva_points.sql
Normal file
@@ -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)
|
||||
);
|
||||
Reference in New Issue
Block a user