diff --git a/PROTOCOL.md b/PROTOCOL.md index 591d30a..a65a470 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -123,6 +123,14 @@ must write some data to the server at least once every 40 seconds. - `disconnect` - Disconnect from the server. +- `broadcast` + - Send a message to all players. + - This command is only available to server administrators. + +- `shutdown ` + - Prevent the creation of new matches and periodically warn players about the server shutting down. + - This command is only available to server administrators. + ## Server events All events are sent in either JSON or human-readable format. The structure of diff --git a/command.go b/command.go index af77007..069ed56 100644 --- a/command.go +++ b/command.go @@ -29,6 +29,8 @@ const ( CommandBoard = "board" // Print current board state in human-readable form. CommandPong = "pong" // Response to server ping. CommandDisconnect = "disconnect" // Disconnect from server. + CommandBroadcast = "broadcast" // Send a message to all players. + CommandShutdown = "shutdown" // Prevent the creation of new matches. ) type EventType string diff --git a/pkg/server/client.go b/pkg/server/client.go index 8133a84..331a4c0 100644 --- a/pkg/server/client.go +++ b/pkg/server/client.go @@ -9,6 +9,7 @@ import ( "time" "code.rocket9labs.com/tslocum/bgammon" + "github.com/leonelquinteros/gotext" ) type clientRating struct { @@ -84,6 +85,10 @@ type serverClient struct { bgammon.Client } +func (c *serverClient) Admin() bool { + return c.accountID == 1 +} + func (c *serverClient) sendEvent(e interface{}) { // JSON formatted messages. if c.json { @@ -205,6 +210,12 @@ func (c *serverClient) sendNotice(message string) { }) } +func (c *serverClient) sendBroadcast(message string) { + c.sendEvent(&bgammon.EventNotice{ + Message: gotext.GetD(c.language, "SERVER BROADCAST:") + " " + message, + }) +} + func (c *serverClient) label() string { if len(c.name) > 0 { return string(c.name) diff --git a/pkg/server/locales/bgammon.pot b/pkg/server/locales/bgammon.pot index 12aa8b0..fe1b2bd 100644 --- a/pkg/server/locales/bgammon.pot +++ b/pkg/server/locales/bgammon.pot @@ -55,6 +55,9 @@ msgstr "" msgid "Failed to create match: Please leave the match you are in before creating another." msgstr "" +msgid "Failed to create match: The server is shutting down. Reason: %s" +msgstr "" + msgid "Failed to log in: %s" msgstr "" @@ -109,6 +112,9 @@ msgstr "" msgid "Please enter an email, username and password." msgstr "" +msgid "Please finish your match as soon as possible." +msgstr "" + msgid "Please leave the match you are in before joining another." msgstr "" @@ -121,6 +127,9 @@ msgstr "" msgid "Resigned." msgstr "" +msgid "SERVER BROADCAST:" +msgstr "" + msgid "Server error" msgstr "" @@ -133,6 +142,15 @@ msgstr "" msgid "The match you are in is still in progress." msgstr "" +msgid "The server is shutting down. Reason:" +msgstr "" + +msgid "The server is shutting down in 1 minute. Reason:" +msgstr "" + +msgid "The server is shutting down in %d minutes. Reason:" +msgstr "" + msgid "Unknown command: %s" msgstr "" diff --git a/pkg/server/server.go b/pkg/server/server.go index cf3ffaf..6db2c4e 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -86,6 +86,9 @@ type server struct { relayChat bool // Chats are not relayed normally. This option is only used by local servers. verbose bool + + shutdownTime time.Time + shutdownReason string } func NewServer(tz string, dataSource string, mailServer string, passwordSalt string, resetSalt string, relayChat bool, verbose bool, allowDebug bool) *server { @@ -539,6 +542,44 @@ func (s *server) Analyze(g *bgammon.Game) { os.Exit(0) } +func (s *server) handleShutdown() { + var mins time.Duration + var minutes int + t := time.NewTicker(time.Minute) + for { + mins = time.Until(s.shutdownTime) + if mins > 0 { + minutes = int(mins.Minutes()) + 1 + } + + s.clientsLock.Lock() + for _, sc := range s.clients { + switch minutes { + case 0: + sc.sendBroadcast(gotext.GetD(sc.language, "The server is shutting down. Reason:")) + case 1: + sc.sendBroadcast(gotext.GetD(sc.language, "The server is shutting down in 1 minute. Reason:")) + default: + sc.sendBroadcast(gotext.GetD(sc.language, "The server is shutting down in %d minutes. Reason:", minutes)) + } + sc.sendBroadcast(s.shutdownReason) + sc.sendBroadcast(gotext.GetD(sc.language, "Please finish your match as soon as possible.")) + } + s.clientsLock.Unlock() + + <-t.C + } +} + +func (s *server) shutdown(delay time.Duration, reason string) { + if !s.shutdownTime.IsZero() { + return + } + s.shutdownTime = time.Now().Add(delay) + s.shutdownReason = reason + go s.handleShutdown() +} + 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 index 81bd786..5219c00 100644 --- a/pkg/server/server_command.go +++ b/pkg/server/server_command.go @@ -245,7 +245,7 @@ COMMANDS: 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.CommandSet, bgammon.CommandDisconnect, bgammon.CommandPong: + case bgammon.CommandHelp, "h", bgammon.CommandJSON, bgammon.CommandList, "ls", bgammon.CommandBoard, "b", bgammon.CommandLeave, "l", bgammon.CommandReplay, bgammon.CommandSet, bgammon.CommandPong, bgammon.CommandDisconnect, bgammon.CommandBroadcast, bgammon.CommandShutdown: // These commands are allowed to be used by spectators. default: cmd.client.sendNotice(gotext.GetD(cmd.client.language, "Command ignored: You are spectating this match.")) @@ -320,6 +320,9 @@ COMMANDS: if clientGame != nil { cmd.client.sendNotice(gotext.GetD(cmd.client.language, "Failed to create match: Please leave the match you are in before creating another.")) continue + } else if !s.shutdownTime.IsZero() { + cmd.client.sendNotice(gotext.GetD(cmd.client.language, "Failed to create match: The server is shutting down. Reason: %s", s.shutdownReason)) + continue } sendUsage := func() { @@ -1232,20 +1235,52 @@ COMMANDS: ev.CasualTabulaMulti = a.casual.tabulaMulti / 100 } cmd.client.sendEvent(ev) + case bgammon.CommandPong: + // Do nothing. case bgammon.CommandDisconnect: if clientGame != nil { clientGame.removeClient(cmd.client) } cmd.client.Terminate("Client disconnected") - case bgammon.CommandPong: - // Do nothing. + case bgammon.CommandBroadcast: + if !cmd.client.Admin() { + cmd.client.sendNotice("Access denied.") + continue + } else if len(params) == 0 { + cmd.client.sendNotice("Please specify a message to broadcast.") + continue + } + + message := string(bytes.Join(params, []byte(" "))) + s.clientsLock.Lock() + for _, sc := range s.clients { + sc.sendBroadcast(message) + } + s.clientsLock.Unlock() + case bgammon.CommandShutdown: + if !cmd.client.Admin() { + cmd.client.sendNotice("Access denied.") + continue + } else if len(params) < 2 { + cmd.client.sendNotice("Please specify the number of minutes until shutdown and the reason.") + continue + } else if !s.shutdownTime.IsZero() { + cmd.client.sendNotice("Server shutdown already in progress.") + continue + } + + minutes, err := strconv.Atoi(string(params[0])) + if err != nil || minutes <= 0 { + cmd.client.sendNotice("Error: Invalid shutdown delay.") + continue + } + + s.shutdown(time.Duration(minutes)*time.Minute, string(bytes.Join(params[1:], []byte(" ")))) case "endgame": if !allowDebugCommands { cmd.client.sendNotice(gotext.GetD(cmd.client.language, "You are not allowed to use that command.")) continue - } - - if clientGame == nil { + } else if clientGame == nil { cmd.client.sendNotice(gotext.GetD(cmd.client.language, "You are not currently in a match.")) continue }