diff --git a/Third Party Notices.md b/Third Party Notices.md index 04bf1e3..58d438e 100644 --- a/Third Party Notices.md +++ b/Third Party Notices.md @@ -22,4 +22,20 @@ Component Name: stretchr/testify License Type: "MIT" -[logrus License](https://github.com/stretchr/testify/blob/master/LICENSE) +[testify License](https://github.com/stretchr/testify/blob/master/LICENSE) + +--- + +Component Name: google/uuid + +License Type: "BSD 3-clause" + +[google uuid License](https://github.com/google/uuid/blob/master/LICENSE) + +--- + +Component Name: caarlos0/env + +License Type: "MIT" + +[caarlos0 env License](https://github.com/caarlos0/env/blob/main/LICENSE.md) diff --git a/simple-game-client/README.md b/simple-game-client/README.md new file mode 100644 index 0000000..a840939 --- /dev/null +++ b/simple-game-client/README.md @@ -0,0 +1,15 @@ +# Simple Game Client + +This is a very simple game client, designed to be used with the simple-matchmaker. + +This app is designed to provide the simplest complete example of using the Multiplay. It uses a very simple matchmaker +which is designed to demonstrate flows that need to be made and is not designed for production use. + +## Expected flow: + +- Simple-game-client app starts +- Creates a Player UUID unique for the game client run. +- Repeatedly call the simple-matchmaker `/player` endpoint with the player UUID +- Eventually the endpoint will return an IP and Port to connecto +- Simple-game-client app connects to port using a basic TCP connection +- App periodically sends messages and displays anything it receives from the connection. diff --git a/simple-game-client/assets/help_en.txt b/simple-game-client/assets/help_en.txt new file mode 100644 index 0000000..b717aa9 --- /dev/null +++ b/simple-game-client/assets/help_en.txt @@ -0,0 +1,8 @@ + +Unity Simple Game Client Example + +This sample represents your game client which will be distributed to players. Here it is a very simple client +which connects to the matchmaker and asks for a game. When the matchmaker receives enough players and has allocated +a match it will then tell this client where to connect to. + +Once connected this game client will stay connected until the match end, and then exit. diff --git a/simple-game-client/go.mod b/simple-game-client/go.mod new file mode 100644 index 0000000..ca0b08e --- /dev/null +++ b/simple-game-client/go.mod @@ -0,0 +1,10 @@ +module github.com/Unity-Technologies/multiplay-examples/simple-game-client + +go 1.17 + +require ( + github.com/Unity-Technologies/multiplay-examples/simple-matchmaker v0.0.0 + github.com/google/uuid v1.3.0 +) + +replace github.com/Unity-Technologies/multiplay-examples/simple-matchmaker => ../simple-matchmaker diff --git a/simple-game-client/go.sum b/simple-game-client/go.sum new file mode 100644 index 0000000..8bb4dfb --- /dev/null +++ b/simple-game-client/go.sum @@ -0,0 +1,15 @@ +github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/simple-game-client/main.go b/simple-game-client/main.go new file mode 100644 index 0000000..0cd8602 --- /dev/null +++ b/simple-game-client/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "bytes" + _ "embed" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/Unity-Technologies/multiplay-examples/simple-matchmaker/pkg/matchmaker" + "github.com/google/uuid" +) + +var ( + //go:embed assets/help_en.txt + helpEn string +) + +func main() { + showHelp := flag.Bool("help", false, "Display help") + matchmakerURL := flag.String("matchmaker", "http://localhost:8085", "The URL where the sample matchmaker is running") + flag.Parse() + + if *showHelp { + displayHelp() + return + } + + fmt.Println("Starting to find a match") + + if err := matchmake(*matchmakerURL); err != nil { + fmt.Println(fmt.Errorf("match: %w", err)) + } + fmt.Println("Ending Match") +} + +func matchmake(matchmakerURL string) (err error) { + // Create a unique player id so the matchmaker can associate requests with us. + playerID := uuid.New().String() + + matchInfo := &matchmaker.MatchInfo{} + for { + + fmt.Printf("Asking matchmaker about match for us (playerid: %s)\n", playerID) + + // Repeatedly call the matchmakers player join endpoint + matchInfo, err = requestPlayerJoin(playerID, matchmakerURL) + if err != nil { + return err + } + + // There are three stages here in matchInfo. + // MatchedPlayers is true - Matchmaker has found players to put together + // AllocationUUID is non-empty - Matchmaker has requested allocation from api + // IP address is non-empty - Matchmaker has been told the game is running here + // We only care about the last one here. + if matchInfo.IP != "" { + // We got a match! Break out of the loop and play the match. + fmt.Println("Matchmaker found us a match") + break + } + + fmt.Println("Matchmaker did not have a match ready") + <-time.After(time.Second) + } + + fmt.Printf("Connecting to match:\n") + fmt.Printf(" - Allocation UUID: %s:%d\n", matchInfo.AllocationUUID, matchInfo.Port) + fmt.Printf(" - Address: %s:%d\n", matchInfo.IP, matchInfo.Port) + fmt.Printf(" - Other players:\n") + for _, pl := range matchInfo.Players { + fmt.Printf(" - - %s - %s\n", pl.PlayerUUID, pl.IP) + } + + tcpAddr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%s:%d", matchInfo.IP, matchInfo.Port)) + if err != nil { + return fmt.Errorf("resolve gameserver address: %w", err) + } + + conn, err := net.DialTCP("tcp", nil, tcpAddr) + if err != nil { + return fmt.Errorf("dial gameserver: %w", err) + } + + wg := sync.WaitGroup{} + wg.Add(1) + go func() { + defer wg.Done() + for { + _, err = conn.Write([]byte(fmt.Sprintf("Player checking in: %s\n", playerID))) + if err != nil { + fmt.Printf("could not send to server: giving up: %s\n", err.Error()) + return + } + <-time.After(time.Second) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + for { + if err = conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + fmt.Printf("could not set read deadline for server: giving up: %s\n", err.Error()) + } + content, err := ioutil.ReadAll(conn) + if err != nil && !os.IsTimeout(err) { + fmt.Printf("could not send to server: giving up: %s\n", err.Error()) + return + } + fmt.Println(string(content)) + <-time.After(time.Millisecond * 200) + } + }() + wg.Wait() + + fmt.Println("Could not read or write to server. Match likely ended.") + return nil +} + +func requestPlayerJoin(playerID string, matchmakerURL string) (*matchmaker.MatchInfo, error) { + player := matchmaker.PlayerInfo{ + PlayerUUID: playerID, + } + + content, err := json.Marshal(player) + if err != nil { + return nil, fmt.Errorf("marshal player info: %w", err) + } + + req, err := http.NewRequest(http.MethodGet, matchmakerURL+"/player", bytes.NewBuffer(content)) + if err != nil { + return nil, fmt.Errorf("matchmaker player request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("mathmaker send player request: %w", err) + } + + matchInfo := matchmaker.MatchInfo{} + err = json.NewDecoder(resp.Body).Decode(&matchInfo) + if err != nil { + return nil, fmt.Errorf("decode match info: %w", err) + } + + return &matchInfo, err +} + +func displayHelp() { + fmt.Println(helpEn) + fmt.Println("Arguments:") + flag.PrintDefaults() +} diff --git a/simple-matchmaker/assets/help_en.txt b/simple-matchmaker/assets/help_en.txt new file mode 100644 index 0000000..ee0884a --- /dev/null +++ b/simple-matchmaker/assets/help_en.txt @@ -0,0 +1,21 @@ + +Unity Simple Matchmaker Example + +To use multiplay, most games need a matchmaker which groups players. +For the most streamlined experience we recommend that you use the unity matchmaker to do this, but you may use an +alternative or your own custom implementation. + +This sample provides an incredibly simple matchmaker to get you started locally. It will group incoming players +together and when enough have joined it will request a match from either its multiplay mock, or the real multiplay API. + +Authentication is provided through environmental variables. You may set this in your system environmental variables +or may temporarily set this for your terminal session as shown below. + +Windows Example: +set MP_ACCESS_KEY=9ff2af788834439b83ae6692f34ea5e5 +set MP_SECRET_KEY=6323354e200a451dba319bc1b98ade59 + +For example on UNIX Style OS (OSX, Linux, BSD): +export MP_ACCESS_KEY=9ff2af788834439b83ae6692f34ea5e5 +export MP_SECRET_KEY=6323354e200a451dba319bc1b98ade59 + diff --git a/simple-matchmaker/assets/standalone_en.txt b/simple-matchmaker/assets/standalone_en.txt new file mode 100644 index 0000000..1ee443d --- /dev/null +++ b/simple-matchmaker/assets/standalone_en.txt @@ -0,0 +1,8 @@ + +The matchmaker is running in standalone mode. + +In this mode it will still matchmake players in groups. however the match will send players to a TCP mirror running +inside this simple matchmaker. + +This is designed to allow you to play around with how this matchmaker works fully standalone and is not representative +of a full allocation/deallocation flow you would see in a real game. \ No newline at end of file diff --git a/simple-matchmaker/go.mod b/simple-matchmaker/go.mod new file mode 100644 index 0000000..918dce6 --- /dev/null +++ b/simple-matchmaker/go.mod @@ -0,0 +1,17 @@ +module github.com/Unity-Technologies/multiplay-examples/simple-matchmaker + +go 1.17 + +require ( + github.com/caarlos0/env v3.5.0+incompatible + github.com/google/uuid v1.3.0 + github.com/stretchr/testify v1.7.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/kr/pretty v0.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/simple-matchmaker/go.sum b/simple-matchmaker/go.sum new file mode 100644 index 0000000..49bff88 --- /dev/null +++ b/simple-matchmaker/go.sum @@ -0,0 +1,23 @@ +github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs= +github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/simple-matchmaker/internal/client/README.md b/simple-matchmaker/internal/client/README.md new file mode 100644 index 0000000..329ae91 --- /dev/null +++ b/simple-matchmaker/internal/client/README.md @@ -0,0 +1,4 @@ +# Simple Matchmaker Multiplay Client Library + +This is a temporary package which provides multiplay API calls. This is a temporary package until the official +multiplay SDK library becomes available. \ No newline at end of file diff --git a/simple-matchmaker/internal/client/allocate.go b/simple-matchmaker/internal/client/allocate.go new file mode 100644 index 0000000..e8045e2 --- /dev/null +++ b/simple-matchmaker/internal/client/allocate.go @@ -0,0 +1,68 @@ +package mpclient + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// AllocateResponse contains the response from the api +type AllocateResponse struct { + ProfileID int64 + UUID string + RegionID string + Created string + Error string +} + +type allocateResponseWrapper struct { + Success bool + Allocation AllocateResponse +} + +// Allocate allocates using the multiplay api +func (m *multiplayClient) Allocate(fleet, region string, profile int64, uuid string) (*AllocateResponse, error) { + fmt.Println("Allocating", m.baseURL) + urlStr := fmt.Sprintf("%s/cfp/v1/server/allocate", m.baseURL) + u, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("parse url %s", urlStr) + } + + params := url.Values{} + params.Add("regionid", region) + params.Add("profileid", strconv.FormatInt(profile, 10)) + params.Add("uuid", uuid) + u.RawQuery = params.Encode() + + req, err := http.NewRequest(http.MethodPost, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("allocate new request") + } + + fmt.Println("Access:", m.accessKey, "Secret:", m.secretKey) + req.SetBasicAuth(m.accessKey, m.secretKey) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send allocate request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("allocate call status not ok: %d", res.StatusCode) + } + + var ar allocateResponseWrapper + if err := json.NewDecoder(res.Body).Decode(&ar); err != nil { + return nil, fmt.Errorf("decode allocate response: %w", err) + } + + if !ar.Success { + return nil, fmt.Errorf("allocation request failed: %+v", ar) + } + + return &ar.Allocation, nil +} diff --git a/simple-matchmaker/internal/client/allocations.go b/simple-matchmaker/internal/client/allocations.go new file mode 100644 index 0000000..11c7eaa --- /dev/null +++ b/simple-matchmaker/internal/client/allocations.go @@ -0,0 +1,89 @@ +package mpclient + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +var ( + // AllocationNotFound is returned when an allocation is not found. + AllocationNotFound = errors.New("allocation not found") +) + +// AllocationResponse is a response to query one or more allocations. +type AllocationResponse struct { + ProfileID int64 + UUID string + Regions string + Created string + Requested string + Fulfilled string + ServerID int64 + FleetID string + RegionID string + MachineID int64 + IP string + GamePort int `json:"game_port"` + Error string +} + +// allocationsResponseWrapper is a wrapper which contains the allocation api success status. +type allocationsResponseWrapper struct { + Success bool + Allocations []AllocationResponse +} + +// Allocations checks allocations using the multiplay api +func (m *multiplayClient) Allocations(fleet string, uuids ...string) ([]AllocationResponse, error) { + urlStr := fmt.Sprintf("%s/cfp/v1/server/allocations", m.baseURL) + u, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("parse url %s", urlStr) + } + + params := url.Values{} + params.Add("fleetid", fleet) + for _, uuid := range uuids { + params.Add("uuid", uuid) + } + u.RawQuery = params.Encode() + + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("allocations new request") + } + + req.Form = make(map[string][]string, 1) + for _, uuid := range uuids { + req.Form.Add("uuid", uuid) + } + + req.SetBasicAuth(m.accessKey, m.secretKey) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send allocations request: %w", err) + } + defer res.Body.Close() + + switch { + case res.StatusCode == http.StatusNotFound: + return nil, AllocationNotFound + case res.StatusCode != http.StatusOK: + return nil, fmt.Errorf("allocations call failed: %d: %s", res.StatusCode, getBody(res.Body)) + } + + var ar allocationsResponseWrapper + if err := json.NewDecoder(res.Body).Decode(&ar); err != nil { + return nil, fmt.Errorf("decode allocations response: %w", err) + } + + if !ar.Success { + return nil, fmt.Errorf("allocations request failed") + } + + return ar.Allocations, nil +} diff --git a/simple-matchmaker/internal/client/client.go b/simple-matchmaker/internal/client/client.go new file mode 100644 index 0000000..2d1765a --- /dev/null +++ b/simple-matchmaker/internal/client/client.go @@ -0,0 +1,74 @@ +package mpclient + +import ( + "fmt" + "io" + "io/ioutil" + + "github.com/caarlos0/env" +) + +const ( + authService = "cf" + authRegion = "eu-west-1" +) + +// MultiplayClient represents something capable of interfacing with the multiplay API +type MultiplayClient interface { + Allocate(fleet, region string, profile int64, uuid string) (*AllocateResponse, error) + Allocations(fleet string, uuids ...string) ([]AllocationResponse, error) + Deallocate(fleet, uuid string) error +} + +// Config holds configuration used to access the multiplay api +type Config struct { + AccessKey string `env:"MP_ACCESS_KEY"` + SecretKey string `env:"MP_SECRET_KEY"` + BaseURL string `env:"MP_BASE_URL"` +} + +// multiplayClient is the implementation of the multiplay client +type multiplayClient struct { + accessKey string + secretKey string + baseURL string +} + +// NewClientFromEnv creates a multiplay client from the environment +func NewClientFromEnv() (MultiplayClient, error) { + cfg := Config{} + if err := env.Parse(&cfg); err != nil { + return nil, fmt.Errorf("failed to load multiplay config from env: %w", err) + } + + if cfg.AccessKey == "" { + return nil, fmt.Errorf("access key is empty") + } + + if cfg.SecretKey == "" { + return nil, fmt.Errorf("access key is empty") + } + + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.multiplay.co.uk" + } + + return NewClient(cfg), nil +} + +// NewClient creates a multiplay client +func NewClient(cfg Config) MultiplayClient { + return &multiplayClient{ + accessKey: cfg.AccessKey, + secretKey: cfg.SecretKey, + baseURL: cfg.BaseURL, + } +} + +func getBody(w io.Reader) string { + v, err := ioutil.ReadAll(w) + if err != nil { + return "" + } + return string(v) +} diff --git a/simple-matchmaker/internal/client/deallocate.go b/simple-matchmaker/internal/client/deallocate.go new file mode 100644 index 0000000..a5ac56c --- /dev/null +++ b/simple-matchmaker/internal/client/deallocate.go @@ -0,0 +1,39 @@ +package mpclient + +import ( + "fmt" + "net/http" + "net/url" +) + +func (m *multiplayClient) Deallocate(fleet, uuid string) error { + fmt.Printf("deallocate: fid: %s uuid: %s\n", fleet, uuid) + urlStr := fmt.Sprintf("%s/cfp/v2/fleet/%s/server/deallocate", m.baseURL, fleet) + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("parse url %s", urlStr) + } + + params := url.Values{} + params.Add("uuid", uuid) + u.RawQuery = params.Encode() + + req, err := http.NewRequest(http.MethodPost, u.String(), nil) + if err != nil { + return fmt.Errorf("deallocate new request") + } + + req.SetBasicAuth(m.accessKey, m.secretKey) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("send deallocate request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("deallocate call failed: %w", err) + } + + return nil +} diff --git a/simple-matchmaker/internal/client/harness_test.go b/simple-matchmaker/internal/client/harness_test.go new file mode 100644 index 0000000..f2106b6 --- /dev/null +++ b/simple-matchmaker/internal/client/harness_test.go @@ -0,0 +1,56 @@ +//go:build manual + +package mpclient + +import ( + "fmt" + "testing" + "time" + + "github.com/caarlos0/env" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type ManualTestConfig struct { + FleetID string `env:"FLEET"` + RegionID string `env:"REGION"` + BuildConfig int64 `env:"BUILD_CFG"` +} + +// TestMultiplayClient_Allocate is a basic test harness that runs through allocating, monitoring the allocation and +// deallocating. It needs the go build tag 'manual' setting to run. e.g. go test --tags manual +func TestMultiplayClient_Allocate(t *testing.T) { + var cfg ManualTestConfig + require.NoError(t, env.Parse(&cfg)) + + c, err := NewClientFromEnv() + require.NoError(t, err) + + allocUUID := uuid.New().String() + + fmt.Println("Making allocation") + fmt.Println("Fleet: ", cfg.FleetID) + fmt.Println("RegionID: ", cfg.RegionID) + fmt.Println("BuildConfig(Profile): ", cfg.BuildConfig) + fmt.Println("UUID: ", allocUUID) + _, err = c.Allocate(cfg.FleetID, cfg.RegionID, cfg.BuildConfig, allocUUID) + require.NoError(t, err) + + ticker := time.NewTicker(time.Second) + + fmt.Println("Waiting for allocation") + for range ticker.C { + allocs, err := c.Allocations(cfg.FleetID, allocUUID) + require.NoError(t, err) + + if len(allocs) > 0 && allocs[0].IP != "" { + fmt.Printf("Got allocation: %s:%d\n", allocs[0].IP, allocs[0].GamePort) + break + } + } + + fmt.Println("Deallocating") + require.NoError(t, c.Deallocate(cfg.FleetID, allocUUID)) + fmt.Println("Deallocated") +} diff --git a/simple-matchmaker/internal/client/mock.go b/simple-matchmaker/internal/client/mock.go new file mode 100644 index 0000000..77b70db --- /dev/null +++ b/simple-matchmaker/internal/client/mock.go @@ -0,0 +1,44 @@ +package mpclient + +import "fmt" + +// MockMultiplayClient is a basic client that returns fixed data for testing. +type MockMultiplayClient struct { +} + +func (m MockMultiplayClient) Allocate(fleet, region string, profile int64, uuid string) (*AllocateResponse, error) { + fmt.Printf("Mock Allocated: %s", uuid) + return &AllocateResponse{ + ProfileID: 0, + UUID: "", + RegionID: "", + Created: "", + Error: "", + }, nil +} + +func (m MockMultiplayClient) Allocations(fleet string, uuids ...string) ([]AllocationResponse, error) { + fmt.Printf("Mock allocations response: %v", uuids) + return []AllocationResponse{ + { + ProfileID: 0, + UUID: "123-123-123", + Regions: "", + Created: "", + Requested: "", + Fulfilled: "", + ServerID: 0, + FleetID: "", + RegionID: "", + MachineID: 0, + IP: "127.0.0.1", // This port lines up with the simulated gameserver port the matchmaker will run. + GamePort: 9200, + Error: "", + }, + }, nil +} + +func (m MockMultiplayClient) Deallocate(fleet, uuid string) error { + fmt.Printf("deallocate: %s, %s\n", fleet, uuid) + return nil +} diff --git a/simple-matchmaker/internal/simplematchmaker/simplematchmaker.go b/simple-matchmaker/internal/simplematchmaker/simplematchmaker.go new file mode 100644 index 0000000..8054a3c --- /dev/null +++ b/simple-matchmaker/internal/simplematchmaker/simplematchmaker.go @@ -0,0 +1,322 @@ +package simplematchmaker + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "sync" + "time" + + mpclient "github.com/Unity-Technologies/multiplay-examples/simple-matchmaker/internal/client" + "github.com/Unity-Technologies/multiplay-examples/simple-matchmaker/pkg/matchmaker" + "github.com/google/uuid" +) + +var ( + // ErrNoMatch is returned when a match does not exist. + ErrNoMatch = errors.New("no match found yet") + + // ErrMatchSearching is returned when a match is being searched for but not ready yet. + ErrMatchSearching = errors.New("match searching") +) + +// Config contains settings for starting the matchmaker +type Config struct { + FleetID string + RegionID string + ProfileID int64 + MatchSize int +} + +// SimpleMatchmaker defines a simple matchmaker. +type SimpleMatchmaker struct { + // mpclient is library we use to interact with multiplay, or the standalone mock of multiplay. + mpClient mpclient.MultiplayClient + + // unmatchedPlayers is a list of players which currently have not been matchmade yet. + unmatchedPlayers []matchmaker.PlayerInfo + // unmatchedPlayersMtx is a mutex to prevent races. + unmatchedPlayersMtx sync.Mutex + + // matches is a map of match allocation UUIDs to matches. + matches map[string]matchmaker.MatchInfo + // matchesMtx is a mutex to prevent races. + matchesMtx sync.Mutex + + // playerAlloc is a map of player UUIDs to allocation UUIDs + playerAlloc map[string]string + // playerAllocsMtx is a mutex to prevent races. + playerAllocsMtx sync.Mutex + + // done is a channel we use to tell any long running goroutines that we want to stop the matchmaker. + done chan struct{} + // wg is a waitgroup that ensures all running goroutines have ended before we allow a stop to complete. + wg sync.WaitGroup + cfg Config +} + +// NewSimpleMatchmaker creates a new simple matchmaker +func NewSimpleMatchmaker(cfg Config, client mpclient.MultiplayClient) *SimpleMatchmaker { + return &SimpleMatchmaker{ + mpClient: client, + matches: make(map[string]matchmaker.MatchInfo), + playerAlloc: make(map[string]string), + cfg: cfg, + done: make(chan struct{}), + } +} + +// Start starts the simple matchmaker. +func (m *SimpleMatchmaker) Start() { + m.wg.Add(1) + go func() { + defer m.wg.Done() + + ticker := time.NewTicker(time.Second) + for { + select { + case <-m.done: + // We are stopping + return + case <-ticker.C: + m.checkMatch() + } + } + }() +} + +// Stop stops the simple matchmaker, waiting for it to finish. +func (m *SimpleMatchmaker) Stop() { + close(m.done) + m.wg.Wait() +} + +// PlayerHandler handles player calls to get a match. +func (m *SimpleMatchmaker) PlayerHandler(w http.ResponseWriter, r *http.Request) { + fmt.Println("Handle player") + + // Decode and validate the request + var pl matchmaker.PlayerInfo + if err := json.NewDecoder(r.Body).Decode(&pl); err != nil { + fmt.Println("failed decoding request: " + err.Error()) + http.Error(w, "decode request", http.StatusBadRequest) + return + } + pl.IP = r.RemoteAddr + if pl.PlayerUUID == "" { + fmt.Println("missing player uuid") + http.Error(w, "missing player uuid", http.StatusBadRequest) + return + } + + // See if the player has a match that exists or is being searched for. + matchInfo, err := m.playerMatch(pl.PlayerUUID) + switch { + case errors.Is(err, ErrMatchSearching): + fmt.Printf("Player searching: %s\n", pl.PlayerUUID) + // Match is currently being looked for. Nothing we can do right now so just return the current match info. + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(matchInfo) + return + case errors.Is(err, ErrNoMatch): + m.queuePlayer(pl) + case err != nil: + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Println("Adding new unmatched player") + json.NewEncoder(w).Encode(matchInfo) +} + +func (m *SimpleMatchmaker) queuePlayer(player matchmaker.PlayerInfo) { + // We need a match so let's queue one. + // Mark player as known but with no allocation. + m.setPlayerAllocation(player.PlayerUUID, "") + // This adds a player to our list of unmatched players. This will be processed in checkMatch when it ticks. + m.addUnmatchedPlayer(player) +} + +// checkMatch is called on an interval +func (m *SimpleMatchmaker) checkMatch() { + // See how many matches we need to make. + matchNum, playersWaiting := m.howManyMatchesNeeded() + fmt.Printf("Players waiting: %d, Match size needed: %d\n", playersWaiting, m.cfg.MatchSize) + + // For each needed match, start the process of creating one. + for i := 0; i < matchNum; i++ { + // Find players from our unmatched waiting list + players := m.grabPlayersForMatch() + + // Create a new match with a unique allocation UUID and the players in it. + mi := matchmaker.MatchInfo{ + MatchedPlayers: true, + Players: players, + AllocationUUID: uuid.New().String(), + } + + // Update the player's info with details of this allocation. + m.setAllocationForPlayers(players, mi.AllocationUUID) + + // Associate this match with the allocation UUID. + m.setAllocationMatchInfo(mi.AllocationUUID, mi) + + // Allocate for this match. + // NOTE: A real matchmaker may want to retry the allocate if it receives an error from this call, or if the + // allocation has failed the following calls to monitor it. + fmt.Println("About to allocate for match") + if _, err := m.mpClient.Allocate(m.cfg.FleetID, m.cfg.RegionID, m.cfg.ProfileID, mi.AllocationUUID); err != nil { + fmt.Printf("Failed to allocate %s: %s\n", mi.AllocationUUID, err.Error()) + m.matchCleanup(mi.AllocationUUID) + continue + } + + // Spawn a goroutine to wait for this allocation to be processed be multiplay. + m.wg.Add(1) + go func() { + defer m.wg.Done() + m.waitForMatchToAllocate(mi.AllocationUUID) + }() + } +} + +// waitForMatchToAllocate waits for a single allocation to allocate. +// NOTE: In a high throughput matchmaker you would need to supply multiple allocations to the 'allocations' +// endpoint (e.g. up to 100) to improve the performance. +func (m *SimpleMatchmaker) waitForMatchToAllocate(allocationUUID string) { + ticker := time.NewTicker(time.Second) + for range ticker.C { + allocs, err := m.mpClient.Allocations(m.cfg.FleetID, allocationUUID) + switch { + case errors.Is(err, mpclient.AllocationNotFound): + // Allocation was not found. This could be because it was cancelled or the match started and ended quickly. + m.matchCleanup(allocationUUID) + fmt.Println("Allocation was not found (deallocated or started and ended quickly)") + return + } + if err != nil { + m.matchCleanup(allocationUUID) + fmt.Printf("Non retryable issue for allocation: %s\n", allocationUUID) + return + } + + if len(allocs) == 0 { + m.matchCleanup(allocationUUID) + fmt.Println("Allocation was not found (deallocated or started and ended quickly)") + return + } + + alloc := allocs[0] + if alloc.IP != "" { + fmt.Printf("Got allocation: %s:%d\n", alloc.IP, alloc.GamePort) + m.matchPlaying(allocationUUID, alloc) + break + } + fmt.Printf("Waiting for allocation: %s\n", allocationUUID) + } +} + +func (m *SimpleMatchmaker) matchPlaying(allocationUUID string, alloc mpclient.AllocationResponse) { + m.matchesMtx.Lock() + defer m.matchesMtx.Unlock() + v := m.matches[allocationUUID] + v.IP = alloc.IP + v.Port = alloc.GamePort + m.matches[allocationUUID] = v + m.scheduleMatchCleanup(allocationUUID) +} + +// playerMatch gets the match a player is currently in. +func (m *SimpleMatchmaker) playerMatch(playerUUID string) (matchInfo *matchmaker.MatchInfo, err error) { + alloc, ok := m.playerAlloc[playerUUID] + if !ok { + return nil, ErrNoMatch + } + if alloc == "" { + // No match found for this player yet. + fmt.Printf("Player %s asked about match status, but it was not ready.\n", playerUUID) + return nil, ErrMatchSearching + } + + mi, ok := m.matches[alloc] + if !ok { + // Should not have had a player alloc. Clean up and continue. + delete(m.playerAlloc, playerUUID) + return nil, ErrNoMatch + } + + // Found a match that this player is in. Return it. + return &mi, nil +} + +// addUnmatchedPlayer adds an unmatched player into the unmatched player list. +func (m *SimpleMatchmaker) addUnmatchedPlayer(playerInfo matchmaker.PlayerInfo) { + m.unmatchedPlayersMtx.Lock() + defer m.unmatchedPlayersMtx.Unlock() + m.unmatchedPlayers = append(m.unmatchedPlayers, playerInfo) +} + +// setPlayerAllocation associates a player with an allocation +func (m *SimpleMatchmaker) setPlayerAllocation(playerUUID, allocationUUID string) { + m.playerAllocsMtx.Lock() + defer m.playerAllocsMtx.Unlock() + m.playerAlloc[playerUUID] = allocationUUID +} + +// howManyMatchesNeeded returns the number of matches needed to satisfy all queued players. +func (m *SimpleMatchmaker) howManyMatchesNeeded() (matchesNeeded, playersWaiting int) { + m.unmatchedPlayersMtx.Lock() + defer m.unmatchedPlayersMtx.Unlock() + return len(m.unmatchedPlayers) / m.cfg.MatchSize, len(m.unmatchedPlayers) +} + +// grabPlayersForMatch pulls players out of the unmatched player list and returns them. +func (m *SimpleMatchmaker) grabPlayersForMatch() []matchmaker.PlayerInfo { + m.unmatchedPlayersMtx.Lock() + defer m.unmatchedPlayersMtx.Unlock() + matchPlayers := m.unmatchedPlayers[:m.cfg.MatchSize] + m.unmatchedPlayers = m.unmatchedPlayers[m.cfg.MatchSize:] + return matchPlayers +} + +// setAllocationForPlayers sets all players allocation UUID +func (m *SimpleMatchmaker) setAllocationForPlayers(players []matchmaker.PlayerInfo, allocationUUID string) { + m.playerAllocsMtx.Lock() + defer m.playerAllocsMtx.Unlock() + for _, p := range players { + m.playerAlloc[p.PlayerUUID] = allocationUUID + } +} + +// setAllocationMatchInfo associates an allocation with a match. +func (m *SimpleMatchmaker) setAllocationMatchInfo(allocationUUID string, matchInfo matchmaker.MatchInfo) { + m.matchesMtx.Lock() + m.matchesMtx.Unlock() + m.matches[allocationUUID] = matchInfo +} + +// scheduleMatchCleanup removes the match after a delay. We want a delay so any players have time to get information +// about this match before we delete it. +func (m *SimpleMatchmaker) scheduleMatchCleanup(allocationUUID string) { + go func() { + select { + case <-time.After(5 * time.Minute): + m.matchCleanup(allocationUUID) + case <-m.done: + // The application is shutting down + return + } + }() +} + +// matchCleanup cleans up all the places we keep information about the match. +func (m *SimpleMatchmaker) matchCleanup(allocationUUID string) { + m.matchesMtx.Lock() + m.playerAllocsMtx.Lock() + defer m.matchesMtx.Unlock() + defer m.playerAllocsMtx.Unlock() + + delete(m.matches, allocationUUID) + delete(m.playerAlloc, allocationUUID) +} diff --git a/simple-matchmaker/internal/simplematchmaker/tcpmirror/tcpmirror.go b/simple-matchmaker/internal/simplematchmaker/tcpmirror/tcpmirror.go new file mode 100644 index 0000000..85ba26e --- /dev/null +++ b/simple-matchmaker/internal/simplematchmaker/tcpmirror/tcpmirror.go @@ -0,0 +1,83 @@ +package tcpmirror + +import ( + "bufio" + "fmt" + "log" + "net" + "sync" +) + +type TCPMirror struct { + l net.Listener + wg sync.WaitGroup + done chan struct{} +} + +// New creates a new TCPMirror. +func New() TCPMirror { + return TCPMirror{ + done: make(chan struct{}), + } +} + +// Start starts the tcp mirror. +func (t *TCPMirror) Start() (err error) { + t.l, err = net.Listen("tcp", ":9200") + if err != nil { + return fmt.Errorf("net listen: %w", err) + } + + t.wg.Add(1) + go func() { + defer t.wg.Done() + defer t.l.Close() + for { + if err := t.acceptConnection(t.l); err != nil { + log.Println(fmt.Errorf("accept connection: %w", err)) + return + } + } + }() + return nil +} + +// Stop stops the tcp mirror. +func (t *TCPMirror) Stop() { + // By closing the connection we cause anything waiting on accept to instantly return and allow it to exit. + t.l.Close() + close(t.done) + t.wg.Wait() +} + +// acceptConnection accepts a single tcp connection and reflects any lines sent. +func (t *TCPMirror) acceptConnection(l net.Listener) error { + conn, err := l.Accept() + if err != nil { + return fmt.Errorf("accept connection: %w", err) + } + // Spawn + + t.wg.Add(1) + go func() { + defer t.wg.Done() + t.handleConnection(conn) + }() + return nil +} + +// handleConnection reflects and lines sent to it. +func (t *TCPMirror) handleConnection(conn net.Conn) { + // Make a buffer to hold incoming data. + reader := bufio.NewReader(conn) + + for { + data, err := reader.ReadString('\n') + if err != nil { + fmt.Println(err) + return + } + fmt.Printf("Simulated game server received message from game client: %s", data) + conn.Write([]byte(data)) + } +} diff --git a/simple-matchmaker/main.go b/simple-matchmaker/main.go new file mode 100644 index 0000000..e080f47 --- /dev/null +++ b/simple-matchmaker/main.go @@ -0,0 +1,94 @@ +package main + +import ( + _ "embed" + "flag" + "fmt" + "log" + "net/http" + + mpclient "github.com/Unity-Technologies/multiplay-examples/simple-matchmaker/internal/client" + "github.com/Unity-Technologies/multiplay-examples/simple-matchmaker/internal/simplematchmaker" + "github.com/Unity-Technologies/multiplay-examples/simple-matchmaker/internal/simplematchmaker/tcpmirror" +) + +var ( + //go:embed assets/help_en.txt + helpEN string +) + +func main() { + showHelp := flag.Bool("help", false, "Display help") + standalone := flag.Bool("standalone", false, "Mocks multiplay. Send clients to a game server mock running inside this matchmaker") + fleetID := flag.String("fleet", "", "Fleet to use") + regionID := flag.String("region", "", "Region to use") + buildCfg := flag.Int64("buildcfg", 0, "Build configuration to use (Previously known as profile)") + matchSize := flag.Int("matchsize", 2, "Size of matches to group players into") + serverAddr := flag.String("server", ":8085", "Address to start server e.g. :8085, 192.168.1.100:8085") + flag.Parse() + + if *showHelp { + displayHelp() + return + } + + var backendClient mpclient.MultiplayClient + cfg := simplematchmaker.Config{ + MatchSize: *matchSize, + } + + if *standalone { + backendClient = mpclient.MockMultiplayClient{} + } else { + if *fleetID == "" { + displayArgs("No fleet specified") + return + } + if *regionID == "" { + displayArgs("No region specified") + return + } + if *buildCfg == 0 { + displayArgs("No buildcfg specified") + return + } + + cfg.FleetID = *fleetID + cfg.RegionID = *regionID + cfg.ProfileID = *buildCfg + var err error + backendClient, err = mpclient.NewClientFromEnv() + if err != nil { + log.Fatal(err) + } + } + + cfg.MatchSize = *matchSize + mm := simplematchmaker.NewSimpleMatchmaker(cfg, backendClient) + mm.Start() + t := tcpmirror.New() + if err := t.Start(); err != nil { + panic(err) + } + + http.HandleFunc("/player", mm.PlayerHandler) + if err := http.ListenAndServe(*serverAddr, nil); err != nil { + log.Println(err) + } + + t.Stop() + mm.Stop() +} + +func displayHelp() { + fmt.Println(helpEN) + displayArgs() +} + +func displayArgs(reasons ...string) { + for _, reason := range reasons { + fmt.Println(reason) + } + fmt.Println("Arguments:") + flag.PrintDefaults() +} diff --git a/simple-matchmaker/pkg/matchmaker/matchmaker.go b/simple-matchmaker/pkg/matchmaker/matchmaker.go new file mode 100644 index 0000000..79cc441 --- /dev/null +++ b/simple-matchmaker/pkg/matchmaker/matchmaker.go @@ -0,0 +1,17 @@ +package matchmaker + +// PlayerInfo contains information about the players in the match. +type PlayerInfo struct { + PlayerUUID string + IP string +} + +// MatchInfo contains information about the match. +type MatchInfo struct { + MatchedPlayers bool + AllocationUUID string `json:",omitempty"` + Players []PlayerInfo `json:",omitempty"` + IP string `json:",omitempty"` + Port int `json:",omitempty"` + Aborted bool `json:",omitempty"` +}