mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-26 09:33:02 +01:00
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:
@@ -1,27 +1,53 @@
|
|||||||
package mhfpacket
|
package mhfpacket
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"erupe-ce/common/byteframe"
|
"erupe-ce/common/byteframe"
|
||||||
"erupe-ce/network"
|
"erupe-ce/network"
|
||||||
"erupe-ce/network/clientctx"
|
"erupe-ce/network/clientctx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MsgMhfAddRewardSongCount represents the MSG_MHF_ADD_REWARD_SONG_COUNT
|
// MsgMhfAddRewardSongCount represents the MSG_MHF_ADD_REWARD_SONG_COUNT packet.
|
||||||
type MsgMhfAddRewardSongCount struct{}
|
// 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.
|
// Opcode returns the ID associated with this packet type.
|
||||||
func (m *MsgMhfAddRewardSongCount) Opcode() network.PacketID {
|
func (m *MsgMhfAddRewardSongCount) Opcode() network.PacketID {
|
||||||
return network.MSG_MHF_ADD_REWARD_SONG_COUNT
|
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 {
|
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.
|
// Build builds a binary packet from the current data.
|
||||||
func (m *MsgMhfAddRewardSongCount) Build(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ func cleanupDiva(s *Session) {
|
|||||||
if err := s.server.divaRepo.DeleteEvents(); err != nil {
|
if err := s.server.divaRepo.DeleteEvents(); err != nil {
|
||||||
s.logger.Error("Failed to delete diva events", zap.Error(err))
|
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 {
|
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())
|
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) {
|
func handleMsgMhfGetKijuInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetKijuInfo)
|
pkt := p.(*mhfpacket.MsgMhfGetKijuInfo)
|
||||||
// Temporary canned response
|
|
||||||
data, _ := hex.DecodeString("04965C959782CC8B468EEC00000000000000000000000000000000000000000000815C82A082E782B582DC82A982BA82CC82AB82B682E3815C0A965C959782C682CD96D282E98E7682A281420A95B782AD8ED282C997458B4382F0975E82A682E98142000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001018BAD8C8282CC8B468EEC00000000000000000000000000000000000000000000815C82AB82E582A482B082AB82CC82AB82B682E3815C0A8BAD8C8282C682CD8BAD82A290BA904681420A95B782AD8ED282CC97CD82F08CA482AC909F82DC82B78142200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003138C8B8F5782CC8B468EEC00000000000000000000000000000000000000000000815C82AF82C182B582E382A482CC82AB82B682E3815C0A8C8B8F5782C682CD8A6D8CC582BD82E9904D978A81420A8F5782DF82E982D982C782C98EEB906C82BD82BF82CC90B8905F97CD82C682C882E9814200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041189CC8CEC82CC8B468EEC00000000000000000000000000000000000000000000815C82A482BD82DC82E082E882CC82AB82B682E3815C0A89CC8CEC82C682CD89CC955082CC8CEC82E881420A8F5782DF82E982D982C782C98EEB906C82BD82BF82CC8E7882A682C682C882E9814220000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000212")
|
// RE-confirmed entry layout (546 bytes each):
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
// +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) {
|
func handleMsgMhfSetKiju(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfSetKiju)
|
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) {
|
func handleMsgMhfAddUdPoint(s *Session, p mhfpacket.MHFPacket) {
|
||||||
@@ -169,6 +206,17 @@ func handleMsgMhfAddUdPoint(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
zap.Uint32("bonusPoints", pkt.BonusPoints),
|
zap.Uint32("bonusPoints", pkt.BonusPoints),
|
||||||
zap.Error(err))
|
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})
|
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) {
|
func handleMsgMhfGetUdMyPoint(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdMyPoint)
|
pkt := p.(*mhfpacket.MsgMhfGetUdMyPoint)
|
||||||
// Temporary canned response
|
|
||||||
data, _ := hex.DecodeString("00040000013C000000FA000000000000000000040000007E0000003C02000000000000000000000000000000000000000000000000000002000004CC00000438000000000000000000000000000000000000000000000000000000020000026E00000230000000000000000000020000007D0000007D000000000000000000000000000000000000000000000000000000")
|
// RE confirms: no count prefix. Client hardcodes exactly 8 loop iterations.
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
// 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 0–12 are populated; slots 13–63 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) {
|
func handleMsgMhfGetUdTotalPointInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdTotalPointInfo)
|
pkt := p.(*mhfpacket.MsgMhfGetUdTotalPointInfo)
|
||||||
// Temporary canned response
|
|
||||||
data, _ := hex.DecodeString("00000000000007A12000000000000F424000000000001E848000000000002DC6C000000000003D090000000000004C4B4000000000005B8D8000000000006ACFC000000000007A1200000000000089544000000000009896800000000000E4E1C00000000001312D0000000000017D78400000000001C9C3800000000002160EC00000000002625A000000000002AEA5400000000002FAF0800000000003473BC0000000000393870000000000042C1D800000000004C4B40000000000055D4A800000000005F5E10000000000008954400000000001C9C3800000000003473BC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001020300000000000000000000000000000000000000000000000000000000000000000000000000000000101F1420")
|
total, err := s.server.divaRepo.GetTotalBeadPoints()
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
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) {
|
func handleMsgMhfGetUdSelectedColorInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdSelectedColorInfo)
|
pkt := p.(*mhfpacket.MsgMhfGetUdSelectedColorInfo)
|
||||||
|
|
||||||
// Unk
|
// RE confirms: exactly 9 bytes = u8 error + u8[8] winning colors.
|
||||||
doAckBufSucceed(s, pkt.AckHandle, []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x02, 0x00, 0x00})
|
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) {
|
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) {
|
func handleMsgMhfGetUdDailyPresentList(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdDailyPresentList)
|
pkt := p.(*mhfpacket.MsgMhfGetUdDailyPresentList)
|
||||||
// Temporary canned response
|
// DailyPresentList: u16 count + count × 15-byte entries.
|
||||||
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
|
// Entry: u8 rank_type, u16 rank_from, u16 rank_to, u8 item_type,
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
// 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) {
|
func handleMsgMhfGetUdNormaPresentList(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdNormaPresentList)
|
pkt := p.(*mhfpacket.MsgMhfGetUdNormaPresentList)
|
||||||
// Temporary canned response
|
// NormaPresentList: u16 count + count × 19-byte entries.
|
||||||
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
|
// Same layout as DailyPresent (+0x00..+0x0D), plus:
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
// +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) {
|
func handleMsgMhfAcquireUdItem(s *Session, p mhfpacket.MHFPacket) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package channelserver
|
package channelserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
|
||||||
|
|
||||||
"erupe-ce/common/byteframe"
|
"erupe-ce/common/byteframe"
|
||||||
"erupe-ce/network/mhfpacket"
|
"erupe-ce/network/mhfpacket"
|
||||||
)
|
)
|
||||||
@@ -18,21 +16,44 @@ func handleMsgMhfGetAdditionalBeatReward(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
|
|
||||||
func handleMsgMhfGetUdRankingRewardList(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfGetUdRankingRewardList(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdRankingRewardList)
|
pkt := p.(*mhfpacket.MsgMhfGetUdRankingRewardList)
|
||||||
// Temporary canned response
|
// RankingRewardList: u16 count + count × 14-byte entries.
|
||||||
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
|
// Entry: u8 rank_type, u16 rank_from, u16 rank_to, u8 item_type,
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
// 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) {
|
func handleMsgMhfGetRewardSong(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetRewardSong)
|
pkt := p.(*mhfpacket.MsgMhfGetRewardSong)
|
||||||
// Temporary canned response
|
// RE-confirmed layout (22 bytes):
|
||||||
data, _ := hex.DecodeString("0100001600000A5397DF00000000000000000000000000000000")
|
// +0x00 u8 error
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
// +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) {
|
func handleMsgMhfAcquireMonthlyReward(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfAcquireMonthlyReward)
|
pkt := p.(*mhfpacket.MsgMhfAcquireMonthlyReward)
|
||||||
|
|||||||
@@ -70,13 +70,17 @@ func TestHandleMsgMhfUseRewardSong(t *testing.T) {
|
|||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
defer func() {
|
pkt := &mhfpacket.MsgMhfUseRewardSong{AckHandle: 12345}
|
||||||
if r := recover(); r != nil {
|
handleMsgMhfUseRewardSong(session, pkt)
|
||||||
t.Errorf("handleMsgMhfUseRewardSong panicked: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
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) {
|
func TestHandleMsgMhfAddRewardSongCount(t *testing.T) {
|
||||||
@@ -193,16 +197,16 @@ func TestEmptyHandlers_MiscFiles_Reward(t *testing.T) {
|
|||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
tests := []struct {
|
// Handlers that accept nil and take no action (no AckHandle).
|
||||||
|
nilSafeTests := []struct {
|
||||||
name string
|
name string
|
||||||
fn func()
|
fn func()
|
||||||
}{
|
}{
|
||||||
{"handleMsgMhfUseRewardSong", func() { handleMsgMhfUseRewardSong(session, nil) }},
|
|
||||||
{"handleMsgMhfAddRewardSongCount", func() { handleMsgMhfAddRewardSongCount(session, nil) }},
|
{"handleMsgMhfAddRewardSongCount", func() { handleMsgMhfAddRewardSongCount(session, nil) }},
|
||||||
{"handleMsgMhfAcceptReadReward", func() { handleMsgMhfAcceptReadReward(session, nil) }},
|
{"handleMsgMhfAcceptReadReward", func() { handleMsgMhfAcceptReadReward(session, nil) }},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range nilSafeTests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
@@ -212,4 +216,18 @@ func TestEmptyHandlers_MiscFiles_Reward(t *testing.T) {
|
|||||||
tt.fn()
|
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
@@ -1,6 +1,9 @@
|
|||||||
package channelserver
|
package channelserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,3 +78,149 @@ func (r *DivaRepository) GetTotalPoints(eventID uint32) (int64, int64, error) {
|
|||||||
}
|
}
|
||||||
return qp, bp, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -322,6 +322,18 @@ type GoocooRepo interface {
|
|||||||
SaveSlot(charID uint32, slot uint32, data []byte) error
|
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.
|
// DivaRepo defines the contract for diva event data access.
|
||||||
type DivaRepo interface {
|
type DivaRepo interface {
|
||||||
DeleteEvents() error
|
DeleteEvents() error
|
||||||
@@ -330,6 +342,23 @@ type DivaRepo interface {
|
|||||||
AddPoints(charID uint32, eventID uint32, questPoints, bonusPoints uint32) error
|
AddPoints(charID uint32, eventID uint32, questPoints, bonusPoints uint32) error
|
||||||
GetPoints(charID uint32, eventID uint32) (questPoints, bonusPoints int64, err error)
|
GetPoints(charID uint32, eventID uint32) (questPoints, bonusPoints int64, err error)
|
||||||
GetTotalPoints(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.
|
// MiscRepo defines the contract for miscellaneous data access.
|
||||||
|
|||||||
@@ -1149,6 +1149,22 @@ func (m *mockDivaRepo) GetTotalPoints(eventID uint32) (int64, int64, error) {
|
|||||||
return tq, tb, nil
|
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 ---
|
// --- mockEventRepo ---
|
||||||
|
|
||||||
type mockEventRepo struct {
|
type mockEventRepo struct {
|
||||||
|
|||||||
@@ -1,6 +1,35 @@
|
|||||||
package channelserver
|
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 {
|
type i18n struct {
|
||||||
|
beads []Bead
|
||||||
language string
|
language string
|
||||||
cafe struct {
|
cafe struct {
|
||||||
reset string
|
reset string
|
||||||
@@ -168,6 +197,27 @@ func getLangStrings(s *Server) i18n {
|
|||||||
|
|
||||||
i.guild.invite.declined.title = "辞退しました"
|
i.guild.invite.declined.title = "辞退しました"
|
||||||
i.guild.invite.declined.body = "招待した狩人が「%s」への招待を辞退しました。"
|
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:
|
default:
|
||||||
i.language = "English"
|
i.language = "English"
|
||||||
i.cafe.reset = "Resets on %d/%d"
|
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.title = "Declined"
|
||||||
i.guild.invite.declined.body = "The recipient declined your invitation to join\n「%s」."
|
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
|
return i
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ type Session struct {
|
|||||||
// Contains the mail list that maps accumulated indexes to mail IDs
|
// Contains the mail list that maps accumulated indexes to mail IDs
|
||||||
mailList []int
|
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
|
Name string
|
||||||
closed atomic.Bool
|
closed atomic.Bool
|
||||||
ackStart map[uint32]time.Time
|
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)
|
cryptConn, captureConn, captureCleanup := startCapture(server, cryptConn, conn.RemoteAddr(), pcap.ServerTypeChannel)
|
||||||
|
|
||||||
s := &Session{
|
s := &Session{
|
||||||
logger: server.logger.Named(conn.RemoteAddr().String()),
|
logger: server.logger.Named(conn.RemoteAddr().String()),
|
||||||
server: server,
|
server: server,
|
||||||
rawConn: conn,
|
rawConn: conn,
|
||||||
cryptConn: cryptConn,
|
cryptConn: cryptConn,
|
||||||
sendPackets: make(chan packet, 20),
|
sendPackets: make(chan packet, 20),
|
||||||
clientContext: &clientctx.ClientContext{RealClientMode: server.erupeConfig.RealClientMode},
|
clientContext: &clientctx.ClientContext{RealClientMode: server.erupeConfig.RealClientMode},
|
||||||
lastPacket: time.Now(),
|
lastPacket: time.Now(),
|
||||||
objectID: server.getObjectId(),
|
objectID: server.getObjectId(),
|
||||||
sessionStart: TimeAdjusted().Unix(),
|
sessionStart: TimeAdjusted().Unix(),
|
||||||
stageMoveStack: stringstack.New(),
|
stageMoveStack: stringstack.New(),
|
||||||
ackStart: make(map[uint32]time.Time),
|
ackStart: make(map[uint32]time.Time),
|
||||||
semaphoreID: make([]uint16, 2),
|
semaphoreID: make([]uint16, 2),
|
||||||
captureConn: captureConn,
|
captureConn: captureConn,
|
||||||
captureCleanup: captureCleanup,
|
captureCleanup: captureCleanup,
|
||||||
|
currentBeadIndex: -1,
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ func createMockServer() *Server {
|
|||||||
state: make([]uint32, 30),
|
state: make([]uint32, 30),
|
||||||
support: 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.i18n = getLangStrings(s)
|
||||||
s.Registry = NewLocalChannelRegistry([]*Server{s})
|
s.Registry = NewLocalChannelRegistry([]*Server{s})
|
||||||
|
|||||||
32
server/migrations/seed/DivaDefaults.sql
Normal file
32
server/migrations/seed/DivaDefaults.sql
Normal 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;
|
||||||
44
server/migrations/sql/0011_diva.sql
Normal file
44
server/migrations/sql/0011_diva.sql
Normal 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()
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user