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.
This commit is contained in:
Houmgaor
2026-03-22 14:30:37 +01:00
parent 5ee9a0e635
commit c714374289
17 changed files with 674 additions and 161 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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