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:
Houmgaor
2026-03-18 12:09:44 +01:00
parent 61d85e749f
commit 792dcd5d91
10 changed files with 217 additions and 18 deletions

View File

@@ -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})
}

View File

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

View File

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

View File

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

View File

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

View 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)
);