Files
Erupe/server/channelserver/handlers_tournament.go
Houmgaor c714374289 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.
2026-03-22 14:30:37 +01:00

317 lines
8.7 KiB
Go

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).
type TournamentInfo0 struct {
ID uint32
MaxPlayers uint32
CurrentPlayers uint32
Unk1 uint16
TextColor uint16
Unk2 uint32
Time1 time.Time
Time2 time.Time
Time3 time.Time
Time4 time.Time
Time5 time.Time
Time6 time.Time
Unk3 uint8
Unk4 uint8
MinHR uint32
MaxHR uint32
Unk5 string
Unk6 string
}
// TournamentInfo21 represents tournament information (type 21).
type TournamentInfo21 struct {
Unk0 uint32
Unk1 uint32
Unk2 uint32
Unk3 uint8
}
// TournamentInfo22 represents tournament information (type 22).
type TournamentInfo22 struct {
Unk0 uint32
Unk1 uint32
Unk2 uint32
Unk3 uint8
Unk4 string
}
// TournamentReward represents a tournament reward entry.
type TournamentReward struct {
Unk0 uint16
Unk1 uint16
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)))
for _, reward := range rewards {
bf.WriteUint16(reward.Unk0)
bf.WriteUint16(reward.Unk1)
bf.WriteUint16(reward.Unk2)
}
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
}