diff --git a/go.mod b/go.mod index d08e49e1..b5ffe410 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( cloud.google.com/go/storage v1.12.0 github.com/disintegration/imaging v1.6.2 github.com/faiface/mainthread v0.0.0-20171120011319-8b78f0a41ae3 + github.com/fsnotify/fsnotify v1.4.9 github.com/gen2brain/x264-go v0.0.0-20200605131102-0523307cbe23 github.com/go-gl/gl v0.0.0-20190320180904-bf2b1f2f34d7 github.com/gofrs/uuid v3.3.0+incompatible @@ -25,11 +26,10 @@ require ( github.com/prometheus/procfs v0.2.0 // indirect github.com/spf13/pflag v1.0.5 github.com/veandco/go-sdl2 v0.4.4 - golang.org/x/crypto v0.0.0-20200930160638-afb6bcd081ae + golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 - golang.org/x/net v0.0.0-20200930145003-4acb6c075d10 // indirect - golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f // indirect - golang.org/x/tools v0.0.0-20200930213115-e57f6d466a48 // indirect - google.golang.org/genproto v0.0.0-20200930140634-01fc692af84b // indirect + golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c // indirect + golang.org/x/tools v0.0.0-20201002184944-ecd9fd270d5d // indirect + google.golang.org/genproto v0.0.0-20201002142447-3860012362da // indirect gopkg.in/hraban/opus.v2 v2.0.0-20200710132758-e28f8214483b ) diff --git a/go.sum b/go.sum index faa43185..ad8e12cd 100644 --- a/go.sum +++ b/go.sum @@ -568,8 +568,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200930160638-afb6bcd081ae h1:duLSQW+DZ5MsXKX7kc4rXlq6/mmxz4G6ewJuBPlhRe0= -golang.org/x/crypto v0.0.0-20200930160638-afb6bcd081ae/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE= +golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -652,8 +652,8 @@ golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81R golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA= golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200927032502-5d4f70055728/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200930145003-4acb6c075d10 h1:YfxMZzv3PjGonQYNUaeU2+DhAdqOxerQ30JFB6WgAXo= -golang.org/x/net v0.0.0-20200930145003-4acb6c075d10/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c h1:dk0ukUIHmGHqASjP0iue2261isepFCC6XRCSd1nHgDw= +golang.org/x/net v0.0.0-20201002202402-0a1ea396d57c/go.mod h1:iQL9McJNjoIa5mjH6nYTCTZXUN6RP+XW3eib7Ya3XcI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -788,8 +788,8 @@ golang.org/x/tools v0.0.0-20200915173823-2db8f0ff891c/go.mod h1:z6u4i615ZeAfBE4X golang.org/x/tools v0.0.0-20200918232735-d647fc253266 h1:k7tVuG0g1JwmD3Jh8oAl1vQ1C3jb4Hi/dUl1wWDBJpQ= golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/tools v0.0.0-20200929161345-d7fc70abf50f/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= -golang.org/x/tools v0.0.0-20200930213115-e57f6d466a48 h1:/N7U6gDrclaaWCzrdxtKeyEcUx2goazt5PelznylQNM= -golang.org/x/tools v0.0.0-20200930213115-e57f6d466a48/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.0.0-20201002184944-ecd9fd270d5d h1:vWQvJ/Z0Lu+9/8oQ/pAYXNzbc7CMnBl+tULGVHOy3oE= +golang.org/x/tools v0.0.0-20201002184944-ecd9fd270d5d/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= @@ -869,8 +869,8 @@ google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20200914193844-75d14daec038/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200921151605-7abf4a1a14d5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200929141702-51c3e5b607fe/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200930140634-01fc692af84b h1:GJQgk4o/yzqYEu9Y6V4gRCI54KRlkOE0xAZeEGUUJjw= -google.golang.org/genproto v0.0.0-20200930140634-01fc692af84b/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201002142447-3860012362da h1:DTQYk4u7nICKkkVZsBv0/0po0ChISxAJ5CTAfUhO0PQ= +google.golang.org/genproto v0.0.0-20201002142447-3860012362da/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= diff --git a/pkg/api/coordinator_api.go b/pkg/api/coordinator_api.go new file mode 100644 index 00000000..f0988077 --- /dev/null +++ b/pkg/api/coordinator_api.go @@ -0,0 +1,41 @@ +package api + +import ( + "encoding/json" +) + +// This list of postfixes is used in the API: +// - *Request postfix denotes clients calls (i.e. from a browser to the HTTP-server). +// - *Call postfix denotes IPC calls (from the coordinator to a worker). + +type GameStartRequest struct { + GameName string `json:"game_name"` + IsMobile bool `json:"is_mobile"` +} + +func (packet *GameStartRequest) From(data string) error { return from(packet, data) } + +type GameStartCall struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` +} + +func (packet *GameStartCall) From(data string) error { return from(packet, data) } +func (packet *GameStartCall) To() (string, error) { return to(packet) } + +func from(source interface{}, data string) error { + err := json.Unmarshal([]byte(data), source) + if err != nil { + return err + } + return nil +} + +func to(target interface{}) (string, error) { + b, err := json.Marshal(target) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1ee81fbf..faf392d4 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -47,6 +47,8 @@ var FileTypeToEmulator = map[string]string{ "z64": "n64", } +var SupportedRomExtensions = listSupportedRomExtensions() + // There is no good way to determine main width and height of the emulator. // When game run, frame width and height can scale abnormally. type EmulatorMeta struct { @@ -103,3 +105,11 @@ var EmulatorConfig = map[string]EmulatorMeta{ } var EmulatorExtension = []string{".so", ".armv7-neon-hf.so", ".dylib", ".dll"} + +func listSupportedRomExtensions() []string { + m := make([]string, 0, len(FileTypeToEmulator)) + for k := range FileTypeToEmulator { + m = append(m, k) + } + return m +} diff --git a/pkg/coordinator/browser.go b/pkg/coordinator/browser.go index 89bae4f7..7eb3b11f 100644 --- a/pkg/coordinator/browser.go +++ b/pkg/coordinator/browser.go @@ -1,10 +1,14 @@ package coordinator import ( + "errors" "fmt" "log" + "github.com/giongto35/cloud-game/v2/pkg/api" "github.com/giongto35/cloud-game/v2/pkg/cws" + "github.com/giongto35/cloud-game/v2/pkg/games" + "github.com/giongto35/cloud-game/v2/pkg/worker/room" "github.com/gorilla/websocket" ) @@ -118,6 +122,17 @@ func (o *Server) RouteBrowser(client *BrowserClient) { if !ok { return cws.EmptyPacket } + + // +injects game data into the original game request + gameStartCall, err := newGameStartCall(resp.RoomID, resp.Data, o.library) + if err != nil { + return cws.EmptyPacket + } + if packet, err := gameStartCall.To(); err != nil { + return cws.EmptyPacket + } else { + resp.Data = packet + } workerResp := wc.SyncSend(resp) // Response from worker contains initialized roomID. Set roomID to the session @@ -187,3 +202,34 @@ func (o *Server) RouteBrowser(client *BrowserClient) { return resp }) } + +// newGameStartCall gathers data for a new game start call of the worker +func newGameStartCall(roomId string, data string, library games.GameLibrary) (api.GameStartCall, error) { + request := api.GameStartRequest{} + if err := request.From(data); err != nil { + return api.GameStartCall{}, errors.New("invalid request") + } + + // the name of the game either in the `room id` field or + // it's in the initial request + game := request.GameName + if roomId != "" { + // ! should be moved into coordinator + name := room.GetGameNameFromRoomID(roomId) + if name == "" { + return api.GameStartCall{}, errors.New("couldn't decode game name from the room id") + } + game = name + } + + gameInfo := library.FindGameByName(game) + if gameInfo.Path == "" { + return api.GameStartCall{}, fmt.Errorf("couldn't find game info for the game %v", game) + } + + return api.GameStartCall{ + Name: gameInfo.Name, + Path: gameInfo.Path, + Type: gameInfo.Type, + }, nil +} diff --git a/pkg/coordinator/config.go b/pkg/coordinator/config.go index 5a02a50d..ce15e825 100644 --- a/pkg/coordinator/config.go +++ b/pkg/coordinator/config.go @@ -6,20 +6,22 @@ import ( ) type Config struct { - Port int - PublicDomain string - PingServer string - URLPrefix string - DebugHost string + Port int + PublicDomain string + PingServer string + URLPrefix string + DebugHost string + LibraryMonitoring bool MonitoringConfig monitoring.ServerMonitoringConfig } func NewDefaultConfig() Config { return Config{ - Port: 8800, - PublicDomain: "http://localhost:8000", - PingServer: "", + Port: 8800, + PublicDomain: "http://localhost:8000", + PingServer: "", + LibraryMonitoring: false, MonitoringConfig: monitoring.ServerMonitoringConfig{ Port: 6601, @@ -40,6 +42,7 @@ func (c *Config) AddFlags(fs *pflag.FlagSet) *Config { fs.StringVarP(&c.DebugHost, "debughost", "d", "", "Specify the server want to connect directly to debug") fs.StringVarP(&c.PublicDomain, "domain", "n", c.PublicDomain, "Specify the public domain of the coordinator") fs.StringVarP(&c.PingServer, "pingServer", "", c.PingServer, "Specify the worker address that the client can ping (with protocol and port)") + fs.BoolVarP(&c.LibraryMonitoring, "libMonitor", "", c.LibraryMonitoring, "Enable ROM library monitoring") return c } diff --git a/pkg/coordinator/coordinator.go b/pkg/coordinator/coordinator.go index af89b2a6..e53d0872 100644 --- a/pkg/coordinator/coordinator.go +++ b/pkg/coordinator/coordinator.go @@ -9,10 +9,10 @@ import ( "time" "github.com/giongto35/cloud-game/v2/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/games" "github.com/giongto35/cloud-game/v2/pkg/monitoring" "github.com/golang/glog" "github.com/gorilla/mux" - "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" ) @@ -95,7 +95,17 @@ func makeHTTPToHTTPSRedirectServer(server *Server) *http.Server { // initializeCoordinator setup an coordinator server func (o *Coordinator) initializeCoordinator() { - coordinator := NewServer(o.cfg) + // init games library + lib := games.NewLibrary(games.Config{ + BasePath: "assets/games", + Supported: config.SupportedRomExtensions, + Ignored: []string{"neogeo", "pgm"}, + Verbose: true, + WatchMode: o.cfg.LibraryMonitoring, + }) + lib.Scan() + + coordinator := NewServer(o.cfg, lib) var certManager *autocert.Manager var httpsSrv *http.Server diff --git a/pkg/coordinator/handlers.go b/pkg/coordinator/handlers.go index cc27a490..3b0b6d96 100644 --- a/pkg/coordinator/handlers.go +++ b/pkg/coordinator/handlers.go @@ -12,8 +12,8 @@ import ( "github.com/giongto35/cloud-game/v2/pkg/config" "github.com/giongto35/cloud-game/v2/pkg/cws" + "github.com/giongto35/cloud-game/v2/pkg/games" "github.com/giongto35/cloud-game/v2/pkg/util" - "github.com/giongto35/cloud-game/v2/pkg/util/gamelist" "github.com/gofrs/uuid" "github.com/gorilla/websocket" ) @@ -24,6 +24,8 @@ const ( type Server struct { cfg Config + // games library + library games.GameLibrary // roomToWorker map roomID to workerID roomToWorker map[string]string // workerClients are the map workerID to worker Client @@ -38,9 +40,10 @@ const devPingServer = "http://localhost:9000/echo" var upgrader = websocket.Upgrader{} var errNotFound = errors.New("Not found") -func NewServer(cfg Config) *Server { +func NewServer(cfg Config, library games.GameLibrary) *Server { return &Server{ - cfg: cfg, + cfg: cfg, + library: library, // Mapping roomID to server roomToWorker: map[string]string{}, // Mapping workerID to worker @@ -245,7 +248,7 @@ func (o *Server) WS(w http.ResponseWriter, r *http.Request) { bc.Send(cws.WSPacket{ ID: "init", - Data: createInitPackage(wc.StunTurnServer), + Data: createInitPackage(wc.StunTurnServer, o.library.GetAll()), }, nil) // If peerconnection is done (client.Done is signalled), we close peerconnection @@ -404,9 +407,9 @@ func (o *Server) cleanWorker(wc *WorkerClient, workerID string) { // createInitPackage returns serverhost + game list in encoded wspacket format // This package will be sent to initialize -func createInitPackage(stunturn string) string { +func createInitPackage(stunturn string, games []games.GameMetadata) string { var gameName []string - for _, game := range gamelist.GameList { + for _, game := range games { gameName = append(gameName, game.Name) } diff --git a/pkg/cws/cws.go b/pkg/cws/cws.go index 5f0d5b66..d368b6c5 100644 --- a/pkg/cws/cws.go +++ b/pkg/cws/cws.go @@ -131,6 +131,23 @@ func (c *Client) SyncSend(request WSPacket) (response WSPacket) { return <-res } +// SendAwait sends some packet while waiting for a tile-limited response +//func (c *Client) SendAwait(packet WSPacket) WSPacket { +// ch := make(chan WSPacket) +// defer close(ch) +// c.Send(packet, func(response WSPacket) { ch <- response }) +// +// for { +// select { +// case packet := <-ch: +// return packet +// case <-time.After(config.WsIpcTimeout): +// log.Printf("Packet receive timeout!") +// return EmptyPacket +// } +// } +//} + // Heartbeat maintains connection to server func (c *Client) Heartbeat() { // send heartbeat every 1s diff --git a/pkg/games/game_library.go b/pkg/games/game_library.go new file mode 100644 index 00000000..8957078b --- /dev/null +++ b/pkg/games/game_library.go @@ -0,0 +1,289 @@ +package games + +import ( + "crypto/md5" + "fmt" + "io" + "log" + "os" + "path" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" +) + +// Config is an external configuration +type Config struct { + // some directory which is gonna be + // the root folder for the library + BasePath string + // a list of supported file extensions + Supported []string + // a list of ignored words in the files + Ignored []string + // print some additional info + Verbose bool + // enable directory changes watch + WatchMode bool +} + +// libConf is an optimized internal library configuration +type libConf struct { + path string + supported map[string]bool + ignored map[string]bool + verbose bool + watchMode bool +} + +type library struct { + config libConf + // indicates repo source existence + hasSource bool + // scan time + lastScanDuration time.Duration + // library entries + // !should be a tree-based structure + // game name -> game meta + // games with duplicate names are merged + games map[string]GameMetadata + + // to restrict parallel execution + // or throttling + // !CAS would be better + mu sync.Mutex + isScanning bool + isScanningDelayed bool +} + +type GameLibrary interface { + GetAll() []GameMetadata + FindGameByName(name string) GameMetadata + Scan() +} + +type GameMetadata struct { + uid string + // the display name of the game + Name string + // the game file extension (e.g. nes, n64) + Type string + // the game path relative to the library base path + Path string +} + +func NewLibrary(conf Config) GameLibrary { + hasSource := true + dir, err := filepath.Abs(conf.BasePath) + if err != nil { + hasSource = false + log.Printf("[lib] invalid source: %v (%v)\n", conf.BasePath, err) + } + + library := &library{ + config: libConf{ + path: dir, + supported: toMap(conf.Supported), + ignored: toMap(conf.Ignored), + verbose: conf.Verbose, + watchMode: conf.WatchMode, + }, + mu: sync.Mutex{}, + games: map[string]GameMetadata{}, + hasSource: hasSource, + } + + if conf.WatchMode && hasSource { + go library.watch() + } + + return library +} + +func (lib *library) GetAll() []GameMetadata { + var res []GameMetadata + for _, value := range lib.games { + res = append(res, value) + } + return res +} + +// FindGameByName returns some game info with its full filepath +func (lib *library) FindGameByName(name string) GameMetadata { + var game GameMetadata + if val, ok := lib.games[name]; ok { + val.Path = path.Join(lib.config.path, val.Path) + game = val + } + return game +} + +func (lib *library) Scan() { + if !lib.hasSource { + log.Printf("[lib] scan... skipped (no source)\n") + return + } + + // scan throttling + lib.mu.Lock() + if lib.isScanning { + defer lib.mu.Unlock() + lib.isScanningDelayed = true + log.Printf("[lib] scan... delayed\n") + return + } + lib.isScanning = true + lib.mu.Unlock() + + log.Printf("[lib] scan... started\n") + + start := time.Now() + var games []GameMetadata + dir := lib.config.path + err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info != nil && !info.IsDir() && lib.isFileExtensionSupported(path) { + meta := getMetadata(path, dir) + meta.uid = hash(path) + + if !lib.config.ignored[meta.Name] { + games = append(games, meta) + } + } + return nil + }) + + if err != nil { + log.Printf("[lib] scan error with %q: %v\n", dir, err) + } + + if len(games) > 0 { + lib.set(games) + } + + lib.lastScanDuration = time.Since(start) + if lib.config.verbose { + lib.dumpLibrary() + } + + // run scan again if delayed + lib.mu.Lock() + defer lib.mu.Unlock() + lib.isScanning = false + if lib.isScanningDelayed { + lib.isScanningDelayed = false + go lib.Scan() + } + + log.Printf("[lib] scan... completed\n") +} + +// watch adds the ability to rescan the entire library +// during filesystem changes in a watched directory. +// !to add incremental library change +func (lib *library) watch() { + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("[lib] watcher has failed: %v", err) + return + } + + done := make(chan bool) + go func(repo *library) { + for { + select { + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Op == fsnotify.Create || event.Op == fsnotify.Remove { + // !to try to add the proper file/dir add/remove scan logic + // which is tricky + repo.Scan() + } + case _, ok := <-watcher.Errors: + if !ok { + return + } + } + } + }(lib) + + if err = watcher.Add(lib.config.path); err != nil { + log.Printf("[lib] watch error %v", err) + } + <-done + _ = watcher.Close() + log.Printf("[lib] the watch has ended\n") +} + +func (lib *library) set(games []GameMetadata) { + res := make(map[string]GameMetadata) + for _, value := range games { + res[value.Name] = value + } + lib.games = res +} + +func (lib *library) isFileExtensionSupported(path string) bool { + ext := filepath.Ext(path) + if ext == "" { + return false + } + return lib.config.supported[ext[1:]] +} + +// getMetadata returns game info from a path +func getMetadata(path string, basePath string) GameMetadata { + name := filepath.Base(path) + ext := filepath.Ext(name) + relPath, _ := filepath.Rel(basePath, path) + + return GameMetadata{ + Name: strings.TrimSuffix(name, ext), + Type: ext[1:], + Path: relPath, + } +} + +// dumpLibrary printouts the current library snapshot of games +func (lib *library) dumpLibrary() { + var gameList strings.Builder + for _, game := range lib.games { + gameList.WriteString(" " + game.Name + " (" + game.Path + ")" + "\n") + } + + log.Printf("\n"+ + "--------------------------------------------\n"+ + "--- The Library of ROMs ---\n"+ + "--------------------------------------------\n"+ + "%v"+ + "--------------------------------------------\n"+ + "--- ROMs: %03d %26s ---\n"+ + "--------------------------------------------\n", + gameList.String(), len(lib.games), lib.lastScanDuration) +} + +// hash makes an MD5 hash of the string +func hash(str string) string { + h := md5.New() + _, err := io.WriteString(h, str) + if err != nil { + return "" + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +func toMap(list []string) map[string]bool { + res := make(map[string]bool) + for _, s := range list { + res[s] = true + } + return res +} diff --git a/pkg/games/game_library_test.go b/pkg/games/game_library_test.go new file mode 100644 index 00000000..c0b5a9fa --- /dev/null +++ b/pkg/games/game_library_test.go @@ -0,0 +1,46 @@ +package games + +import ( + "reflect" + "testing" +) + +func TestLibraryScan(t *testing.T) { + tests := []struct { + directory string + expected []string + }{ + { + directory: "../../assets/games", + expected: []string{ + "Super Mario Bros", "Sushi The Cat", "anguna", + }, + }, + } + + for _, test := range tests { + library := NewLibrary(Config{ + BasePath: test.directory, + Supported: []string{"gba", "zip", "nes"}, + Ignored: []string{"neogeo", "pgm"}, + }) + library.Scan() + games := library.GetAll() + + list := _map(games, func(meta GameMetadata) string { + return meta.Name + }) + + if !reflect.DeepEqual(test.expected, list) { + t.Errorf("Test fail for dir %v with %v != %v", test.directory, list, test.expected) + } + } +} + +func _map(vs []GameMetadata, f func(info GameMetadata) string) []string { + vsm := make([]string, len(vs)) + for i, v := range vs { + vsm[i] = f(v) + } + return vsm +} diff --git a/pkg/util/common.go b/pkg/util/common.go index c50332a9..31933ec1 100644 --- a/pkg/util/common.go +++ b/pkg/util/common.go @@ -6,13 +6,3 @@ func MinInt(x int, y int) int { } return y } - -func ContainsString(sslice []string, s string) bool { - for _, ss := range sslice { - if ss == s { - return true - } - } - - return false -} diff --git a/pkg/util/gamelist/games.go b/pkg/util/gamelist/games.go deleted file mode 100644 index d36b5d14..00000000 --- a/pkg/util/gamelist/games.go +++ /dev/null @@ -1,78 +0,0 @@ -package gamelist - -import ( - "os" - "path/filepath" - "strings" - - "github.com/giongto35/cloud-game/v2/pkg/config" - "github.com/giongto35/cloud-game/v2/pkg/util" -) - -type GameInfo struct { - Name string - Path string - Type string -} - -const gamePath = "assets/games" - -var excludeFiles = []string{"neogeo", "pgm"} - -var GameList []GameInfo - -func init() { - GameList = getAllGames(gamePath) -} - -// GetGameInfoFromName returns game info from a gameName -func GetGameInfoFromName(name string) GameInfo { - for _, game := range GameList { - if game.Name == name { - return game - } - } - - return GameInfo{} -} - -// getAllGames returns list of games stored in games. This call should be called when server start (package init) -// TODO: Maybe later we need to make realtime update without server restart -func getAllGames(gamePath string) []GameInfo { - var games []GameInfo - - filepath.Walk(gamePath, func(path string, info os.FileInfo, err error) error { - if info != nil && !info.IsDir() && isValidGameType(path) { - // Add to games list - gameInfo := getGameInfo(path) - // Skip file that is in excludeFiles (bios files) - if util.ContainsString(excludeFiles, gameInfo.Name) { - return nil - } - - games = append(games, gameInfo) - } - return nil - }) - - return games -} - -// isValidGameType check if a game is valid for cloud retro based on extension -func isValidGameType(gamePath string) bool { - ext := filepath.Ext(gamePath)[1:] - _, ok := config.FileTypeToEmulator[ext] - return ok -} - -// getGameInfo returns game info from a path -func getGameInfo(path string) GameInfo { - // Remove prefix to obtain file names - fileName := filepath.Base(path) - ext := filepath.Ext(fileName) - return GameInfo{ - Name: strings.TrimSuffix(fileName, ext), - Type: ext[1:], - Path: path, - } -} diff --git a/pkg/worker/coordinator.go b/pkg/worker/coordinator.go index 6d65fb0c..07d91601 100644 --- a/pkg/worker/coordinator.go +++ b/pkg/worker/coordinator.go @@ -5,7 +5,9 @@ import ( "log" "strconv" + "github.com/giongto35/cloud-game/v2/pkg/api" "github.com/giongto35/cloud-game/v2/pkg/cws" + "github.com/giongto35/cloud-game/v2/pkg/games" "github.com/giongto35/cloud-game/v2/pkg/util" "github.com/giongto35/cloud-game/v2/pkg/webrtc" "github.com/giongto35/cloud-game/v2/pkg/worker/room" @@ -156,18 +158,17 @@ func (h *Handler) RouteCoordinator() { peerconnection := session.peerconnection // TODO: Standardize for all types of packet. Make WSPacket generic - var startPacket struct { - GameName string `json:"game_name"` - IsMobile bool `json:"is_mobile"` - } - - err := json.Unmarshal([]byte(resp.Data), &startPacket) - if err != nil { - log.Println("Error: Cannot decode json:", err) + startPacket := api.GameStartCall{} + if err := startPacket.From(resp.Data); err != nil { return cws.EmptyPacket } + gameMeta := games.GameMetadata{ + Name: startPacket.Name, + Type: startPacket.Type, + Path: startPacket.Path, + } - room := h.startGameHandler(startPacket.GameName, resp.RoomID, resp.PlayerIndex, peerconnection, util.GetVideoEncoder(startPacket.IsMobile)) + room := h.startGameHandler(gameMeta, resp.RoomID, resp.PlayerIndex, peerconnection, util.GetVideoEncoder(false)) session.RoomID = room.ID // TODO: can data race h.rooms[room.ID] = room @@ -317,8 +318,8 @@ func getServerIDOfRoom(oc *CoordinatorClient, roomID string) string { } // startGameHandler starts a game if roomID is given, if not create new room -func (h *Handler) startGameHandler(gameName, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC, videoEncoderType string) *room.Room { - log.Println("Starting game", gameName) +func (h *Handler) startGameHandler(game games.GameMetadata, existedRoomID string, playerIndex int, peerconnection *webrtc.WebRTC, videoEncoderType string) *room.Room { + log.Printf("Loading game: %v\n", game.Name) // If we are connecting to coordinator, request corresponding serverID based on roomID // TODO: check if existedRoomID is in the current server room := h.getRoom(existedRoomID) @@ -326,7 +327,7 @@ func (h *Handler) startGameHandler(gameName, existedRoomID string, playerIndex i if room == nil { log.Println("Got Room from local ", room, " ID: ", existedRoomID) // Create new room and update player index - room = h.createNewRoom(gameName, existedRoomID, videoEncoderType) + room = h.createNewRoom(game, existedRoomID, videoEncoderType) room.UpdatePlayerIndex(peerconnection, playerIndex) // Wait for done signal from room diff --git a/pkg/worker/handlers.go b/pkg/worker/handlers.go index 7fb32cd8..890019fb 100644 --- a/pkg/worker/handlers.go +++ b/pkg/worker/handlers.go @@ -10,7 +10,7 @@ import ( "github.com/giongto35/cloud-game/v2/pkg/config" "github.com/giongto35/cloud-game/v2/pkg/config/worker" - + "github.com/giongto35/cloud-game/v2/pkg/games" "github.com/giongto35/cloud-game/v2/pkg/util" "github.com/giongto35/cloud-game/v2/pkg/webrtc" storage "github.com/giongto35/cloud-game/v2/pkg/worker/cloud-storage" @@ -170,12 +170,12 @@ func (h *Handler) detachRoom(roomID string) { // createNewRoom creates a new room // Return nil in case of room is existed -func (h *Handler) createNewRoom(gameName string, roomID string, videoEncoderType string) *room.Room { +func (h *Handler) createNewRoom(game games.GameMetadata, roomID string, videoEncoderType string) *room.Room { // If the roomID is empty, // or the roomID doesn't have any running sessions (room was closed) // we spawn a new room if roomID == "" || !h.isRoomRunning(roomID) { - room := room.NewRoom(roomID, gameName, videoEncoderType, h.onlineStorage, h.cfg) + room := room.NewRoom(roomID, game, videoEncoderType, h.onlineStorage, h.cfg) // TODO: Might have race condition h.rooms[room.ID] = room return room diff --git a/pkg/worker/room/room.go b/pkg/worker/room/room.go index b224d25b..c20f5234 100644 --- a/pkg/worker/room/room.go +++ b/pkg/worker/room/room.go @@ -16,13 +16,12 @@ import ( "strings" "sync" - "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "github.com/giongto35/cloud-game/v2/pkg/config" + "github.com/giongto35/cloud-game/v2/pkg/config/worker" "github.com/giongto35/cloud-game/v2/pkg/emulator" "github.com/giongto35/cloud-game/v2/pkg/emulator/libretro/nanoarch" + "github.com/giongto35/cloud-game/v2/pkg/games" "github.com/giongto35/cloud-game/v2/pkg/util" - "github.com/giongto35/cloud-game/v2/pkg/util/gamelist" "github.com/giongto35/cloud-game/v2/pkg/webrtc" storage "github.com/giongto35/cloud-game/v2/pkg/worker/cloud-storage" ) @@ -128,18 +127,12 @@ func NewVideoImporter(roomID string) chan nanoarch.GameFrame { } // NewRoom creates a new room -func NewRoom(roomID string, gameName string, videoEncoderType string, onlineStorage *storage.Client, cfg worker.Config) *Room { - // If no roomID is given, generate it from gameName - // If the is roomID, get gameName from roomID +func NewRoom(roomID string, game games.GameMetadata, videoEncoderType string, onlineStorage *storage.Client, cfg worker.Config) *Room { if roomID == "" { - roomID = generateRoomID(gameName) - } else { - gameName = getGameNameFromRoomID(roomID) - log.Println("Get Gamename from RoomID", gameName) + roomID = generateRoomID(game.Name) } - gameInfo := gamelist.GetGameInfoFromName(gameName) - log.Println("Init new room: ", roomID, gameName, gameInfo) + log.Println("Init new room: ", roomID, game) inputChannel := make(chan nanoarch.InputEvent, 100) room := &Room{ @@ -158,7 +151,7 @@ func NewRoom(roomID string, gameName string, videoEncoderType string, onlineStor } // Check if room is on local storage, if not, pull from GCS to local storage - go func(game gamelist.GameInfo, roomID string) { + go func(game games.GameMetadata, roomID string) { // Check room is on local or fetch from server savepath := util.GetSavePath(roomID) log.Println("Check ", savepath, " on online storage : ", room.isGameOnLocal(savepath)) @@ -167,7 +160,7 @@ func NewRoom(roomID string, gameName string, videoEncoderType string, onlineStor } // If not then load room or create room from local. - log.Printf("Room %s started. GamePath: %s, GameName: %s, WithGame: %t", roomID, game.Path, game.Name, cfg.WithoutGame) + log.Printf("Room %s started. GameName: %s, WithGame: %t", roomID, game.Name, cfg.WithoutGame) // Spawn new emulator based on gameName and plug-in all channels emuName, _ := config.FileTypeToEmulator[game.Type] @@ -227,7 +220,7 @@ func NewRoom(roomID string, gameName string, videoEncoderType string, onlineStor // TODO: do we need GC, we can remove it runtime.GC() - }(gameInfo, roomID) + }(game, roomID) return room } @@ -250,7 +243,7 @@ func getEmulator(emuName string, roomID string, imageChannel chan<- nanoarch.Gam } // getGameNameFromRoomID parse roomID to get roomID and gameName -func getGameNameFromRoomID(roomID string) string { +func GetGameNameFromRoomID(roomID string) string { parts := strings.Split(roomID, separator) if len(parts) > 1 { return parts[1] diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go index ad4b4a5f..767b4aa9 100644 --- a/pkg/worker/worker.go +++ b/pkg/worker/worker.go @@ -12,11 +12,10 @@ import ( "github.com/giongto35/cloud-game/v2/pkg/config" "github.com/giongto35/cloud-game/v2/pkg/config/worker" - "golang.org/x/crypto/acme" - "golang.org/x/crypto/acme/autocert" - "github.com/giongto35/cloud-game/v2/pkg/monitoring" "github.com/golang/glog" + "golang.org/x/crypto/acme" + "golang.org/x/crypto/acme/autocert" ) type Worker struct {