mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 15:43:49 +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]**.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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{}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
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