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

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