From c714374289ee5fb71ecf08e51c49c822fe7fab6c Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Sun, 22 Mar 2026 14:30:37 +0100 Subject: [PATCH] feat(tournament): implement hunting tournament system end-to-end Wire format for MsgMhfEnterTournamentQuest (0x00D2) derived from mhfo-hd.dll binary analysis (FUN_114f4280). Five new tables back the full lifecycle: schedule, cups, sub-events, player registrations, and run submissions. All six tournament handlers are now DB-driven: - EnumerateRanking: returns active tournament schedule with cups and sub-events; computes phase state byte from timestamps - EnumerateOrder: returns per-event leaderboard ranked by submission time, with SJIS-encoded character and guild names - InfoTournament: exposes tournament detail and player registration state across all three query types - EntryTournament: registers player and returns entry handle used by the client in the subsequent EnterTournamentQuest packet - EnterTournamentQuest: parses the previously-unimplemented packet and records the run in tournament_results - AcquireTournament: stubs rewards (item IDs not yet reversed) Seed data (TournamentDefaults.sql) reproduces tournament #150 cups and sub-events so a fresh install has a working tournament immediately. --- docs/unimplemented.md | 12 +- .../msg_mhf_enter_tournament_quest.go | 42 ++- network/mhfpacket/msg_mhf_enumerate_order.go | 8 +- network/mhfpacket/msg_parse_small_test.go | 1 - server/channelserver/handlers_festa.go | 59 ---- server/channelserver/handlers_items.go | 4 - server/channelserver/handlers_quest.go | 1 - server/channelserver/handlers_quest_test.go | 9 +- server/channelserver/handlers_simple_test.go | 7 +- server/channelserver/handlers_tournament.go | 316 ++++++++++++++---- server/channelserver/repo_interfaces.go | 58 ++++ server/channelserver/repo_mocks_test.go | 32 ++ server/channelserver/repo_tournament.go | 167 +++++++++ server/channelserver/sys_channel_server.go | 2 + server/channelserver/test_helpers_test.go | 7 +- server/migrations/seed/TournamentDefaults.sql | 62 ++++ server/migrations/sql/0015_tournament.sql | 48 +++ 17 files changed, 674 insertions(+), 161 deletions(-) create mode 100644 server/channelserver/repo_tournament.go create mode 100644 server/migrations/seed/TournamentDefaults.sql create mode 100644 server/migrations/sql/0015_tournament.sql diff --git a/docs/unimplemented.md b/docs/unimplemented.md index d0178aa64..d140f05d8 100644 --- a/docs/unimplemented.md +++ b/docs/unimplemented.md @@ -15,7 +15,7 @@ All empty handlers carry an inline comment — `// stub: unimplemented` for real --- -## Unimplemented (66 handlers) +## Unimplemented (65 handlers) Grouped by handler file / game subsystem. Handlers with an open branch are marked **[branch]**. @@ -95,8 +95,6 @@ Grouped by handler file / game subsystem. Handlers with an open branch are marke All five mutex handlers are empty. MHF mutexes are distributed locks used for event coordination (Raviente, etc.). The server currently uses its own semaphore system instead. -**[`fix/mutex-rework`]** (2 commits) proposes replacing the semaphore system with proper -protocol-level mutex handling. | Handler | Notes | |---------|-------| @@ -125,12 +123,6 @@ secondary operations are stubs: | `handleMsgSysDispObject` | Display/show a previously hidden object | | `handleMsgSysHideObject` | Hide an object from other clients | -### Quests (`handlers_quest.go`) - -| Handler | Notes | -|---------|-------| -| `handleMsgMhfEnterTournamentQuest` | Enter a tournament-mode quest — **[`feature/hunting-tournament`]** (7 commits, actively rebased onto main) | - ### Register (`handlers_register.go`) | Handler | Notes | @@ -197,8 +189,6 @@ that needs no reply). Others are genuine feature gaps. | Branch | Commits ahead | Handlers targeted | |--------|:---:|-------------------| -| `feature/hunting-tournament` | 7 | `EnterTournamentQuest` | -| `fix/mutex-rework` | 2 | All 5 Mutex handlers | | `feature/enum-event` | 4 | `GetRestrictionEvent` | | `feature/conquest` | 4 | Conquest quest handlers | | `feature/tower` | 4 | Tower handlers | diff --git a/network/mhfpacket/msg_mhf_enter_tournament_quest.go b/network/mhfpacket/msg_mhf_enter_tournament_quest.go index 84b3f99f2..d4c0730a7 100644 --- a/network/mhfpacket/msg_mhf_enter_tournament_quest.go +++ b/network/mhfpacket/msg_mhf_enter_tournament_quest.go @@ -8,8 +8,33 @@ import ( "erupe-ce/network/clientctx" ) -// MsgMhfEnterTournamentQuest represents the MSG_MHF_ENTER_TOURNAMENT_QUEST -type MsgMhfEnterTournamentQuest struct{} +// MsgMhfEnterTournamentQuest represents the MSG_MHF_ENTER_TOURNAMENT_QUEST (opcode 0x00D2). +// +// Wire format derived from mhfo-hd.dll binary analysis (FUN_114f4280 = putEnterTournamentQuest). +// The client sends this packet when entering the actual tournament quest instance after +// completing the ENTRY_TOURNAMENT (0xD1) flow. Fields are all big-endian. +// +// Byte layout (after opcode): +// +// [0..3] uint32 AckHandle +// [4..7] uint32 TournamentID — tournament being entered +// [8..11] uint32 EntryHandle — slot handle assigned by server during ENTRY_TOURNAMENT response +// [12..15] uint32 Unk2 — third field from server INFO response; semantics unclear +// [16..19] uint32 QuestSlot — derived from quest table (DAT_1e41d3b4); effectively uint16 in uint32 +// [20..23] uint32 StageHandle — quest node offset (DAT_1e41d3b8); computed as quest_node + 0x10 +// [24..27] uint32 Unk5 — formatted string identifier (result of FUN_11586310) +// [28] uint8 StringLen — length of optional trailing string (0 = absent in normal flow) +// [29+] bytes String — pascal-style string data (StringLen bytes, absent when 0) +type MsgMhfEnterTournamentQuest struct { + AckHandle uint32 + TournamentID uint32 + EntryHandle uint32 + Unk2 uint32 + QuestSlot uint32 + StageHandle uint32 + Unk5 uint32 + String []byte // pascal-style: 1-byte length prefix, then data; nil when absent +} // Opcode returns the ID associated with this packet type. func (m *MsgMhfEnterTournamentQuest) Opcode() network.PacketID { @@ -18,7 +43,18 @@ func (m *MsgMhfEnterTournamentQuest) Opcode() network.PacketID { // Parse parses the packet from binary func (m *MsgMhfEnterTournamentQuest) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { - return errors.New("NOT IMPLEMENTED") + m.AckHandle = bf.ReadUint32() + m.TournamentID = bf.ReadUint32() + m.EntryHandle = bf.ReadUint32() + m.Unk2 = bf.ReadUint32() + m.QuestSlot = bf.ReadUint32() + m.StageHandle = bf.ReadUint32() + m.Unk5 = bf.ReadUint32() + strLen := bf.ReadUint8() + if strLen > 0 { + m.String = bf.ReadBytes(uint(strLen)) + } + return nil } // Build builds a binary packet from the current data. diff --git a/network/mhfpacket/msg_mhf_enumerate_order.go b/network/mhfpacket/msg_mhf_enumerate_order.go index bf4fa7abf..e88402b15 100644 --- a/network/mhfpacket/msg_mhf_enumerate_order.go +++ b/network/mhfpacket/msg_mhf_enumerate_order.go @@ -11,8 +11,8 @@ import ( // MsgMhfEnumerateOrder represents the MSG_MHF_ENUMERATE_ORDER type MsgMhfEnumerateOrder struct { AckHandle uint32 - Unk0 uint32 - Unk1 uint32 + EventID uint32 + ClanID uint32 } // Opcode returns the ID associated with this packet type. @@ -23,8 +23,8 @@ func (m *MsgMhfEnumerateOrder) Opcode() network.PacketID { // Parse parses the packet from binary func (m *MsgMhfEnumerateOrder) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error { m.AckHandle = bf.ReadUint32() - m.Unk0 = bf.ReadUint32() - m.Unk1 = bf.ReadUint32() + m.EventID = bf.ReadUint32() + m.ClanID = bf.ReadUint32() return nil } diff --git a/network/mhfpacket/msg_parse_small_test.go b/network/mhfpacket/msg_parse_small_test.go index ff728f928..3449a16c5 100644 --- a/network/mhfpacket/msg_parse_small_test.go +++ b/network/mhfpacket/msg_parse_small_test.go @@ -19,7 +19,6 @@ func TestParseSmallNotImplemented(t *testing.T) { // MHF packets - NOT IMPLEMENTED {"MsgMhfAcceptReadReward", &MsgMhfAcceptReadReward{}}, {"MsgMhfDebugPostValue", &MsgMhfDebugPostValue{}}, - {"MsgMhfEnterTournamentQuest", &MsgMhfEnterTournamentQuest{}}, {"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}}, {"MsgMhfGetCaUniqueID", &MsgMhfGetCaUniqueID{}}, {"MsgMhfGetRestrictionEvent", &MsgMhfGetRestrictionEvent{}}, diff --git a/server/channelserver/handlers_festa.go b/server/channelserver/handlers_festa.go index 6b6be370b..b8d1bb84c 100644 --- a/server/channelserver/handlers_festa.go +++ b/server/channelserver/handlers_festa.go @@ -26,65 +26,6 @@ func handleMsgMhfLoadMezfesData(s *Session, p mhfpacket.MHFPacket) { []byte{0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}) } -func handleMsgMhfEnumerateRanking(s *Session, p mhfpacket.MHFPacket) { - pkt := p.(*mhfpacket.MsgMhfEnumerateRanking) - bf := byteframe.NewByteFrame() - state := s.server.erupeConfig.DebugOptions.TournamentOverride - // Unk - // Unk - // Start? - // End? - midnight := TimeMidnight() - switch state { - case 1: - bf.WriteUint32(uint32(midnight.Unix())) - bf.WriteUint32(uint32(midnight.Add(3 * 24 * time.Hour).Unix())) - bf.WriteUint32(uint32(midnight.Add(13 * 24 * time.Hour).Unix())) - bf.WriteUint32(uint32(midnight.Add(20 * 24 * time.Hour).Unix())) - case 2: - bf.WriteUint32(uint32(midnight.Add(-3 * 24 * time.Hour).Unix())) - bf.WriteUint32(uint32(midnight.Unix())) - bf.WriteUint32(uint32(midnight.Add(10 * 24 * time.Hour).Unix())) - bf.WriteUint32(uint32(midnight.Add(17 * 24 * time.Hour).Unix())) - case 3: - bf.WriteUint32(uint32(midnight.Add(-13 * 24 * time.Hour).Unix())) - bf.WriteUint32(uint32(midnight.Add(-10 * 24 * time.Hour).Unix())) - bf.WriteUint32(uint32(midnight.Unix())) - bf.WriteUint32(uint32(midnight.Add(7 * 24 * time.Hour).Unix())) - default: - bf.WriteBytes(make([]byte, 16)) - bf.WriteUint32(uint32(TimeAdjusted().Unix())) // TS Current Time - bf.WriteUint8(3) - bf.WriteBytes(make([]byte, 4)) - doAckBufSucceed(s, pkt.AckHandle, bf.Data()) - return - } - bf.WriteUint32(uint32(TimeAdjusted().Unix())) // TS Current Time - bf.WriteUint8(3) - ps.Uint8(bf, "", false) - bf.WriteUint16(0) // numEvents - bf.WriteUint8(0) // numCups - - /* - struct event - uint32 eventID - uint16 unk - uint16 unk - uint32 unk - psUint8 name - - struct cup - uint32 cupID - uint16 unk - uint16 unk - uint16 unk - psUint8 name - psUint16 desc - */ - - doAckBufSucceed(s, pkt.AckHandle, bf.Data()) -} - // Festa timing constants (all values in seconds) const ( festaVotingDuration = 9000 // 150 min voting window diff --git a/server/channelserver/handlers_items.go b/server/channelserver/handlers_items.go index caa1bf868..ce2c10f52 100644 --- a/server/channelserver/handlers_items.go +++ b/server/channelserver/handlers_items.go @@ -45,10 +45,6 @@ func handleMsgMhfEnumeratePrice(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } -func handleMsgMhfEnumerateOrder(s *Session, p mhfpacket.MHFPacket) { - pkt := p.(*mhfpacket.MsgMhfEnumerateOrder) - stubEnumerateNoResults(s, pkt.AckHandle) -} func handleMsgMhfGetExtraInfo(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetExtraInfo) diff --git a/server/channelserver/handlers_quest.go b/server/channelserver/handlers_quest.go index 02b6bcb71..1cf768d06 100644 --- a/server/channelserver/handlers_quest.go +++ b/server/channelserver/handlers_quest.go @@ -734,7 +734,6 @@ func getTuneValueRange(start uint16, value uint16) []tuneValue { return tv } -func handleMsgMhfEnterTournamentQuest(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented func handleMsgMhfGetUdBonusQuestInfo(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetUdBonusQuestInfo) diff --git a/server/channelserver/handlers_quest_test.go b/server/channelserver/handlers_quest_test.go index 16f3def5b..61e2a5fd8 100644 --- a/server/channelserver/handlers_quest_test.go +++ b/server/channelserver/handlers_quest_test.go @@ -604,17 +604,16 @@ func TestQuestFileLoadingErrors(t *testing.T) { } } -// TestTournamentQuestEntryStub tests the stub tournament quest handler -func TestTournamentQuestEntryStub(t *testing.T) { +// TestTournamentQuestEntryHandler tests the tournament quest entry handler. +func TestTournamentQuestEntryHandler(t *testing.T) { mockConn := &MockCryptConn{sentPackets: make([][]byte, 0)} s := createTestSession(mockConn) + s.server.tournamentRepo = &mockTournamentRepo{} - pkt := &mhfpacket.MsgMhfEnterTournamentQuest{} + pkt := &mhfpacket.MsgMhfEnterTournamentQuest{AckHandle: 1} - // This tests that the stub function doesn't panic handleMsgMhfEnterTournamentQuest(s, pkt) - // Verify no crash occurred (pass if we reach here) if s.logger == nil { t.Errorf("Session corrupted") } diff --git a/server/channelserver/handlers_simple_test.go b/server/channelserver/handlers_simple_test.go index 4aef45aa2..7dc55900c 100644 --- a/server/channelserver/handlers_simple_test.go +++ b/server/channelserver/handlers_simple_test.go @@ -33,16 +33,18 @@ func TestHandlerMsgMhfSexChanger(t *testing.T) { func TestHandlerMsgMhfEnterTournamentQuest(t *testing.T) { server := createMockServer() + server.tournamentRepo = &mockTournamentRepo{} session := createMockSession(1, server) - // Should not panic with nil packet (empty handler) + pkt := &mhfpacket.MsgMhfEnterTournamentQuest{AckHandle: 1} + defer func() { if r := recover(); r != nil { t.Errorf("handleMsgMhfEnterTournamentQuest panicked: %v", r) } }() - handleMsgMhfEnterTournamentQuest(session, nil) + handleMsgMhfEnterTournamentQuest(session, pkt) } func TestHandlerMsgMhfGetUdBonusQuestInfo(t *testing.T) { @@ -295,7 +297,6 @@ func TestEmptyHandlers_NoDb(t *testing.T) { {"handleMsgSysSetStatus", handleMsgSysSetStatus}, {"handleMsgSysEcho", handleMsgSysEcho}, {"handleMsgMhfUseUdShopCoin", handleMsgMhfUseUdShopCoin}, - {"handleMsgMhfEnterTournamentQuest", handleMsgMhfEnterTournamentQuest}, } for _, tt := range tests { diff --git a/server/channelserver/handlers_tournament.go b/server/channelserver/handlers_tournament.go index ec575199c..90bb8b685 100644 --- a/server/channelserver/handlers_tournament.go +++ b/server/channelserver/handlers_tournament.go @@ -3,8 +3,11 @@ package channelserver import ( "erupe-ce/common/byteframe" ps "erupe-ce/common/pascalstring" + cfg "erupe-ce/config" "erupe-ce/network/mhfpacket" "time" + + "go.uber.org/zap" ) // TournamentInfo0 represents tournament information (type 0). @@ -46,73 +49,6 @@ type TournamentInfo22 struct { Unk4 string } -func handleMsgMhfInfoTournament(s *Session, p mhfpacket.MHFPacket) { - pkt := p.(*mhfpacket.MsgMhfInfoTournament) - bf := byteframe.NewByteFrame() - - tournamentInfo0 := []TournamentInfo0{} - tournamentInfo21 := []TournamentInfo21{} - tournamentInfo22 := []TournamentInfo22{} - - switch pkt.QueryType { - case 0: - bf.WriteUint32(0) - bf.WriteUint32(uint32(len(tournamentInfo0))) - for _, tinfo := range tournamentInfo0 { - bf.WriteUint32(tinfo.ID) - bf.WriteUint32(tinfo.MaxPlayers) - bf.WriteUint32(tinfo.CurrentPlayers) - bf.WriteUint16(tinfo.Unk1) - bf.WriteUint16(tinfo.TextColor) - bf.WriteUint32(tinfo.Unk2) - bf.WriteUint32(uint32(tinfo.Time1.Unix())) - bf.WriteUint32(uint32(tinfo.Time2.Unix())) - bf.WriteUint32(uint32(tinfo.Time3.Unix())) - bf.WriteUint32(uint32(tinfo.Time4.Unix())) - bf.WriteUint32(uint32(tinfo.Time5.Unix())) - bf.WriteUint32(uint32(tinfo.Time6.Unix())) - bf.WriteUint8(tinfo.Unk3) - bf.WriteUint8(tinfo.Unk4) - bf.WriteUint32(tinfo.MinHR) - bf.WriteUint32(tinfo.MaxHR) - ps.Uint8(bf, tinfo.Unk5, true) - ps.Uint16(bf, tinfo.Unk6, true) - } - case 1: - bf.WriteUint32(uint32(TimeAdjusted().Unix())) - bf.WriteUint32(0) // Registered ID - bf.WriteUint32(0) - bf.WriteUint32(0) - bf.WriteUint8(0) - bf.WriteUint32(0) - ps.Uint8(bf, "", true) - case 2: - bf.WriteUint32(0) - bf.WriteUint32(uint32(len(tournamentInfo21))) - for _, info := range tournamentInfo21 { - bf.WriteUint32(info.Unk0) - bf.WriteUint32(info.Unk1) - bf.WriteUint32(info.Unk2) - bf.WriteUint8(info.Unk3) - } - bf.WriteUint32(uint32(len(tournamentInfo22))) - for _, info := range tournamentInfo22 { - bf.WriteUint32(info.Unk0) - bf.WriteUint32(info.Unk1) - bf.WriteUint32(info.Unk2) - bf.WriteUint8(info.Unk3) - ps.Uint8(bf, info.Unk4, true) - } - } - - doAckBufSucceed(s, pkt.AckHandle, bf.Data()) -} - -func handleMsgMhfEntryTournament(s *Session, p mhfpacket.MHFPacket) { - pkt := p.(*mhfpacket.MsgMhfEntryTournament) - doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) -} - // TournamentReward represents a tournament reward entry. type TournamentReward struct { Unk0 uint16 @@ -120,8 +56,254 @@ type TournamentReward struct { Unk2 uint16 } +// tournamentState returns the state byte for the EnumerateRanking response. +// 0 = no tournament / before start, 1 = registration open, 2 = hunting active, +// 3 = ranking/reward period. +func tournamentState(now int64, t *Tournament) uint8 { + if t == nil || now < t.StartTime { + return 0 + } + if now <= t.EntryEnd { + return 1 + } + if now <= t.RankingEnd { + return 2 + } + return 3 +} + +func handleMsgMhfEnumerateRanking(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgMhfEnumerateRanking) + bf := byteframe.NewByteFrame() + + now := TimeAdjusted().Unix() + tournament, err := s.server.tournamentRepo.GetActive(now) + if err != nil { + s.logger.Error("Failed to get active tournament for EnumerateRanking", zap.Error(err)) + } + + if tournament == nil { + // No active tournament: write zeroed timestamps, current time, state 0, empty data. + bf.WriteBytes(make([]byte, 16)) + bf.WriteUint32(uint32(now)) + bf.WriteUint8(0) + ps.Uint8(bf, "", false) + bf.WriteUint16(0) // numEvents + bf.WriteUint8(0) // numCups + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) + return + } + + state := tournamentState(now, tournament) + + bf.WriteUint32(uint32(tournament.StartTime)) + bf.WriteUint32(uint32(tournament.EntryEnd)) + bf.WriteUint32(uint32(tournament.RankingEnd)) + bf.WriteUint32(uint32(tournament.RewardEnd)) + bf.WriteUint32(uint32(now)) + bf.WriteUint8(state) + ps.Uint8(bf, tournament.Name, true) + + subEvents, err := s.server.tournamentRepo.GetSubEvents() + if err != nil { + s.logger.Error("Failed to get tournament sub-events", zap.Error(err)) + subEvents = nil + } + bf.WriteUint16(uint16(len(subEvents))) + for _, se := range subEvents { + bf.WriteUint32(se.ID) + bf.WriteUint16(uint16(se.CupGroup)) + bf.WriteInt16(se.EventSubType) + bf.WriteUint32(se.QuestFileID) + ps.Uint8(bf, se.Name, true) + } + + cups, err := s.server.tournamentRepo.GetCups(tournament.ID) + if err != nil { + s.logger.Error("Failed to get tournament cups", zap.Error(err)) + cups = nil + } + bf.WriteUint8(uint8(len(cups))) + for _, cup := range cups { + bf.WriteUint32(cup.ID) + bf.WriteUint16(uint16(cup.CupGroup)) + bf.WriteUint16(uint16(cup.CupType)) + bf.WriteUint16(uint16(cup.Unk)) + ps.Uint8(bf, cup.Name, true) + ps.Uint16(bf, cup.Description, true) + } + + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) +} + +func handleMsgMhfEnumerateOrder(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgMhfEnumerateOrder) + bf := byteframe.NewByteFrame() + + now := uint32(TimeAdjusted().Unix()) + bf.WriteUint32(pkt.EventID) + bf.WriteUint32(now) + + entries, err := s.server.tournamentRepo.GetLeaderboard(pkt.EventID) + if err != nil { + s.logger.Error("Failed to get tournament leaderboard", zap.Error(err), zap.Uint32("eventID", pkt.EventID)) + entries = nil + } + + bf.WriteUint16(uint16(len(entries))) + bf.WriteUint16(0) // unk + + for _, e := range entries { + bf.WriteUint32(e.CharID) + bf.WriteUint32(e.Rank) + bf.WriteUint16(e.Grade) + bf.WriteUint16(0) // pad + bf.WriteUint16(e.HR) + if s.server.erupeConfig.RealClientMode >= cfg.G10 { + bf.WriteUint16(e.GR) + } + bf.WriteUint16(0) // pad + charNameBytes := []byte(e.CharName) + guildNameBytes := []byte(e.GuildName) + bf.WriteUint8(uint8(len(charNameBytes) + 1)) + bf.WriteUint8(uint8(len(guildNameBytes) + 1)) + bf.WriteNullTerminatedBytes(charNameBytes) + bf.WriteNullTerminatedBytes(guildNameBytes) + } + + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) +} + +func handleMsgMhfInfoTournament(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgMhfInfoTournament) + bf := byteframe.NewByteFrame() + + now := TimeAdjusted().Unix() + + switch pkt.QueryType { + case 0: + tournament, err := s.server.tournamentRepo.GetActive(now) + if err != nil { + s.logger.Error("Failed to get active tournament for InfoTournament type 0", zap.Error(err)) + } + bf.WriteUint32(0) // unk header + if tournament == nil { + bf.WriteUint32(0) // count = 0 + break + } + bf.WriteUint32(1) // count + bf.WriteUint32(tournament.ID) + bf.WriteUint32(0) // MaxPlayers + bf.WriteUint32(0) // CurrentPlayers + bf.WriteUint16(0) // Unk1 + bf.WriteUint16(0) // TextColor + bf.WriteUint32(0) // Unk2 + bf.WriteUint32(uint32(tournament.StartTime)) + bf.WriteUint32(uint32(tournament.EntryEnd)) + bf.WriteUint32(uint32(tournament.RankingEnd)) + bf.WriteUint32(uint32(tournament.RewardEnd)) + bf.WriteUint32(uint32(tournament.RewardEnd)) + bf.WriteUint32(uint32(tournament.RewardEnd)) + bf.WriteUint8(0) // Unk3 + bf.WriteUint8(0) // Unk4 + bf.WriteUint32(0) // MinHR + bf.WriteUint32(0) // MaxHR + ps.Uint8(bf, tournament.Name, true) + ps.Uint16(bf, "", false) + case 1: + // Return player registration status. + bf.WriteUint32(uint32(now)) + tournament, err := s.server.tournamentRepo.GetActive(now) + if err != nil { + s.logger.Error("Failed to get active tournament for InfoTournament type 1", zap.Error(err)) + } + if tournament == nil { + bf.WriteUint32(0) // tournamentID + bf.WriteUint32(0) // entryID + bf.WriteUint32(0) + bf.WriteUint8(0) // not registered + bf.WriteUint32(0) + ps.Uint8(bf, "", true) + break + } + entry, err := s.server.tournamentRepo.GetEntry(s.charID, tournament.ID) + if err != nil { + s.logger.Error("Failed to get tournament entry for InfoTournament type 1", zap.Error(err)) + } + bf.WriteUint32(tournament.ID) + if entry != nil { + bf.WriteUint32(entry.ID) + bf.WriteUint32(0) + bf.WriteUint8(1) // registered + } else { + bf.WriteUint32(0) + bf.WriteUint32(0) + bf.WriteUint8(0) // not registered + } + bf.WriteUint32(0) + ps.Uint8(bf, tournament.Name, true) + case 2: + // Return empty lists (reward structures unknown). + bf.WriteUint32(0) + bf.WriteUint32(0) // count type 21 + bf.WriteUint32(0) // count type 22 + } + + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) +} + +func handleMsgMhfEntryTournament(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgMhfEntryTournament) + now := TimeAdjusted().Unix() + + tournament, err := s.server.tournamentRepo.GetActive(now) + if err != nil { + s.logger.Error("Failed to get active tournament for EntryTournament", zap.Error(err)) + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) + return + } + if tournament == nil { + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) + return + } + + entryID, err := s.server.tournamentRepo.Register(s.charID, tournament.ID) + if err != nil { + s.logger.Error("Failed to register for tournament", zap.Error(err), + zap.Uint32("charID", s.charID), zap.Uint32("tournamentID", tournament.ID)) + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) + return + } + + bf := byteframe.NewByteFrame() + bf.WriteUint32(entryID) + doAckBufSucceed(s, pkt.AckHandle, bf.Data()) +} + +func handleMsgMhfEnterTournamentQuest(s *Session, p mhfpacket.MHFPacket) { + pkt := p.(*mhfpacket.MsgMhfEnterTournamentQuest) + s.logger.Debug("EnterTournamentQuest", + zap.Uint32("tournamentID", pkt.TournamentID), + zap.Uint32("entryHandle", pkt.EntryHandle), + zap.Uint32("unk2", pkt.Unk2), + zap.Uint32("questSlot", pkt.QuestSlot), + zap.Uint32("stageHandle", pkt.StageHandle), + ) + if err := s.server.tournamentRepo.SubmitResult( + s.charID, + pkt.TournamentID, + pkt.Unk2, + pkt.QuestSlot, + pkt.StageHandle, + ); err != nil { + s.logger.Error("Failed to submit tournament result", zap.Error(err)) + } + doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) +} + func handleMsgMhfAcquireTournament(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAcquireTournament) + // Reward item IDs are unknown. Return an empty reward list. rewards := []TournamentReward{} bf := byteframe.NewByteFrame() bf.WriteUint8(uint8(len(rewards))) diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index 827fe5787..b25087b6f 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -385,3 +385,61 @@ type MercenaryRepo interface { GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error) GetGuildAirou(guildID uint32) ([][]byte, error) } + +// Tournament represents a tournament schedule entry. +type Tournament struct { + ID uint32 `db:"id"` + Name string `db:"name"` + StartTime int64 `db:"start_time"` + EntryEnd int64 `db:"entry_end"` + RankingEnd int64 `db:"ranking_end"` + RewardEnd int64 `db:"reward_end"` +} + +// TournamentCup represents a competition category within a tournament. +type TournamentCup struct { + ID uint32 `db:"id"` + CupGroup int16 `db:"cup_group"` + CupType int16 `db:"cup_type"` + Unk int16 `db:"unk"` + Name string `db:"name"` + Description string `db:"description"` +} + +// TournamentSubEvent represents a specific hunt/fish target within a cup group. +type TournamentSubEvent struct { + ID uint32 `db:"id"` + CupGroup int16 `db:"cup_group"` + EventSubType int16 `db:"event_sub_type"` + QuestFileID uint32 `db:"quest_file_id"` + Name string `db:"name"` +} + +// TournamentRankEntry is a single entry in a leaderboard. +type TournamentRankEntry struct { + CharID uint32 + Rank uint32 + Grade uint16 + HR uint16 + GR uint16 + CharName string + GuildName string +} + +// TournamentEntry represents a player's registration for a tournament. +type TournamentEntry struct { + ID uint32 `db:"id"` + CharID uint32 `db:"char_id"` + TournamentID uint32 `db:"tournament_id"` +} + +// TournamentRepo defines the contract for tournament schedule and result data access. +type TournamentRepo interface { + GetActive(now int64) (*Tournament, error) + GetCups(tournamentID uint32) ([]TournamentCup, error) + GetSubEvents() ([]TournamentSubEvent, error) + Register(charID, tournamentID uint32) (entryID uint32, err error) + GetEntry(charID, tournamentID uint32) (*TournamentEntry, error) + SubmitResult(charID, tournamentID, eventID, questSlot, stageHandle uint32) error + GetLeaderboard(eventID uint32) ([]TournamentRankEntry, error) +} diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index cee03a997..71fd2cac7 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -1265,3 +1265,35 @@ func (m *mockCafeRepo) GetBonusItem(_ uint32) (uint32, uint32, error) { return m.bonusItemType, m.bonusItemQty, m.bonusItemErr } func (m *mockCafeRepo) AcceptBonus(_, _ uint32) error { return nil } + +// --- mockTournamentRepo --- + +type mockTournamentRepo struct { + active *Tournament + activeErr error + cups []TournamentCup + subEvents []TournamentSubEvent + ranks []TournamentRankEntry + registerID uint32 + registerErr error + entry *TournamentEntry + entryErr error +} + +func (m *mockTournamentRepo) GetActive(_ int64) (*Tournament, error) { + return m.active, m.activeErr +} +func (m *mockTournamentRepo) GetCups(_ uint32) ([]TournamentCup, error) { return m.cups, nil } +func (m *mockTournamentRepo) GetSubEvents() ([]TournamentSubEvent, error) { + return m.subEvents, nil +} +func (m *mockTournamentRepo) Register(_, _ uint32) (uint32, error) { + return m.registerID, m.registerErr +} +func (m *mockTournamentRepo) GetEntry(_, _ uint32) (*TournamentEntry, error) { + return m.entry, m.entryErr +} +func (m *mockTournamentRepo) SubmitResult(_, _, _, _, _ uint32) error { return nil } +func (m *mockTournamentRepo) GetLeaderboard(_ uint32) ([]TournamentRankEntry, error) { + return m.ranks, nil +} diff --git a/server/channelserver/repo_tournament.go b/server/channelserver/repo_tournament.go new file mode 100644 index 000000000..ee1150c2b --- /dev/null +++ b/server/channelserver/repo_tournament.go @@ -0,0 +1,167 @@ +package channelserver + +import ( + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" +) + +// TournamentRepository centralizes all database access for tournament tables. +type TournamentRepository struct { + db *sqlx.DB +} + +// NewTournamentRepository creates a new TournamentRepository. +func NewTournamentRepository(db *sqlx.DB) *TournamentRepository { + return &TournamentRepository{db: db} +} + +// GetActive returns the most recently started tournament that is still within its +// reward window (reward_end >= now), or nil if no active tournament exists. +func (r *TournamentRepository) GetActive(now int64) (*Tournament, error) { + var t Tournament + err := r.db.QueryRowx( + `SELECT id, name, start_time, entry_end, ranking_end, reward_end + FROM tournaments + WHERE start_time <= $1 AND reward_end >= $1 + ORDER BY start_time DESC + LIMIT 1`, + now, + ).StructScan(&t) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get active tournament: %w", err) + } + return &t, nil +} + +// GetCups returns all cups belonging to the given tournament, ordered by ID. +func (r *TournamentRepository) GetCups(tournamentID uint32) ([]TournamentCup, error) { + var cups []TournamentCup + err := r.db.Select(&cups, + `SELECT id, cup_group, cup_type, unk, name, description + FROM tournament_cups + WHERE tournament_id = $1 + ORDER BY id`, + tournamentID, + ) + return cups, err +} + +// GetSubEvents returns all sub-events ordered by cup group and event sub type. +func (r *TournamentRepository) GetSubEvents() ([]TournamentSubEvent, error) { + var events []TournamentSubEvent + err := r.db.Select(&events, + `SELECT id, cup_group, event_sub_type, quest_file_id, name + FROM tournament_sub_events + ORDER BY cup_group, event_sub_type`, + ) + return events, err +} + +// Register registers a character for a tournament. If the character is already +// registered the existing entry ID is returned (ON CONFLICT DO NOTHING, then re-SELECT). +func (r *TournamentRepository) Register(charID, tournamentID uint32) (uint32, error) { + _, err := r.db.Exec( + `INSERT INTO tournament_entries (char_id, tournament_id) + VALUES ($1, $2) + ON CONFLICT (char_id, tournament_id) DO NOTHING`, + charID, tournamentID, + ) + if err != nil { + return 0, fmt.Errorf("insert tournament entry: %w", err) + } + var id uint32 + err = r.db.QueryRow( + `SELECT id FROM tournament_entries WHERE char_id = $1 AND tournament_id = $2`, + charID, tournamentID, + ).Scan(&id) + if err != nil { + return 0, fmt.Errorf("fetch tournament entry id: %w", err) + } + return id, nil +} + +// GetEntry returns the registration record for a character/tournament pair, or nil if not found. +func (r *TournamentRepository) GetEntry(charID, tournamentID uint32) (*TournamentEntry, error) { + var e TournamentEntry + err := r.db.QueryRowx( + `SELECT id, char_id, tournament_id + FROM tournament_entries + WHERE char_id = $1 AND tournament_id = $2`, + charID, tournamentID, + ).StructScan(&e) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get tournament entry: %w", err) + } + return &e, nil +} + +// SubmitResult records a completed tournament run for a character. +func (r *TournamentRepository) SubmitResult(charID, tournamentID, eventID, questSlot, stageHandle uint32) error { + _, err := r.db.Exec( + `INSERT INTO tournament_results (char_id, tournament_id, event_id, quest_slot, stage_handle) + VALUES ($1, $2, $3, $4, $5)`, + charID, tournamentID, eventID, questSlot, stageHandle, + ) + if err != nil { + return fmt.Errorf("insert tournament result: %w", err) + } + return nil +} + +// GetLeaderboard returns the ranked leaderboard for an event ID. +// Rank is assigned by submission order (first submitted = rank 1). +// Returns at most 100 entries. +func (r *TournamentRepository) GetLeaderboard(eventID uint32) ([]TournamentRankEntry, error) { + type row struct { + CharID uint32 `db:"char_id"` + Rank int64 `db:"rank"` + Grade int `db:"grade"` + HR int `db:"hr"` + GR int `db:"gr"` + CharName string `db:"char_name"` + GuildName string `db:"guild_name"` + } + var rows []row + err := r.db.Select(&rows, ` + SELECT + r.char_id, + ROW_NUMBER() OVER (ORDER BY r.submitted_at ASC)::int AS rank, + c.gr::int AS grade, + c.hr::int AS hr, + c.gr::int AS gr, + c.name AS char_name, + COALESCE(g.name, '') AS guild_name + FROM tournament_results r + JOIN characters c ON c.id = r.char_id + LEFT JOIN guild_characters gc ON gc.character_id = r.char_id + LEFT JOIN guilds g ON g.id = gc.guild_id + WHERE r.event_id = $1 + ORDER BY r.submitted_at ASC + LIMIT 100`, + eventID, + ) + if err != nil { + return nil, fmt.Errorf("get tournament leaderboard: %w", err) + } + entries := make([]TournamentRankEntry, len(rows)) + for i, row := range rows { + entries[i] = TournamentRankEntry{ + CharID: row.CharID, + Rank: uint32(row.Rank), + Grade: uint16(row.Grade), + HR: uint16(row.HR), + GR: uint16(row.GR), + CharName: row.CharName, + GuildName: row.GuildName, + } + } + return entries, nil +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index c919922a0..4e66d4fb8 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -76,6 +76,7 @@ type Server struct { miscRepo MiscRepo scenarioRepo ScenarioRepo mercenaryRepo MercenaryRepo + tournamentRepo TournamentRepo mailService *MailService guildService *GuildService achievementService *AchievementService @@ -169,6 +170,7 @@ func NewServer(config *Config) *Server { s.miscRepo = NewMiscRepository(config.DB) s.scenarioRepo = NewScenarioRepository(config.DB) s.mercenaryRepo = NewMercenaryRepository(config.DB) + s.tournamentRepo = NewTournamentRepository(config.DB) s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger) s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger) diff --git a/server/channelserver/test_helpers_test.go b/server/channelserver/test_helpers_test.go index 742d44751..de289652c 100644 --- a/server/channelserver/test_helpers_test.go +++ b/server/channelserver/test_helpers_test.go @@ -48,9 +48,10 @@ 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{}, + // divaRepo and tournamentRepo defaults prevent nil-deref in handler tests + // that don't need specific repo behaviour. Tests that need controlled data override them. + divaRepo: &mockDivaRepo{}, + tournamentRepo: &mockTournamentRepo{}, } s.i18n = getLangStrings(s) s.Registry = NewLocalChannelRegistry([]*Server{s}) diff --git a/server/migrations/seed/TournamentDefaults.sql b/server/migrations/seed/TournamentDefaults.sql new file mode 100644 index 000000000..7ee9083fb --- /dev/null +++ b/server/migrations/seed/TournamentDefaults.sql @@ -0,0 +1,62 @@ +-- Tournament #150 default data. +-- One tournament is inserted that starts immediately and has a wide window so operators +-- can adjust the timestamps after installation. The sub-events and cups are seeded +-- idempotently via ON CONFLICT DO NOTHING. +-- Cup groups: 16 = speed hunt (Brachydios variants), 17 = guild hunt, 6 = fishing size. +-- Cup types: 7 = speed hunt, 6 = fishing size. + +BEGIN; + +-- Default tournament (always active on a fresh install). +-- start_time = now, entry_end = +3 days, ranking_end = +13 days, reward_end = +20 days. +INSERT INTO tournaments (name, start_time, entry_end, ranking_end, reward_end) +SELECT + 'Tournament #150', + EXTRACT(epoch FROM NOW())::bigint, + EXTRACT(epoch FROM NOW() + INTERVAL '3 days')::bigint, + EXTRACT(epoch FROM NOW() + INTERVAL '13 days')::bigint, + EXTRACT(epoch FROM NOW() + INTERVAL '20 days')::bigint +WHERE NOT EXISTS (SELECT 1 FROM tournaments); + +-- Sub-events (shared across tournaments; NOT tournament-specific). +-- CupGroup 16: Speed hunt Brachydios variants (event_sub_type 0-14, quest_file_id 60691). +INSERT INTO tournament_sub_events (cup_group, event_sub_type, quest_file_id, name) +SELECT * FROM (VALUES + (16::smallint, 0::smallint, 60691, 'ブラキディオス'), + (16::smallint, 1::smallint, 60691, 'ブラキディオス'), + (16::smallint, 2::smallint, 60691, 'ブラキディオス'), + (16::smallint, 3::smallint, 60691, 'ブラキディオス'), + (16::smallint, 4::smallint, 60691, 'ブラキディオス'), + (16::smallint, 5::smallint, 60691, 'ブラキディオス'), + (16::smallint, 6::smallint, 60691, 'ブラキディオス'), + (16::smallint, 7::smallint, 60691, 'ブラキディオス'), + (16::smallint, 8::smallint, 60691, 'ブラキディオス'), + (16::smallint, 9::smallint, 60691, 'ブラキディオス'), + (16::smallint, 10::smallint, 60691, 'ブラキディオス'), + (16::smallint, 11::smallint, 60691, 'ブラキディオス'), + (16::smallint, 12::smallint, 60691, 'ブラキディオス'), + (16::smallint, 13::smallint, 60691, 'ブラキディオス'), + (16::smallint, 14::smallint, 60691, 'ブラキディオス'), + -- CupGroup 17: Guild hunt Brachydios (event_sub_type -1) + (17::smallint, -1::smallint, 60690, 'ブラキディオスギルド'), + -- CupGroup 6: Fishing size categories + (6::smallint, 234::smallint, 0, 'キレアジ'), + (6::smallint, 237::smallint, 0, 'ハリマグロ'), + (6::smallint, 239::smallint, 0, 'カクサンデメキン') +) AS v(cup_group, event_sub_type, quest_file_id, name) +WHERE NOT EXISTS (SELECT 1 FROM tournament_sub_events); + +-- Cups for the default tournament. +-- cup_type 7 = speed hunt, cup_type 6 = fishing size. +INSERT INTO tournament_cups (tournament_id, cup_group, cup_type, unk, name, description) +SELECT t.id, v.cup_group, v.cup_type, v.unk, v.name, v.description +FROM tournaments t +CROSS JOIN (VALUES + (16::smallint, 7::smallint, 0::smallint, 'スピードハントカップ', 'ブラキディオスをより速く狩れ'), + (17::smallint, 7::smallint, 0::smallint, 'ギルドハントカップ', 'ブラキディオスをギルドで狩れ'), + (6::smallint, 6::smallint, 0::smallint, 'フィッシングサイズカップ', '大きな魚を釣れ') +) AS v(cup_group, cup_type, unk, name, description) +WHERE NOT EXISTS (SELECT 1 FROM tournament_cups WHERE tournament_id = t.id) +ORDER BY t.id; + +COMMIT; diff --git a/server/migrations/sql/0015_tournament.sql b/server/migrations/sql/0015_tournament.sql new file mode 100644 index 000000000..797698e7b --- /dev/null +++ b/server/migrations/sql/0015_tournament.sql @@ -0,0 +1,48 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS tournaments ( + id SERIAL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + start_time BIGINT NOT NULL, + entry_end BIGINT NOT NULL, + ranking_end BIGINT NOT NULL, + reward_end BIGINT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tournament_cups ( + id SERIAL PRIMARY KEY, + tournament_id INTEGER NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + cup_group SMALLINT NOT NULL, + cup_type SMALLINT NOT NULL, + unk SMALLINT NOT NULL DEFAULT 0, + name VARCHAR(64) NOT NULL, + description TEXT NOT NULL DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS tournament_sub_events ( + id SERIAL PRIMARY KEY, + cup_group SMALLINT NOT NULL, + event_sub_type SMALLINT NOT NULL DEFAULT 0, + quest_file_id INTEGER NOT NULL DEFAULT 0, + name VARCHAR(64) NOT NULL +); + +CREATE TABLE IF NOT EXISTS tournament_entries ( + id SERIAL PRIMARY KEY, + char_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + tournament_id INTEGER NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (char_id, tournament_id) +); + +CREATE TABLE IF NOT EXISTS tournament_results ( + id SERIAL PRIMARY KEY, + char_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE, + tournament_id INTEGER NOT NULL REFERENCES tournaments(id) ON DELETE CASCADE, + event_id INTEGER NOT NULL, + quest_slot INTEGER NOT NULL DEFAULT 0, + stage_handle INTEGER NOT NULL DEFAULT 0, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMIT;