updated slapapi

This commit is contained in:
2026-02-21 14:45:14 +11:00
parent 3dcb864a3e
commit 9b4d78997e
9 changed files with 597 additions and 19 deletions

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ internal/view/**/*_templ.go
internal/view/**/*_templ.txt
cmd/test/*
.opencode
Matches/
# Database backups (compressed)
backups/*.sql.gz

View File

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

53
pkg/slapshotapi/games.go Normal file
View File

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

187
pkg/slapshotapi/lobbies.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

158
pkg/slapshotapi/types.go Normal file
View File

@@ -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"`
}