Files
Erupe/server/channelserver/repo_tournament.go
Houmgaor c714374289 feat(tournament): implement hunting tournament system end-to-end
Wire format for MsgMhfEnterTournamentQuest (0x00D2) derived from
mhfo-hd.dll binary analysis (FUN_114f4280). Five new tables back
the full lifecycle: schedule, cups, sub-events, player registrations,
and run submissions. All six tournament handlers are now DB-driven:

- EnumerateRanking: returns active tournament schedule with cups and
  sub-events; computes phase state byte from timestamps
- EnumerateOrder: returns per-event leaderboard ranked by submission
  time, with SJIS-encoded character and guild names
- InfoTournament: exposes tournament detail and player registration
  state across all three query types
- EntryTournament: registers player and returns entry handle used by
  the client in the subsequent EnterTournamentQuest packet
- EnterTournamentQuest: parses the previously-unimplemented packet and
  records the run in tournament_results
- AcquireTournament: stubs rewards (item IDs not yet reversed)

Seed data (TournamentDefaults.sql) reproduces tournament #150 cups and
sub-events so a fresh install has a working tournament immediately.
2026-03-22 14:30:37 +01:00

168 lines
4.9 KiB
Go

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
}