mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-25 17:12:52 +01:00
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:
@@ -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]**.
|
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
|
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.
|
(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 |
|
| Handler | Notes |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
@@ -125,12 +123,6 @@ secondary operations are stubs:
|
|||||||
| `handleMsgSysDispObject` | Display/show a previously hidden object |
|
| `handleMsgSysDispObject` | Display/show a previously hidden object |
|
||||||
| `handleMsgSysHideObject` | Hide an object from other clients |
|
| `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`)
|
### Register (`handlers_register.go`)
|
||||||
|
|
||||||
| Handler | Notes |
|
| Handler | Notes |
|
||||||
@@ -197,8 +189,6 @@ that needs no reply). Others are genuine feature gaps.
|
|||||||
|
|
||||||
| Branch | Commits ahead | Handlers targeted |
|
| Branch | Commits ahead | Handlers targeted |
|
||||||
|--------|:---:|-------------------|
|
|--------|:---:|-------------------|
|
||||||
| `feature/hunting-tournament` | 7 | `EnterTournamentQuest` |
|
|
||||||
| `fix/mutex-rework` | 2 | All 5 Mutex handlers |
|
|
||||||
| `feature/enum-event` | 4 | `GetRestrictionEvent` |
|
| `feature/enum-event` | 4 | `GetRestrictionEvent` |
|
||||||
| `feature/conquest` | 4 | Conquest quest handlers |
|
| `feature/conquest` | 4 | Conquest quest handlers |
|
||||||
| `feature/tower` | 4 | Tower handlers |
|
| `feature/tower` | 4 | Tower handlers |
|
||||||
|
|||||||
@@ -8,8 +8,33 @@ import (
|
|||||||
"erupe-ce/network/clientctx"
|
"erupe-ce/network/clientctx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MsgMhfEnterTournamentQuest represents the MSG_MHF_ENTER_TOURNAMENT_QUEST
|
// MsgMhfEnterTournamentQuest represents the MSG_MHF_ENTER_TOURNAMENT_QUEST (opcode 0x00D2).
|
||||||
type MsgMhfEnterTournamentQuest struct{}
|
//
|
||||||
|
// 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.
|
// Opcode returns the ID associated with this packet type.
|
||||||
func (m *MsgMhfEnterTournamentQuest) Opcode() network.PacketID {
|
func (m *MsgMhfEnterTournamentQuest) Opcode() network.PacketID {
|
||||||
@@ -18,7 +43,18 @@ func (m *MsgMhfEnterTournamentQuest) Opcode() network.PacketID {
|
|||||||
|
|
||||||
// Parse parses the packet from binary
|
// Parse parses the packet from binary
|
||||||
func (m *MsgMhfEnterTournamentQuest) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
|
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.
|
// Build builds a binary packet from the current data.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
// MsgMhfEnumerateOrder represents the MSG_MHF_ENUMERATE_ORDER
|
// MsgMhfEnumerateOrder represents the MSG_MHF_ENUMERATE_ORDER
|
||||||
type MsgMhfEnumerateOrder struct {
|
type MsgMhfEnumerateOrder struct {
|
||||||
AckHandle uint32
|
AckHandle uint32
|
||||||
Unk0 uint32
|
EventID uint32
|
||||||
Unk1 uint32
|
ClanID uint32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Opcode returns the ID associated with this packet type.
|
// 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
|
// Parse parses the packet from binary
|
||||||
func (m *MsgMhfEnumerateOrder) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
|
func (m *MsgMhfEnumerateOrder) Parse(bf *byteframe.ByteFrame, ctx *clientctx.ClientContext) error {
|
||||||
m.AckHandle = bf.ReadUint32()
|
m.AckHandle = bf.ReadUint32()
|
||||||
m.Unk0 = bf.ReadUint32()
|
m.EventID = bf.ReadUint32()
|
||||||
m.Unk1 = bf.ReadUint32()
|
m.ClanID = bf.ReadUint32()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ func TestParseSmallNotImplemented(t *testing.T) {
|
|||||||
// MHF packets - NOT IMPLEMENTED
|
// MHF packets - NOT IMPLEMENTED
|
||||||
{"MsgMhfAcceptReadReward", &MsgMhfAcceptReadReward{}},
|
{"MsgMhfAcceptReadReward", &MsgMhfAcceptReadReward{}},
|
||||||
{"MsgMhfDebugPostValue", &MsgMhfDebugPostValue{}},
|
{"MsgMhfDebugPostValue", &MsgMhfDebugPostValue{}},
|
||||||
{"MsgMhfEnterTournamentQuest", &MsgMhfEnterTournamentQuest{}},
|
|
||||||
{"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}},
|
{"MsgMhfGetCaAchievementHist", &MsgMhfGetCaAchievementHist{}},
|
||||||
{"MsgMhfGetCaUniqueID", &MsgMhfGetCaUniqueID{}},
|
{"MsgMhfGetCaUniqueID", &MsgMhfGetCaUniqueID{}},
|
||||||
{"MsgMhfGetRestrictionEvent", &MsgMhfGetRestrictionEvent{}},
|
{"MsgMhfGetRestrictionEvent", &MsgMhfGetRestrictionEvent{}},
|
||||||
|
|||||||
@@ -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})
|
[]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)
|
// Festa timing constants (all values in seconds)
|
||||||
const (
|
const (
|
||||||
festaVotingDuration = 9000 // 150 min voting window
|
festaVotingDuration = 9000 // 150 min voting window
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ func handleMsgMhfEnumeratePrice(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
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) {
|
func handleMsgMhfGetExtraInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetExtraInfo)
|
pkt := p.(*mhfpacket.MsgMhfGetExtraInfo)
|
||||||
|
|||||||
@@ -734,7 +734,6 @@ func getTuneValueRange(start uint16, value uint16) []tuneValue {
|
|||||||
return tv
|
return tv
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMsgMhfEnterTournamentQuest(s *Session, p mhfpacket.MHFPacket) {} // stub: unimplemented
|
|
||||||
|
|
||||||
func handleMsgMhfGetUdBonusQuestInfo(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfGetUdBonusQuestInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetUdBonusQuestInfo)
|
pkt := p.(*mhfpacket.MsgMhfGetUdBonusQuestInfo)
|
||||||
|
|||||||
@@ -604,17 +604,16 @@ func TestQuestFileLoadingErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTournamentQuestEntryStub tests the stub tournament quest handler
|
// TestTournamentQuestEntryHandler tests the tournament quest entry handler.
|
||||||
func TestTournamentQuestEntryStub(t *testing.T) {
|
func TestTournamentQuestEntryHandler(t *testing.T) {
|
||||||
mockConn := &MockCryptConn{sentPackets: make([][]byte, 0)}
|
mockConn := &MockCryptConn{sentPackets: make([][]byte, 0)}
|
||||||
s := createTestSession(mockConn)
|
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)
|
handleMsgMhfEnterTournamentQuest(s, pkt)
|
||||||
|
|
||||||
// Verify no crash occurred (pass if we reach here)
|
|
||||||
if s.logger == nil {
|
if s.logger == nil {
|
||||||
t.Errorf("Session corrupted")
|
t.Errorf("Session corrupted")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,18 @@ func TestHandlerMsgMhfSexChanger(t *testing.T) {
|
|||||||
|
|
||||||
func TestHandlerMsgMhfEnterTournamentQuest(t *testing.T) {
|
func TestHandlerMsgMhfEnterTournamentQuest(t *testing.T) {
|
||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
|
server.tournamentRepo = &mockTournamentRepo{}
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
// Should not panic with nil packet (empty handler)
|
pkt := &mhfpacket.MsgMhfEnterTournamentQuest{AckHandle: 1}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if r := recover(); r != nil {
|
if r := recover(); r != nil {
|
||||||
t.Errorf("handleMsgMhfEnterTournamentQuest panicked: %v", r)
|
t.Errorf("handleMsgMhfEnterTournamentQuest panicked: %v", r)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
handleMsgMhfEnterTournamentQuest(session, nil)
|
handleMsgMhfEnterTournamentQuest(session, pkt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerMsgMhfGetUdBonusQuestInfo(t *testing.T) {
|
func TestHandlerMsgMhfGetUdBonusQuestInfo(t *testing.T) {
|
||||||
@@ -295,7 +297,6 @@ func TestEmptyHandlers_NoDb(t *testing.T) {
|
|||||||
{"handleMsgSysSetStatus", handleMsgSysSetStatus},
|
{"handleMsgSysSetStatus", handleMsgSysSetStatus},
|
||||||
{"handleMsgSysEcho", handleMsgSysEcho},
|
{"handleMsgSysEcho", handleMsgSysEcho},
|
||||||
{"handleMsgMhfUseUdShopCoin", handleMsgMhfUseUdShopCoin},
|
{"handleMsgMhfUseUdShopCoin", handleMsgMhfUseUdShopCoin},
|
||||||
{"handleMsgMhfEnterTournamentQuest", handleMsgMhfEnterTournamentQuest},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package channelserver
|
|||||||
import (
|
import (
|
||||||
"erupe-ce/common/byteframe"
|
"erupe-ce/common/byteframe"
|
||||||
ps "erupe-ce/common/pascalstring"
|
ps "erupe-ce/common/pascalstring"
|
||||||
|
cfg "erupe-ce/config"
|
||||||
"erupe-ce/network/mhfpacket"
|
"erupe-ce/network/mhfpacket"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TournamentInfo0 represents tournament information (type 0).
|
// TournamentInfo0 represents tournament information (type 0).
|
||||||
@@ -46,73 +49,6 @@ type TournamentInfo22 struct {
|
|||||||
Unk4 string
|
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.
|
// TournamentReward represents a tournament reward entry.
|
||||||
type TournamentReward struct {
|
type TournamentReward struct {
|
||||||
Unk0 uint16
|
Unk0 uint16
|
||||||
@@ -120,8 +56,254 @@ type TournamentReward struct {
|
|||||||
Unk2 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) {
|
func handleMsgMhfAcquireTournament(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfAcquireTournament)
|
pkt := p.(*mhfpacket.MsgMhfAcquireTournament)
|
||||||
|
// Reward item IDs are unknown. Return an empty reward list.
|
||||||
rewards := []TournamentReward{}
|
rewards := []TournamentReward{}
|
||||||
bf := byteframe.NewByteFrame()
|
bf := byteframe.NewByteFrame()
|
||||||
bf.WriteUint8(uint8(len(rewards)))
|
bf.WriteUint8(uint8(len(rewards)))
|
||||||
|
|||||||
@@ -385,3 +385,61 @@ type MercenaryRepo interface {
|
|||||||
GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error)
|
GetGuildHuntCatsUsed(charID uint32) ([]GuildHuntCatUsage, error)
|
||||||
GetGuildAirou(guildID uint32) ([][]byte, 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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1265,3 +1265,35 @@ func (m *mockCafeRepo) GetBonusItem(_ uint32) (uint32, uint32, error) {
|
|||||||
return m.bonusItemType, m.bonusItemQty, m.bonusItemErr
|
return m.bonusItemType, m.bonusItemQty, m.bonusItemErr
|
||||||
}
|
}
|
||||||
func (m *mockCafeRepo) AcceptBonus(_, _ uint32) error { return nil }
|
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
|
||||||
|
}
|
||||||
|
|||||||
167
server/channelserver/repo_tournament.go
Normal file
167
server/channelserver/repo_tournament.go
Normal 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
|
||||||
|
}
|
||||||
@@ -76,6 +76,7 @@ type Server struct {
|
|||||||
miscRepo MiscRepo
|
miscRepo MiscRepo
|
||||||
scenarioRepo ScenarioRepo
|
scenarioRepo ScenarioRepo
|
||||||
mercenaryRepo MercenaryRepo
|
mercenaryRepo MercenaryRepo
|
||||||
|
tournamentRepo TournamentRepo
|
||||||
mailService *MailService
|
mailService *MailService
|
||||||
guildService *GuildService
|
guildService *GuildService
|
||||||
achievementService *AchievementService
|
achievementService *AchievementService
|
||||||
@@ -169,6 +170,7 @@ func NewServer(config *Config) *Server {
|
|||||||
s.miscRepo = NewMiscRepository(config.DB)
|
s.miscRepo = NewMiscRepository(config.DB)
|
||||||
s.scenarioRepo = NewScenarioRepository(config.DB)
|
s.scenarioRepo = NewScenarioRepository(config.DB)
|
||||||
s.mercenaryRepo = NewMercenaryRepository(config.DB)
|
s.mercenaryRepo = NewMercenaryRepository(config.DB)
|
||||||
|
s.tournamentRepo = NewTournamentRepository(config.DB)
|
||||||
|
|
||||||
s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger)
|
s.mailService = NewMailService(s.mailRepo, s.guildRepo, s.logger)
|
||||||
s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger)
|
s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger)
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ func createMockServer() *Server {
|
|||||||
state: make([]uint32, 30),
|
state: make([]uint32, 30),
|
||||||
support: make([]uint32, 30),
|
support: make([]uint32, 30),
|
||||||
},
|
},
|
||||||
// divaRepo default prevents nil-deref in diva handler tests that don't
|
// divaRepo and tournamentRepo defaults prevent nil-deref in handler tests
|
||||||
// need specific repo behaviour. Tests that need controlled data override it.
|
// that don't need specific repo behaviour. Tests that need controlled data override them.
|
||||||
divaRepo: &mockDivaRepo{},
|
divaRepo: &mockDivaRepo{},
|
||||||
|
tournamentRepo: &mockTournamentRepo{},
|
||||||
}
|
}
|
||||||
s.i18n = getLangStrings(s)
|
s.i18n = getLangStrings(s)
|
||||||
s.Registry = NewLocalChannelRegistry([]*Server{s})
|
s.Registry = NewLocalChannelRegistry([]*Server{s})
|
||||||
|
|||||||
62
server/migrations/seed/TournamentDefaults.sql
Normal file
62
server/migrations/seed/TournamentDefaults.sql
Normal 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;
|
||||||
48
server/migrations/sql/0015_tournament.sql
Normal file
48
server/migrations/sql/0015_tournament.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user