From 2bd92c9ae7ba3293d66377e0f467e16cca48c7ea Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Fri, 20 Mar 2026 17:52:01 +0100 Subject: [PATCH] feat(diva): implement Diva Defense (UD) system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../msg_mhf_add_reward_song_count.go | 40 ++++- server/channelserver/handlers_diva.go | 162 ++++++++++++++++-- server/channelserver/handlers_reward.go | 41 +++-- server/channelserver/handlers_reward_test.go | 36 +++- server/channelserver/handlers_tactics.go | 121 +++++++++++-- server/channelserver/repo_diva.go | 149 ++++++++++++++++ server/channelserver/repo_interfaces.go | 29 ++++ server/channelserver/repo_mocks_test.go | 16 ++ server/channelserver/sys_language.go | 71 ++++++++ server/channelserver/sys_session.go | 33 ++-- server/channelserver/test_helpers_test.go | 3 + server/migrations/seed/DivaDefaults.sql | 32 ++++ server/migrations/sql/0011_diva.sql | 44 +++++ 13 files changed, 708 insertions(+), 69 deletions(-) create mode 100644 server/migrations/seed/DivaDefaults.sql create mode 100644 server/migrations/sql/0011_diva.sql diff --git a/network/mhfpacket/msg_mhf_add_reward_song_count.go b/network/mhfpacket/msg_mhf_add_reward_song_count.go index fa6afc236..20db789a1 100644 --- a/network/mhfpacket/msg_mhf_add_reward_song_count.go +++ b/network/mhfpacket/msg_mhf_add_reward_song_count.go @@ -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 } diff --git a/server/channelserver/handlers_diva.go b/server/channelserver/handlers_diva.go index b79979d8e..c576d83bb 100644 --- a/server/channelserver/handlers_diva.go +++ b/server/channelserver/handlers_diva.go @@ -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 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) { 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) { diff --git a/server/channelserver/handlers_reward.go b/server/channelserver/handlers_reward.go index 9e6a04cdf..56a3a5dd8 100644 --- a/server/channelserver/handlers_reward.go +++ b/server/channelserver/handlers_reward.go @@ -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) diff --git a/server/channelserver/handlers_reward_test.go b/server/channelserver/handlers_reward_test.go index 028b20865..289e46640 100644 --- a/server/channelserver/handlers_reward_test.go +++ b/server/channelserver/handlers_reward_test.go @@ -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") + } + }) } diff --git a/server/channelserver/handlers_tactics.go b/server/channelserver/handlers_tactics.go index 7ad787995..6ec231a7d 100644 --- a/server/channelserver/handlers_tactics.go +++ b/server/channelserver/handlers_tactics.go @@ -2,29 +2,121 @@ package channelserver import ( "encoding/hex" + "fmt" + "strconv" + "erupe-ce/common/byteframe" "erupe-ce/network/mhfpacket" + "go.uber.org/zap" ) func handleMsgMhfGetUdTacticsPoint(s *Session, p mhfpacket.MHFPacket) { // Diva defense interception points pkt := p.(*mhfpacket.MsgMhfGetUdTacticsPoint) - // Temporary canned response - data, _ := hex.DecodeString("000000A08F0BE2DAE30BE30AE2EAE2E9E2E8E2F5E2F3E2F2E2F1E2BB") - doAckBufSucceed(s, pkt.AckHandle, data) + + pointsMap, err := s.server.divaRepo.GetCharacterInterceptionPoints(s.charID) + if err != nil { + s.logger.Warn("Failed to get interception points", zap.Uint32("charID", s.charID), zap.Error(err)) + pointsMap = map[string]int{} + } + + // Build per-quest list and compute total. + type questEntry struct { + questFileID int + points int + } + var entries []questEntry + var total int + for k, pts := range pointsMap { + qid, err := strconv.Atoi(k) + if err != nil { + continue + } + entries = append(entries, questEntry{qid, pts}) + total += pts + } + + bf := byteframe.NewByteFrame() + bf.WriteUint32(uint32(total)) + bf.WriteUint32(uint32(len(entries))) + for _, e := range entries { + bf.WriteUint32(uint32(e.questFileID)) + bf.WriteUint32(uint32(e.points)) + } + + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } +// udTacticsQuestFileIDs is the allowed range of interception quest file IDs. +const ( + udTacticsQuestMin = 58079 + udTacticsQuestMax = 58083 +) + func handleMsgMhfAddUdTacticsPoint(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAddUdTacticsPoint) - stubEnumerateNoResults(s, pkt.AckHandle) + questFileID := int(pkt.QuestID) + points := int(pkt.TacticsPoints) + + if questFileID < udTacticsQuestMin || questFileID > udTacticsQuestMax { + s.logger.Warn("AddUdTacticsPoint: quest file ID out of range", + zap.Int("questFileID", questFileID), + zap.String("range", fmt.Sprintf("%d-%d", udTacticsQuestMin, udTacticsQuestMax))) + doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) + return + } + + if points > 0 { + if err := s.server.divaRepo.AddInterceptionPoints(s.charID, questFileID, points); err != nil { + s.logger.Warn("Failed to add interception points", + zap.Uint32("charID", s.charID), + zap.Int("questFileID", questFileID), + zap.Int("points", points), + zap.Error(err)) + } + } + + doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) +} + +func writeDivaPrizeList(bf *byteframe.ByteFrame, prizes []DivaPrize) { + bf.WriteUint32(uint32(len(prizes))) + for _, p := range prizes { + bf.WriteUint32(uint32(p.PointsReq)) + bf.WriteUint16(uint16(p.ItemType)) + bf.WriteUint16(uint16(p.ItemID)) + bf.WriteUint16(uint16(p.Quantity)) + if p.GR { + bf.WriteUint8(1) + } else { + bf.WriteUint8(0) + } + if p.Repeatable { + bf.WriteUint8(1) + } else { + bf.WriteUint8(0) + } + } } func handleMsgMhfGetUdTacticsRewardList(s *Session, p mhfpacket.MHFPacket) { - // Diva defense interception + // Diva defense interception reward list pkt := p.(*mhfpacket.MsgMhfGetUdTacticsRewardList) - // Temporary canned response - data, _ := hex.DecodeString("000094000000010732DD00010000000000010732DD00010100000000C8071F2800050100000000C80705C000050000000001901A000001F40000000001901A000001F40100000002580705C00005000000000258071F2800050100000003201A000003E80100000003201A000003E80000000003E81A000004B00100000003E81A000004B00000000004B01A000005DC0100000004B01A000005DC0000000005781A000008FC0100000005781A000008FC0000000006401A000009C40000000006401A000009C40100000007081A00000BB80100000007081A00000BB80000000007D00725FA00010000000007D01A00000CE40000000007D00725FC00010100000007D00725FB00010100000007D00725FA00010100000007D01A00000CE40100000007D00725FC00010000000007D00725FB0001000000000BB80705C00005000000000BB8071F280005010000000FA01A00000DAC000000000FA01A00000DAC0100000013880705C00005000000001388071F2800050100000017700725FE00010100000017700725FD00010100000017700725FF00010100000017700725FD00010000000017700725FE00010000000017700725FF0001000000001B581A00000E74000000001B581A00000E74010000001F400727D00005010000001F400727D000050000000023281A00000FA00000000023281A00000FA00100000027100736EF000100000000271007369600010100000027100736EF00010100000027100736EF0001000000002EE00727D10005010000002EE00727D100050000000036B01D000000010100000036B01D00000001000000003A980737DB0001010000003A980736EF00010000000046500725E600010100000046500725E60001000000004E200738C90001010000004E200736EF00010000000055F01A000010680100000055F01A000010680000000061A80736EF00010000000061A80739A600010100000065900727D200050000000065900727D20005010000007530073A0600010100000075300736EF00010000000075300736EF00010000000075300736EF00010100000084D01D000000020000000084D01D00000002010000009C400727D30005010000009C400727D3000500000000B3B01A0000119400000000B3B01A0000119401000000C3500727D4000500000000C3500727D4000501000000D2F01D0000000300000000D2F01D0000000301000000EA600736EF000100000000EA600736EF000101000000F6181A0000125C00000000F6181A0000125C0100000111700727D500050000000111700727D500050100000119400727D600050100000119400727D600050000000121101D000000040000000121101D000000040100000130B01A000013880000000130B01A000013880100000140500727D700050000000140500727D700050100000148201D000000050000000148201D00000005010000014FF01A000014B4000000014FF01A000014B4010000015F900736EF0001000000015F900736EF00010100000167600729EA00050000000167600729EA0005010000016F301D00000006010000016F301D00000006000000017ED00729EB0005000000017ED00729EB0005010000018E701A0000157C010000018E701A0000157C0000000196401D000000070000000196401D00000007010000019E100729EC0005000000019E100729EC000501000001ADB00727CD000100000001ADB00727CD000101000001BD501D0000000800000001BD501D0000000801000001CCF01A0000164401000001CCF01A0000164400000001E4601D0000000901000001E4601D0000000900000001EC300727CC000101000001EC300727CC0001000000020B701D0000000A000000020B701D0000000A010000023A501A0000170C010000023A501A0000170C0000000249F00736EF00010100000249F00736EF00010000000271001A000017D40100000271001A000017D400000002A7B01A0000189C01000002A7B01A0000189C00000002BF200736EF000100000002BF200736EF000101000002D6901A0000196401000002D6901A00001964000000030D400727CB0001000000030D400727CB00010100000343F01A00001A2C0100000343F01A00001A2C0000000372D0072CB0000F0000000372D0072CB0000F01000003A9801A00001BBC00000003A9801A00001BBC01000003F7A01A000003E800010003F7A01A000003E80101000445C01A000003E80101000445C01A000003E80001005E000000020704020005010000000002070402000500000000000307040200140000000000030704020014010000000005071D200003010000000005071D20000300000000000607040200140100000000060704020014000000000008071D210003010000000008071D21000300000000000A070402001401000000000A070402001400000000000C0722EC000501000000000C0722ED000500000000000C0722F2000500000000000C0722EC000500000000000C0722EF000500000000000C0722ED000501000000000C0722F2000501000000000C0722EF000501000000000D1A000003E801000000000D1A000003E800000000000F07357C000501000000000F07357D000501000000000F07357C000500000000000F07357D00050000000000111A000007D00000000000111A000007D00100000000141C00000001000000000014071D2200030000000000141C00000001010000000014071D22000301000000001607357D000701000000001607357C00070000000000160704020028000000000016070402002801000000001607357C000701000000001607357D0007000000000018071D270003000000000018071D27000301000000001A1A00000BB800000000001A1A00000BB801000000001C07357D000701000000001C070402002801000000001C07357D000700000000001C07357C000700000000001C070402002800000000001C07357C000701000000001E070402003C01000000001E070402003C000000000020071D26000301000000002007357C000700000000002007357D000700000000002007357C000701000000002007357D0007010000000020071D260003000000000023071D280003010000000023071D28000300000000002A070402003C00000000002A070402003C01000000002C0725EE000100000000002C0725EE000101000000002E070402005001000000002E07357D000A01000000002E070402005000000000002E07357C000A00000000002E07357D000A00000000002E07357C000A0100000000300725ED00010000000000300725ED0001010000000032071D200003010000000032071D200003000000000034072C7B0001000000000034072C7B0001010000000037071D210003000000000037071D21000301000000003C0722F1000A00000000003C0722F1000A01000000004107040200500000000000410704020050010000000046071D220003010000000046071D22000300000000004B071D27000301000000004B071D2700030000000000500722F1000F0100000000500722F1000F0000000000550704020050010000000055070402005000000000005A071D26000301000000005A071D26000300000000005F071D28000300000000005F071D2800030100000000641A0000C3500100000000641A0000C3500000002607000E00C8000000010000000307000F0032000000010000000307001000320000000100000003070011003200000001000000030700120032000000010000000307000E0096000000040000000A07000F0028000000040000000A0700100028000000040000000A0700110028000000040000000A0700120028000000040000000A07000E00640000000B0000001907000F001E0000000B00000019070010001E0000000B00000019070011001E0000000B00000019070012001E0000000B0000001907000E00320000001A0000002807000F00140000001A0000002807001000140000001A0000002807001100140000001A0000002807001200140000001A0000002807000E001E000000290000004607000F000A0000002900000046070010000A000000290000004607001100010000002900000046070012000A000000290000004607000E0019000000470000006407000F0008000000470000006407001000080000004700000064070011000100000047000000640700120008000000470000006407000E000F000000650000009607000F0006000000650000009607001000010000006500000096070011000600000065000000960700120006000000650000009607000E000500000097000001F407000F000500000097000001F4070010000500000097000001F4") - doAckBufSucceed(s, pkt.AckHandle, data) + + personal, err := s.server.divaRepo.GetPersonalPrizes() + if err != nil { + s.logger.Warn("Failed to get personal prizes", zap.Error(err)) + } + guild, err := s.server.divaRepo.GetGuildPrizes() + if err != nil { + s.logger.Warn("Failed to get guild prizes", zap.Error(err)) + } + + bf := byteframe.NewByteFrame() + writeDivaPrizeList(bf, personal) + writeDivaPrizeList(bf, guild) + + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfGetUdTacticsFollower(s *Session, p mhfpacket.MHFPacket) { @@ -39,11 +131,18 @@ func handleMsgMhfGetUdTacticsBonusQuest(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, data) } +// udTacticsFirstQuestBonuses are the static first-quest bonus point values. +var udTacticsFirstQuestBonuses = []uint32{1500, 2000, 2500, 3500, 4500} + func handleMsgMhfGetUdTacticsFirstQuestBonus(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetUdTacticsFirstQuestBonus) - // Temporary canned response - data, _ := hex.DecodeString("0500000005DC01000007D002000009C40300000BB80400001194") - doAckBufSucceed(s, pkt.AckHandle, data) + bf := byteframe.NewByteFrame() + bf.WriteUint32(uint32(len(udTacticsFirstQuestBonuses))) + for i, bonus := range udTacticsFirstQuestBonuses { + bf.WriteUint32(bonus) + bf.WriteUint32(uint32(i)) + } + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfGetUdTacticsRemainingPoint(s *Session, p mhfpacket.MHFPacket) { diff --git a/server/channelserver/repo_diva.go b/server/channelserver/repo_diva.go index 69fe3c658..20cf286cf 100644 --- a/server/channelserver/repo_diva.go +++ b/server/channelserver/repo_diva.go @@ -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 +} diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 1a7104396..6f1fb83cb 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -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. diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 0484ef43e..595d5e973 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -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 { diff --git a/server/channelserver/sys_language.go b/server/channelserver/sys_language.go index 6f24b6f32..c51c850ba 100644 --- a/server/channelserver/sys_language.go +++ b/server/channelserver/sys_language.go @@ -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 } diff --git a/server/channelserver/sys_session.go b/server/channelserver/sys_session.go index be9d23e3d..6f3c7a235 100644 --- a/server/channelserver/sys_session.go +++ b/server/channelserver/sys_session.go @@ -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 } diff --git a/server/channelserver/test_helpers_test.go b/server/channelserver/test_helpers_test.go index ed7c55933..742d44751 100644 --- a/server/channelserver/test_helpers_test.go +++ b/server/channelserver/test_helpers_test.go @@ -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}) diff --git a/server/migrations/seed/DivaDefaults.sql b/server/migrations/seed/DivaDefaults.sql new file mode 100644 index 000000000..30a388e65 --- /dev/null +++ b/server/migrations/seed/DivaDefaults.sql @@ -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; diff --git a/server/migrations/sql/0011_diva.sql b/server/migrations/sql/0011_diva.sql new file mode 100644 index 000000000..6cb891db7 --- /dev/null +++ b/server/migrations/sql/0011_diva.sql @@ -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() +);