diff --git a/.gitignore b/.gitignore index 7a77d4d..9a41aa3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ internal/view/**/*_templ.go internal/view/**/*_templ.txt cmd/test/* .opencode +Matches/ # Database backups (compressed) backups/*.sql.gz diff --git a/pkg/slapshotapi/enums.go b/pkg/slapshotapi/enums.go index 04e2c05..c336cb9 100644 --- a/pkg/slapshotapi/enums.go +++ b/pkg/slapshotapi/enums.go @@ -1,12 +1,16 @@ package slapshotapi +// Regions const ( RegionEUWest = "eu-west" RegionNAEast = "na-east" RegionNACentral = "na-central" RegionNAWest = "na-west" RegionOCEEast = "oce-east" +) +// Arenas - API format (used in API responses and lobby creation) +const ( ArenaSlapstadium = "Slapstadium" ArenaSlapville = "Slapville" ArenaSlapstadiumMini = "Slapstadium_mini" @@ -18,7 +22,25 @@ const ( ArenaIsland = "Island" ArenaObstacles = "Obstacles" ArenaObstaclesXL = "Obstacles_XL" + ArenaCyberpuck = "Cyberpuck" +) +// Arenas - Display format (used in local match logs) +const ( + ArenaDisplaySlapStadium = "Slap Stadium" + ArenaDisplaySlapville = "Slapville" + ArenaDisplaySlapStadiumMini = "Slap Stadium Mini" + ArenaDisplayTableHockey = "Table Hockey" + ArenaDisplayColosseum = "Colosseum" + ArenaDisplaySlapvilleJumbo = "Slapville Jumbo" + ArenaDisplaySlapstadiumXL = "Slapstadium XL" + ArenaDisplayIsland = "Island" + ArenaDisplayObstaclesXL = "Obstacles XL" + ArenaDisplayCyberpuck = "Cyberpuck" +) + +// End reasons +const ( EndReasonEndOfReg = "EndOfRegulation" EndReasonOvertime = "Overtime" EndReasonHomeTeamLeft = "HomeTeamLeft" @@ -28,8 +50,30 @@ const ( EndReasonForfeit = "Forfeit" EndReasonCancelled = "Cancelled" EndReasonUnknown = "Unknown" +) +// Game modes +const ( GameModeHockey = "hockey" GameModeDodgePuck = "dodgepuck" GameModeTag = "tag" ) + +// Teams +const ( + TeamHome = "home" + TeamAway = "away" +) + +// Match winners +const ( + WinnerHome = "home" + WinnerAway = "away" + WinnerNone = "none" +) + +// Match types +const ( + MatchTypePublic = "public" + MatchTypePrivate = "private" +) diff --git a/pkg/slapshotapi/games.go b/pkg/slapshotapi/games.go new file mode 100644 index 0000000..295eaee --- /dev/null +++ b/pkg/slapshotapi/games.go @@ -0,0 +1,53 @@ +package slapshotapi + +import ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" +) + +type endpointGame struct { + gameID string +} + +func getEndpointGame(gameID string) *endpointGame { + return &endpointGame{ + gameID: gameID, + } +} + +func (ep *endpointGame) path() string { + return fmt.Sprintf("/api/public/games/%s", ep.gameID) +} + +func (ep *endpointGame) method() string { + return "GET" +} + +// ErrGameNotFound is returned when a game ID does not match any known game +var ErrGameNotFound = errors.New("game not found") + +// GetGame retrieves match details for a specific game by its ID. +func (c *SlapAPI) GetGame( + ctx context.Context, + gameID string, +) (*Game, error) { + if gameID == "" { + return nil, errors.New("gameID cannot be empty") + } + endpoint := getEndpointGame(gameID) + data, err := c.request(ctx, endpoint) + if err != nil { + if strings.Contains(err.Error(), "404") { + return nil, ErrGameNotFound + } + return nil, errors.Wrap(err, "c.request") + } + game, err := unmarshal[Game](data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return game, nil +} diff --git a/pkg/slapshotapi/lobbies.go b/pkg/slapshotapi/lobbies.go new file mode 100644 index 0000000..e0747f2 --- /dev/null +++ b/pkg/slapshotapi/lobbies.go @@ -0,0 +1,187 @@ +package slapshotapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" +) + +// --- Get Lobby --- + +type endpointGetLobby struct { + lobbyID string +} + +func getEndpointGetLobby(lobbyID string) *endpointGetLobby { + return &endpointGetLobby{lobbyID: lobbyID} +} + +func (ep *endpointGetLobby) path() string { + return fmt.Sprintf("/api/public/lobbies/%s", ep.lobbyID) +} + +func (ep *endpointGetLobby) method() string { + return "GET" +} + +// ErrLobbyNotFound is returned when a lobby ID does not match any known lobby +var ErrLobbyNotFound = errors.New("lobby not found") + +// GetLobby retrieves details for a specific lobby by its ID. +func (c *SlapAPI) GetLobby( + ctx context.Context, + lobbyID string, +) (*Lobby, error) { + if lobbyID == "" { + return nil, errors.New("lobbyID cannot be empty") + } + endpoint := getEndpointGetLobby(lobbyID) + data, err := c.request(ctx, endpoint) + if err != nil { + if strings.Contains(err.Error(), "404") { + return nil, ErrLobbyNotFound + } + return nil, errors.Wrap(err, "c.request") + } + lobby, err := unmarshal[Lobby](data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return lobby, nil +} + +// --- Get Lobby Matches --- + +type endpointGetLobbyMatches struct { + lobbyID string +} + +func getEndpointGetLobbyMatches(lobbyID string) *endpointGetLobbyMatches { + return &endpointGetLobbyMatches{lobbyID: lobbyID} +} + +func (ep *endpointGetLobbyMatches) path() string { + return fmt.Sprintf("/api/public/lobbies/%s/matches", ep.lobbyID) +} + +func (ep *endpointGetLobbyMatches) method() string { + return "GET" +} + +// GetLobbyMatches retrieves the list of matches played in a specific lobby. +func (c *SlapAPI) GetLobbyMatches( + ctx context.Context, + lobbyID string, +) ([]Game, error) { + if lobbyID == "" { + return nil, errors.New("lobbyID cannot be empty") + } + endpoint := getEndpointGetLobbyMatches(lobbyID) + data, err := c.request(ctx, endpoint) + if err != nil { + if strings.Contains(err.Error(), "404") { + return nil, ErrLobbyNotFound + } + return nil, errors.Wrap(err, "c.request") + } + var games []Game + err = json.Unmarshal(data, &games) + if err != nil { + return nil, errors.Wrap(err, "json.Unmarshal") + } + return games, nil +} + +// --- Create Lobby --- + +type endpointCreateLobby struct { + request LobbyCreationRequest +} + +func getEndpointCreateLobby(req LobbyCreationRequest) *endpointCreateLobby { + return &endpointCreateLobby{request: req} +} + +func (ep *endpointCreateLobby) path() string { + return "/api/public/lobbies" +} + +func (ep *endpointCreateLobby) method() string { + return "POST" +} + +func (ep *endpointCreateLobby) body() ([]byte, error) { + data, err := json.Marshal(ep.request) + if err != nil { + return nil, errors.Wrap(err, "json.Marshal") + } + return data, nil +} + +// CreateLobby creates a new custom lobby with the specified settings. +func (c *SlapAPI) CreateLobby( + ctx context.Context, + req LobbyCreationRequest, +) (*LobbyCreationResponse, error) { + if req.Region == "" { + return nil, errors.New("region cannot be empty") + } + if req.Name == "" { + return nil, errors.New("name cannot be empty") + } + if req.CreatorName == "" { + return nil, errors.New("creator_name cannot be empty") + } + endpoint := getEndpointCreateLobby(req) + data, err := c.request(ctx, endpoint) + if err != nil { + return nil, errors.Wrap(err, "c.request") + } + resp, err := unmarshal[LobbyCreationResponse](data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return resp, nil +} + +// --- Delete Lobby --- + +type endpointDeleteLobby struct { + lobbyID string +} + +func getEndpointDeleteLobby(lobbyID string) *endpointDeleteLobby { + return &endpointDeleteLobby{lobbyID: lobbyID} +} + +func (ep *endpointDeleteLobby) path() string { + return fmt.Sprintf("/api/public/lobbies/%s", ep.lobbyID) +} + +func (ep *endpointDeleteLobby) method() string { + return "DELETE" +} + +// DeleteLobby deletes an existing lobby by its ID. +// Returns true if the lobby was successfully deleted. +func (c *SlapAPI) DeleteLobby( + ctx context.Context, + lobbyID string, +) (bool, error) { + if lobbyID == "" { + return false, errors.New("lobbyID cannot be empty") + } + endpoint := getEndpointDeleteLobby(lobbyID) + status, body, err := c.requestRaw(ctx, endpoint) + if err != nil { + return false, errors.Wrap(err, "c.requestRaw") + } + if status == http.StatusNotFound { + return false, ErrLobbyNotFound + } + return string(body) == "OK", nil +} diff --git a/pkg/slapshotapi/matchlog.go b/pkg/slapshotapi/matchlog.go new file mode 100644 index 0000000..ae0e189 --- /dev/null +++ b/pkg/slapshotapi/matchlog.go @@ -0,0 +1,64 @@ +package slapshotapi + +import ( + "github.com/pkg/errors" +) + +// MatchLog represents the raw JSON format of a local match log file. +// This differs slightly from the API Game response in structure — the +// match log is a flat object with game stats at the top level, whereas +// the API response wraps game stats inside a nested "game_stats" field +// alongside metadata like region, match type, and creation time. +type MatchLog struct { + Type string `json:"type"` + MatchID string `json:"match_id,omitempty"` + Winner string `json:"winner"` + Arena string `json:"arena"` + PeriodsEnabled string `json:"periods_enabled"` + CurrentPeriod string `json:"current_period"` + CustomMercyRule string `json:"custom_mercy_rule"` + MatchLength string `json:"match_length"` + EndReason string `json:"end_reason"` + Score Score `json:"score"` + Players []Player `json:"players"` +} + +// ParseMatchLog parses a local match log JSON file into a MatchLog struct. +func ParseMatchLog(data []byte) (*MatchLog, error) { + if len(data) == 0 { + return nil, errors.New("data cannot be empty") + } + log, err := unmarshal[MatchLog](data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") + } + return log, nil +} + +// ToGameStats converts a MatchLog into a GameStats struct, normalizing the +// local match log format into the same structure used by the API. +func (ml *MatchLog) ToGameStats() GameStats { + return GameStats{ + Type: ml.Type, + Arena: ml.Arena, + Score: ml.Score, + Winner: ml.Winner, + EndReason: ml.EndReason, + MatchLength: ml.MatchLength, + PeriodsEnabled: ml.PeriodsEnabled, + CurrentPeriod: ml.CurrentPeriod, + CustomMercyRule: ml.CustomMercyRule, + Players: ml.Players, + } +} + +// ToGame converts a MatchLog into a Game struct. Since match logs don't +// contain all fields present in an API response (region, match_type, +// gamemode, created), those fields will be empty. The match_id from the +// log (if present) is used as the Game ID. +func (ml *MatchLog) ToGame() Game { + return Game{ + ID: ml.MatchID, + GameStats: ml.ToGameStats(), + } +} diff --git a/pkg/slapshotapi/queuestatus.go b/pkg/slapshotapi/queuestatus.go index df579a1..f091454 100644 --- a/pkg/slapshotapi/queuestatus.go +++ b/pkg/slapshotapi/queuestatus.go @@ -2,7 +2,6 @@ package slapshotapi import ( "context" - "encoding/json" "fmt" "github.com/pkg/errors" @@ -54,9 +53,11 @@ func (c *SlapAPI) GetQueueStatus( endpoint := getEndpointMatchmaking(regions) data, err := c.request(ctx, endpoint) if err != nil { - return nil, errors.Wrap(err, "slapapiReq") + return nil, errors.Wrap(err, "c.request") + } + resp, err := unmarshal[matchmakingresp](data) + if err != nil { + return nil, errors.Wrap(err, "unmarshal") } - resp := matchmakingresp{} - json.Unmarshal(data, &resp) return &resp.Playlists, nil } diff --git a/pkg/slapshotapi/request.go b/pkg/slapshotapi/request.go index 5e504cd..dd376d4 100644 --- a/pkg/slapshotapi/request.go +++ b/pkg/slapshotapi/request.go @@ -1,7 +1,9 @@ package slapshotapi import ( + "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -14,31 +16,101 @@ type endpoint interface { method() string } +// bodyEndpoint is an optional interface for endpoints that send a JSON body +type bodyEndpoint interface { + endpoint + body() ([]byte, error) +} + func (c *SlapAPI) request( ctx context.Context, ep endpoint, ) ([]byte, error) { baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path()) - req, err := http.NewRequest(ep.method(), baseurl, nil) - if err != nil { - return nil, errors.Wrap(err, "http.NewRequest") + + var bodyReader io.Reader + if bep, ok := ep.(bodyEndpoint); ok { + data, err := bep.body() + if err != nil { + return nil, errors.Wrap(err, "endpoint.body") + } + bodyReader = bytes.NewReader(data) } - req.Header.Add("accept", "application/json") + + req, err := http.NewRequestWithContext(ctx, ep.method(), baseurl, bodyReader) + if err != nil { + return nil, errors.Wrap(err, "http.NewRequestWithContext") + } + req.Header.Add("Accept", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key)) + if bodyReader != nil { + req.Header.Add("Content-Type", "application/json") + } + res, err := c.do(ctx, req) if err != nil { - return nil, errors.Wrap(err, "http.DefaultClient.Do") + return nil, errors.Wrap(err, "c.do") } - if res.StatusCode != 200 { - return nil, errors.New(fmt.Sprintf("Error making request: %v", res.StatusCode)) + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + return nil, errors.New(fmt.Sprintf("API request failed with status %d", res.StatusCode)) } + body, err := io.ReadAll(res.Body) if err != nil { return nil, errors.Wrap(err, "io.ReadAll") } - err = res.Body.Close() - if err != nil { - return nil, errors.Wrap(err, "resp.Body.Close") - } return body, nil } + +// requestRaw performs an API request and returns the raw status code and body. +// This is used for endpoints where non-200 status codes carry meaningful data +// (e.g. DELETE returning a plain text response). +func (c *SlapAPI) requestRaw( + ctx context.Context, + ep endpoint, +) (int, []byte, error) { + baseurl := fmt.Sprintf("https://%s.slapshot.gg%s", c.env, ep.path()) + + var bodyReader io.Reader + if bep, ok := ep.(bodyEndpoint); ok { + data, err := bep.body() + if err != nil { + return 0, nil, errors.Wrap(err, "endpoint.body") + } + bodyReader = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, ep.method(), baseurl, bodyReader) + if err != nil { + return 0, nil, errors.Wrap(err, "http.NewRequestWithContext") + } + req.Header.Add("Accept", "application/json") + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.key)) + if bodyReader != nil { + req.Header.Add("Content-Type", "application/json") + } + + res, err := c.do(ctx, req) + if err != nil { + return 0, nil, errors.Wrap(err, "c.do") + } + defer func() { _ = res.Body.Close() }() + + body, err := io.ReadAll(res.Body) + if err != nil { + return 0, nil, errors.Wrap(err, "io.ReadAll") + } + return res.StatusCode, body, nil +} + +// unmarshal is a helper that unmarshals JSON response data into a target struct +func unmarshal[T any](data []byte) (*T, error) { + var result T + err := json.Unmarshal(data, &result) + if err != nil { + return nil, errors.Wrap(err, "json.Unmarshal") + } + return &result, nil +} diff --git a/pkg/slapshotapi/slapid.go b/pkg/slapshotapi/slapid.go index 1b16dad..b880204 100644 --- a/pkg/slapshotapi/slapid.go +++ b/pkg/slapshotapi/slapid.go @@ -2,7 +2,6 @@ package slapshotapi import ( "context" - "encoding/json" "fmt" "strings" @@ -46,10 +45,9 @@ func (c *SlapAPI) GetSlapID( } return 0, errors.Wrap(err, "c.request") } - resp := idresp{} - err = json.Unmarshal(data, &resp) + resp, err := unmarshal[idresp](data) if err != nil { - return 0, errors.Wrap(err, "json.Unmarshal") + return 0, errors.Wrap(err, "unmarshal") } return resp.ID, nil } diff --git a/pkg/slapshotapi/types.go b/pkg/slapshotapi/types.go new file mode 100644 index 0000000..420aae5 --- /dev/null +++ b/pkg/slapshotapi/types.go @@ -0,0 +1,158 @@ +package slapshotapi + +// Score represents the score for both teams in a match +type Score struct { + Home int `json:"home"` + Away int `json:"away"` +} + +// PlayerStats contains all possible player statistics from a match. +// Fields use pointers because not every stat is present in every match response. +type PlayerStats struct { + Goals *float64 `json:"goals,omitempty"` + Assists *float64 `json:"assists,omitempty"` + PrimaryAssists *float64 `json:"primary_assists,omitempty"` + SecondaryAssists *float64 `json:"secondary_assists,omitempty"` + Saves *float64 `json:"saves,omitempty"` + Blocks *float64 `json:"blocks,omitempty"` + Shots *float64 `json:"shots,omitempty"` + Turnovers *float64 `json:"turnovers,omitempty"` + Takeaways *float64 `json:"takeaways,omitempty"` + Passes *float64 `json:"passes,omitempty"` + PossessionTime *float64 `json:"possession_time_sec,omitempty"` + FaceoffsWon *float64 `json:"faceoffs_won,omitempty"` + FaceoffsLost *float64 `json:"faceoffs_lost,omitempty"` + PostHits *float64 `json:"post_hits,omitempty"` + OvertimeGoals *float64 `json:"overtime_goals,omitempty"` + GameWinningGoals *float64 `json:"game_winning_goals,omitempty"` + Score *float64 `json:"score,omitempty"` + ContributedGoals *float64 `json:"contributed_goals,omitempty"` + ConcededGoals *float64 `json:"conceded_goals,omitempty"` + GamesPlayed *float64 `json:"games_played,omitempty"` + Wins *float64 `json:"wins,omitempty"` + Losses *float64 `json:"losses,omitempty"` + OvertimeWins *float64 `json:"overtime_wins,omitempty"` + OvertimeLosses *float64 `json:"overtime_losses,omitempty"` + Ties *float64 `json:"ties,omitempty"` + Shutouts *float64 `json:"shutouts,omitempty"` + ShutsAgainst *float64 `json:"shutouts_against,omitempty"` + HasMercyRuled *float64 `json:"has_mercy_ruled,omitempty"` + WasMercyRuled *float64 `json:"was_mercy_ruled,omitempty"` + PeriodsPlayed *float64 `json:"periods_played,omitempty"` +} + +// Player represents a single player's data in a match +type Player struct { + GameUserID string `json:"game_user_id"` + Team string `json:"team"` + Username string `json:"username"` + Stats PlayerStats `json:"stats"` +} + +// GameStats contains the in-game statistics and settings for a match. +// This is the core match data returned both by the API and in local match logs. +type GameStats struct { + Type string `json:"type"` + Arena string `json:"arena"` + Score Score `json:"score"` + Winner string `json:"winner"` + EndReason string `json:"end_reason"` + MatchLength string `json:"match_length"` + PeriodsEnabled string `json:"periods_enabled"` + CurrentPeriod string `json:"current_period"` + CustomMercyRule string `json:"custom_mercy_rule"` + Players []Player `json:"players"` +} + +// Game represents a full match as returned by the API /games/{id} endpoint +type Game struct { + ID string `json:"id"` + Region string `json:"region"` + MatchType string `json:"match_type"` + Gamemode string `json:"gamemode"` + Created string `json:"created"` + GameStats GameStats `json:"game_stats"` +} + +// LobbyPlayer represents a player in a lobby +type LobbyPlayer struct { + ID int `json:"id"` + Username string `json:"username"` +} + +// LobbyTeams represents the team assignments in a lobby +type LobbyTeams struct { + Home []LobbyPlayer `json:"home"` + Away []LobbyPlayer `json:"away"` + Spectators []LobbyPlayer `json:"spectators"` +} + +// Lobby represents a custom lobby as returned by the API +type Lobby struct { + UUID string `json:"uuid"` + Region string `json:"region"` + Name string `json:"name"` + HasPassword bool `json:"has_password"` + Owner int `json:"owner"` + OwnerName string `json:"owner_name"` + PlayerCount int `json:"player_count"` + MaxPlayers int `json:"max_players"` + InGame bool `json:"in_game"` + Players LobbyTeams `json:"players"` + MercyRule int `json:"mercy_rule"` + Arena string `json:"arena"` + PeriodsEnabled bool `json:"periods_enabled"` + CurrentPeriod int `json:"current_period"` + Score Score `json:"score"` + Starting bool `json:"starting"` + CanStart bool `json:"can_start"` +} + +// LobbyCreationRequest contains the parameters for creating a new lobby +type LobbyCreationRequest struct { + Region string `json:"region"` + Name string `json:"name"` + Password string `json:"password"` + CreatorName string `json:"creator_name"` + + // Optional fields + IsPeriods *bool `json:"is_periods,omitempty"` + CurrentPeriod *int `json:"current_period,omitempty"` + MatchLength *int `json:"match_length,omitempty"` + MercyRule *int `json:"mercy_rule,omitempty"` + Arena string `json:"arena,omitempty"` + + InitialScore *Score `json:"initial_score,omitempty"` +} + +// LobbyCreationResponse is returned when a lobby is successfully created +type LobbyCreationResponse struct { + Success bool `json:"success"` + LobbyID string `json:"lobby_id"` +} + +// MatchmakingPlayer represents a player in the matchmaking queue +type MatchmakingPlayer struct { + UUID int `json:"uuid"` + Username string `json:"username"` +} + +// MatchmakingRegion represents a region in a matchmaking entity +type MatchmakingRegion struct { + Key string `json:"key"` + Name string `json:"name"` +} + +// MatchmakingEntity represents a group/party in the matchmaking queue +type MatchmakingEntity struct { + Players []MatchmakingPlayer `json:"players"` + Regions []MatchmakingRegion `json:"regions"` + MMR int `json:"mmr"` + MMROffset int `json:"mmr_offset"` +} + +// MatchmakingResponse is the full matchmaking queue response from the API +type MatchmakingResponse struct { + Entities []MatchmakingEntity `json:"entities"` + Playlists PubsQueue `json:"playlists"` +}