diff --git a/game.go b/game.go index 3ee7f71..57e8e8e 100644 --- a/game.go +++ b/game.go @@ -728,20 +728,21 @@ func (g *Game) BoardState(player int8, local bool) []byte { return g.RenderSpace(player, space, spaceValue, legalMoves) } + const verticalBar rune = '│' for i := int8(0); i < 11; i++ { - t.WriteRune(VerticalBar) + t.WriteRune(verticalBar) t.Write([]byte("")) for j := int8(0); j < 12; j++ { t.Write(space(i, j)) if j == 5 { - t.WriteRune(VerticalBar) + t.WriteRune(verticalBar) t.Write(space(i, -1)) - t.WriteRune(VerticalBar) + t.WriteRune(verticalBar) } } - t.Write([]byte("" + string(VerticalBar) + " ")) + t.Write([]byte("" + string(verticalBar) + " ")) if i == 0 { t.Write([]byte(opponentColor + " " + opponentName + " (" + opponentRating + ")")) @@ -758,14 +759,14 @@ func (g *Game) BoardState(player int8, local bool) []byte { if opponentRoll != 0 { t.Write([]byte(fmt.Sprintf(" %d", opponentRoll))) } else { - t.Write([]byte(fmt.Sprintf(" -"))) + t.Write([]byte(" -")) } } } else if g.Turn != player { if g.Roll1 > 0 { t.Write([]byte(fmt.Sprintf(" %d %d ", g.Roll1, g.Roll2))) } else if opponentName != "" { - t.Write([]byte(fmt.Sprintf(" - - "))) + t.Write([]byte(" - - ")) } } } else if i == 8 { @@ -774,14 +775,14 @@ func (g *Game) BoardState(player int8, local bool) []byte { if playerRoll != 0 { t.Write([]byte(fmt.Sprintf(" %d", playerRoll))) } else { - t.Write([]byte(fmt.Sprintf(" -"))) + t.Write([]byte(" -")) } } } else if g.Turn == player { if g.Roll1 > 0 { t.Write([]byte(fmt.Sprintf(" %d %d ", g.Roll1, g.Roll2))) } else if playerName != "" { - t.Write([]byte(fmt.Sprintf(" - - "))) + t.Write([]byte(" - - ")) } } } else if i == 10 { @@ -958,7 +959,3 @@ func FormatAndFlipMoves(moves [][]int8, player int8) []byte { func ValidSpace(space int8) bool { return space >= 0 && space <= 27 } - -const ( - VerticalBar rune = '\u2502' // │ -) diff --git a/pkg/server/server.go b/pkg/server/server.go index b6c94ed..651ca26 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -5,7 +5,6 @@ import ( "bytes" "crypto/rand" "encoding/base64" - "encoding/json" "fmt" "log" "math/big" @@ -20,7 +19,6 @@ import ( "time" "code.rocket9labs.com/tslocum/bgammon" - "github.com/gorilla/mux" ) const clientTimeout = 40 * time.Second @@ -121,314 +119,10 @@ func NewServer(tz string, dataSource string, mailServer string, passwordSalt str return s } -func (s *server) cachedMatches() []byte { - s.gamesCacheLock.Lock() - defer s.gamesCacheLock.Unlock() - - if time.Since(s.gamesCacheTime) < 5*time.Second { - return s.gamesCache - } - - s.gamesLock.Lock() - defer s.gamesLock.Unlock() - - var games []*bgammon.GameListing - for _, g := range s.games { - listing := g.listing(nil) - if listing == nil || listing.Password || listing.Players == 2 { - continue - } - games = append(games, listing) - } - - s.gamesCacheTime = time.Now() - if len(games) == 0 { - s.gamesCache = []byte("[]") - return s.gamesCache - } - var err error - s.gamesCache, err = json.Marshal(games) - if err != nil { - log.Fatalf("failed to marshal %+v: %s", games, err) - } - return s.gamesCache -} - -func (s *server) cachedLeaderboard(matchType int, acey bool, multiPoint bool) []byte { - s.leaderboardCacheLock.Lock() - defer s.leaderboardCacheLock.Unlock() - - var i int - switch matchType { - case matchTypeCasual: - if multiPoint { - i = 1 - } - case matchTypeRated: - if !multiPoint { - i = 2 - } else { - i = 3 - } - } - if acey { - i += 4 - } - - if time.Since(s.leaderboardCacheTime) < 5*time.Minute { - return s.leaderboardCache[i] - } - s.leaderboardCacheTime = time.Now() - - for j := 0; j < 2; j++ { - i := 0 - var acey bool - if j == 1 { - i += 4 - acey = true - } - result, err := getLeaderboard(matchTypeCasual, acey, false) - if err != nil { - log.Fatalf("failed to get leaderboard: %s", err) - } - s.leaderboardCache[i], err = json.Marshal(result) - if err != nil { - log.Fatalf("failed to marshal %+v: %s", result, err) - } - - result, err = getLeaderboard(matchTypeCasual, acey, true) - if err != nil { - log.Fatalf("failed to get leaderboard: %s", err) - } - s.leaderboardCache[i+1], err = json.Marshal(result) - if err != nil { - log.Fatalf("failed to marshal %+v: %s", result, err) - } - - result, err = getLeaderboard(matchTypeRated, acey, false) - if err != nil { - log.Fatalf("failed to get leaderboard: %s", err) - } - s.leaderboardCache[i+2], err = json.Marshal(result) - if err != nil { - log.Fatalf("failed to marshal %+v: %s", result, err) - } - - result, err = getLeaderboard(matchTypeRated, acey, true) - if err != nil { - log.Fatalf("failed to get leaderboard: %s", err) - } - s.leaderboardCache[i+3], err = json.Marshal(result) - if err != nil { - log.Fatalf("failed to marshal %+v: %s", result, err) - } - } - - return s.leaderboardCache[i] -} - -func (s *server) cachedStats(statsType int) []byte { - s.statsCacheLock.Lock() - defer s.statsCacheLock.Unlock() - - if time.Since(s.statsCacheTime) < 5*time.Minute { - return s.statsCache[statsType] - } - s.statsCacheTime = time.Now() - - { - stats, err := dailyStats(s.tz) - if err != nil { - log.Fatalf("failed to fetch server statistics: %s", err) - } - s.statsCache[0], err = json.Marshal(stats) - if err != nil { - log.Fatalf("failed to marshal %+v: %s", stats, err) - } - - stats, err = cumulativeStats(s.tz) - if err != nil { - log.Fatalf("failed to fetch server statistics: %s", err) - } - s.statsCache[1], err = json.Marshal(stats) - if err != nil { - log.Fatalf("failed to fetch serialize server statistics: %s", err) - } - } - - { - stats, err := botStats("BOT_tabula", s.tz) - if err != nil { - log.Fatalf("failed to fetch tabula statistics: %s", err) - } - s.statsCache[2], err = json.Marshal(stats) - if err != nil { - log.Fatalf("failed to fetch serialize tabula statistics: %s", err) - } - - stats, err = botStats("BOT_wildbg", s.tz) - if err != nil { - log.Fatalf("failed to fetch wildbg statistics: %s", err) - } - s.statsCache[3], err = json.Marshal(stats) - if err != nil { - log.Fatalf("failed to fetch serialize wildbg statistics: %s", err) - } - } - - return s.statsCache[statsType] -} - -func (s *server) handleResetPassword(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["id"]) - if err != nil || id <= 0 { - return - } - key := vars["key"] - - newPassword, err := confirmResetAccount(s.resetSalt, s.passwordSalt, id, key) - if err != nil { - log.Printf("failed to reset password: %s", err) - } - - w.Header().Set("Content-Type", "text/html") - if err != nil || newPassword == "" { - w.Write([]byte(`

Invalid or expired password reset link.

`)) - return - } - w.Write([]byte(`

Your bgammon.org password has been reset.

Your new password is ` + newPassword + ``)) -} - -func (s *server) handleMatch(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - id, err := strconv.Atoi(vars["id"]) - if err != nil || id <= 0 { - return - } - - timestamp, player1, player2, replay, err := matchInfo(id) - if err != nil || len(replay) == 0 { - log.Printf("failed to retrieve match: %s", err) - return - } - - w.Header().Set("Content-Type", "text/plain") - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%d_%s_%s.match"`, timestamp, player1, player2)) - w.Write(replay) -} - -func (s *server) handleListMatches(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedMatches()) -} - -func (s *server) handleLeaderboardCasualBackgammonSingle(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeCasual, false, false)) -} - -func (s *server) handleLeaderboardCasualBackgammonMulti(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeCasual, false, true)) -} - -func (s *server) handleLeaderboardCasualAceySingle(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeCasual, true, false)) -} - -func (s *server) handleLeaderboardCasualAceyMulti(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeCasual, true, true)) -} - -func (s *server) handleLeaderboardRatedBackgammonSingle(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeRated, false, false)) -} - -func (s *server) handleLeaderboardRatedBackgammonMulti(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeRated, false, true)) -} - -func (s *server) handleLeaderboardRatedAceySingle(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeRated, true, false)) -} - -func (s *server) handleLeaderboardRatedAceyMulti(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedLeaderboard(matchTypeRated, true, true)) -} - -func (s *server) handlePrintDailyStats(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedStats(0)) -} - -func (s *server) handlePrintCumulativeStats(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedStats(1)) -} - -func (s *server) handlePrintTabulaStats(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedStats(2)) -} - -func (s *server) handlePrintWildBGStats(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write(s.cachedStats(3)) -} - -func (s *server) handleWebSocket(w http.ResponseWriter, r *http.Request) { - const bufferSize = 8 - commands := make(chan []byte, bufferSize) - events := make(chan []byte, bufferSize) - - wsClient := newWebSocketClient(r, w, commands, events, s.verbose) - if wsClient == nil { - return - } - - now := time.Now().Unix() - - c := &serverClient{ - id: <-s.newClientIDs, - account: -1, - connected: now, - lastActive: now, - commands: commands, - Client: wsClient, - } - s.handleClient(c) -} - -func (s *server) listenWebSocket(address string) { - log.Printf("Listening for WebSocket connections on %s...", address) - - m := mux.NewRouter() - m.HandleFunc("/reset/{id:[0-9]+}/{key:[A-Za-z0-9]+}", s.handleResetPassword) - m.HandleFunc("/match/{id:[0-9]+}", s.handleMatch) - m.HandleFunc("/matches", s.handleListMatches) - m.HandleFunc("/leaderboard-casual-backgammon-single", s.handleLeaderboardCasualBackgammonSingle) - m.HandleFunc("/leaderboard-casual-backgammon-multi", s.handleLeaderboardCasualBackgammonMulti) - m.HandleFunc("/leaderboard-casual-acey-single", s.handleLeaderboardCasualAceySingle) - m.HandleFunc("/leaderboard-casual-acey-multi", s.handleLeaderboardCasualAceyMulti) - m.HandleFunc("/leaderboard-rated-backgammon-single", s.handleLeaderboardRatedBackgammonSingle) - m.HandleFunc("/leaderboard-rated-backgammon-multi", s.handleLeaderboardRatedBackgammonMulti) - m.HandleFunc("/leaderboard-rated-acey-single", s.handleLeaderboardRatedAceySingle) - m.HandleFunc("/leaderboard-rated-acey-multi", s.handleLeaderboardRatedAceyMulti) - m.HandleFunc("/stats", s.handlePrintDailyStats) - m.HandleFunc("/stats-total", s.handlePrintCumulativeStats) - m.HandleFunc("/stats-tabula", s.handlePrintTabulaStats) - m.HandleFunc("/stats-wildbg", s.handlePrintWildBGStats) - m.HandleFunc("/", s.handleWebSocket) - - err := http.ListenAndServe(address, m) - log.Fatalf("failed to listen on %s: %s", address, err) +func (s *server) ListenLocal() chan net.Conn { + conns := make(chan net.Conn) + go s.handleLocal(conns) + return conns } func (s *server) handleLocal(conns chan net.Conn) { @@ -440,12 +134,6 @@ func (s *server) handleLocal(conns chan net.Conn) { } } -func (s *server) ListenLocal() chan net.Conn { - conns := make(chan net.Conn) - go s.handleLocal(conns) - return conns -} - func (s *server) Listen(network string, address string) { if strings.ToLower(network) == "ws" { go s.listenWebSocket(address) @@ -471,6 +159,29 @@ func (s *server) handleListener(listener net.Listener) { } } +func (s *server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + const bufferSize = 8 + commands := make(chan []byte, bufferSize) + events := make(chan []byte, bufferSize) + + wsClient := newWebSocketClient(r, w, commands, events, s.verbose) + if wsClient == nil { + return + } + + now := time.Now().Unix() + + c := &serverClient{ + id: <-s.newClientIDs, + account: -1, + connected: now, + lastActive: now, + commands: commands, + Client: wsClient, + } + s.handleClient(c) +} + func (s *server) nameAllowed(username []byte) bool { return !guestName.Match(username) } @@ -702,1322 +413,6 @@ func (s *server) Analyze(g *bgammon.Game) { os.Exit(0) } -func (s *server) handleCommands() { - var cmd serverCommand -COMMANDS: - for cmd = range s.commands { - if cmd.client == nil { - log.Panicf("nil client with command %s", cmd.command) - } else if cmd.client.terminating || cmd.client.Terminated() { - continue - } - - cmd.command = bytes.TrimSpace(cmd.command) - - firstSpace := bytes.IndexByte(cmd.command, ' ') - var keyword string - var startParameters int - if firstSpace == -1 { - keyword = string(cmd.command) - startParameters = len(cmd.command) - } else { - keyword = string(cmd.command[:firstSpace]) - startParameters = firstSpace + 1 - } - if keyword == "" { - continue - } - keyword = strings.ToLower(keyword) - params := bytes.Fields(cmd.command[startParameters:]) - - // Require users to send login command first. - if cmd.client.account == -1 { - resetCommand := keyword == bgammon.CommandResetPassword - if resetCommand { - if len(params) > 0 { - email := bytes.ToLower(bytes.TrimSpace(params[0])) - if len(email) > 0 { - err := resetAccount(s.mailServer, s.resetSalt, email) - if err != nil { - log.Fatalf("failed to reset password: %s", err) - } - } - } - cmd.client.Terminate("resetpasswordok") - continue - } - - loginCommand := keyword == bgammon.CommandLogin || keyword == bgammon.CommandLoginJSON || keyword == "lj" - registerCommand := keyword == bgammon.CommandRegister || keyword == bgammon.CommandRegisterJSON || keyword == "rj" - if loginCommand || registerCommand { - if keyword == bgammon.CommandLoginJSON || keyword == bgammon.CommandRegisterJSON || keyword == "lj" || keyword == "rj" { - cmd.client.json = true - } - - var username []byte - var password []byte - var randomUsername bool - if registerCommand { - sendUsage := func() { - cmd.client.Terminate("Please enter an email, username and password.") - } - - var email []byte - if keyword == bgammon.CommandRegisterJSON || keyword == "rj" { - if len(params) < 4 { - sendUsage() - continue - } - email = params[1] - username = params[2] - password = bytes.Join(params[3:], []byte("_")) - } else { - if len(params) < 3 { - sendUsage() - continue - } - email = params[0] - username = params[1] - password = bytes.Join(params[2:], []byte("_")) - } - if onlyNumbers.Match(username) { - cmd.client.Terminate("Failed to register: Invalid username: must contain at least one non-numeric character.") - continue - } - password = bytes.ReplaceAll(password, []byte(" "), []byte("_")) - a := &account{ - email: email, - username: username, - password: password, - } - err := registerAccount(s.passwordSalt, a) - if err != nil { - cmd.client.Terminate(fmt.Sprintf("Failed to register: %s", err)) - continue - } - } else { - s.clientsLock.Lock() - - readUsername := func() bool { - if cmd.client.json { - if len(params) > 1 { - username = params[1] - } - } else { - if len(params) > 0 { - username = params[0] - } - } - if len(bytes.TrimSpace(username)) == 0 { - username = s.randomUsername() - randomUsername = true - } else if !alphaNumericUnderscore.Match(username) { - cmd.client.Terminate("Invalid username: must contain only letters, numbers and underscores.") - return false - } - if onlyNumbers.Match(username) { - cmd.client.Terminate("Invalid username: must contain at least one non-numeric character.") - return false - } else if s.clientByUsername(username) != nil || s.clientByUsername(append([]byte("Guest_"), username...)) != nil || (!randomUsername && !s.nameAllowed(username)) { - cmd.client.Terminate("That username is already in use.") - return false - } - return true - } - if !readUsername() { - s.clientsLock.Unlock() - continue - } - if len(params) > 2 { - password = bytes.ReplaceAll(bytes.Join(params[2:], []byte(" ")), []byte(" "), []byte("_")) - } - - s.clientsLock.Unlock() - } - - if len(password) > 0 { - a, err := loginAccount(s.passwordSalt, username, password) - if err != nil { - cmd.client.Terminate(fmt.Sprintf("Failed to log in: %s", err)) - continue - } else if a == nil { - cmd.client.Terminate("No account was found with the provided username and password. To log in as a guest, do not enter a password.") - continue - } - - var name []byte - if bytes.HasPrefix(a.username, []byte("bot_")) { - name = append([]byte("BOT_"), a.username[4:]...) - } else { - name = a.username - } - if s.clientByUsername(name) != nil { - cmd.client.Terminate("That username is already in use.") - continue - } - - cmd.client.account = a.id - cmd.client.name = name - cmd.client.sendEvent(&bgammon.EventSettings{ - Highlight: a.highlight, - Pips: a.pips, - Moves: a.moves, - Flip: a.flip, - }) - } else { - cmd.client.account = 0 - if !randomUsername && !bytes.HasPrefix(username, []byte("BOT_")) && !bytes.HasPrefix(username, []byte("Guest_")) { - username = append([]byte("Guest_"), username...) - } - cmd.client.name = username - } - - cmd.client.sendEvent(&bgammon.EventWelcome{ - PlayerName: string(cmd.client.name), - Clients: len(s.clients), - Games: len(s.games), - }) - - log.Printf("Client %d logged in as %s", cmd.client.id, cmd.client.name) - - // Rejoin match in progress. - s.gamesLock.RLock() - for _, g := range s.games { - if g.terminated() || g.Winner != 0 { - continue - } - - var rejoin bool - if bytes.Equal(cmd.client.name, g.allowed1) { - rejoin = g.rejoin1 - } else if bytes.Equal(cmd.client.name, g.allowed2) { - rejoin = g.rejoin2 - } - if rejoin { - g.addClient(cmd.client) - cmd.client.sendNotice(fmt.Sprintf("Rejoined match: %s", g.name)) - } - } - s.gamesLock.RUnlock() - continue - } - - cmd.client.Terminate("You must login before using other commands.") - continue - } - - clientGame := s.gameByClient(cmd.client) - if clientGame != nil && clientGame.client1 != cmd.client && clientGame.client2 != cmd.client { - switch keyword { - case bgammon.CommandHelp, "h", bgammon.CommandJSON, bgammon.CommandList, "ls", bgammon.CommandBoard, "b", bgammon.CommandLeave, "l", bgammon.CommandReplay, bgammon.CommandDisconnect, bgammon.CommandPong: - // These commands are allowed to be used by spectators. - default: - cmd.client.sendNotice("Command ignored: You are spectating this match.") - continue - } - } - - switch keyword { - case bgammon.CommandHelp, "h": - // TODO get extended help by specifying a command after help - cmd.client.sendEvent(&bgammon.EventHelp{ - Topic: "", - Message: "Test help text", - }) - case bgammon.CommandJSON: - sendUsage := func() { - cmd.client.sendNotice("To enable JSON formatted messages, send 'json on'. To disable JSON formatted messages, send 'json off'.") - } - if len(params) != 1 { - sendUsage() - continue - } - paramLower := strings.ToLower(string(params[0])) - switch paramLower { - case "on": - cmd.client.json = true - cmd.client.sendNotice("JSON formatted messages enabled.") - case "off": - cmd.client.json = false - cmd.client.sendNotice("JSON formatted messages disabled.") - default: - sendUsage() - } - case bgammon.CommandSay, "s": - if len(params) == 0 { - continue - } - if clientGame == nil { - cmd.client.sendNotice("Message not sent: You are not currently in a match.") - continue - } - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendNotice("Message not sent: There is no one else in the match.") - continue - } - ev := &bgammon.EventSay{ - Message: string(bytes.Join(params, []byte(" "))), - } - ev.Player = string(cmd.client.name) - opponent.sendEvent(ev) - if s.relayChat { - for _, spectator := range clientGame.spectators { - spectator.sendEvent(ev) - } - } - case bgammon.CommandList, "ls": - ev := &bgammon.EventList{} - - s.gamesLock.RLock() - for _, g := range s.games { - listing := g.listing(cmd.client.name) - if listing == nil { - continue - } - ev.Games = append(ev.Games, *listing) - } - s.gamesLock.RUnlock() - - cmd.client.sendEvent(ev) - case bgammon.CommandCreate, "c": - if clientGame != nil { - cmd.client.sendNotice("Failed to create match: Please leave the match you are in before creating another.") - continue - } - - sendUsage := func() { - cmd.client.sendNotice("To create a public match please specify whether it is public or private, and also specify how many points are needed to win the match. When creating a private match, a password must also be provided.") - } - if len(params) < 2 { - sendUsage() - continue - } - - var gamePassword []byte - gameType := bytes.ToLower(params[0]) - var gameName []byte - var gamePoints []byte - switch { - case bytes.Equal(gameType, []byte("public")): - gamePoints = params[1] - if len(params) > 2 { - gameName = bytes.Join(params[2:], []byte(" ")) - } - case bytes.Equal(gameType, []byte("private")): - if len(params) < 3 { - sendUsage() - continue - } - gamePassword = bytes.ReplaceAll(params[1], []byte("_"), []byte(" ")) - gamePoints = params[2] - if len(params) > 3 { - gameName = bytes.Join(params[3:], []byte(" ")) - } - default: - sendUsage() - continue - } - - var acey bool - - // Backwards-compatible acey-deucey parameter. Added in v1.1.5. - noAcey := bytes.HasPrefix(gameName, []byte("0 ")) || bytes.Equal(gameName, []byte("0")) - yesAcey := bytes.HasPrefix(gameName, []byte("1 ")) || bytes.Equal(gameName, []byte("1")) - if noAcey || yesAcey { - acey = yesAcey - if len(gameName) > 1 { - gameName = gameName[2:] - } else { - gameName = nil - } - } - - points, err := strconv.Atoi(string(gamePoints)) - if err != nil || points < 1 || points > 99 { - sendUsage() - continue - } - - // Set default game name. - if len(bytes.TrimSpace(gameName)) == 0 { - abbr := "'s" - lastLetter := cmd.client.name[len(cmd.client.name)-1] - if lastLetter == 's' || lastLetter == 'S' { - abbr = "'" - } - gameName = []byte(fmt.Sprintf("%s%s match", cmd.client.name, abbr)) - } - - g := newServerGame(<-s.newGameIDs, acey) - g.name = gameName - g.Points = int8(points) - g.password = gamePassword - g.addClient(cmd.client) - - s.gamesLock.Lock() - s.games = append(s.games, g) - s.gamesLock.Unlock() - - cmd.client.sendNotice(fmt.Sprintf("Created match: %s", g.name)) - - if len(g.password) == 0 { - cmd.client.sendNotice("Note: Please be patient as you wait for another player to join the match. A chime will sound when another player joins. While you wait, join the bgammon.org community via Discord, Matrix or IRC at bgammon.org/community") - } - case bgammon.CommandJoin, "j": - if clientGame != nil { - cmd.client.sendEvent(&bgammon.EventFailedJoin{ - Reason: "Please leave the match you are in before joining another.", - }) - continue - } - - sendUsage := func() { - cmd.client.sendNotice("To join a match please specify its ID or the name of a player in the match. To join a private match, a password must also be specified.") - } - - if len(params) == 0 { - sendUsage() - continue - } - - var joinGameID int - if onlyNumbers.Match(params[0]) { - gameID, err := strconv.Atoi(string(params[0])) - if err == nil && gameID > 0 { - joinGameID = gameID - } - - if joinGameID == 0 { - sendUsage() - continue - } - } else { - paramLower := bytes.ToLower(params[0]) - s.clientsLock.Lock() - for _, sc := range s.clients { - if bytes.Equal(paramLower, bytes.ToLower(sc.name)) { - g := s.gameByClient(sc) - if g != nil { - joinGameID = g.id - } - break - } - } - s.clientsLock.Unlock() - - if joinGameID == 0 { - cmd.client.sendEvent(&bgammon.EventFailedJoin{ - Reason: "Match not found.", - }) - continue - } - } - - s.gamesLock.Lock() - for _, g := range s.games { - if g.terminated() { - continue - } - if g.id == joinGameID { - providedPassword := bytes.ReplaceAll(bytes.Join(params[1:], []byte(" ")), []byte("_"), []byte(" ")) - if len(g.password) != 0 && (len(params) < 2 || !bytes.Equal(g.password, providedPassword)) { - cmd.client.sendEvent(&bgammon.EventFailedJoin{ - Reason: "Invalid password.", - }) - s.gamesLock.Unlock() - continue COMMANDS - } - - if bytes.HasPrefix(bytes.ToLower(cmd.client.name), []byte("bot_")) && ((g.client1 != nil && !bytes.HasPrefix(bytes.ToLower(g.client1.name), []byte("bot_"))) || (g.client2 != nil && !bytes.HasPrefix(bytes.ToLower(g.client2.name), []byte("bot_")))) { - cmd.client.sendEvent(&bgammon.EventFailedJoin{ - Reason: "Bots are not allowed to join player matches. Please create a match instead.", - }) - continue COMMANDS - } - - spectator := g.addClient(cmd.client) - s.gamesLock.Unlock() - cmd.client.sendNotice(fmt.Sprintf("Joined match: %s", g.name)) - if spectator { - cmd.client.sendNotice("You are spectating this match. Chat messages are not relayed.") - } - continue COMMANDS - } - } - s.gamesLock.Unlock() - - cmd.client.sendEvent(&bgammon.EventFailedJoin{ - Reason: "Match not found.", - }) - case bgammon.CommandLeave, "l": - if clientGame == nil { - cmd.client.sendEvent(&bgammon.EventFailedLeave{ - Reason: "You are not currently in a match.", - }) - continue - } - - if cmd.client.playerNumber == 1 { - clientGame.rejoin1 = false - } else { - clientGame.rejoin2 = false - } - - clientGame.removeClient(cmd.client) - case bgammon.CommandDouble, "d": - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } else if clientGame.Winner != 0 { - continue - } - - if clientGame.Turn != cmd.client.playerNumber { - cmd.client.sendNotice("It is not your turn.") - continue - } - - gameState := &bgammon.GameState{ - Game: clientGame.Game, - PlayerNumber: cmd.client.playerNumber, - Available: clientGame.LegalMoves(false), - } - if !gameState.MayDouble() { - cmd.client.sendNotice("You may not double at this time.") - continue - } - - if clientGame.DoublePlayer != 0 && clientGame.DoublePlayer != cmd.client.playerNumber { - cmd.client.sendNotice("You do not currently hold the doubling cube.") - continue - } - - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendNotice("You may not double until your opponent rejoins the match.") - continue - } - - clientGame.DoubleOffered = true - - cmd.client.sendNotice(fmt.Sprintf("Double offered to opponent (%d points).", clientGame.DoubleValue*2)) - clientGame.opponent(cmd.client).sendNotice(fmt.Sprintf("%s offers a double (%d points).", cmd.client.name, clientGame.DoubleValue*2)) - - clientGame.eachClient(func(client *serverClient) { - if client.json { - clientGame.sendBoard(client) - } - }) - case bgammon.CommandResign: - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } else if clientGame.Winner != 0 { - continue - } - - gameState := &bgammon.GameState{ - Game: clientGame.Game, - PlayerNumber: cmd.client.playerNumber, - Available: clientGame.LegalMoves(false), - } - if !gameState.MayResign() { - cmd.client.sendNotice("You may not resign at this time.") - continue - } - - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendNotice("You may not resign until your opponent rejoins the match.") - continue - } - - cmd.client.sendNotice("Declined double offer") - clientGame.opponent(cmd.client).sendNotice(fmt.Sprintf("%s declined double offer.", cmd.client.name)) - - acey := 0 - if clientGame.Acey { - acey = 1 - } - clientGame.replay = append([][]byte{[]byte(fmt.Sprintf("i %d %s %s %d %d %d %d %d %d", clientGame.Started.Unix(), clientGame.Player1.Name, clientGame.Player2.Name, clientGame.Points, clientGame.Player1.Points, clientGame.Player2.Points, clientGame.Winner, clientGame.DoubleValue, acey))}, clientGame.replay...) - - clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d d %d 0", clientGame.Turn, clientGame.DoubleValue*2))) - - var reset bool - if cmd.client.playerNumber == 1 { - clientGame.Player2.Points = clientGame.Player2.Points + clientGame.DoubleValue - if clientGame.Player2.Points >= clientGame.Points { - clientGame.Winner = 2 - clientGame.Ended = time.Now() - } else { - reset = true - } - } else { - clientGame.Player1.Points = clientGame.Player2.Points + clientGame.DoubleValue - if clientGame.Player1.Points >= clientGame.Points { - clientGame.Winner = 1 - clientGame.Ended = time.Now() - } else { - reset = true - } - } - - var winEvent *bgammon.EventWin - if clientGame.Winner != 0 { - winEvent = &bgammon.EventWin{ - Points: clientGame.DoubleValue, - } - if clientGame.Winner == 1 { - winEvent.Player = clientGame.Player1.Name - } else { - winEvent.Player = clientGame.Player2.Name - } - - err := recordGameResult(clientGame.Game, 4, clientGame.client1.account, clientGame.client2.account, clientGame.replay) - if err != nil { - log.Fatalf("failed to record game result: %s", err) - } - - if !reset { - err := recordMatchResult(clientGame.Game, matchTypeCasual, clientGame.client1.account, clientGame.client2.account) - if err != nil { - log.Fatalf("failed to record match result: %s", err) - } - } - } - - if reset { - clientGame.Reset() - clientGame.replay = clientGame.replay[:0] - } - - clientGame.eachClient(func(client *serverClient) { - clientGame.sendBoard(client) - if winEvent != nil { - client.sendEvent(winEvent) - } - }) - case bgammon.CommandRoll, "r": - if clientGame == nil { - cmd.client.sendEvent(&bgammon.EventFailedRoll{ - Reason: "You are not currently in a match.", - }) - continue - } else if clientGame.Winner != 0 { - continue - } - - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendEvent(&bgammon.EventFailedRoll{ - Reason: "You may not roll until your opponent rejoins the match.", - }) - continue - } - - if !clientGame.roll(cmd.client.playerNumber) { - cmd.client.sendEvent(&bgammon.EventFailedRoll{ - Reason: "It is not your turn to roll.", - }) - continue - } - - clientGame.eachClient(func(client *serverClient) { - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - Roll2: clientGame.Roll2, - } - ev.Player = string(cmd.client.name) - if clientGame.Turn == 0 && client.playerNumber == 2 { - ev.Roll1, ev.Roll2 = ev.Roll2, ev.Roll1 - } - client.sendEvent(ev) - }) - - var skipBoard bool - if clientGame.Turn == 0 && clientGame.Roll1 != 0 && clientGame.Roll2 != 0 { - reroll := func() { - clientGame.Roll1 = 0 - clientGame.Roll2 = 0 - if !clientGame.roll(clientGame.Turn) { - log.Fatal("failed to re-roll while starting acey-deucey game") - } - - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - Roll2: clientGame.Roll2, - } - ev.Player = string(clientGame.Player1.Name) - if clientGame.Turn == 2 { - ev.Player = string(clientGame.Player2.Name) - } - clientGame.eachClient(func(client *serverClient) { - clientGame.sendBoard(client) - client.sendEvent(ev) - }) - skipBoard = true - } - - if clientGame.Roll1 > clientGame.Roll2 { - clientGame.Turn = 1 - if clientGame.Acey { - reroll() - } - } else if clientGame.Roll2 > clientGame.Roll1 { - clientGame.Turn = 2 - if clientGame.Acey { - reroll() - } - } else { - for { - clientGame.Roll1 = 0 - clientGame.Roll2 = 0 - if !clientGame.roll(1) { - log.Fatal("failed to re-roll to determine starting player") - } - if !clientGame.roll(2) { - log.Fatal("failed to re-roll to determine starting player") - } - clientGame.eachClient(func(client *serverClient) { - { - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - } - ev.Player = clientGame.Player1.Name - if clientGame.Turn == 0 && client.playerNumber == 2 { - ev.Roll1, ev.Roll2 = ev.Roll2, ev.Roll1 - } - client.sendEvent(ev) - } - { - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - Roll2: clientGame.Roll2, - } - ev.Player = clientGame.Player2.Name - if clientGame.Turn == 0 && client.playerNumber == 2 { - ev.Roll1, ev.Roll2 = ev.Roll2, ev.Roll1 - } - client.sendEvent(ev) - } - }) - if clientGame.Roll1 > clientGame.Roll2 { - clientGame.Turn = 1 - if clientGame.Acey { - reroll() - } - break - } else if clientGame.Roll2 > clientGame.Roll1 { - clientGame.Turn = 2 - if clientGame.Acey { - reroll() - } - break - } - } - } - } - if !skipBoard { - clientGame.eachClient(func(client *serverClient) { - if clientGame.Turn != 0 || !client.json { - clientGame.sendBoard(client) - } - }) - } - case bgammon.CommandMove, "m", "mv": - if clientGame == nil { - cmd.client.sendEvent(&bgammon.EventFailedMove{ - Reason: "You are not currently in a match.", - }) - continue - } else if clientGame.Winner != 0 { - clientGame.sendBoard(cmd.client) - continue - } - - if clientGame.Turn != cmd.client.playerNumber { - cmd.client.sendEvent(&bgammon.EventFailedMove{ - Reason: "It is not your turn to move.", - }) - continue - } - - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendEvent(&bgammon.EventFailedMove{ - Reason: "You may not move until your opponent rejoins the match.", - }) - continue - } - - sendUsage := func() { - cmd.client.sendEvent(&bgammon.EventFailedMove{ - Reason: "Specify one or more moves in the form FROM/TO. For example: 8/4 6/4", - }) - } - - if len(params) == 0 { - sendUsage() - continue - } - - var moves [][]int8 - for i := range params { - split := bytes.Split(params[i], []byte("/")) - if len(split) != 2 { - sendUsage() - continue COMMANDS - } - from := bgammon.ParseSpace(string(split[0])) - if from == -1 { - sendUsage() - continue COMMANDS - } - to := bgammon.ParseSpace(string(split[1])) - if to == -1 { - sendUsage() - continue COMMANDS - } - - if !bgammon.ValidSpace(from) || !bgammon.ValidSpace(to) { - cmd.client.sendEvent(&bgammon.EventFailedMove{ - From: from, - To: to, - Reason: "Illegal move.", - }) - continue COMMANDS - } - - from, to = bgammon.FlipSpace(from, cmd.client.playerNumber), bgammon.FlipSpace(to, cmd.client.playerNumber) - moves = append(moves, []int8{from, to}) - } - - ok, expandedMoves := clientGame.AddMoves(moves, false) - if !ok { - cmd.client.sendEvent(&bgammon.EventFailedMove{ - From: 0, - To: 0, - Reason: "Illegal move.", - }) - continue - } - - var winEvent *bgammon.EventWin - if clientGame.Winner != 0 { - var opponent int8 = 1 - opponentHome := bgammon.SpaceHomePlayer - opponentEntered := clientGame.Player1.Entered - playerBar := bgammon.SpaceBarPlayer - if clientGame.Winner == 1 { - opponent = 2 - opponentHome = bgammon.SpaceHomeOpponent - opponentEntered = clientGame.Player2.Entered - playerBar = bgammon.SpaceBarOpponent - } - - backgammon := bgammon.PlayerCheckers(clientGame.Board[playerBar], opponent) != 0 - if !backgammon { - homeStart, homeEnd := bgammon.HomeRange(clientGame.Winner) - bgammon.IterateSpaces(homeStart, homeEnd, clientGame.Acey, func(space int8, spaceCount int8) { - if bgammon.PlayerCheckers(clientGame.Board[space], opponent) != 0 { - backgammon = true - } - }) - } - - var winPoints int8 - if !clientGame.Acey { - if backgammon { - winPoints = 3 // Award backgammon. - } else if clientGame.Board[opponentHome] == 0 { - winPoints = 2 // Award gammon. - } else { - winPoints = 1 - } - } else { - for space := int8(0); space < bgammon.BoardSpaces; space++ { - if (space == bgammon.SpaceHomePlayer || space == bgammon.SpaceHomeOpponent) && opponentEntered { - continue - } - winPoints += bgammon.PlayerCheckers(clientGame.Board[space], opponent) - } - } - - acey := 0 - if clientGame.Acey { - acey = 1 - } - clientGame.replay = append([][]byte{[]byte(fmt.Sprintf("i %d %s %s %d %d %d %d %d %d", clientGame.Started.Unix(), clientGame.Player1.Name, clientGame.Player2.Name, clientGame.Points, clientGame.Player1.Points, clientGame.Player2.Points, clientGame.Winner, winPoints, acey))}, clientGame.replay...) - - r1, r2 := clientGame.Roll1, clientGame.Roll2 - if r2 > r1 { - r1, r2 = r2, r1 - } - var movesFormatted []byte - if len(clientGame.Moves) != 0 { - movesFormatted = append([]byte(" "), bgammon.FormatMoves(clientGame.Moves)...) - } - clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d r %d-%d%s", clientGame.Turn, r1, r2, movesFormatted))) - - winEvent = &bgammon.EventWin{ - Points: winPoints * clientGame.DoubleValue, - } - var reset bool - if clientGame.Winner == 1 { - winEvent.Player = clientGame.Player1.Name - clientGame.Player1.Points = clientGame.Player1.Points + winPoints*clientGame.DoubleValue - if clientGame.Player1.Points < clientGame.Points { - reset = true - } else { - clientGame.Ended = time.Now() - } - } else { - winEvent.Player = clientGame.Player2.Name - clientGame.Player2.Points = clientGame.Player2.Points + winPoints*clientGame.DoubleValue - if clientGame.Player2.Points < clientGame.Points { - reset = true - } else { - clientGame.Ended = time.Now() - } - } - - winType := winPoints - if clientGame.Acey { - winType = 1 - } - err := recordGameResult(clientGame.Game, winType, clientGame.client1.account, clientGame.client2.account, clientGame.replay) - if err != nil { - log.Fatalf("failed to record game result: %s", err) - } - - if !reset { - err := recordMatchResult(clientGame.Game, matchTypeCasual, clientGame.client1.account, clientGame.client2.account) - if err != nil { - log.Fatalf("failed to record match result: %s", err) - } - } else { - clientGame.Reset() - clientGame.replay = clientGame.replay[:0] - } - } - - clientGame.eachClient(func(client *serverClient) { - ev := &bgammon.EventMoved{ - Moves: bgammon.FlipMoves(expandedMoves, client.playerNumber), - } - ev.Player = string(cmd.client.name) - client.sendEvent(ev) - - clientGame.sendBoard(client) - - if winEvent != nil { - client.sendEvent(winEvent) - } - }) - case bgammon.CommandReset: - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } else if clientGame.Winner != 0 { - continue - } - - if clientGame.Turn != cmd.client.playerNumber { - cmd.client.sendNotice("It is not your turn.") - continue - } - - if len(clientGame.Moves) == 0 { - continue - } - - l := len(clientGame.Moves) - undoMoves := make([][]int8, l) - for i, move := range clientGame.Moves { - undoMoves[l-1-i] = []int8{move[1], move[0]} - } - ok, _ := clientGame.AddMoves(undoMoves, false) - if !ok { - cmd.client.sendNotice("Failed to undo move: invalid move.") - } else { - clientGame.eachClient(func(client *serverClient) { - ev := &bgammon.EventMoved{ - Moves: bgammon.FlipMoves(undoMoves, client.playerNumber), - } - ev.Player = string(cmd.client.name) - - client.sendEvent(ev) - clientGame.sendBoard(client) - }) - } - case bgammon.CommandOk, "k": - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } else if clientGame.Winner != 0 { - continue - } - - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendNotice("You must wait until your opponent rejoins the match before continuing the game.") - continue - } - - if clientGame.DoubleOffered { - if clientGame.Turn != cmd.client.playerNumber { - opponent := clientGame.opponent(cmd.client) - if opponent == nil { - cmd.client.sendNotice("You may not accept the double until your opponent rejoins the match.") - continue - } - - clientGame.DoubleOffered = false - clientGame.DoubleValue = clientGame.DoubleValue * 2 - clientGame.DoublePlayer = cmd.client.playerNumber - - cmd.client.sendNotice("Accepted double.") - opponent.sendNotice(fmt.Sprintf("%s accepted double.", cmd.client.name)) - - clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d d %d 1", clientGame.Turn, clientGame.DoubleValue))) - clientGame.eachClient(func(client *serverClient) { - clientGame.sendBoard(client) - }) - } else { - cmd.client.sendNotice("Waiting for response from opponent.") - } - continue - } else if clientGame.Turn != cmd.client.playerNumber { - cmd.client.sendNotice("It is not your turn.") - continue - } - - if clientGame.Roll1 == 0 || clientGame.Roll2 == 0 { - cmd.client.sendNotice("You must roll first.") - continue - } - - legalMoves := clientGame.LegalMoves(false) - if len(legalMoves) != 0 { - available := bgammon.FlipMoves(legalMoves, cmd.client.playerNumber) - bgammon.SortMoves(available) - cmd.client.sendEvent(&bgammon.EventFailedOk{ - Reason: fmt.Sprintf("The following legal moves are available: %s", bgammon.FormatMoves(available)), - }) - continue - } - - recordEvent := func() { - r1, r2 := clientGame.Roll1, clientGame.Roll2 - if r2 > r1 { - r1, r2 = r2, r1 - } - var movesFormatted []byte - if len(clientGame.Moves) != 0 { - movesFormatted = append([]byte(" "), bgammon.FormatMoves(clientGame.Moves)...) - } - clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d r %d-%d%s", clientGame.Turn, r1, r2, movesFormatted))) - } - - if clientGame.Acey && ((clientGame.Roll1 == 1 && clientGame.Roll2 == 2) || (clientGame.Roll1 == 2 && clientGame.Roll2 == 1)) && len(clientGame.Moves) == 2 { - var doubles int - if len(params) > 0 { - doubles, _ = strconv.Atoi(string(params[0])) - } - if doubles < 1 || doubles > 6 { - cmd.client.sendEvent(&bgammon.EventFailedOk{ - Reason: "Choose which doubles you want for your acey-deucey.", - }) - continue - } - - recordEvent() - clientGame.NextTurn(true) - clientGame.Roll1, clientGame.Roll2 = int8(doubles), int8(doubles) - clientGame.Reroll = true - - clientGame.eachClient(func(client *serverClient) { - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - Roll2: clientGame.Roll2, - Selected: true, - } - ev.Player = string(cmd.client.name) - client.sendEvent(ev) - }) - } else if clientGame.Acey && clientGame.Reroll { - recordEvent() - clientGame.NextTurn(true) - clientGame.Roll1, clientGame.Roll2 = 0, 0 - if !clientGame.roll(cmd.client.playerNumber) { - cmd.client.Terminate("Server error") - opponent.Terminate("Server error") - continue - } - clientGame.Reroll = false - - clientGame.eachClient(func(client *serverClient) { - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - Roll2: clientGame.Roll2, - } - ev.Player = string(cmd.client.name) - client.sendEvent(ev) - clientGame.sendBoard(client) - }) - } else { - recordEvent() - clientGame.NextTurn(false) - if clientGame.Winner == 0 { - gameState := &bgammon.GameState{ - Game: clientGame.Game, - PlayerNumber: clientGame.Turn, - Available: clientGame.LegalMoves(false), - } - if !gameState.MayDouble() { - if !clientGame.roll(clientGame.Turn) { - cmd.client.Terminate("Server error") - opponent.Terminate("Server error") - continue - } - clientGame.eachClient(func(client *serverClient) { - ev := &bgammon.EventRolled{ - Roll1: clientGame.Roll1, - Roll2: clientGame.Roll2, - } - if clientGame.Turn == 1 { - ev.Player = gameState.Player1.Name - } else { - ev.Player = gameState.Player2.Name - } - client.sendEvent(ev) - }) - } - } - } - - clientGame.eachClient(func(client *serverClient) { - clientGame.sendBoard(client) - }) - case bgammon.CommandRematch, "rm": - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } else if clientGame.Winner == 0 { - cmd.client.sendNotice("The match you are in is still in progress.") - continue - } else if clientGame.rematch == cmd.client.playerNumber { - cmd.client.sendNotice("You have already requested a rematch.") - continue - } else if clientGame.client1 == nil || clientGame.client2 == nil { - cmd.client.sendNotice("Your opponent left the match.") - continue - } else if clientGame.rematch != 0 && clientGame.rematch != cmd.client.playerNumber { - s.gamesLock.Lock() - - newGame := newServerGame(<-s.newGameIDs, clientGame.Acey) - newGame.name = clientGame.name - newGame.Points = clientGame.Points - newGame.password = clientGame.password - newGame.client1 = clientGame.client1 - newGame.client2 = clientGame.client2 - newGame.spectators = make([]*serverClient, len(clientGame.spectators)) - copy(newGame.spectators, clientGame.spectators) - newGame.Player1.Name = clientGame.Player1.Name - newGame.Player2.Name = clientGame.Player2.Name - newGame.Player1.Points = clientGame.Player1.Points - newGame.Player2.Points = clientGame.Player2.Points - newGame.allowed1 = clientGame.allowed1 - newGame.allowed2 = clientGame.allowed2 - s.games = append(s.games, newGame) - - clientGame.client1 = nil - clientGame.client2 = nil - clientGame.spectators = nil - - s.gamesLock.Unlock() - - { - ev1 := &bgammon.EventJoined{ - GameID: newGame.id, - PlayerNumber: 1, - } - ev1.Player = newGame.Player1.Name - ev2 := &bgammon.EventJoined{ - GameID: newGame.id, - PlayerNumber: 2, - } - ev2.Player = newGame.Player2.Name - newGame.client1.sendEvent(ev1) - newGame.client1.sendEvent(ev2) - newGame.sendBoard(newGame.client1) - } - - { - ev1 := &bgammon.EventJoined{ - GameID: newGame.id, - PlayerNumber: 1, - } - ev1.Player = newGame.Player2.Name - ev2 := &bgammon.EventJoined{ - GameID: newGame.id, - PlayerNumber: 2, - } - ev2.Player = newGame.Player1.Name - newGame.client2.sendEvent(ev1) - newGame.client2.sendEvent(ev2) - newGame.sendBoard(newGame.client2) - } - - for _, spectator := range newGame.spectators { - newGame.sendBoard(spectator) - } - } else { - clientGame.rematch = cmd.client.playerNumber - - clientGame.opponent(cmd.client).sendNotice("Your opponent would like to play again. Type /rematch to accept.") - cmd.client.sendNotice("Rematch offer sent.") - continue - } - case bgammon.CommandBoard, "b": - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } - - clientGame.sendBoard(cmd.client) - case bgammon.CommandPassword: - if cmd.client.account == 0 { - cmd.client.sendNotice("Failed to change password: you are logged in as a guest.") - continue - } else if len(params) < 2 { - cmd.client.sendNotice("Please specify your old and new passwords as follows: password ") - continue - } - - a, err := loginAccount(s.passwordSalt, cmd.client.name, params[0]) - if err != nil || a == nil || a.id == 0 { - cmd.client.sendNotice("Failed to change password: incorrect existing password.") - continue - } - - err = setAccountPassword(s.passwordSalt, a.id, string(bytes.Join(params[1:], []byte("_")))) - if err != nil { - cmd.client.sendNotice("Failed to change password.") - continue - } - cmd.client.sendNotice("Password changed successfully.") - case bgammon.CommandSet: - if cmd.client.account == 0 { - continue - } else if len(params) < 2 { - cmd.client.sendNotice("Please specify the setting name and value as follows: set ") - continue - } - - name := string(bytes.ToLower(params[0])) - settings := []string{"highlight", "pips", "moves", "flip"} - var found bool - for i := range settings { - if name == settings[i] { - found = true - break - } - } - if !found { - cmd.client.sendNotice("Please specify the setting name and value as follows: set ") - continue - } - - value, err := strconv.Atoi(string(params[1])) - if err != nil || value < 0 { - cmd.client.sendNotice("Invalid setting value provided.") - continue - } - _ = setAccountSetting(cmd.client.account, name, value) - case bgammon.CommandReplay: - var ( - id int - replay []byte - err error - ) - if len(params) == 0 { - if clientGame == nil || clientGame.Winner == 0 { - cmd.client.sendNotice("Please specify the game as follows: replay ") - continue - } - id = -1 - replay = bytes.Join(clientGame.replay, []byte("\n")) - } else { - id, err = strconv.Atoi(string(params[0])) - if err != nil || id < 0 { - cmd.client.sendNotice("Invalid replay ID provided.") - continue - } - replay, err = replayByID(id) - if err != nil { - cmd.client.sendNotice("Invalid replay ID provided.") - continue - } - } - if len(replay) == 0 { - cmd.client.sendNotice("No replay was recorded for that game.") - continue - } - cmd.client.sendEvent(&bgammon.EventReplay{ - ID: id, - Content: replay, - }) - case bgammon.CommandHistory: - if len(params) == 0 { - cmd.client.sendNotice("Please specify the player as follows: history ") - continue - } - - matches, err := matchHistory(string(params[0])) - if err != nil { - cmd.client.sendNotice("Invalid replay ID provided.") - continue - } - ev := &bgammon.EventHistory{ - Matches: matches, - } - ev.Player = string(params[0]) - cmd.client.sendEvent(ev) - case bgammon.CommandDisconnect: - if clientGame != nil { - clientGame.removeClient(cmd.client) - } - cmd.client.Terminate("Client disconnected") - case bgammon.CommandPong: - // Do nothing. - case "endgame": - if !allowDebugCommands { - cmd.client.sendNotice("You are not allowed to use that command.") - continue - } - - if clientGame == nil { - cmd.client.sendNotice("You are not currently in a match.") - continue - } - - clientGame.Turn = 1 - clientGame.Roll1 = 6 - clientGame.Roll2 = 6 - clientGame.Board = []int8{1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, 0, 0, 0, 0} - - clientGame.eachClient(func(client *serverClient) { - clientGame.sendBoard(client) - }) - default: - log.Printf("Received unknown command from client %s: %s", cmd.client.label(), cmd.command) - cmd.client.sendNotice(fmt.Sprintf("Unknown command: %s", cmd.command)) - } - } -} - func RandInt(max int) int { i, err := rand.Int(rand.Reader, big.NewInt(int64(max))) if err != nil { diff --git a/pkg/server/server_command.go b/pkg/server/server_command.go new file mode 100644 index 0000000..5fa9550 --- /dev/null +++ b/pkg/server/server_command.go @@ -0,0 +1,1328 @@ +package server + +import ( + "bytes" + "fmt" + "log" + "strconv" + "strings" + "time" + + "code.rocket9labs.com/tslocum/bgammon" +) + +func (s *server) handleCommands() { + var cmd serverCommand +COMMANDS: + for cmd = range s.commands { + if cmd.client == nil { + log.Panicf("nil client with command %s", cmd.command) + } else if cmd.client.terminating || cmd.client.Terminated() { + continue + } + + cmd.command = bytes.TrimSpace(cmd.command) + + firstSpace := bytes.IndexByte(cmd.command, ' ') + var keyword string + var startParameters int + if firstSpace == -1 { + keyword = string(cmd.command) + startParameters = len(cmd.command) + } else { + keyword = string(cmd.command[:firstSpace]) + startParameters = firstSpace + 1 + } + if keyword == "" { + continue + } + keyword = strings.ToLower(keyword) + params := bytes.Fields(cmd.command[startParameters:]) + + // Require users to send login command first. + if cmd.client.account == -1 { + resetCommand := keyword == bgammon.CommandResetPassword + if resetCommand { + if len(params) > 0 { + email := bytes.ToLower(bytes.TrimSpace(params[0])) + if len(email) > 0 { + err := resetAccount(s.mailServer, s.resetSalt, email) + if err != nil { + log.Fatalf("failed to reset password: %s", err) + } + } + } + cmd.client.Terminate("resetpasswordok") + continue + } + + loginCommand := keyword == bgammon.CommandLogin || keyword == bgammon.CommandLoginJSON || keyword == "lj" + registerCommand := keyword == bgammon.CommandRegister || keyword == bgammon.CommandRegisterJSON || keyword == "rj" + if loginCommand || registerCommand { + if keyword == bgammon.CommandLoginJSON || keyword == bgammon.CommandRegisterJSON || keyword == "lj" || keyword == "rj" { + cmd.client.json = true + } + + var username []byte + var password []byte + var randomUsername bool + if registerCommand { + sendUsage := func() { + cmd.client.Terminate("Please enter an email, username and password.") + } + + var email []byte + if keyword == bgammon.CommandRegisterJSON || keyword == "rj" { + if len(params) < 4 { + sendUsage() + continue + } + email = params[1] + username = params[2] + password = bytes.Join(params[3:], []byte("_")) + } else { + if len(params) < 3 { + sendUsage() + continue + } + email = params[0] + username = params[1] + password = bytes.Join(params[2:], []byte("_")) + } + if onlyNumbers.Match(username) { + cmd.client.Terminate("Failed to register: Invalid username: must contain at least one non-numeric character.") + continue + } + password = bytes.ReplaceAll(password, []byte(" "), []byte("_")) + a := &account{ + email: email, + username: username, + password: password, + } + err := registerAccount(s.passwordSalt, a) + if err != nil { + cmd.client.Terminate(fmt.Sprintf("Failed to register: %s", err)) + continue + } + } else { + s.clientsLock.Lock() + + readUsername := func() bool { + if cmd.client.json { + if len(params) > 1 { + username = params[1] + } + } else { + if len(params) > 0 { + username = params[0] + } + } + if len(bytes.TrimSpace(username)) == 0 { + username = s.randomUsername() + randomUsername = true + } else if !alphaNumericUnderscore.Match(username) { + cmd.client.Terminate("Invalid username: must contain only letters, numbers and underscores.") + return false + } + if onlyNumbers.Match(username) { + cmd.client.Terminate("Invalid username: must contain at least one non-numeric character.") + return false + } else if s.clientByUsername(username) != nil || s.clientByUsername(append([]byte("Guest_"), username...)) != nil || (!randomUsername && !s.nameAllowed(username)) { + cmd.client.Terminate("That username is already in use.") + return false + } + return true + } + if !readUsername() { + s.clientsLock.Unlock() + continue + } + if len(params) > 2 { + password = bytes.ReplaceAll(bytes.Join(params[2:], []byte(" ")), []byte(" "), []byte("_")) + } + + s.clientsLock.Unlock() + } + + if len(password) > 0 { + a, err := loginAccount(s.passwordSalt, username, password) + if err != nil { + cmd.client.Terminate(fmt.Sprintf("Failed to log in: %s", err)) + continue + } else if a == nil { + cmd.client.Terminate("No account was found with the provided username and password. To log in as a guest, do not enter a password.") + continue + } + + var name []byte + if bytes.HasPrefix(a.username, []byte("bot_")) { + name = append([]byte("BOT_"), a.username[4:]...) + } else { + name = a.username + } + if s.clientByUsername(name) != nil { + cmd.client.Terminate("That username is already in use.") + continue + } + + cmd.client.account = a.id + cmd.client.name = name + cmd.client.sendEvent(&bgammon.EventSettings{ + Highlight: a.highlight, + Pips: a.pips, + Moves: a.moves, + Flip: a.flip, + }) + } else { + cmd.client.account = 0 + if !randomUsername && !bytes.HasPrefix(username, []byte("BOT_")) && !bytes.HasPrefix(username, []byte("Guest_")) { + username = append([]byte("Guest_"), username...) + } + cmd.client.name = username + } + + cmd.client.sendEvent(&bgammon.EventWelcome{ + PlayerName: string(cmd.client.name), + Clients: len(s.clients), + Games: len(s.games), + }) + + log.Printf("Client %d logged in as %s", cmd.client.id, cmd.client.name) + + // Rejoin match in progress. + s.gamesLock.RLock() + for _, g := range s.games { + if g.terminated() || g.Winner != 0 { + continue + } + + var rejoin bool + if bytes.Equal(cmd.client.name, g.allowed1) { + rejoin = g.rejoin1 + } else if bytes.Equal(cmd.client.name, g.allowed2) { + rejoin = g.rejoin2 + } + if rejoin { + g.addClient(cmd.client) + cmd.client.sendNotice(fmt.Sprintf("Rejoined match: %s", g.name)) + } + } + s.gamesLock.RUnlock() + continue + } + + cmd.client.Terminate("You must login before using other commands.") + continue + } + + clientGame := s.gameByClient(cmd.client) + if clientGame != nil && clientGame.client1 != cmd.client && clientGame.client2 != cmd.client { + switch keyword { + case bgammon.CommandHelp, "h", bgammon.CommandJSON, bgammon.CommandList, "ls", bgammon.CommandBoard, "b", bgammon.CommandLeave, "l", bgammon.CommandReplay, bgammon.CommandDisconnect, bgammon.CommandPong: + // These commands are allowed to be used by spectators. + default: + cmd.client.sendNotice("Command ignored: You are spectating this match.") + continue + } + } + + switch keyword { + case bgammon.CommandHelp, "h": + // TODO get extended help by specifying a command after help + cmd.client.sendEvent(&bgammon.EventHelp{ + Topic: "", + Message: "Test help text", + }) + case bgammon.CommandJSON: + sendUsage := func() { + cmd.client.sendNotice("To enable JSON formatted messages, send 'json on'. To disable JSON formatted messages, send 'json off'.") + } + if len(params) != 1 { + sendUsage() + continue + } + paramLower := strings.ToLower(string(params[0])) + switch paramLower { + case "on": + cmd.client.json = true + cmd.client.sendNotice("JSON formatted messages enabled.") + case "off": + cmd.client.json = false + cmd.client.sendNotice("JSON formatted messages disabled.") + default: + sendUsage() + } + case bgammon.CommandSay, "s": + if len(params) == 0 { + continue + } + if clientGame == nil { + cmd.client.sendNotice("Message not sent: You are not currently in a match.") + continue + } + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendNotice("Message not sent: There is no one else in the match.") + continue + } + ev := &bgammon.EventSay{ + Message: string(bytes.Join(params, []byte(" "))), + } + ev.Player = string(cmd.client.name) + opponent.sendEvent(ev) + if s.relayChat { + for _, spectator := range clientGame.spectators { + spectator.sendEvent(ev) + } + } + case bgammon.CommandList, "ls": + ev := &bgammon.EventList{} + + s.gamesLock.RLock() + for _, g := range s.games { + listing := g.listing(cmd.client.name) + if listing == nil { + continue + } + ev.Games = append(ev.Games, *listing) + } + s.gamesLock.RUnlock() + + cmd.client.sendEvent(ev) + case bgammon.CommandCreate, "c": + if clientGame != nil { + cmd.client.sendNotice("Failed to create match: Please leave the match you are in before creating another.") + continue + } + + sendUsage := func() { + cmd.client.sendNotice("To create a public match please specify whether it is public or private, and also specify how many points are needed to win the match. When creating a private match, a password must also be provided.") + } + if len(params) < 2 { + sendUsage() + continue + } + + var gamePassword []byte + gameType := bytes.ToLower(params[0]) + var gameName []byte + var gamePoints []byte + switch { + case bytes.Equal(gameType, []byte("public")): + gamePoints = params[1] + if len(params) > 2 { + gameName = bytes.Join(params[2:], []byte(" ")) + } + case bytes.Equal(gameType, []byte("private")): + if len(params) < 3 { + sendUsage() + continue + } + gamePassword = bytes.ReplaceAll(params[1], []byte("_"), []byte(" ")) + gamePoints = params[2] + if len(params) > 3 { + gameName = bytes.Join(params[3:], []byte(" ")) + } + default: + sendUsage() + continue + } + + var acey bool + + // Backwards-compatible acey-deucey parameter. Added in v1.1.5. + noAcey := bytes.HasPrefix(gameName, []byte("0 ")) || bytes.Equal(gameName, []byte("0")) + yesAcey := bytes.HasPrefix(gameName, []byte("1 ")) || bytes.Equal(gameName, []byte("1")) + if noAcey || yesAcey { + acey = yesAcey + if len(gameName) > 1 { + gameName = gameName[2:] + } else { + gameName = nil + } + } + + points, err := strconv.Atoi(string(gamePoints)) + if err != nil || points < 1 || points > 99 { + sendUsage() + continue + } + + // Set default game name. + if len(bytes.TrimSpace(gameName)) == 0 { + abbr := "'s" + lastLetter := cmd.client.name[len(cmd.client.name)-1] + if lastLetter == 's' || lastLetter == 'S' { + abbr = "'" + } + gameName = []byte(fmt.Sprintf("%s%s match", cmd.client.name, abbr)) + } + + g := newServerGame(<-s.newGameIDs, acey) + g.name = gameName + g.Points = int8(points) + g.password = gamePassword + g.addClient(cmd.client) + + s.gamesLock.Lock() + s.games = append(s.games, g) + s.gamesLock.Unlock() + + cmd.client.sendNotice(fmt.Sprintf("Created match: %s", g.name)) + + if len(g.password) == 0 { + cmd.client.sendNotice("Note: Please be patient as you wait for another player to join the match. A chime will sound when another player joins. While you wait, join the bgammon.org community via Discord, Matrix or IRC at bgammon.org/community") + } + case bgammon.CommandJoin, "j": + if clientGame != nil { + cmd.client.sendEvent(&bgammon.EventFailedJoin{ + Reason: "Please leave the match you are in before joining another.", + }) + continue + } + + sendUsage := func() { + cmd.client.sendNotice("To join a match please specify its ID or the name of a player in the match. To join a private match, a password must also be specified.") + } + + if len(params) == 0 { + sendUsage() + continue + } + + var joinGameID int + if onlyNumbers.Match(params[0]) { + gameID, err := strconv.Atoi(string(params[0])) + if err == nil && gameID > 0 { + joinGameID = gameID + } + + if joinGameID == 0 { + sendUsage() + continue + } + } else { + paramLower := bytes.ToLower(params[0]) + s.clientsLock.Lock() + for _, sc := range s.clients { + if bytes.Equal(paramLower, bytes.ToLower(sc.name)) { + g := s.gameByClient(sc) + if g != nil { + joinGameID = g.id + } + break + } + } + s.clientsLock.Unlock() + + if joinGameID == 0 { + cmd.client.sendEvent(&bgammon.EventFailedJoin{ + Reason: "Match not found.", + }) + continue + } + } + + s.gamesLock.Lock() + for _, g := range s.games { + if g.terminated() { + continue + } + if g.id == joinGameID { + providedPassword := bytes.ReplaceAll(bytes.Join(params[1:], []byte(" ")), []byte("_"), []byte(" ")) + if len(g.password) != 0 && (len(params) < 2 || !bytes.Equal(g.password, providedPassword)) { + cmd.client.sendEvent(&bgammon.EventFailedJoin{ + Reason: "Invalid password.", + }) + s.gamesLock.Unlock() + continue COMMANDS + } + + if bytes.HasPrefix(bytes.ToLower(cmd.client.name), []byte("bot_")) && ((g.client1 != nil && !bytes.HasPrefix(bytes.ToLower(g.client1.name), []byte("bot_"))) || (g.client2 != nil && !bytes.HasPrefix(bytes.ToLower(g.client2.name), []byte("bot_")))) { + cmd.client.sendEvent(&bgammon.EventFailedJoin{ + Reason: "Bots are not allowed to join player matches. Please create a match instead.", + }) + continue COMMANDS + } + + spectator := g.addClient(cmd.client) + s.gamesLock.Unlock() + cmd.client.sendNotice(fmt.Sprintf("Joined match: %s", g.name)) + if spectator { + cmd.client.sendNotice("You are spectating this match. Chat messages are not relayed.") + } + continue COMMANDS + } + } + s.gamesLock.Unlock() + + cmd.client.sendEvent(&bgammon.EventFailedJoin{ + Reason: "Match not found.", + }) + case bgammon.CommandLeave, "l": + if clientGame == nil { + cmd.client.sendEvent(&bgammon.EventFailedLeave{ + Reason: "You are not currently in a match.", + }) + continue + } + + if cmd.client.playerNumber == 1 { + clientGame.rejoin1 = false + } else { + clientGame.rejoin2 = false + } + + clientGame.removeClient(cmd.client) + case bgammon.CommandDouble, "d": + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } else if clientGame.Winner != 0 { + continue + } + + if clientGame.Turn != cmd.client.playerNumber { + cmd.client.sendNotice("It is not your turn.") + continue + } + + gameState := &bgammon.GameState{ + Game: clientGame.Game, + PlayerNumber: cmd.client.playerNumber, + Available: clientGame.LegalMoves(false), + } + if !gameState.MayDouble() { + cmd.client.sendNotice("You may not double at this time.") + continue + } + + if clientGame.DoublePlayer != 0 && clientGame.DoublePlayer != cmd.client.playerNumber { + cmd.client.sendNotice("You do not currently hold the doubling cube.") + continue + } + + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendNotice("You may not double until your opponent rejoins the match.") + continue + } + + clientGame.DoubleOffered = true + + cmd.client.sendNotice(fmt.Sprintf("Double offered to opponent (%d points).", clientGame.DoubleValue*2)) + clientGame.opponent(cmd.client).sendNotice(fmt.Sprintf("%s offers a double (%d points).", cmd.client.name, clientGame.DoubleValue*2)) + + clientGame.eachClient(func(client *serverClient) { + if client.json { + clientGame.sendBoard(client) + } + }) + case bgammon.CommandResign: + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } else if clientGame.Winner != 0 { + continue + } + + gameState := &bgammon.GameState{ + Game: clientGame.Game, + PlayerNumber: cmd.client.playerNumber, + Available: clientGame.LegalMoves(false), + } + if !gameState.MayResign() { + cmd.client.sendNotice("You may not resign at this time.") + continue + } + + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendNotice("You may not resign until your opponent rejoins the match.") + continue + } + + cmd.client.sendNotice("Declined double offer") + clientGame.opponent(cmd.client).sendNotice(fmt.Sprintf("%s declined double offer.", cmd.client.name)) + + acey := 0 + if clientGame.Acey { + acey = 1 + } + clientGame.replay = append([][]byte{[]byte(fmt.Sprintf("i %d %s %s %d %d %d %d %d %d", clientGame.Started.Unix(), clientGame.Player1.Name, clientGame.Player2.Name, clientGame.Points, clientGame.Player1.Points, clientGame.Player2.Points, clientGame.Winner, clientGame.DoubleValue, acey))}, clientGame.replay...) + + clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d d %d 0", clientGame.Turn, clientGame.DoubleValue*2))) + + var reset bool + if cmd.client.playerNumber == 1 { + clientGame.Player2.Points = clientGame.Player2.Points + clientGame.DoubleValue + if clientGame.Player2.Points >= clientGame.Points { + clientGame.Winner = 2 + clientGame.Ended = time.Now() + } else { + reset = true + } + } else { + clientGame.Player1.Points = clientGame.Player2.Points + clientGame.DoubleValue + if clientGame.Player1.Points >= clientGame.Points { + clientGame.Winner = 1 + clientGame.Ended = time.Now() + } else { + reset = true + } + } + + var winEvent *bgammon.EventWin + if clientGame.Winner != 0 { + winEvent = &bgammon.EventWin{ + Points: clientGame.DoubleValue, + } + if clientGame.Winner == 1 { + winEvent.Player = clientGame.Player1.Name + } else { + winEvent.Player = clientGame.Player2.Name + } + + err := recordGameResult(clientGame.Game, 4, clientGame.client1.account, clientGame.client2.account, clientGame.replay) + if err != nil { + log.Fatalf("failed to record game result: %s", err) + } + + if !reset { + err := recordMatchResult(clientGame.Game, matchTypeCasual, clientGame.client1.account, clientGame.client2.account) + if err != nil { + log.Fatalf("failed to record match result: %s", err) + } + } + } + + if reset { + clientGame.Reset() + clientGame.replay = clientGame.replay[:0] + } + + clientGame.eachClient(func(client *serverClient) { + clientGame.sendBoard(client) + if winEvent != nil { + client.sendEvent(winEvent) + } + }) + case bgammon.CommandRoll, "r": + if clientGame == nil { + cmd.client.sendEvent(&bgammon.EventFailedRoll{ + Reason: "You are not currently in a match.", + }) + continue + } else if clientGame.Winner != 0 { + continue + } + + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendEvent(&bgammon.EventFailedRoll{ + Reason: "You may not roll until your opponent rejoins the match.", + }) + continue + } + + if !clientGame.roll(cmd.client.playerNumber) { + cmd.client.sendEvent(&bgammon.EventFailedRoll{ + Reason: "It is not your turn to roll.", + }) + continue + } + + clientGame.eachClient(func(client *serverClient) { + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + Roll2: clientGame.Roll2, + } + ev.Player = string(cmd.client.name) + if clientGame.Turn == 0 && client.playerNumber == 2 { + ev.Roll1, ev.Roll2 = ev.Roll2, ev.Roll1 + } + client.sendEvent(ev) + }) + + var skipBoard bool + if clientGame.Turn == 0 && clientGame.Roll1 != 0 && clientGame.Roll2 != 0 { + reroll := func() { + clientGame.Roll1 = 0 + clientGame.Roll2 = 0 + if !clientGame.roll(clientGame.Turn) { + log.Fatal("failed to re-roll while starting acey-deucey game") + } + + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + Roll2: clientGame.Roll2, + } + ev.Player = string(clientGame.Player1.Name) + if clientGame.Turn == 2 { + ev.Player = string(clientGame.Player2.Name) + } + clientGame.eachClient(func(client *serverClient) { + clientGame.sendBoard(client) + client.sendEvent(ev) + }) + skipBoard = true + } + + if clientGame.Roll1 > clientGame.Roll2 { + clientGame.Turn = 1 + if clientGame.Acey { + reroll() + } + } else if clientGame.Roll2 > clientGame.Roll1 { + clientGame.Turn = 2 + if clientGame.Acey { + reroll() + } + } else { + for { + clientGame.Roll1 = 0 + clientGame.Roll2 = 0 + if !clientGame.roll(1) { + log.Fatal("failed to re-roll to determine starting player") + } + if !clientGame.roll(2) { + log.Fatal("failed to re-roll to determine starting player") + } + clientGame.eachClient(func(client *serverClient) { + { + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + } + ev.Player = clientGame.Player1.Name + if clientGame.Turn == 0 && client.playerNumber == 2 { + ev.Roll1, ev.Roll2 = ev.Roll2, ev.Roll1 + } + client.sendEvent(ev) + } + { + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + Roll2: clientGame.Roll2, + } + ev.Player = clientGame.Player2.Name + if clientGame.Turn == 0 && client.playerNumber == 2 { + ev.Roll1, ev.Roll2 = ev.Roll2, ev.Roll1 + } + client.sendEvent(ev) + } + }) + if clientGame.Roll1 > clientGame.Roll2 { + clientGame.Turn = 1 + if clientGame.Acey { + reroll() + } + break + } else if clientGame.Roll2 > clientGame.Roll1 { + clientGame.Turn = 2 + if clientGame.Acey { + reroll() + } + break + } + } + } + } + if !skipBoard { + clientGame.eachClient(func(client *serverClient) { + if clientGame.Turn != 0 || !client.json { + clientGame.sendBoard(client) + } + }) + } + case bgammon.CommandMove, "m", "mv": + if clientGame == nil { + cmd.client.sendEvent(&bgammon.EventFailedMove{ + Reason: "You are not currently in a match.", + }) + continue + } else if clientGame.Winner != 0 { + clientGame.sendBoard(cmd.client) + continue + } + + if clientGame.Turn != cmd.client.playerNumber { + cmd.client.sendEvent(&bgammon.EventFailedMove{ + Reason: "It is not your turn to move.", + }) + continue + } + + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendEvent(&bgammon.EventFailedMove{ + Reason: "You may not move until your opponent rejoins the match.", + }) + continue + } + + sendUsage := func() { + cmd.client.sendEvent(&bgammon.EventFailedMove{ + Reason: "Specify one or more moves in the form FROM/TO. For example: 8/4 6/4", + }) + } + + if len(params) == 0 { + sendUsage() + continue + } + + var moves [][]int8 + for i := range params { + split := bytes.Split(params[i], []byte("/")) + if len(split) != 2 { + sendUsage() + continue COMMANDS + } + from := bgammon.ParseSpace(string(split[0])) + if from == -1 { + sendUsage() + continue COMMANDS + } + to := bgammon.ParseSpace(string(split[1])) + if to == -1 { + sendUsage() + continue COMMANDS + } + + if !bgammon.ValidSpace(from) || !bgammon.ValidSpace(to) { + cmd.client.sendEvent(&bgammon.EventFailedMove{ + From: from, + To: to, + Reason: "Illegal move.", + }) + continue COMMANDS + } + + from, to = bgammon.FlipSpace(from, cmd.client.playerNumber), bgammon.FlipSpace(to, cmd.client.playerNumber) + moves = append(moves, []int8{from, to}) + } + + ok, expandedMoves := clientGame.AddMoves(moves, false) + if !ok { + cmd.client.sendEvent(&bgammon.EventFailedMove{ + From: 0, + To: 0, + Reason: "Illegal move.", + }) + continue + } + + var winEvent *bgammon.EventWin + if clientGame.Winner != 0 { + var opponent int8 = 1 + opponentHome := bgammon.SpaceHomePlayer + opponentEntered := clientGame.Player1.Entered + playerBar := bgammon.SpaceBarPlayer + if clientGame.Winner == 1 { + opponent = 2 + opponentHome = bgammon.SpaceHomeOpponent + opponentEntered = clientGame.Player2.Entered + playerBar = bgammon.SpaceBarOpponent + } + + backgammon := bgammon.PlayerCheckers(clientGame.Board[playerBar], opponent) != 0 + if !backgammon { + homeStart, homeEnd := bgammon.HomeRange(clientGame.Winner) + bgammon.IterateSpaces(homeStart, homeEnd, clientGame.Acey, func(space int8, spaceCount int8) { + if bgammon.PlayerCheckers(clientGame.Board[space], opponent) != 0 { + backgammon = true + } + }) + } + + var winPoints int8 + if !clientGame.Acey { + if backgammon { + winPoints = 3 // Award backgammon. + } else if clientGame.Board[opponentHome] == 0 { + winPoints = 2 // Award gammon. + } else { + winPoints = 1 + } + } else { + for space := int8(0); space < bgammon.BoardSpaces; space++ { + if (space == bgammon.SpaceHomePlayer || space == bgammon.SpaceHomeOpponent) && opponentEntered { + continue + } + winPoints += bgammon.PlayerCheckers(clientGame.Board[space], opponent) + } + } + + acey := 0 + if clientGame.Acey { + acey = 1 + } + clientGame.replay = append([][]byte{[]byte(fmt.Sprintf("i %d %s %s %d %d %d %d %d %d", clientGame.Started.Unix(), clientGame.Player1.Name, clientGame.Player2.Name, clientGame.Points, clientGame.Player1.Points, clientGame.Player2.Points, clientGame.Winner, winPoints, acey))}, clientGame.replay...) + + r1, r2 := clientGame.Roll1, clientGame.Roll2 + if r2 > r1 { + r1, r2 = r2, r1 + } + var movesFormatted []byte + if len(clientGame.Moves) != 0 { + movesFormatted = append([]byte(" "), bgammon.FormatMoves(clientGame.Moves)...) + } + clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d r %d-%d%s", clientGame.Turn, r1, r2, movesFormatted))) + + winEvent = &bgammon.EventWin{ + Points: winPoints * clientGame.DoubleValue, + } + var reset bool + if clientGame.Winner == 1 { + winEvent.Player = clientGame.Player1.Name + clientGame.Player1.Points = clientGame.Player1.Points + winPoints*clientGame.DoubleValue + if clientGame.Player1.Points < clientGame.Points { + reset = true + } else { + clientGame.Ended = time.Now() + } + } else { + winEvent.Player = clientGame.Player2.Name + clientGame.Player2.Points = clientGame.Player2.Points + winPoints*clientGame.DoubleValue + if clientGame.Player2.Points < clientGame.Points { + reset = true + } else { + clientGame.Ended = time.Now() + } + } + + winType := winPoints + if clientGame.Acey { + winType = 1 + } + err := recordGameResult(clientGame.Game, winType, clientGame.client1.account, clientGame.client2.account, clientGame.replay) + if err != nil { + log.Fatalf("failed to record game result: %s", err) + } + + if !reset { + err := recordMatchResult(clientGame.Game, matchTypeCasual, clientGame.client1.account, clientGame.client2.account) + if err != nil { + log.Fatalf("failed to record match result: %s", err) + } + } else { + clientGame.Reset() + clientGame.replay = clientGame.replay[:0] + } + } + + clientGame.eachClient(func(client *serverClient) { + ev := &bgammon.EventMoved{ + Moves: bgammon.FlipMoves(expandedMoves, client.playerNumber), + } + ev.Player = string(cmd.client.name) + client.sendEvent(ev) + + clientGame.sendBoard(client) + + if winEvent != nil { + client.sendEvent(winEvent) + } + }) + case bgammon.CommandReset: + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } else if clientGame.Winner != 0 { + continue + } + + if clientGame.Turn != cmd.client.playerNumber { + cmd.client.sendNotice("It is not your turn.") + continue + } + + if len(clientGame.Moves) == 0 { + continue + } + + l := len(clientGame.Moves) + undoMoves := make([][]int8, l) + for i, move := range clientGame.Moves { + undoMoves[l-1-i] = []int8{move[1], move[0]} + } + ok, _ := clientGame.AddMoves(undoMoves, false) + if !ok { + cmd.client.sendNotice("Failed to undo move: invalid move.") + } else { + clientGame.eachClient(func(client *serverClient) { + ev := &bgammon.EventMoved{ + Moves: bgammon.FlipMoves(undoMoves, client.playerNumber), + } + ev.Player = string(cmd.client.name) + + client.sendEvent(ev) + clientGame.sendBoard(client) + }) + } + case bgammon.CommandOk, "k": + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } else if clientGame.Winner != 0 { + continue + } + + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendNotice("You must wait until your opponent rejoins the match before continuing the game.") + continue + } + + if clientGame.DoubleOffered { + if clientGame.Turn != cmd.client.playerNumber { + opponent := clientGame.opponent(cmd.client) + if opponent == nil { + cmd.client.sendNotice("You may not accept the double until your opponent rejoins the match.") + continue + } + + clientGame.DoubleOffered = false + clientGame.DoubleValue = clientGame.DoubleValue * 2 + clientGame.DoublePlayer = cmd.client.playerNumber + + cmd.client.sendNotice("Accepted double.") + opponent.sendNotice(fmt.Sprintf("%s accepted double.", cmd.client.name)) + + clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d d %d 1", clientGame.Turn, clientGame.DoubleValue))) + clientGame.eachClient(func(client *serverClient) { + clientGame.sendBoard(client) + }) + } else { + cmd.client.sendNotice("Waiting for response from opponent.") + } + continue + } else if clientGame.Turn != cmd.client.playerNumber { + cmd.client.sendNotice("It is not your turn.") + continue + } + + if clientGame.Roll1 == 0 || clientGame.Roll2 == 0 { + cmd.client.sendNotice("You must roll first.") + continue + } + + legalMoves := clientGame.LegalMoves(false) + if len(legalMoves) != 0 { + available := bgammon.FlipMoves(legalMoves, cmd.client.playerNumber) + bgammon.SortMoves(available) + cmd.client.sendEvent(&bgammon.EventFailedOk{ + Reason: fmt.Sprintf("The following legal moves are available: %s", bgammon.FormatMoves(available)), + }) + continue + } + + recordEvent := func() { + r1, r2 := clientGame.Roll1, clientGame.Roll2 + if r2 > r1 { + r1, r2 = r2, r1 + } + var movesFormatted []byte + if len(clientGame.Moves) != 0 { + movesFormatted = append([]byte(" "), bgammon.FormatMoves(clientGame.Moves)...) + } + clientGame.replay = append(clientGame.replay, []byte(fmt.Sprintf("%d r %d-%d%s", clientGame.Turn, r1, r2, movesFormatted))) + } + + if clientGame.Acey && ((clientGame.Roll1 == 1 && clientGame.Roll2 == 2) || (clientGame.Roll1 == 2 && clientGame.Roll2 == 1)) && len(clientGame.Moves) == 2 { + var doubles int + if len(params) > 0 { + doubles, _ = strconv.Atoi(string(params[0])) + } + if doubles < 1 || doubles > 6 { + cmd.client.sendEvent(&bgammon.EventFailedOk{ + Reason: "Choose which doubles you want for your acey-deucey.", + }) + continue + } + + recordEvent() + clientGame.NextTurn(true) + clientGame.Roll1, clientGame.Roll2 = int8(doubles), int8(doubles) + clientGame.Reroll = true + + clientGame.eachClient(func(client *serverClient) { + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + Roll2: clientGame.Roll2, + Selected: true, + } + ev.Player = string(cmd.client.name) + client.sendEvent(ev) + }) + } else if clientGame.Acey && clientGame.Reroll { + recordEvent() + clientGame.NextTurn(true) + clientGame.Roll1, clientGame.Roll2 = 0, 0 + if !clientGame.roll(cmd.client.playerNumber) { + cmd.client.Terminate("Server error") + opponent.Terminate("Server error") + continue + } + clientGame.Reroll = false + + clientGame.eachClient(func(client *serverClient) { + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + Roll2: clientGame.Roll2, + } + ev.Player = string(cmd.client.name) + client.sendEvent(ev) + clientGame.sendBoard(client) + }) + } else { + recordEvent() + clientGame.NextTurn(false) + if clientGame.Winner == 0 { + gameState := &bgammon.GameState{ + Game: clientGame.Game, + PlayerNumber: clientGame.Turn, + Available: clientGame.LegalMoves(false), + } + if !gameState.MayDouble() { + if !clientGame.roll(clientGame.Turn) { + cmd.client.Terminate("Server error") + opponent.Terminate("Server error") + continue + } + clientGame.eachClient(func(client *serverClient) { + ev := &bgammon.EventRolled{ + Roll1: clientGame.Roll1, + Roll2: clientGame.Roll2, + } + if clientGame.Turn == 1 { + ev.Player = gameState.Player1.Name + } else { + ev.Player = gameState.Player2.Name + } + client.sendEvent(ev) + }) + } + } + } + + clientGame.eachClient(func(client *serverClient) { + clientGame.sendBoard(client) + }) + case bgammon.CommandRematch, "rm": + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } else if clientGame.Winner == 0 { + cmd.client.sendNotice("The match you are in is still in progress.") + continue + } else if clientGame.rematch == cmd.client.playerNumber { + cmd.client.sendNotice("You have already requested a rematch.") + continue + } else if clientGame.client1 == nil || clientGame.client2 == nil { + cmd.client.sendNotice("Your opponent left the match.") + continue + } else if clientGame.rematch != 0 && clientGame.rematch != cmd.client.playerNumber { + s.gamesLock.Lock() + + newGame := newServerGame(<-s.newGameIDs, clientGame.Acey) + newGame.name = clientGame.name + newGame.Points = clientGame.Points + newGame.password = clientGame.password + newGame.client1 = clientGame.client1 + newGame.client2 = clientGame.client2 + newGame.spectators = make([]*serverClient, len(clientGame.spectators)) + copy(newGame.spectators, clientGame.spectators) + newGame.Player1.Name = clientGame.Player1.Name + newGame.Player2.Name = clientGame.Player2.Name + newGame.Player1.Points = clientGame.Player1.Points + newGame.Player2.Points = clientGame.Player2.Points + newGame.allowed1 = clientGame.allowed1 + newGame.allowed2 = clientGame.allowed2 + s.games = append(s.games, newGame) + + clientGame.client1 = nil + clientGame.client2 = nil + clientGame.spectators = nil + + s.gamesLock.Unlock() + + { + ev1 := &bgammon.EventJoined{ + GameID: newGame.id, + PlayerNumber: 1, + } + ev1.Player = newGame.Player1.Name + ev2 := &bgammon.EventJoined{ + GameID: newGame.id, + PlayerNumber: 2, + } + ev2.Player = newGame.Player2.Name + newGame.client1.sendEvent(ev1) + newGame.client1.sendEvent(ev2) + newGame.sendBoard(newGame.client1) + } + + { + ev1 := &bgammon.EventJoined{ + GameID: newGame.id, + PlayerNumber: 1, + } + ev1.Player = newGame.Player2.Name + ev2 := &bgammon.EventJoined{ + GameID: newGame.id, + PlayerNumber: 2, + } + ev2.Player = newGame.Player1.Name + newGame.client2.sendEvent(ev1) + newGame.client2.sendEvent(ev2) + newGame.sendBoard(newGame.client2) + } + + for _, spectator := range newGame.spectators { + newGame.sendBoard(spectator) + } + } else { + clientGame.rematch = cmd.client.playerNumber + + clientGame.opponent(cmd.client).sendNotice("Your opponent would like to play again. Type /rematch to accept.") + cmd.client.sendNotice("Rematch offer sent.") + continue + } + case bgammon.CommandBoard, "b": + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } + + clientGame.sendBoard(cmd.client) + case bgammon.CommandPassword: + if cmd.client.account == 0 { + cmd.client.sendNotice("Failed to change password: you are logged in as a guest.") + continue + } else if len(params) < 2 { + cmd.client.sendNotice("Please specify your old and new passwords as follows: password ") + continue + } + + a, err := loginAccount(s.passwordSalt, cmd.client.name, params[0]) + if err != nil || a == nil || a.id == 0 { + cmd.client.sendNotice("Failed to change password: incorrect existing password.") + continue + } + + err = setAccountPassword(s.passwordSalt, a.id, string(bytes.Join(params[1:], []byte("_")))) + if err != nil { + cmd.client.sendNotice("Failed to change password.") + continue + } + cmd.client.sendNotice("Password changed successfully.") + case bgammon.CommandSet: + if cmd.client.account == 0 { + continue + } else if len(params) < 2 { + cmd.client.sendNotice("Please specify the setting name and value as follows: set ") + continue + } + + name := string(bytes.ToLower(params[0])) + settings := []string{"highlight", "pips", "moves", "flip"} + var found bool + for i := range settings { + if name == settings[i] { + found = true + break + } + } + if !found { + cmd.client.sendNotice("Please specify the setting name and value as follows: set ") + continue + } + + value, err := strconv.Atoi(string(params[1])) + if err != nil || value < 0 { + cmd.client.sendNotice("Invalid setting value provided.") + continue + } + _ = setAccountSetting(cmd.client.account, name, value) + case bgammon.CommandReplay: + var ( + id int + replay []byte + err error + ) + if len(params) == 0 { + if clientGame == nil || clientGame.Winner == 0 { + cmd.client.sendNotice("Please specify the game as follows: replay ") + continue + } + id = -1 + replay = bytes.Join(clientGame.replay, []byte("\n")) + } else { + id, err = strconv.Atoi(string(params[0])) + if err != nil || id < 0 { + cmd.client.sendNotice("Invalid replay ID provided.") + continue + } + replay, err = replayByID(id) + if err != nil { + cmd.client.sendNotice("Invalid replay ID provided.") + continue + } + } + if len(replay) == 0 { + cmd.client.sendNotice("No replay was recorded for that game.") + continue + } + cmd.client.sendEvent(&bgammon.EventReplay{ + ID: id, + Content: replay, + }) + case bgammon.CommandHistory: + if len(params) == 0 { + cmd.client.sendNotice("Please specify the player as follows: history ") + continue + } + + matches, err := matchHistory(string(params[0])) + if err != nil { + cmd.client.sendNotice("Invalid replay ID provided.") + continue + } + ev := &bgammon.EventHistory{ + Matches: matches, + } + ev.Player = string(params[0]) + cmd.client.sendEvent(ev) + case bgammon.CommandDisconnect: + if clientGame != nil { + clientGame.removeClient(cmd.client) + } + cmd.client.Terminate("Client disconnected") + case bgammon.CommandPong: + // Do nothing. + case "endgame": + if !allowDebugCommands { + cmd.client.sendNotice("You are not allowed to use that command.") + continue + } + + if clientGame == nil { + cmd.client.sendNotice("You are not currently in a match.") + continue + } + + clientGame.Turn = 1 + clientGame.Roll1 = 6 + clientGame.Roll2 = 6 + clientGame.Board = []int8{1, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -2, 0, 0, 0, 0} + + clientGame.eachClient(func(client *serverClient) { + clientGame.sendBoard(client) + }) + default: + log.Printf("Received unknown command from client %s: %s", cmd.client.label(), cmd.command) + cmd.client.sendNotice(fmt.Sprintf("Unknown command: %s", cmd.command)) + } + } +} diff --git a/pkg/server/server_web.go b/pkg/server/server_web.go new file mode 100644 index 0000000..4d9cf7f --- /dev/null +++ b/pkg/server/server_web.go @@ -0,0 +1,300 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "time" + + "code.rocket9labs.com/tslocum/bgammon" + "github.com/gorilla/mux" +) + +func (s *server) listenWebSocket(address string) { + log.Printf("Listening for WebSocket connections on %s...", address) + + m := mux.NewRouter() + m.HandleFunc("/reset/{id:[0-9]+}/{key:[A-Za-z0-9]+}", s.handleResetPassword) + m.HandleFunc("/match/{id:[0-9]+}", s.handleMatch) + m.HandleFunc("/matches", s.handleListMatches) + m.HandleFunc("/leaderboard-casual-backgammon-single", s.handleLeaderboardCasualBackgammonSingle) + m.HandleFunc("/leaderboard-casual-backgammon-multi", s.handleLeaderboardCasualBackgammonMulti) + m.HandleFunc("/leaderboard-casual-acey-single", s.handleLeaderboardCasualAceySingle) + m.HandleFunc("/leaderboard-casual-acey-multi", s.handleLeaderboardCasualAceyMulti) + m.HandleFunc("/leaderboard-rated-backgammon-single", s.handleLeaderboardRatedBackgammonSingle) + m.HandleFunc("/leaderboard-rated-backgammon-multi", s.handleLeaderboardRatedBackgammonMulti) + m.HandleFunc("/leaderboard-rated-acey-single", s.handleLeaderboardRatedAceySingle) + m.HandleFunc("/leaderboard-rated-acey-multi", s.handleLeaderboardRatedAceyMulti) + m.HandleFunc("/stats", s.handlePrintDailyStats) + m.HandleFunc("/stats-total", s.handlePrintCumulativeStats) + m.HandleFunc("/stats-tabula", s.handlePrintTabulaStats) + m.HandleFunc("/stats-wildbg", s.handlePrintWildBGStats) + m.HandleFunc("/", s.handleWebSocket) + + err := http.ListenAndServe(address, m) + log.Fatalf("failed to listen on %s: %s", address, err) +} + +func (s *server) cachedMatches() []byte { + s.gamesCacheLock.Lock() + defer s.gamesCacheLock.Unlock() + + if time.Since(s.gamesCacheTime) < 5*time.Second { + return s.gamesCache + } + + s.gamesLock.Lock() + defer s.gamesLock.Unlock() + + var games []*bgammon.GameListing + for _, g := range s.games { + listing := g.listing(nil) + if listing == nil || listing.Password || listing.Players == 2 { + continue + } + games = append(games, listing) + } + + s.gamesCacheTime = time.Now() + if len(games) == 0 { + s.gamesCache = []byte("[]") + return s.gamesCache + } + var err error + s.gamesCache, err = json.Marshal(games) + if err != nil { + log.Fatalf("failed to marshal %+v: %s", games, err) + } + return s.gamesCache +} + +func (s *server) cachedLeaderboard(matchType int, acey bool, multiPoint bool) []byte { + s.leaderboardCacheLock.Lock() + defer s.leaderboardCacheLock.Unlock() + + var i int + switch matchType { + case matchTypeCasual: + if multiPoint { + i = 1 + } + case matchTypeRated: + if !multiPoint { + i = 2 + } else { + i = 3 + } + } + if acey { + i += 4 + } + + if time.Since(s.leaderboardCacheTime) < 5*time.Minute { + return s.leaderboardCache[i] + } + s.leaderboardCacheTime = time.Now() + + for j := 0; j < 2; j++ { + i := 0 + var acey bool + if j == 1 { + i += 4 + acey = true + } + result, err := getLeaderboard(matchTypeCasual, acey, false) + if err != nil { + log.Fatalf("failed to get leaderboard: %s", err) + } + s.leaderboardCache[i], err = json.Marshal(result) + if err != nil { + log.Fatalf("failed to marshal %+v: %s", result, err) + } + + result, err = getLeaderboard(matchTypeCasual, acey, true) + if err != nil { + log.Fatalf("failed to get leaderboard: %s", err) + } + s.leaderboardCache[i+1], err = json.Marshal(result) + if err != nil { + log.Fatalf("failed to marshal %+v: %s", result, err) + } + + result, err = getLeaderboard(matchTypeRated, acey, false) + if err != nil { + log.Fatalf("failed to get leaderboard: %s", err) + } + s.leaderboardCache[i+2], err = json.Marshal(result) + if err != nil { + log.Fatalf("failed to marshal %+v: %s", result, err) + } + + result, err = getLeaderboard(matchTypeRated, acey, true) + if err != nil { + log.Fatalf("failed to get leaderboard: %s", err) + } + s.leaderboardCache[i+3], err = json.Marshal(result) + if err != nil { + log.Fatalf("failed to marshal %+v: %s", result, err) + } + } + + return s.leaderboardCache[i] +} + +func (s *server) cachedStats(statsType int) []byte { + s.statsCacheLock.Lock() + defer s.statsCacheLock.Unlock() + + if time.Since(s.statsCacheTime) < 5*time.Minute { + return s.statsCache[statsType] + } + s.statsCacheTime = time.Now() + + { + stats, err := dailyStats(s.tz) + if err != nil { + log.Fatalf("failed to fetch server statistics: %s", err) + } + s.statsCache[0], err = json.Marshal(stats) + if err != nil { + log.Fatalf("failed to marshal %+v: %s", stats, err) + } + + stats, err = cumulativeStats(s.tz) + if err != nil { + log.Fatalf("failed to fetch server statistics: %s", err) + } + s.statsCache[1], err = json.Marshal(stats) + if err != nil { + log.Fatalf("failed to fetch serialize server statistics: %s", err) + } + } + + { + stats, err := botStats("BOT_tabula", s.tz) + if err != nil { + log.Fatalf("failed to fetch tabula statistics: %s", err) + } + s.statsCache[2], err = json.Marshal(stats) + if err != nil { + log.Fatalf("failed to fetch serialize tabula statistics: %s", err) + } + + stats, err = botStats("BOT_wildbg", s.tz) + if err != nil { + log.Fatalf("failed to fetch wildbg statistics: %s", err) + } + s.statsCache[3], err = json.Marshal(stats) + if err != nil { + log.Fatalf("failed to fetch serialize wildbg statistics: %s", err) + } + } + + return s.statsCache[statsType] +} + +func (s *server) handleResetPassword(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil || id <= 0 { + return + } + key := vars["key"] + + newPassword, err := confirmResetAccount(s.resetSalt, s.passwordSalt, id, key) + if err != nil { + log.Printf("failed to reset password: %s", err) + } + + w.Header().Set("Content-Type", "text/html") + if err != nil || newPassword == "" { + w.Write([]byte(`

Invalid or expired password reset link.

`)) + return + } + w.Write([]byte(`

Your bgammon.org password has been reset.

Your new password is ` + newPassword + ``)) +} + +func (s *server) handleMatch(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + id, err := strconv.Atoi(vars["id"]) + if err != nil || id <= 0 { + return + } + + timestamp, player1, player2, replay, err := matchInfo(id) + if err != nil || len(replay) == 0 { + log.Printf("failed to retrieve match: %s", err) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%d_%s_%s.match"`, timestamp, player1, player2)) + w.Write(replay) +} + +func (s *server) handleListMatches(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedMatches()) +} + +func (s *server) handleLeaderboardCasualBackgammonSingle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeCasual, false, false)) +} + +func (s *server) handleLeaderboardCasualBackgammonMulti(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeCasual, false, true)) +} + +func (s *server) handleLeaderboardCasualAceySingle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeCasual, true, false)) +} + +func (s *server) handleLeaderboardCasualAceyMulti(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeCasual, true, true)) +} + +func (s *server) handleLeaderboardRatedBackgammonSingle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeRated, false, false)) +} + +func (s *server) handleLeaderboardRatedBackgammonMulti(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeRated, false, true)) +} + +func (s *server) handleLeaderboardRatedAceySingle(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeRated, true, false)) +} + +func (s *server) handleLeaderboardRatedAceyMulti(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedLeaderboard(matchTypeRated, true, true)) +} + +func (s *server) handlePrintDailyStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedStats(0)) +} + +func (s *server) handlePrintCumulativeStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedStats(1)) +} + +func (s *server) handlePrintTabulaStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedStats(2)) +} + +func (s *server) handlePrintWildBGStats(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write(s.cachedStats(3)) +}