feat(diva): implement Diva Defense (UD) system

Add full Diva Defense / United Defense system: schema, repo layer,
i18n bead names, and RE-verified packet handler implementations.

Schema (0011_diva.sql): diva_beads, diva_beads_assignment,
diva_beads_points, diva_prizes tables; interception_maps/points
columns on guilds and guild_characters.

Seed (DivaDefaults.sql): 26 prize milestones for personal and
guild reward tracks (item_type=26 diva coins).

Repo (DivaRepo): 11 new methods covering bead assignment, point
accumulation, interception point tracking, prize queries, and
cleanup. Mocks wired in test_helpers_test.go.

i18n: Bead struct with EN/JP names for all 18 bead types (IDs
1–25). Session tracks currentBeadIndex (-1 = none assigned).

Packet handlers corrected against mhfo-hd.dll RE findings:
- GetKijuInfo: u8 count, 512-byte desc, color_id+bead_type per entry
- SetKiju: 1-byte ACK; persists bead assignment to DB
- GetUdMyPoint: 8×18-byte entries, no count prefix
- GetUdTotalPointInfo: u8 error + u64[64] + u8[64] + u64 (~585 B)
- GetUdSelectedColorInfo: u8 error + u8[8] = 9 bytes
- GetUdDailyPresentList: correct u16 count format (was wrong hex)
- GetUdNormaPresentList: correct u16 count format (was wrong hex)
- GetUdRankingRewardList: correct u16 count with u32 item_id/qty
- GetRewardSong: 22-byte layout with 0xFFFFFFFF prayer_end sentinel
- AddRewardSongCount: parse implemented (was NOT IMPLEMENTED stub)
This commit is contained in:
Houmgaor
2026-03-20 17:52:01 +01:00
parent 7ff033e36e
commit 2bd92c9ae7
13 changed files with 708 additions and 69 deletions

View File

@@ -1,27 +1,53 @@
package mhfpacket
import (
"errors"
"erupe-ce/common/byteframe"
"erupe-ce/network"
"erupe-ce/network/clientctx"
)
// MsgMhfAddRewardSongCount represents the MSG_MHF_ADD_REWARD_SONG_COUNT
type MsgMhfAddRewardSongCount struct{}
// MsgMhfAddRewardSongCount represents the MSG_MHF_ADD_REWARD_SONG_COUNT packet.
// Request layout:
//
// u32 ack_handle
// u32 prayer_id
// u16 array_size_bytes (= count × 2)
// u8 count
// u16[count] entries
type MsgMhfAddRewardSongCount struct {
AckHandle uint32
PrayerID uint32
ArraySizeBytes uint16
Count uint8
Entries []uint16
}
// Opcode returns the ID associated with this packet type.
func (m *MsgMhfAddRewardSongCount) Opcode() network.PacketID {
return network.MSG_MHF_ADD_REWARD_SONG_COUNT
}
// Parse parses the packet from binary
// Parse parses the packet from binary.
func (m *MsgMhfAddRewardSongCount) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
return errors.New("NOT IMPLEMENTED")
m.AckHandle = bf.ReadUint32()
m.PrayerID = bf.ReadUint32()
m.ArraySizeBytes = bf.ReadUint16()
m.Count = bf.ReadUint8()
m.Entries = make([]uint16, m.Count)
for i := range m.Entries {
m.Entries[i] = bf.ReadUint16()
}
return bf.Err()
}
// Build builds a binary packet from the current data.
func (m *MsgMhfAddRewardSongCount) Build(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
return errors.New("NOT IMPLEMENTED")
bf.WriteUint32(m.AckHandle)
bf.WriteUint32(m.PrayerID)
bf.WriteUint16(uint16(len(m.Entries) * 2))
bf.WriteUint8(uint8(len(m.Entries)))
for _, e := range m.Entries {
bf.WriteUint16(e)
}
return nil
}

View File

@@ -23,6 +23,9 @@ func cleanupDiva(s *Session) {
if err := s.server.divaRepo.DeleteEvents(); err != nil {
s.logger.Error("Failed to delete diva events", zap.Error(err))
}
if err := s.server.divaRepo.CleanupBeads(); err != nil {
s.logger.Error("Failed to cleanup diva beads", zap.Error(err))
}
}
func generateDivaTimestamps(s *Session, start uint32, debug bool) []uint32 {
@@ -137,16 +140,50 @@ func handleMsgMhfGetUdInfo(s *Session, p mhfpacket.MHFPacket) {
doAckBufSucceed(s, pkt.AckHandle, resp.Data())
}
// defaultBeadTypes are used when the database has no bead rows configured.
var defaultBeadTypes = []int{1, 3, 4, 8}
func handleMsgMhfGetKijuInfo(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetKijuInfo)
// Temporary canned response
data, _ := hex.DecodeString("04965C959782CC8B468EEC00000000000000000000000000000000000000000000815C82A082E782B582DC82A982BA82CC82AB82B682E3815C0A965C959782C682CD96D282E98E7682A281420A95B782AD8ED282C997458B4382F0975E82A682E98142000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001018BAD8C8282CC8B468EEC00000000000000000000000000000000000000000000815C82AB82E582A482B082AB82CC82AB82B682E3815C0A8BAD8C8282C682CD8BAD82A290BA904681420A95B782AD8ED282CC97CD82F08CA482AC909F82DC82B78142200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003138C8B8F5782CC8B468EEC00000000000000000000000000000000000000000000815C82AF82C182B582E382A482CC82AB82B682E3815C0A8C8B8F5782C682CD8A6D8CC582BD82E9904D978A81420A8F5782DF82E982D982C782C98EEB906C82BD82BF82CC90B8905F97CD82C682C882E9814200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041189CC8CEC82CC8B468EEC00000000000000000000000000000000000000000000815C82A482BD82DC82E082E882CC82AB82B682E3815C0A89CC8CEC82C682CD89CC955082CC8CEC82E881420A8F5782DF82E982D982C782C98EEB906C82BD82BF82CC8E7882A682C682C882E9814220000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000212")
doAckBufSucceed(s, pkt.AckHandle, data)
// RE-confirmed entry layout (546 bytes each):
// +0x000 char[32] name
// +0x020 char[512] description
// +0x220 u8 color_id (slot index, 1-based)
// +0x221 u8 bead_type (effect ID)
// Response: u8 count + count × 546 bytes.
beadTypes, err := s.server.divaRepo.GetBeads()
if err != nil || len(beadTypes) == 0 {
beadTypes = defaultBeadTypes
}
lang := getLangStrings(s.server)
bf := byteframe.NewByteFrame()
bf.WriteUint8(uint8(len(beadTypes)))
for i, bt := range beadTypes {
name, desc := lang.beadName(bt), lang.beadDescription(bt)
bf.WriteBytes(stringsupport.PaddedString(name, 32, true))
bf.WriteBytes(stringsupport.PaddedString(desc, 512, true))
bf.WriteUint8(uint8(i + 1)) // color_id: slot 1..N
bf.WriteUint8(uint8(bt)) // bead_type: effect ID
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfSetKiju(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfSetKiju)
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
beadIndex := int(pkt.Unk1)
expiry := TimeAdjusted().Add(24 * time.Hour)
if err := s.server.divaRepo.AssignBead(s.charID, beadIndex, expiry); err != nil {
s.logger.Warn("Failed to assign bead",
zap.Uint32("charID", s.charID),
zap.Int("beadIndex", beadIndex),
zap.Error(err))
} else {
s.currentBeadIndex = beadIndex
}
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00})
}
func handleMsgMhfAddUdPoint(s *Session, p mhfpacket.MHFPacket) {
@@ -169,6 +206,17 @@ func handleMsgMhfAddUdPoint(s *Session, p mhfpacket.MHFPacket) {
zap.Uint32("bonusPoints", pkt.BonusPoints),
zap.Error(err))
}
if s.currentBeadIndex >= 0 {
total := int(pkt.QuestPoints) + int(pkt.BonusPoints)
if total > 0 {
if err := s.server.divaRepo.AddBeadPoints(s.charID, s.currentBeadIndex, total); err != nil {
s.logger.Warn("Failed to add bead points",
zap.Uint32("charID", s.charID),
zap.Int("beadIndex", s.currentBeadIndex),
zap.Error(err))
}
}
}
}
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
@@ -176,23 +224,92 @@ func handleMsgMhfAddUdPoint(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfGetUdMyPoint(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdMyPoint)
// Temporary canned response
data, _ := hex.DecodeString("00040000013C000000FA000000000000000000040000007E0000003C02000000000000000000000000000000000000000000000000000002000004CC00000438000000000000000000000000000000000000000000000000000000020000026E00000230000000000000000000020000007D0000007D000000000000000000000000000000000000000000000000000000")
doAckBufSucceed(s, pkt.AckHandle, data)
// RE confirms: no count prefix. Client hardcodes exactly 8 loop iterations.
// Per-entry stride is 18 bytes:
// +0x00 u8 bead_index
// +0x01 u32 points
// +0x05 u32 points_dupe (same value as points)
// +0x09 u8 unk1 (half-period: 0=first 12h, 1=second 12h)
// +0x0A u32 unk2
// +0x0E u32 unk3
// Total: 8 × 18 = 144 bytes.
beadPoints, err := s.server.divaRepo.GetCharacterBeadPoints(s.charID)
if err != nil {
s.logger.Warn("Failed to get bead points", zap.Uint32("charID", s.charID), zap.Error(err))
beadPoints = map[int]int{}
}
activeBead := uint8(0)
if s.currentBeadIndex >= 0 {
activeBead = uint8(s.currentBeadIndex)
}
pts := uint32(0)
if s.currentBeadIndex >= 0 {
if p, ok := beadPoints[s.currentBeadIndex]; ok {
pts = uint32(p)
}
}
bf := byteframe.NewByteFrame()
for i := 0; i < 8; i++ {
bf.WriteUint8(activeBead)
bf.WriteUint32(pts)
bf.WriteUint32(pts) // points_dupe
bf.WriteUint8(uint8(i % 2)) // unk1: 0=first half, 1=second half
bf.WriteUint32(0) // unk2
bf.WriteUint32(0) // unk3
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
// udMilestones are the global contribution milestones for Diva Defense.
// RE confirms: 64 × u64 target_values + 64 × u8 target_types + u64 total = ~585 bytes.
// Slots 012 are populated; slots 1363 are zero.
var udMilestones = []uint64{
500000, 1000000, 2000000, 3000000, 5000000, 7000000, 10000000,
15000000, 20000000, 30000000, 50000000, 70000000, 100000000,
}
func handleMsgMhfGetUdTotalPointInfo(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdTotalPointInfo)
// Temporary canned response
data, _ := hex.DecodeString("00000000000007A12000000000000F424000000000001E848000000000002DC6C000000000003D090000000000004C4B4000000000005B8D8000000000006ACFC000000000007A1200000000000089544000000000009896800000000000E4E1C00000000001312D0000000000017D78400000000001C9C3800000000002160EC00000000002625A000000000002AEA5400000000002FAF0800000000003473BC0000000000393870000000000042C1D800000000004C4B40000000000055D4A800000000005F5E10000000000008954400000000001C9C3800000000003473BC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001020300000000000000000000000000000000000000000000000000000000000000000000000000000000101F1420")
doAckBufSucceed(s, pkt.AckHandle, data)
total, err := s.server.divaRepo.GetTotalBeadPoints()
if err != nil {
s.logger.Warn("Failed to get total bead points", zap.Error(err))
}
bf := byteframe.NewByteFrame()
bf.WriteUint8(0) // error = success
// 64 × u64 target_values (big-endian)
for i := 0; i < 64; i++ {
var v uint64
if i < len(udMilestones) {
v = udMilestones[i]
}
bf.WriteUint64(v)
}
// 64 × u8 target_types (0 = global)
for i := 0; i < 64; i++ {
bf.WriteUint8(0)
}
// u64 total_souls
bf.WriteUint64(uint64(total))
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfGetUdSelectedColorInfo(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdSelectedColorInfo)
// Unk
doAckBufSucceed(s, pkt.AckHandle, []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x02, 0x00, 0x00})
// RE confirms: exactly 9 bytes = u8 error + u8[8] winning colors.
bf := byteframe.NewByteFrame()
bf.WriteUint8(0) // error = success
for day := 0; day < 8; day++ {
topBead, err := s.server.divaRepo.GetTopBeadPerDay(day)
if err != nil {
topBead = 0
}
bf.WriteUint8(uint8(topBead))
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfGetUdMonsterPoint(s *Session, p mhfpacket.MHFPacket) {
@@ -329,16 +446,25 @@ func handleMsgMhfGetUdMonsterPoint(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfGetUdDailyPresentList(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdDailyPresentList)
// Temporary canned response
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
doAckBufSucceed(s, pkt.AckHandle, data)
// DailyPresentList: u16 count + count × 15-byte entries.
// Entry: u8 rank_type, u16 rank_from, u16 rank_to, u8 item_type,
// u16 _pad0(skip), u16 item_id, u16 _pad1(skip), u16 quantity, u8 unk.
// Padding at +6 and +10 is NOT read by the client.
bf := byteframe.NewByteFrame()
bf.WriteUint16(0) // count = 0 (no entries configured)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfGetUdNormaPresentList(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdNormaPresentList)
// Temporary canned response
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
doAckBufSucceed(s, pkt.AckHandle, data)
// NormaPresentList: u16 count + count × 19-byte entries.
// Same layout as DailyPresent (+0x00..+0x0D), plus:
// +0x0E u32 points_required (norma threshold)
// +0x12 u8 bead_type (BeadType that unlocks this tier)
// Padding at +6 and +10 NOT read.
bf := byteframe.NewByteFrame()
bf.WriteUint16(0) // count = 0 (no entries configured)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfAcquireUdItem(s *Session, p mhfpacket.MHFPacket) {

View File

@@ -1,8 +1,6 @@
package channelserver
import (
"encoding/hex"
"erupe-ce/common/byteframe"
"erupe-ce/network/mhfpacket"
)
@@ -18,21 +16,44 @@ func handleMsgMhfGetAdditionalBeatReward(s *Session, p mhfpacket.MHFPacket) {
func handleMsgMhfGetUdRankingRewardList(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetUdRankingRewardList)
// Temporary canned response
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
doAckBufSucceed(s, pkt.AckHandle, data)
// RankingRewardList: u16 count + count × 14-byte entries.
// Entry: u8 rank_type, u16 rank_from, u16 rank_to, u8 item_type,
// u32 item_id, u32 quantity. No padding gaps.
bf := byteframe.NewByteFrame()
bf.WriteUint16(0) // count = 0 (no entries configured)
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfGetRewardSong(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfGetRewardSong)
// Temporary canned response
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
doAckBufSucceed(s, pkt.AckHandle, data)
// RE-confirmed layout (22 bytes):
// +0x00 u8 error
// +0x01 u8 usage_count
// +0x02 u32 prayer_id
// +0x06 u32 prayer_end (0xFFFFFFFF = no active prayer)
// then 4 × (u8 color_error, u8 color_id, u8 color_usage_count)
bf := byteframe.NewByteFrame()
bf.WriteUint8(0) // error
bf.WriteUint8(0) // usage_count
bf.WriteUint32(0) // prayer_id
bf.WriteUint32(0xFFFFFFFF) // prayer_end: no active prayer
for colorID := uint8(1); colorID <= 4; colorID++ {
bf.WriteUint8(0) // color_error
bf.WriteUint8(colorID) // color_id
bf.WriteUint8(0) // color_usage_count
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}
func handleMsgMhfUseRewardSong(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented
func handleMsgMhfUseRewardSong(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfUseRewardSong)
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00})
}
func handleMsgMhfAddRewardSongCount(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented
func handleMsgMhfAddRewardSongCount(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAddRewardSongCount)
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00})
}
func handleMsgMhfAcquireMonthlyReward(s *Session, p mhfpacket.MHFPacket) {
pkt := p.(*mhfpacket.MsgMhfAcquireMonthlyReward)

View File

@@ -70,13 +70,17 @@ func TestHandleMsgMhfUseRewardSong(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
defer func() {
if r := recover(); r != nil {
t.Errorf("handleMsgMhfUseRewardSong panicked: %v", r)
}
}()
pkt := &mhfpacket.MsgMhfUseRewardSong{AckHandle: 12345}
handleMsgMhfUseRewardSong(session, pkt)
handleMsgMhfUseRewardSong(session, nil)
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("Response packet should have data")
}
default:
t.Error("No response packet queued")
}
}
func TestHandleMsgMhfAddRewardSongCount(t *testing.T) {
@@ -193,16 +197,16 @@ func TestEmptyHandlers_MiscFiles_Reward(t *testing.T) {
server := createMockServer()
session := createMockSession(1, server)
tests := []struct {
// Handlers that accept nil and take no action (no AckHandle).
nilSafeTests := []struct {
name string
fn func()
}{
{"handleMsgMhfUseRewardSong", func() { handleMsgMhfUseRewardSong(session, nil) }},
{"handleMsgMhfAddRewardSongCount", func() { handleMsgMhfAddRewardSongCount(session, nil) }},
{"handleMsgMhfAcceptReadReward", func() { handleMsgMhfAcceptReadReward(session, nil) }},
}
for _, tt := range tests {
for _, tt := range nilSafeTests {
t.Run(tt.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
@@ -212,4 +216,18 @@ func TestEmptyHandlers_MiscFiles_Reward(t *testing.T) {
tt.fn()
})
}
// handleMsgMhfUseRewardSong is a real handler (requires a typed packet).
t.Run("handleMsgMhfUseRewardSong", func(t *testing.T) {
pkt := &mhfpacket.MsgMhfUseRewardSong{AckHandle: 1}
handleMsgMhfUseRewardSong(session, pkt)
select {
case p := <-session.sendPackets:
if len(p.data) == 0 {
t.Error("handleMsgMhfUseRewardSong: response should have data")
}
default:
t.Error("handleMsgMhfUseRewardSong: no response queued")
}
})
}

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,9 @@
package channelserver
import (
"encoding/json"
"time"
"github.com/jmoiron/sqlx"
)
@@ -75,3 +78,149 @@ func (r *DivaRepository) GetTotalPoints(eventID uint32) (int64, int64, error) {
}
return qp, bp, nil
}
// GetBeads returns all active bead types from the diva_beads table.
func (r *DivaRepository) GetBeads() ([]int, error) {
var types []int
err := r.db.Select(&types, "SELECT type FROM diva_beads ORDER BY id")
return types, err
}
// AssignBead inserts a bead assignment for a character, replacing any existing one for that bead slot.
func (r *DivaRepository) AssignBead(characterID uint32, beadIndex int, expiry time.Time) error {
_, err := r.db.Exec(`
INSERT INTO diva_beads_assignment (character_id, bead_index, expiry)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING`,
characterID, beadIndex, expiry)
return err
}
// AddBeadPoints records a bead point contribution for a character.
func (r *DivaRepository) AddBeadPoints(characterID uint32, beadIndex int, points int) error {
_, err := r.db.Exec(
"INSERT INTO diva_beads_points (character_id, bead_index, points) VALUES ($1, $2, $3)",
characterID, beadIndex, points)
return err
}
// GetCharacterBeadPoints returns the summed points per bead_index for a character.
func (r *DivaRepository) GetCharacterBeadPoints(characterID uint32) (map[int]int, error) {
rows, err := r.db.Query(
"SELECT bead_index, COALESCE(SUM(points),0) FROM diva_beads_points WHERE character_id=$1 GROUP BY bead_index",
characterID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[int]int)
for rows.Next() {
var idx, pts int
if err := rows.Scan(&idx, &pts); err != nil {
return nil, err
}
result[idx] = pts
}
return result, rows.Err()
}
// GetTotalBeadPoints returns the sum of all points across all characters and bead slots.
func (r *DivaRepository) GetTotalBeadPoints() (int64, error) {
var total int64
err := r.db.QueryRow("SELECT COALESCE(SUM(points),0) FROM diva_beads_points").Scan(&total)
return total, err
}
// GetTopBeadPerDay returns the bead_index with the most points contributed on day offset `day`
// (0 = today, 1 = yesterday, etc.). Returns 0 if no data exists for that day.
func (r *DivaRepository) GetTopBeadPerDay(day int) (int, error) {
var beadIndex int
err := r.db.QueryRow(`
SELECT bead_index
FROM diva_beads_points
WHERE timestamp >= (NOW() - ($1 + 1) * INTERVAL '1 day')
AND timestamp < (NOW() - $1 * INTERVAL '1 day')
GROUP BY bead_index
ORDER BY SUM(points) DESC
LIMIT 1`,
day).Scan(&beadIndex)
if err != nil {
return 0, nil // no data for this day is not an error
}
return beadIndex, nil
}
// CleanupBeads deletes all rows from diva_beads, diva_beads_assignment, and diva_beads_points.
func (r *DivaRepository) CleanupBeads() error {
if _, err := r.db.Exec("DELETE FROM diva_beads_points"); err != nil {
return err
}
if _, err := r.db.Exec("DELETE FROM diva_beads_assignment"); err != nil {
return err
}
_, err := r.db.Exec("DELETE FROM diva_beads")
return err
}
// GetPersonalPrizes returns all prize rows with type='personal', ordered by points_req.
func (r *DivaRepository) GetPersonalPrizes() ([]DivaPrize, error) {
return r.getPrizesByType("personal")
}
// GetGuildPrizes returns all prize rows with type='guild', ordered by points_req.
func (r *DivaRepository) GetGuildPrizes() ([]DivaPrize, error) {
return r.getPrizesByType("guild")
}
func (r *DivaRepository) getPrizesByType(prizeType string) ([]DivaPrize, error) {
rows, err := r.db.Query(`
SELECT id, type, points_req, item_type, item_id, quantity, gr, repeatable
FROM diva_prizes
WHERE type=$1
ORDER BY points_req`,
prizeType)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var prizes []DivaPrize
for rows.Next() {
var p DivaPrize
if err := rows.Scan(&p.ID, &p.Type, &p.PointsReq, &p.ItemType, &p.ItemID, &p.Quantity, &p.GR, &p.Repeatable); err != nil {
return nil, err
}
prizes = append(prizes, p)
}
return prizes, rows.Err()
}
// GetCharacterInterceptionPoints returns the interception_points JSON map from guild_characters.
func (r *DivaRepository) GetCharacterInterceptionPoints(characterID uint32) (map[string]int, error) {
var raw []byte
err := r.db.QueryRow(
"SELECT interception_points FROM guild_characters WHERE char_id=$1",
characterID).Scan(&raw)
if err != nil {
return map[string]int{}, nil
}
result := make(map[string]int)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &result); err != nil {
return map[string]int{}, nil
}
}
return result, nil
}
// AddInterceptionPoints increments the interception points for a quest file ID in guild_characters.
func (r *DivaRepository) AddInterceptionPoints(characterID uint32, questFileID int, points int) error {
_, err := r.db.Exec(`
UPDATE guild_characters
SET interception_points = interception_points || jsonb_build_object(
$2::text,
COALESCE((interception_points->>$2::text)::int, 0) + $3
)
WHERE char_id=$1`,
characterID, questFileID, points)
return err
}

View File

@@ -322,6 +322,18 @@ type GoocooRepo interface {
SaveSlot(charID uint32, slot uint32, data []byte) error
}
// DivaPrize represents a single reward milestone for the personal or guild track.
type DivaPrize struct {
ID int
Type string
PointsReq int
ItemType int
ItemID int
Quantity int
GR bool
Repeatable bool
}
// DivaRepo defines the contract for diva event data access.
type DivaRepo interface {
DeleteEvents() error
@@ -330,6 +342,23 @@ type DivaRepo interface {
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)
// Bead management
GetBeads() ([]int, error)
AssignBead(characterID uint32, beadIndex int, expiry time.Time) error
AddBeadPoints(characterID uint32, beadIndex int, points int) error
GetCharacterBeadPoints(characterID uint32) (map[int]int, error)
GetTotalBeadPoints() (int64, error)
GetTopBeadPerDay(day int) (int, error)
CleanupBeads() error
// Prize rewards
GetPersonalPrizes() ([]DivaPrize, error)
GetGuildPrizes() ([]DivaPrize, error)
// Interception points (guild_characters.interception_points JSON)
GetCharacterInterceptionPoints(characterID uint32) (map[string]int, error)
AddInterceptionPoints(characterID uint32, questFileID int, points int) error
}
// MiscRepo defines the contract for miscellaneous data access.

View File

@@ -1149,6 +1149,22 @@ func (m *mockDivaRepo) GetTotalPoints(eventID uint32) (int64, int64, error) {
return tq, tb, nil
}
func (m *mockDivaRepo) GetBeads() ([]int, error) { return nil, nil }
func (m *mockDivaRepo) AssignBead(_ uint32, _ int, _ time.Time) error { return nil }
func (m *mockDivaRepo) AddBeadPoints(_ uint32, _ int, _ int) error { return nil }
func (m *mockDivaRepo) GetCharacterBeadPoints(_ uint32) (map[int]int, error) {
return map[int]int{}, nil
}
func (m *mockDivaRepo) GetTotalBeadPoints() (int64, error) { return 0, nil }
func (m *mockDivaRepo) GetTopBeadPerDay(_ int) (int, error) { return 0, nil }
func (m *mockDivaRepo) CleanupBeads() error { return nil }
func (m *mockDivaRepo) GetPersonalPrizes() ([]DivaPrize, error) { return nil, nil }
func (m *mockDivaRepo) GetGuildPrizes() ([]DivaPrize, error) { return nil, nil }
func (m *mockDivaRepo) GetCharacterInterceptionPoints(_ uint32) (map[string]int, error) {
return map[string]int{}, nil
}
func (m *mockDivaRepo) AddInterceptionPoints(_ uint32, _ int, _ int) error { return nil }
// --- mockEventRepo ---
type mockEventRepo struct {

View File

@@ -1,6 +1,35 @@
package channelserver
// Bead holds the display strings for a single kiju prayer bead type.
type Bead struct {
ID int
Name string
Description string
}
// beadName returns the localised name for a bead type, falling back to a
// generic label if the type is not in the table.
func (i *i18n) beadName(beadType int) string {
for _, b := range i.beads {
if b.ID == beadType {
return b.Name
}
}
return ""
}
// beadDescription returns the localised description for a bead type.
func (i *i18n) beadDescription(beadType int) string {
for _, b := range i.beads {
if b.ID == beadType {
return b.Description
}
}
return ""
}
type i18n struct {
beads []Bead
language string
cafe struct {
reset string
@@ -168,6 +197,27 @@ func getLangStrings(s *Server) i18n {
i.guild.invite.declined.title = "辞退しました"
i.guild.invite.declined.body = "招待した狩人が「%s」への招待を辞退しました。"
i.beads = []Bead{
{1, "暴風の祈珠", "暴風の力を宿した祈珠。\n嵐を呼ぶ力で仲間を鼓舞する。"},
{3, "断力の祈珠", "断力の力を宿した祈珠。\n斬撃の力を仲間に授ける。"},
{4, "活力の祈珠", "活力の力を宿した祈珠。\n体力を高める力で仲間を鼓舞する。"},
{8, "癒しの祈珠", "癒しの力を宿した祈珠。\n回復の力で仲間を守る。"},
{9, "激昂の祈珠", "激昂の力を宿した祈珠。\n怒りの力を仲間に与える。"},
{10, "瘴気の祈珠", "瘴気の力を宿した祈珠。\n毒霧の力を仲間に与える。"},
{11, "剛力の祈珠", "剛力の力を宿した祈珠。\n強大な力を仲間に授ける。"},
{14, "雷光の祈珠", "雷光の力を宿した祈珠。\n稲妻の力を仲間に与える。"},
{15, "氷結の祈珠", "氷結の力を宿した祈珠。\n冷気の力を仲間に与える。"},
{17, "炎熱の祈珠", "炎熱の力を宿した祈珠。\n炎の力を仲間に与える。"},
{18, "水流の祈珠", "水流の力を宿した祈珠。\n水の力を仲間に与える。"},
{19, "龍気の祈珠", "龍気の力を宿した祈珠。\n龍属性の力を仲間に与える。"},
{20, "大地の祈珠", "大地の力を宿した祈珠。\n大地の力を仲間に与える。"},
{21, "疾風の祈珠", "疾風の力を宿した祈珠。\n素早さを高める力を仲間に与える。"},
{22, "光輝の祈珠", "光輝の力を宿した祈珠。\n光の力で仲間を鼓舞する。"},
{23, "暗影の祈珠", "暗影の力を宿した祈珠。\n闇の力を仲間に与える。"},
{24, "鋼鉄の祈珠", "鋼鉄の力を宿した祈珠。\n防御力を高める力を仲間に与える。"},
{25, "封属の祈珠", "封属の力を宿した祈珠。\n属性を封じる力を仲間に与える。"},
}
default:
i.language = "English"
i.cafe.reset = "Resets on %d/%d"
@@ -236,6 +286,27 @@ func getLangStrings(s *Server) i18n {
i.guild.invite.declined.title = "Declined"
i.guild.invite.declined.body = "The recipient declined your invitation to join\n「%s」."
i.beads = []Bead{
{1, "Bead of Storms", "A prayer bead imbued with the power of storms.\nSummons raging winds to bolster allies."},
{3, "Bead of Severing", "A prayer bead imbued with severing power.\nGrants allies increased cutting strength."},
{4, "Bead of Vitality", "A prayer bead imbued with vitality.\nBoosts the health of those around it."},
{8, "Bead of Healing", "A prayer bead imbued with healing power.\nProtects allies with restorative energy."},
{9, "Bead of Fury", "A prayer bead imbued with furious energy.\nFuels allies with battle rage."},
{10, "Bead of Blight", "A prayer bead imbued with miasma.\nInfuses allies with poisonous power."},
{11, "Bead of Power", "A prayer bead imbued with raw might.\nGrants allies overwhelming strength."},
{14, "Bead of Thunder", "A prayer bead imbued with lightning.\nCharges allies with electric force."},
{15, "Bead of Ice", "A prayer bead imbued with freezing cold.\nGrants allies chilling elemental power."},
{17, "Bead of Fire", "A prayer bead imbued with searing heat.\nIgnites allies with fiery elemental power."},
{18, "Bead of Water", "A prayer bead imbued with flowing water.\nGrants allies water elemental power."},
{19, "Bead of Dragon", "A prayer bead imbued with dragon energy.\nGrants allies dragon elemental power."},
{20, "Bead of Earth", "A prayer bead imbued with earth power.\nGrounds allies with elemental earth force."},
{21, "Bead of Wind", "A prayer bead imbued with swift wind.\nGrants allies increased agility."},
{22, "Bead of Light", "A prayer bead imbued with radiant light.\nInspires allies with luminous energy."},
{23, "Bead of Shadow", "A prayer bead imbued with darkness.\nInfuses allies with shadowy power."},
{24, "Bead of Iron", "A prayer bead imbued with iron strength.\nGrants allies fortified defence."},
{25, "Bead of Immunity", "A prayer bead imbued with sealing power.\nNullifies elemental weaknesses for allies."},
}
}
return i
}

View File

@@ -72,6 +72,10 @@ type Session struct {
// Contains the mail list that maps accumulated indexes to mail IDs
mailList []int
// currentBeadIndex is the bead slot selected by the player via MsgMhfSetKiju.
// A value of -1 means no bead is currently assigned this session.
currentBeadIndex int
Name string
closed atomic.Bool
ackStart map[uint32]time.Time
@@ -86,20 +90,21 @@ func NewSession(server *Server, conn net.Conn) *Session {
cryptConn, captureConn, captureCleanup := startCapture(server, cryptConn, conn.RemoteAddr(), pcap.ServerTypeChannel)
s := &Session{
logger: server.logger.Named(conn.RemoteAddr().String()),
server: server,
rawConn: conn,
cryptConn: cryptConn,
sendPackets: make(chan packet, 20),
clientContext: &clientctx.ClientContext{RealClientMode: server.erupeConfig.RealClientMode},
lastPacket: time.Now(),
objectID: server.getObjectId(),
sessionStart: TimeAdjusted().Unix(),
stageMoveStack: stringstack.New(),
ackStart: make(map[uint32]time.Time),
semaphoreID: make([]uint16, 2),
captureConn: captureConn,
captureCleanup: captureCleanup,
logger: server.logger.Named(conn.RemoteAddr().String()),
server: server,
rawConn: conn,
cryptConn: cryptConn,
sendPackets: make(chan packet, 20),
clientContext: &clientctx.ClientContext{RealClientMode: server.erupeConfig.RealClientMode},
lastPacket: time.Now(),
objectID: server.getObjectId(),
sessionStart: TimeAdjusted().Unix(),
stageMoveStack: stringstack.New(),
ackStart: make(map[uint32]time.Time),
semaphoreID: make([]uint16, 2),
captureConn: captureConn,
captureCleanup: captureCleanup,
currentBeadIndex: -1,
}
return s
}

View File

@@ -48,6 +48,9 @@ func createMockServer() *Server {
state: make([]uint32, 30),
support: make([]uint32, 30),
},
// divaRepo default prevents nil-deref in diva handler tests that don't
// need specific repo behaviour. Tests that need controlled data override it.
divaRepo: &mockDivaRepo{},
}
s.i18n = getLangStrings(s)
s.Registry = NewLocalChannelRegistry([]*Server{s})

View File

@@ -0,0 +1,32 @@
-- Diva Defense default prize rewards.
-- Personal track: type='personal', quantity=1 per milestone.
-- Guild track: type='guild', quantity=5 per milestone.
-- item_type=26 is Diva Coins; item_id=0 for all.
INSERT INTO diva_prizes (type, points_req, item_type, item_id, quantity, gr, repeatable) VALUES
('personal', 500000, 26, 0, 1, false, false),
('personal', 1000000, 26, 0, 1, false, false),
('personal', 2000000, 26, 0, 1, false, false),
('personal', 3000000, 26, 0, 1, false, false),
('personal', 5000000, 26, 0, 1, false, false),
('personal', 7000000, 26, 0, 1, false, false),
('personal', 10000000, 26, 0, 1, false, false),
('personal', 15000000, 26, 0, 1, false, false),
('personal', 20000000, 26, 0, 1, false, false),
('personal', 30000000, 26, 0, 1, false, false),
('personal', 50000000, 26, 0, 1, false, false),
('personal', 70000000, 26, 0, 1, false, false),
('personal', 100000000, 26, 0, 1, false, false),
('guild', 500000, 26, 0, 5, false, false),
('guild', 1000000, 26, 0, 5, false, false),
('guild', 2000000, 26, 0, 5, false, false),
('guild', 3000000, 26, 0, 5, false, false),
('guild', 5000000, 26, 0, 5, false, false),
('guild', 7000000, 26, 0, 5, false, false),
('guild', 10000000, 26, 0, 5, false, false),
('guild', 15000000, 26, 0, 5, false, false),
('guild', 20000000, 26, 0, 5, false, false),
('guild', 30000000, 26, 0, 5, false, false),
('guild', 50000000, 26, 0, 5, false, false),
('guild', 70000000, 26, 0, 5, false, false),
('guild', 100000000, 26, 0, 5, false, false)
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,44 @@
-- Diva Defense (United Defense) extended schema.
-- Adds bead selection, per-bead point accumulation, interception points,
-- and prize reward tables for personal and guild tracks.
-- Interception map data per guild (binary blob, existing column pattern).
ALTER TABLE guilds ADD COLUMN IF NOT EXISTS interception_maps bytea;
-- Per-character interception points keyed by quest file ID.
ALTER TABLE guild_characters ADD COLUMN IF NOT EXISTS interception_points jsonb NOT NULL DEFAULT '{}';
-- Prize reward table for personal and guild tracks.
CREATE TABLE IF NOT EXISTS diva_prizes (
id SERIAL PRIMARY KEY,
type VARCHAR(10) NOT NULL CHECK (type IN ('personal', 'guild')),
points_req INTEGER NOT NULL,
item_type INTEGER NOT NULL,
item_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
gr BOOLEAN NOT NULL DEFAULT false,
repeatable BOOLEAN NOT NULL DEFAULT false
);
-- Active bead types for the current Diva Defense event.
CREATE TABLE IF NOT EXISTS diva_beads (
id SERIAL PRIMARY KEY,
type INTEGER NOT NULL
);
-- Per-character bead slot assignments with expiry.
CREATE TABLE IF NOT EXISTS diva_beads_assignment (
id SERIAL PRIMARY KEY,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
bead_index INTEGER NOT NULL,
expiry TIMESTAMPTZ NOT NULL
);
-- Per-character bead point accumulation log.
CREATE TABLE IF NOT EXISTS diva_beads_points (
id SERIAL PRIMARY KEY,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
bead_index INTEGER NOT NULL,
points INTEGER NOT NULL,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);