From c28fb6fca5b5c7a2b496e12b8f46859658321c42 Mon Sep 17 00:00:00 2001 From: Marcel Schramm Date: Sun, 16 Mar 2025 18:30:03 +0100 Subject: [PATCH] Initial resurrection implementation --- internal/api/http.go | 1 + internal/api/v1.go | 26 +++- internal/api/ws.go | 7 +- internal/frontend/index.js | 40 +++++++ internal/frontend/lobby.js | 69 ++++++++--- internal/game/data.go | 98 +++++++++------ internal/game/data_test.go | 6 +- internal/game/lobby.go | 229 +++++++++++++++++++++--------------- internal/game/lobby_test.go | 50 ++++---- internal/game/shared.go | 26 ++-- internal/game/words.go | 10 +- internal/game/words_test.go | 63 ++++++---- internal/state/lobbies.go | 24 ++++ 13 files changed, 433 insertions(+), 216 deletions(-) diff --git a/internal/api/http.go b/internal/api/http.go index b8b0408e..cf7a87a9 100644 --- a/internal/api/http.go +++ b/internal/api/http.go @@ -31,6 +31,7 @@ func (handler *V1Handler) SetupRoutes(rootPath string, register func(string, str // We support both path parameter and cookie. register("GET", path.Join(v1, "lobby", "ws"), handler.websocketUpgrade) + register("POST", path.Join(v1, "lobby", "resurrect"), handler.resurrectLobby) register("POST", path.Join(v1, "lobby", "{lobby_id}", "player"), handler.postPlayer) } diff --git a/internal/api/v1.go b/internal/api/v1.go index 3dd4d47d..9aa97897 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -3,6 +3,7 @@ package api import ( + "encoding/base64" json "encoding/json" "errors" "fmt" @@ -63,7 +64,6 @@ type LobbyEntry struct { func (handler *V1Handler) getLobbies(writer http.ResponseWriter, _ *http.Request) { // REMARK: If paging is ever implemented, we might want to maintain order // when deleting lobbies from state in the state package. - lobbies := state.GetPublicLobbies() lobbyEntries := make(LobbyEntries, 0, len(lobbies)) for _, lobby := range lobbies { @@ -80,7 +80,7 @@ func (handler *V1Handler) getLobbies(writer http.ResponseWriter, _ *http.Request MaxClientsPerIP: lobby.ClientsPerIPLimit, Wordpack: lobby.Wordpack, State: lobby.State, - Scoring: lobby.ScoreCalculation.Identifier(), + Scoring: lobby.ScoreCalculationIdentifier, }) } @@ -92,6 +92,28 @@ func (handler *V1Handler) getLobbies(writer http.ResponseWriter, _ *http.Request } } +func (handler *V1Handler) resurrectLobby(writer http.ResponseWriter, request *http.Request) { + var data game.LobbyRestoreData + base64Decoder := base64.NewDecoder(base64.StdEncoding, request.Body) + if err := json.NewDecoder(base64Decoder).Decode(&data); err != nil { + log.Println("Error unmarshalling lobby resurrection data:", err) + http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + lobby := data.Lobby + // We add the lobby, while the lobby mutex is aqcuired. This prevents us + // from attempting to connect to the lobby, before the internal state has + // been restored correctly. + lobby.Synchronized(func() { + if state.ResurrectLobby(lobby) { + lobby.WriteObject = WriteObject + lobby.WritePreparedMessage = WritePreparedMessage + lobby.ResurrectUnsynchronized(&data) + } + }) +} + func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Request) { if err := request.ParseForm(); err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) diff --git a/internal/api/ws.go b/internal/api/ws.go index d67007d1..f524378d 100644 --- a/internal/api/ws.go +++ b/internal/api/ws.go @@ -51,7 +51,12 @@ func (handler *V1Handler) websocketUpgrade(writer http.ResponseWriter, request * lobby := state.GetLobby(lobbyId) if lobby == nil { - http.Error(writer, ErrLobbyNotExistent.Error(), http.StatusNotFound) + socket, err := upgrader.Upgrade(writer, request) + if err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + return + } + socket.WriteClose(1000, []byte("lobby_gone")) return } diff --git a/internal/frontend/index.js b/internal/frontend/index.js index 20248648..53a1676f 100644 --- a/internal/frontend/index.js +++ b/internal/frontend/index.js @@ -1,6 +1,46 @@ const discordInstanceId = getCookie("discord-instance-id") const rootPath = `${discordInstanceId ? ".proxy/" : ""}{{.RootPath}}` +function createLabel(cssClass, forElement, text) { + const label = document.createElement("label"); + label.setAttribute("for", forElement); + label.classList.add(cssClass); + label.innerText = text; + return label; +} + +function createNumberInput(id, min, max, value) { + const input = document.createElement("input"); + input.setAttribute("type", "number"); + // Figure out why I did this exactly. + input.setAttribute("size", "4"); + input.setAttribute("id", id); + input.setAttribute("name", id); + input.setAttribute("value", Number.toString(value)); + input.setAttribute("min", Number.toString(min)); + input.setAttribute("max", Number.toString(max)); + + const decButton = document.createElement("button"); + decButton.setAttribute("type", "button"); + decButton.classList.add("number-decrement"); + decButton.addEventListener("click", function() { + input.stepDown(); + }) + decButton.innerText = "-"; + + const incButton = document.createElement("button"); + incButton.setAttribute("type", "button"); + incButton.classList.add("number-increment"); + incButton.addEventListener("click", function() { + input.stepUp(); + }) + incButton.innerText = "+"; + + const div = document.createElement("div") + div.append(decButton, input, incButton); + return div; +} + Array .from(document.getElementsByClassName("number-input")) .forEach(number_input => { diff --git a/internal/frontend/lobby.js b/internal/frontend/lobby.js index 2a240f2e..596f0954 100644 --- a/internal/frontend/lobby.js +++ b/internal/frontend/lobby.js @@ -10,41 +10,73 @@ let hasSocketEverConnected = false; let socket; function connectToWebsocket() { if (socketIsConnecting === true) { + console.log("aborting connection attempt."); return; } socketIsConnecting = true; - socket = new WebSocket(`${rootPath}/v1/lobby/ws`); + try { + socket = new WebSocket(`${rootPath}/v1/lobby/ws`); + } catch (exception) { + console.log("Connection error:" + exception) + socketIsConnecting = false; + connectToWebsocket(); + return; + } socket.onerror = error => { //Is not connected and we haven't yet said that we are done trying to //connect, this means that we could never even establish a connection. - if (socket.readyState != 1 && !hasSocketEverConnected) { + if (socket.readyState != 1) { socketIsConnecting = false; - showTextDialog("connection-error-dialog", - '{{.Translation.Get "error-connecting"}}', - `{{.Translation.Get "error-connecting-text"}}`); - console.log("Error establishing connection: ", error); + if (!hasSocketEverConnected) { + showTextDialog("connection-error-dialog", + '{{.Translation.Get "error-connecting"}}', + `{{.Translation.Get "error-connecting-text"}}`); + console.log("Error establishing connection: ", error); + } else { + connectToWebsocket(); + } } else { console.log("Socket error: ", error) } }; socket.onopen = () => { + closeDialog(shutdownDialogId); closeDialog(reconnectDialogId); hasSocketEverConnected = true; socketIsConnecting = false; socket.onclose = event => { - //We want to avoid handling the error multiple times and showing the incorrect dialogs. + //We w to avoid handling the error multiple times and showing the incorrect dialogs. socket.onerror = null; console.log("Socket Closed Connection: ", event); - console.log("Attempting to reestablish socket connection."); - showReconnectDialogIfNotShown(); - connectToWebsocket(); + + if (restoreData && event.reason === "lobby_gone") { + console.log("Resurrecting lobby ...",); + fetch('/v1/lobby/resurrect', { + method: 'POST', + body: restoreData, + }).then(() => { + console.log("Attempting to reestablish socket connection after resurrection ..."); + socketIsConnecting = false; + connectToWebsocket(); + }); + + return + } + + if (event.reason !== "lobby_gone" && event.reason !== "server_restart") { + console.log("Attempting to reestablish socket connection."); + showReconnectDialogIfNotShown(); + } + if (event.reason === "server_restart") { + connectToWebsocket(); + } }; registerMessageHandler(socket); @@ -53,6 +85,7 @@ function connectToWebsocket() { }; } +const shutdownDialogId = "shutdown-dialog"; const reconnectDialogId = "reconnect-dialog"; function showReconnectDialogIfNotShown() { const previousReconnectDialog = document.getElementById(reconnectDialogId); @@ -833,6 +866,7 @@ let rounds = 0; let roundEndTime = 0; let gameState = "unstarted"; let drawingTimeSetting = "∞"; +let restoreData; function registerMessageHandler(targetSocket) { targetSocket.onmessage = event => { @@ -985,10 +1019,16 @@ function registerMessageHandler(targetSocket) { + '{{.Translation.Get "custom-words-per-turn-setting"}}: ' + parsed.data.customWordsPerTurn + "%\n" + '{{.Translation.Get "players-per-ip-limit-setting"}}: ' + parsed.data.clientsPerIpLimit); } else if (parsed.type === "shutdown") { - socket.onclose = null; - socket.close(); - showDialog("shutdown-info", "Server shutting down", - document.createTextNode("Sorry, but the server is about to shut down. Please come back at a later time.")); + console.log("Shutdown event received"); + if (parsed.data) { + restoreData = parsed.data; + // FIXMe Text anpassen! + showDialog("shutdown-dialog", "Server shutting down", + document.createTextNode("Sorry, but the server is about to shut down. Attempting to restore lobby on restart ...")); + } else { + showDialog("shutdown-dialog", "Server shutting down", + document.createTextNode("Sorry, but the server is about to shut down. Please come back at a later time.")); + } } } }; @@ -1033,6 +1073,7 @@ function setRoundTimeLeft(timeLeftMs) { } function handleReadyEvent(ready) { + restoreData = null; ownerID = ready.ownerId; ownID = ready.playerId; diff --git a/internal/game/data.go b/internal/game/data.go index 9c897444..9a84e9aa 100644 --- a/internal/game/data.go +++ b/internal/game/data.go @@ -21,7 +21,7 @@ type Lobby struct { // ID uniquely identified the Lobby. LobbyID string - EditableLobbySettings + LobbySettings // DrawingTimeNew is the new value of the drawing time. If a round is // already ongoing, we can't simply change the drawing time, as it would @@ -29,10 +29,13 @@ type Lobby struct { DrawingTimeNew int CustomWords []string - words []string + Words []string - // players references all participants of the Lobby. - players []*Player + // Players references all participants of the Lobby. Indices are synced + // with [Lobby.UserSessions]. + Players []*Player + // Holds all UserSessions. Indices are synced with [Lobby.Players]. + UserSessions []uuid.UUID // Whether the game has started, is ongoing or already over. State State @@ -41,19 +44,19 @@ type Lobby struct { OwnerID uuid.UUID // ScoreCalculation decides how scores for both guessers and drawers are // determined. - ScoreCalculation ScoreCalculation + ScoreCalculation ScoreCalculation `json:"-"` // CurrentWord represents the word that was last selected. If no word has // been selected yet or the round is already over, this should be empty. CurrentWord string - // wordHints for the current word. - wordHints []*WordHint - // wordHintsShown are the same as wordHints with characters visible. - wordHintsShown []*WordHint - // hintsLeft is the amount of hints still available for revelation. - hintsLeft int - // hintCount is the amount of hints that were initially available + // WordHints for the current word. + WordHints []*WordHint + // WordHintsShown are the same as wordHints with characters visible. + WordHintsShown []*WordHint + // HintsLeft is the amount of hints still available for revelation. + HintsLeft int + // HintCount is the amount of hints that were initially available // for revelation. - hintCount int + HintCount int // Round is the round that the Lobby is currently in. This is a number // between 0 and Rounds. 0 indicates that it hasn't started yet. Round int @@ -61,17 +64,16 @@ type Lobby struct { preSelectedWord int // wordChoice represents the current choice of words present to the drawer. wordChoice []string - Wordpack string - // roundEndTime represents the time at which the current round will end. + // RoundEndTime represents the time at which the current round will end. // This is a UTC unix-timestamp in milliseconds. - roundEndTime int64 + RoundEndTime int64 timeLeftTicker *time.Ticker - // currentDrawing represents the state of the current canvas. The elements + // CurrentDrawing represents the state of the current canvas. The elements // consist of LineEvent and FillEvent. Please do not modify the contents // of this array an only move AppendLine and AppendFill on the respective // lobby object. - currentDrawing []any + CurrentDrawing []any // These variables are used to define the ranges of connected drawing events. // For example a line that has been drawn or a fill that has been executed. @@ -81,18 +83,43 @@ type Lobby struct { // connected, but that could technically undo a whole drawing. lastDrawEvent time.Time - connectedDrawEventsIndexStack []int + ConnectedDrawEventsIndexStack []int lowercaser cases.Caser // LastPlayerDisconnectTime is used to know since when a lobby is empty, in case - // it is empty. + // it is empty. If the time is nil, it's treated the same as when the + // timelimit has been reached. LastPlayerDisconnectTime *time.Time mutex sync.Mutex - WriteObject func(*Player, any) error - WritePreparedMessage func(*Player, *gws.Broadcaster) error + WriteObject func(*Player, any) error `json:"-"` + WritePreparedMessage func(*Player, *gws.Broadcaster) error `json:"-"` +} + +type LobbyRestoreData struct { + ShutdownTime time.Time + Lobby *Lobby +} + +func (lobby *Lobby) ResurrectUnsynchronized(restoreData *LobbyRestoreData) { + lobby.lowercaser = WordlistData[lobby.Wordpack].Lowercaser() + + // Since we don't know how long the restart took, we extend all timers.\ + // We add an additional second for good measure. + now := time.Now() + timeDiff := now.Sub(restoreData.ShutdownTime).Milliseconds() + 1000 + + lobby.RoundEndTime = lobby.RoundEndTime + int64(timeDiff) + + if lobby.CurrentWord != "" { + lobby.timeLeftTicker = time.NewTicker(1 * time.Second) + go startTurnTimeTicker(lobby, lobby.timeLeftTicker) + } else if len(lobby.wordChoice) > 0 { + lobby.wordChoiceEndTime = lobby.wordChoiceEndTime + int64(timeDiff) + go lobby.startWordChoiceTimer(lobby.wordChoiceEndTime - now.UTC().UnixMilli()) + } } // MaxPlayerNameLength defines how long a string can be at max when used @@ -138,7 +165,7 @@ const ( ) func (lobby *Lobby) GetPlayerByID(id uuid.UUID) *Player { - for _, player := range lobby.players { + for _, player := range lobby.Players { if player.ID == player.ID { return player } @@ -148,12 +175,11 @@ func (lobby *Lobby) GetPlayerByID(id uuid.UUID) *Player { } func (lobby *Lobby) GetPlayerBySession(userSession uuid.UUID) *Player { - for _, player := range lobby.players { - if player.userSession == userSession { - return player + for index, uuid := range lobby.UserSessions { + if uuid == userSession { + return lobby.Players[index] } } - return nil } @@ -162,22 +188,22 @@ func (lobby *Lobby) GetOwner() *Player { } func (lobby *Lobby) ClearDrawing() { - lobby.currentDrawing = make([]any, 0) - lobby.connectedDrawEventsIndexStack = nil + lobby.CurrentDrawing = make([]any, 0) + lobby.ConnectedDrawEventsIndexStack = nil } // AppendLine adds a line direction to the current drawing. This exists in order // to prevent adding arbitrary elements to the drawing, as the backing array is // an empty interface type. func (lobby *Lobby) AppendLine(line *LineEvent) { - lobby.currentDrawing = append(lobby.currentDrawing, line) + lobby.CurrentDrawing = append(lobby.CurrentDrawing, line) } // AppendFill adds a fill direction to the current drawing. This exists in order // to prevent adding arbitrary elements to the drawing, as the backing array is // an empty interface type. func (lobby *Lobby) AppendFill(fill *FillEvent) { - lobby.currentDrawing = append(lobby.currentDrawing, fill) + lobby.CurrentDrawing = append(lobby.CurrentDrawing, fill) } // SanitizeName removes invalid characters from the players name, resolves @@ -203,7 +229,7 @@ func SanitizeName(name string) string { // established a socket connection. func (lobby *Lobby) GetConnectedPlayerCount() int { var count int - for _, player := range lobby.players { + for _, player := range lobby.Players { if player.Connected { count++ } @@ -216,7 +242,7 @@ func (lobby *Lobby) HasConnectedPlayers() bool { lobby.mutex.Lock() defer lobby.mutex.Unlock() - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { if otherPlayer.Connected { return true } @@ -247,7 +273,7 @@ func (lobby *Lobby) IsPublic() bool { } func (lobby *Lobby) GetPlayers() []*Player { - return lobby.players + return lobby.Players } // GetOccupiedPlayerSlots counts the available slots which can be taken by new @@ -258,7 +284,7 @@ func (lobby *Lobby) GetPlayers() []*Player { func (lobby *Lobby) GetOccupiedPlayerSlots() int { var occupiedPlayerSlots int now := time.Now() - for _, player := range lobby.players { + for _, player := range lobby.Players { if player.Connected { occupiedPlayerSlots++ } else { @@ -281,7 +307,7 @@ func (lobby *Lobby) GetOccupiedPlayerSlots() int { // will be preserved for 5 minutes. This function should be used over // Lobby.GetOccupiedPlayerSlots, as it is potentially faster. func (lobby *Lobby) HasFreePlayerSlot() bool { - if len(lobby.players) < lobby.MaxPlayers { + if len(lobby.Players) < lobby.MaxPlayers { return true } diff --git a/internal/game/data_test.go b/internal/game/data_test.go index 10ef6936..f45a23c3 100644 --- a/internal/game/data_test.go +++ b/internal/game/data_test.go @@ -14,12 +14,12 @@ func TestOccupiedPlayerCount(t *testing.T) { } // While disconnect, there's no disconnect time, which we count as occupied. - lobby.players = append(lobby.players, &Player{}) + lobby.Players = append(lobby.Players, &Player{}) if lobby.GetOccupiedPlayerSlots() != 1 { t.Errorf("Occupied player count expected to be 1, but was %d", lobby.GetOccupiedPlayerSlots()) } - lobby.players = append(lobby.players, &Player{ + lobby.Players = append(lobby.Players, &Player{ Connected: true, }) if lobby.GetOccupiedPlayerSlots() != 2 { @@ -29,7 +29,7 @@ func TestOccupiedPlayerCount(t *testing.T) { disconnectedPlayer := &Player{ Connected: false, } - lobby.players = append(lobby.players, disconnectedPlayer) + lobby.Players = append(lobby.Players, disconnectedPlayer) if lobby.GetOccupiedPlayerSlots() != 3 { t.Errorf("Occupied player count expected to be 3, but was %d", lobby.GetOccupiedPlayerSlots()) } diff --git a/internal/game/lobby.go b/internal/game/lobby.go index c2833e03..bf0f0cc8 100644 --- a/internal/game/lobby.go +++ b/internal/game/lobby.go @@ -9,6 +9,7 @@ import ( "math/rand/v2" "sort" "strings" + "sync" "time" "unicode/utf8" @@ -42,6 +43,7 @@ const ( DrawingBoardBaseHeight = 900 MinBrushSize = 8 MaxBrushSize = 32 + wordChoiceDurationMs = 30000 ) // SettingBounds defines the lower and upper bounds for the user-specified @@ -88,7 +90,7 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player player.SpectateToggleRequested = false } - lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.players}) + lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.Players}) } else if eventType == EventTypeMessage { var message StringDataEvent if err := json.Unmarshal(payload, &message); err != nil { @@ -113,7 +115,7 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player now := time.Now() if now.Sub(lobby.lastDrawEvent) > 150*time.Millisecond || lobby.wasLastDrawEventFill() { - lobby.connectedDrawEventsIndexStack = append(lobby.connectedDrawEventsIndexStack, len(lobby.currentDrawing)) + lobby.ConnectedDrawEventsIndexStack = append(lobby.ConnectedDrawEventsIndexStack, len(lobby.CurrentDrawing)) } lobby.lastDrawEvent = now @@ -129,7 +131,7 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player return fmt.Errorf("error decoding data: %w", err) } - lobby.connectedDrawEventsIndexStack = append(lobby.connectedDrawEventsIndexStack, len(lobby.currentDrawing)) + lobby.ConnectedDrawEventsIndexStack = append(lobby.ConnectedDrawEventsIndexStack, len(lobby.CurrentDrawing)) lobby.lastDrawEvent = time.Now() lobby.AppendFill(&fill) @@ -138,19 +140,19 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player lobby.broadcastConditional(&fill, ExcludePlayer(player)) } } else if eventType == EventTypeClearDrawingBoard { - if lobby.canDraw(player) && len(lobby.currentDrawing) > 0 { + if lobby.canDraw(player) && len(lobby.CurrentDrawing) > 0 { lobby.ClearDrawing() lobby.broadcastConditional( EventTypeOnly{Type: EventTypeClearDrawingBoard}, ExcludePlayer(player)) } } else if eventType == EventTypeUndo { - if lobby.canDraw(player) && len(lobby.currentDrawing) > 0 && len(lobby.connectedDrawEventsIndexStack) > 0 { - undoFrom := lobby.connectedDrawEventsIndexStack[len(lobby.connectedDrawEventsIndexStack)-1] - lobby.connectedDrawEventsIndexStack = lobby.connectedDrawEventsIndexStack[:len(lobby.connectedDrawEventsIndexStack)-1] - if undoFrom < len(lobby.currentDrawing) { - lobby.currentDrawing = lobby.currentDrawing[:undoFrom] - lobby.Broadcast(&Event{Type: EventTypeDrawing, Data: lobby.currentDrawing}) + if lobby.canDraw(player) && len(lobby.CurrentDrawing) > 0 && len(lobby.ConnectedDrawEventsIndexStack) > 0 { + undoFrom := lobby.ConnectedDrawEventsIndexStack[len(lobby.ConnectedDrawEventsIndexStack)-1] + lobby.ConnectedDrawEventsIndexStack = lobby.ConnectedDrawEventsIndexStack[:len(lobby.ConnectedDrawEventsIndexStack)-1] + if undoFrom < len(lobby.CurrentDrawing) { + lobby.CurrentDrawing = lobby.CurrentDrawing[:undoFrom] + lobby.Broadcast(&Event{Type: EventTypeDrawing, Data: lobby.CurrentDrawing}) } } } else if eventType == EventTypeChooseWord { @@ -191,8 +193,8 @@ func (lobby *Lobby) HandleEvent(eventType string, payload []byte, player *Player } else if eventType == EventTypeRequestDrawing { // Since the client shouldn't be blocking to wait for the drawing, it's // fine to emit the event if there's no drawing. - if len(lobby.currentDrawing) != 0 { - _ = lobby.WriteObject(player, Event{Type: EventTypeDrawing, Data: lobby.currentDrawing}) + if len(lobby.CurrentDrawing) != 0 { + _ = lobby.WriteObject(player, Event{Type: EventTypeDrawing, Data: lobby.CurrentDrawing}) } } @@ -210,7 +212,7 @@ func (lobby *Lobby) handleToggleReadinessEvent(player *Player) { if lobby.readyToStart() { lobby.startGame() } else { - lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.players}) + lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.Players}) } } } @@ -220,7 +222,7 @@ func (lobby *Lobby) readyToStart() bool { // if a lobby is created and the owner refreshes. var hasConnectedPlayers bool - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { if !otherPlayer.Connected { continue } @@ -282,9 +284,9 @@ func handleMessage(message string, sender *Player, lobby *Lobby) { advanceLobby(lobby) } else { // Since the word has been guessed correctly, we reveal it. - _ = lobby.WriteObject(sender, Event{Type: EventTypeUpdateWordHint, Data: lobby.wordHintsShown}) + _ = lobby.WriteObject(sender, Event{Type: EventTypeUpdateWordHint, Data: lobby.WordHintsShown}) recalculateRanks(lobby) - lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.players}) + lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.Players}) } } case CloseGuess: @@ -301,15 +303,15 @@ func handleMessage(message string, sender *Player, lobby *Lobby) { } func (lobby *Lobby) wasLastDrawEventFill() bool { - if len(lobby.currentDrawing) == 0 { + if len(lobby.CurrentDrawing) == 0 { return false } - _, isFillEvent := lobby.currentDrawing[len(lobby.currentDrawing)-1].(*FillEvent) + _, isFillEvent := lobby.CurrentDrawing[len(lobby.CurrentDrawing)-1].(*FillEvent) return isFillEvent } func (lobby *Lobby) isAnyoneStillGuessing() bool { - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { if otherPlayer.State == Guessing && otherPlayer.Connected { return true } @@ -359,7 +361,7 @@ func (lobby *Lobby) Broadcast(data any) { func (lobby *Lobby) broadcastConditional(data any, condition func(*Player) bool) { var message *gws.Broadcaster - for _, player := range lobby.players { + for _, player := range lobby.Players { if condition(player) { if message == nil { bytes, err := json.Marshal(data) @@ -382,7 +384,7 @@ func (lobby *Lobby) startGame() { // We are reseting each players score, since players could // technically be player a second game after the last one // has already ended. - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { otherPlayer.Score = 0 otherPlayer.LastScore = 0 // Everyone has the same score and therefore the same rank. @@ -407,7 +409,7 @@ func handleKickVoteEvent(lobby *Lobby, player *Player, toKickID uuid.UUID) { } playerToKickIndex := -1 - for index, otherPlayer := range lobby.players { + for index, otherPlayer := range lobby.Players { if otherPlayer.ID == toKickID { playerToKickIndex = index break @@ -419,11 +421,11 @@ func handleKickVoteEvent(lobby *Lobby, player *Player, toKickID uuid.UUID) { return } - playerToKick := lobby.players[playerToKickIndex] + playerToKick := lobby.Players[playerToKickIndex] player.votedForKick[toKickID] = true var voteKickCount int - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { if otherPlayer.Connected && otherPlayer.votedForKick[toKickID] { voteKickCount++ } @@ -450,6 +452,11 @@ func handleKickVoteEvent(lobby *Lobby, player *Player, toKickID uuid.UUID) { } } +func (lobby *Lobby) removePlayerByIndex(index int) { + lobby.UserSessions = append(lobby.UserSessions[:index], lobby.UserSessions[index+1:]...) + lobby.Players = append(lobby.Players[:index], lobby.Players[index+1:]...) +} + // kickPlayer kicks the given player from the lobby, updating the lobby // state and sending all necessary events. func kickPlayer(lobby *Lobby, playerToKick *Player, playerToKickIndex int) { @@ -459,13 +466,13 @@ func kickPlayer(lobby *Lobby, playerToKick *Player, playerToKickIndex int) { } // Since the player is already kicked, we first clean up the kicking information related to that player - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { delete(otherPlayer.votedForKick, playerToKick.ID) } // If the owner is kicked, we choose the next best person as the owner. if lobby.OwnerID == playerToKick.ID { - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { potentialOwner := otherPlayer if potentialOwner.Connected { lobby.OwnerID = potentialOwner.ID @@ -483,25 +490,25 @@ func kickPlayer(lobby *Lobby, playerToKick *Player, playerToKickIndex int) { if playerToKick.State == Drawing { newDrawer, roundOver := determineNextDrawer(lobby) - lobby.players = append(lobby.players[:playerToKickIndex], lobby.players[playerToKickIndex+1:]...) + lobby.removePlayerByIndex(playerToKickIndex) lobby.Broadcast(&EventTypeOnly{Type: EventTypeDrawerKicked}) // Since the drawer has been kicked, that probably means that they were // probably trolling, therefore we redact everyones last earned score. - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { otherPlayer.Score -= otherPlayer.LastScore otherPlayer.LastScore = 0 } advanceLobbyPredefineDrawer(lobby, roundOver, newDrawer) } else { - lobby.players = append(lobby.players[:playerToKickIndex], lobby.players[playerToKickIndex+1:]...) + lobby.removePlayerByIndex(playerToKickIndex) if lobby.isAnyoneStillGuessing() { // This isn't necessary in case we need to advanced the lobby, as it has // to happen anyways and sending events twice would be wasteful. recalculateRanks(lobby) - lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.players}) + lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.Players}) } else { advanceLobby(lobby) } @@ -509,7 +516,7 @@ func kickPlayer(lobby *Lobby, playerToKick *Player, playerToKickIndex int) { } func (lobby *Lobby) Drawer() *Player { - for _, player := range lobby.players { + for _, player := range lobby.Players { if player.State == Drawing { return player } @@ -584,13 +591,13 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player // client to know which word was previously supposed to be guessed. previousWord := lobby.CurrentWord lobby.CurrentWord = "" - lobby.wordHints = nil + lobby.WordHints = nil if lobby.DrawingTimeNew != 0 { lobby.DrawingTime = lobby.DrawingTimeNew } - for _, otherPlayer := range lobby.players { + for _, otherPlayer := range lobby.Players { // If the round ends and people are still guessing, that means the // "LastScore" value for the next turn has to be "no score earned". // We also reset spectating players, to prevent any score fuckups. @@ -626,7 +633,7 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player if lobby.Round == lobby.Rounds || newDrawer == nil { lobby.State = GameOver - for _, player := range lobby.players { + for _, player := range lobby.Players { readyData := generateReadyData(lobby, player) // The drawing is always available on the client, as the // game-over event is only sent to already connected players. @@ -654,31 +661,34 @@ func advanceLobbyPredefineDrawer(lobby *Lobby, roundOver bool, newDrawer *Player lobby.wordChoice = GetRandomWords(3, lobby) lobby.preSelectedWord = rand.IntN(len(lobby.wordChoice)) - wordChoiceDuration := 30 lobby.Broadcast(&Event{ Type: EventTypeNextTurn, Data: &NextTurn{ Round: lobby.Round, - Players: lobby.players, - ChoiceTimeLeft: wordChoiceDuration * 1000, + Players: lobby.Players, + ChoiceTimeLeft: wordChoiceDurationMs, PreviousWord: previousWord, }, }) - lobby.wordChoiceEndTime = getTimeAsMillis() + int64(wordChoiceDuration)*1000 - go func() { - timer := time.NewTimer(time.Duration(wordChoiceDuration) * time.Second) - <-timer.C + lobby.wordChoiceEndTime = getTimeAsMillis() + wordChoiceDurationMs + go lobby.startWordChoiceTimer(wordChoiceDurationMs) - lobby.mutex.Lock() - defer lobby.mutex.Unlock() + lobby.SendYourTurnEvent(newDrawer) +} - // We let the timer run out as long as it doesn't seem to cause any - // issues and make sure it doesn't fire when it would break stuff. - lobby.selectWord(int(lobby.preSelectedWord)) - }() +func (lobby *Lobby) startWordChoiceTimer(durationMs int64) { + timer := time.NewTimer(time.Duration(durationMs) * time.Millisecond) + <-timer.C - lobby.SendYourTurnEvent(newDrawer) + lobby.mutex.Lock() + defer lobby.mutex.Unlock() + + // We let the timer run out as long as it doesn't seem to cause any + // issues and make sure it doesn't fire when it would break stuff. + if err := lobby.selectWord(int(lobby.preSelectedWord)); err != nil { + log.Println("Error automatically selecting word:", err) + } } // advanceLobby will either start the game or jump over to the next turn. @@ -700,11 +710,11 @@ func (player *Player) desiresToDraw() bool { // doesn't tell the lobby yet. The boolean signals whether the current round // is over. func determineNextDrawer(lobby *Lobby) (*Player, bool) { - for index, player := range lobby.players { + for index, player := range lobby.Players { if player.State == Drawing { // If we have someone that's drawing, take the next one - for i := index + 1; i < len(lobby.players); i++ { - nextPlayer := lobby.players[i] + for i := index + 1; i < len(lobby.Players); i++ { + nextPlayer := lobby.Players[i] if !nextPlayer.desiresToDraw() || !nextPlayer.Connected { continue } @@ -719,7 +729,7 @@ func determineNextDrawer(lobby *Lobby) (*Player, bool) { } // We prefer the first connected player and non-spectating. - for _, player := range lobby.players { + for _, player := range lobby.Players { if !player.desiresToDraw() || !player.Connected { continue } @@ -758,35 +768,35 @@ func (lobby *Lobby) tickLogic(expectedTicker *time.Ticker) bool { } currentTime := getTimeAsMillis() - if currentTime >= lobby.roundEndTime { + if currentTime >= lobby.RoundEndTime { expectedTicker.Stop() advanceLobby(lobby) // Kill outer goroutine and therefore avoid executing hint logic. return false } - if lobby.hintsLeft > 0 && lobby.wordHints != nil { - revealHintEveryXMilliseconds := int64(lobby.DrawingTime * 1000 / (lobby.hintCount + 1)) + if lobby.HintsLeft > 0 && lobby.WordHints != nil { + revealHintEveryXMilliseconds := int64(lobby.DrawingTime * 1000 / (lobby.HintCount + 1)) // If you have a drawingtime of 120 seconds and three hints, you // want to reveal a hint every 40 seconds, so that the two hints // are visible for at least a third of the time. //If the word // was chosen at 60 seconds, we'll still reveal one hint // instantly, as the time is already lower than 80. - revealHintAtXOrLower := revealHintEveryXMilliseconds * int64(lobby.hintsLeft) - timeLeft := lobby.roundEndTime - currentTime + revealHintAtXOrLower := revealHintEveryXMilliseconds * int64(lobby.HintsLeft) + timeLeft := lobby.RoundEndTime - currentTime if timeLeft <= revealHintAtXOrLower { - lobby.hintsLeft-- + lobby.HintsLeft-- // We are trying til we find a yet unshown wordhint. Since we have // thread safety and have already checked that there's a hint // left, this loop can never spin forever. for { - randomIndex := rand.Int() % len(lobby.wordHints) - if lobby.wordHints[randomIndex].Character == 0 { - lobby.wordHints[randomIndex].Character = []rune(lobby.CurrentWord)[randomIndex] + randomIndex := rand.Int() % len(lobby.WordHints) + if lobby.WordHints[randomIndex].Character == 0 { + lobby.WordHints[randomIndex].Character = []rune(lobby.CurrentWord)[randomIndex] wordHintData := &Event{ Type: EventTypeUpdateWordHint, - Data: lobby.wordHints, + Data: lobby.WordHints, } lobby.broadcastConditional(wordHintData, IsAllowedToSeeHints) break @@ -807,8 +817,8 @@ func getTimeAsMillis() int64 { func recalculateRanks(lobby *Lobby) { // We don't directly sort the players, since the order determines in which // order the players will have to draw. - sortedPlayers := make([]*Player, len(lobby.players)) - copy(sortedPlayers, lobby.players) + sortedPlayers := make([]*Player, len(lobby.Players)) + copy(sortedPlayers, lobby.Players) sort.Slice(sortedPlayers, func(a, b int) bool { return sortedPlayers[a].Score > sortedPlayers[b].Score }) @@ -853,20 +863,20 @@ func (lobby *Lobby) selectWord(index int) error { // would be too easy or too hard. runeCount := utf8.RuneCountInString(lobby.CurrentWord) if runeCount <= 2 { - lobby.hintCount = 0 + lobby.HintCount = 0 } else if runeCount <= 4 { - lobby.hintCount = 1 + lobby.HintCount = 1 } else if runeCount <= 9 { - lobby.hintCount = 2 + lobby.HintCount = 2 } else { - lobby.hintCount = 3 + lobby.HintCount = 3 } - lobby.hintsLeft = lobby.hintCount + lobby.HintsLeft = lobby.HintCount // We generate both the "empty" word hints and the hints for the // drawer. Since the length is the same, we do it in one run. - lobby.wordHints = make([]*WordHint, 0, runeCount) - lobby.wordHintsShown = make([]*WordHint, 0, runeCount) + lobby.WordHints = make([]*WordHint, 0, runeCount) + lobby.WordHintsShown = make([]*WordHint, 0, runeCount) for _, char := range lobby.CurrentWord { // These characters are part of the word, but aren't relevant for the @@ -878,40 +888,40 @@ func (lobby *Lobby) selectWord(index int) error { // The hints for the drawer are always visible, therefore they // don't require any handling of different cases. - lobby.wordHintsShown = append(lobby.wordHintsShown, &WordHint{ + lobby.WordHintsShown = append(lobby.WordHintsShown, &WordHint{ Character: char, Underline: !isAlwaysVisibleCharacter, }) if isAlwaysVisibleCharacter { - lobby.wordHints = append(lobby.wordHints, &WordHint{ + lobby.WordHints = append(lobby.WordHints, &WordHint{ Character: char, Underline: false, }) } else { - lobby.wordHints = append(lobby.wordHints, &WordHint{ + lobby.WordHints = append(lobby.WordHints, &WordHint{ Underline: true, }) } } // We use milliseconds for higher accuracy - lobby.roundEndTime = getTimeAsMillis() + int64(lobby.DrawingTime)*1000 + lobby.RoundEndTime = getTimeAsMillis() + int64(lobby.DrawingTime)*1000 lobby.timeLeftTicker = time.NewTicker(1 * time.Second) go startTurnTimeTicker(lobby, lobby.timeLeftTicker) wordHintData := &Event{ Type: EventTypeWordChosen, Data: &WordChosen{ - Hints: lobby.wordHints, - TimeLeft: int(lobby.roundEndTime - getTimeAsMillis()), + Hints: lobby.WordHints, + TimeLeft: int(lobby.RoundEndTime - getTimeAsMillis()), }, } lobby.broadcastConditional(wordHintData, IsAllowedToSeeHints) wordHintDataRevealed := &Event{ Type: EventTypeWordChosen, Data: &WordChosen{ - Hints: lobby.wordHintsShown, - TimeLeft: int(lobby.roundEndTime - getTimeAsMillis()), + Hints: lobby.WordHintsShown, + TimeLeft: int(lobby.RoundEndTime - getTimeAsMillis()), }, } lobby.broadcastConditional(wordHintDataRevealed, IsAllowedToSeeRevealedHints) @@ -934,16 +944,19 @@ func CreateLobby( } lobby := &Lobby{ LobbyID: desiredLobbyId, - EditableLobbySettings: EditableLobbySettings{ - Rounds: rounds, - DrawingTime: drawingTime, - MaxPlayers: maxPlayers, - CustomWordsPerTurn: customWordsPerTurn, - ClientsPerIPLimit: clientsPerIPLimit, - Public: publicLobby, + LobbySettings: LobbySettings{ + ScoreCalculationIdentifier: scoringCalculation.Identifier(), + EditableLobbySettings: EditableLobbySettings{ + Rounds: rounds, + DrawingTime: drawingTime, + MaxPlayers: maxPlayers, + CustomWordsPerTurn: customWordsPerTurn, + ClientsPerIPLimit: clientsPerIPLimit, + Public: publicLobby, + }, }, CustomWords: customWords, - currentDrawing: make([]any, 0), + CurrentDrawing: make([]any, 0), State: Unstarted, ScoreCalculation: scoringCalculation, } @@ -995,15 +1008,15 @@ func generateReadyData(lobby *Lobby, player *Player) *ReadyEvent { Rounds: lobby.Rounds, DrawingTimeSetting: lobby.DrawingTime, WordHints: lobby.GetAvailableWordHints(player), - Players: lobby.players, - CurrentDrawing: lobby.currentDrawing, + Players: lobby.Players, + CurrentDrawing: lobby.CurrentDrawing, } if lobby.State != Ongoing { // Clients should interpret 0 as "time over", unless the gamestate isn't "ongoing" ready.TimeLeft = 0 } else { - ready.TimeLeft = int(lobby.roundEndTime - getTimeAsMillis()) + ready.TimeLeft = int(lobby.RoundEndTime - getTimeAsMillis()) } return ready @@ -1037,7 +1050,7 @@ func (lobby *Lobby) OnPlayerConnectUnsynchronized(player *Player) { // that player and update event for players. lobby.broadcastConditional(&Event{ Type: EventTypeUpdatePlayers, - Data: lobby.players, + Data: lobby.Players, }, ExcludePlayer(player)) } @@ -1078,7 +1091,7 @@ func (lobby *Lobby) OnPlayerDisconnect(player *Player) { // points when disconnecting, they shouldn't preserve their ranking. Upon // reconnecting, the ranking will be recalculated though. recalculateRanks(lobby) - lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.players}) + lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.Players}) } // GetAvailableWordHints returns a WordHint array depending on the players @@ -1089,10 +1102,10 @@ func (lobby *Lobby) GetAvailableWordHints(player *Player) []*WordHint { // the hints for displaying the word, instead of having yet another GUI // element that wastes space. if player.State != Guessing { - return lobby.wordHintsShown + return lobby.WordHintsShown } - return lobby.wordHints + return lobby.WordHints } // JoinPlayer creates a new player object using the given name and adds it @@ -1112,7 +1125,8 @@ func (lobby *Lobby) JoinPlayer(name string) *Player { } else { player.State = Standby } - lobby.players = append(lobby.players, player) + lobby.Players = append(lobby.Players, player) + lobby.UserSessions = append(lobby.UserSessions, player.userSession) return player } @@ -1129,7 +1143,28 @@ func (lobby *Lobby) Shutdown() { defer lobby.mutex.Unlock() log.Println("Lobby Shutdown: Mutex acquired") - lobby.Broadcast(&EventTypeOnly{Type: EventTypeShutdown}) + state, err := json.Marshal(LobbyRestoreData{ + ShutdownTime: time.Now(), + Lobby: lobby, + }) + if err != nil { + log.Println("Error marshalling lobby:", err) + lobby.Broadcast(&EventTypeOnly{Type: EventTypeShutdown}) + } else { + lobby.Broadcast(&Event{Type: EventTypeShutdown, Data: state}) + } + + // Since broadcast is synchronous, we gotta use the asynchronous queue, to + // make sure the message is received before closing. + var waitGroup sync.WaitGroup + waitGroup.Add(len(lobby.Players)) + for _, player := range lobby.Players { + player.ws.Async(func() { + defer waitGroup.Done() + player.ws.WriteClose(1012, []byte("server_restart")) + }) + } + waitGroup.Wait() } // ScoreCalculation allows having different scoring systems for a lobby. @@ -1168,7 +1203,7 @@ func (s *adjustableScoringAlgorithm) Identifier() string { } func (s *adjustableScoringAlgorithm) CalculateGuesserScore(lobby *Lobby) int { - return s.CalculateGuesserScoreInternal(lobby.hintCount, lobby.hintsLeft, lobby.DrawingTime, lobby.roundEndTime) + return s.CalculateGuesserScoreInternal(lobby.HintCount, lobby.HintsLeft, lobby.DrawingTime, lobby.RoundEndTime) } func (s *adjustableScoringAlgorithm) MaxScore() int { diff --git a/internal/game/lobby_test.go b/internal/game/lobby_test.go index c1c89949..3c419e98 100644 --- a/internal/game/lobby_test.go +++ b/internal/game/lobby_test.go @@ -19,7 +19,7 @@ func createLobbyWithDemoPlayers(playercount int) *Lobby { OwnerID: owner.ID, } for range playercount { - lobby.players = append(lobby.players, &Player{ + lobby.Players = append(lobby.Players, &Player{ Connected: true, }) } @@ -145,40 +145,40 @@ func Test_recalculateRanks(t *testing.T) { t.Parallel() lobby := &Lobby{} - lobby.players = append(lobby.players, &Player{ + lobby.Players = append(lobby.Players, &Player{ ID: uuid.Must(uuid.NewV4()), Score: 1, Connected: true, }) - lobby.players = append(lobby.players, &Player{ + lobby.Players = append(lobby.Players, &Player{ ID: uuid.Must(uuid.NewV4()), Score: 1, Connected: true, }) recalculateRanks(lobby) - rankPlayerA := lobby.players[0].Rank - rankPlayerB := lobby.players[1].Rank + rankPlayerA := lobby.Players[0].Rank + rankPlayerB := lobby.Players[1].Rank if rankPlayerA != 1 || rankPlayerB != 1 { t.Errorf("With equal score, ranks should be equal. (A: %d; B: %d)", rankPlayerA, rankPlayerB) } - lobby.players = append(lobby.players, &Player{ + lobby.Players = append(lobby.Players, &Player{ ID: uuid.Must(uuid.NewV4()), Score: 0, Connected: true, }) recalculateRanks(lobby) - rankPlayerA = lobby.players[0].Rank - rankPlayerB = lobby.players[1].Rank + rankPlayerA = lobby.Players[0].Rank + rankPlayerB = lobby.Players[1].Rank if rankPlayerA != 1 || rankPlayerB != 1 { t.Errorf("With equal score, ranks should be equal. (A: %d; B: %d)", rankPlayerA, rankPlayerB) } - rankPlayerC := lobby.players[2].Rank + rankPlayerC := lobby.Players[2].Rank if rankPlayerC != 2 { t.Errorf("new player should be rank 2, since the previous two players had the same rank. (C: %d)", rankPlayerC) } @@ -233,11 +233,13 @@ func Test_wordSelectionEvent(t *testing.T) { t.Parallel() lobby := &Lobby{ - EditableLobbySettings: EditableLobbySettings{ - DrawingTime: 10, - Rounds: 10, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + DrawingTime: 10, + Rounds: 10, + }, }, - words: []string{"abc", "def", "ghi"}, + Words: []string{"abc", "def", "ghi"}, } wordHintEvents := make(map[uuid.UUID][]*WordHint) var wordChoice []string @@ -330,12 +332,14 @@ func Test_kickDrawer(t *testing.T) { t.Parallel() lobby := &Lobby{ - EditableLobbySettings: EditableLobbySettings{ - DrawingTime: 10, - Rounds: 10, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + DrawingTime: 10, + Rounds: 10, + }, }, ScoreCalculation: ChillScoring, - words: []string{"a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a"}, + Words: []string{"a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a"}, } lobby.WriteObject = noOpWriteObject lobby.WritePreparedMessage = noOpWritePreparedMessage @@ -393,7 +397,7 @@ func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() drawer := &Player{State: Drawing} lobby := Lobby{ - players: []*Player{ + Players: []*Player{ drawer, { Connected: false, @@ -413,7 +417,7 @@ func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() drawer := &Player{State: Drawing} lobby := Lobby{ - players: []*Player{ + Players: []*Player{ drawer, { Connected: false, @@ -433,7 +437,7 @@ func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() drawer := &Player{State: Drawing} lobby := Lobby{ - players: []*Player{ + Players: []*Player{ drawer, { Connected: true, @@ -453,7 +457,7 @@ func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() drawer := &Player{State: Drawing} lobby := Lobby{ - players: []*Player{ + Players: []*Player{ drawer, { Connected: true, @@ -473,7 +477,7 @@ func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() drawer := &Player{State: Drawing} lobby := Lobby{ - players: []*Player{ + Players: []*Player{ drawer, { Connected: true, @@ -501,7 +505,7 @@ func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() drawer := &Player{State: Drawing} lobby := Lobby{ - players: []*Player{ + Players: []*Player{ drawer, { Connected: true, diff --git a/internal/game/shared.go b/internal/game/shared.go index 3cbf508d..2912a1be 100644 --- a/internal/game/shared.go +++ b/internal/game/shared.go @@ -205,16 +205,6 @@ type ReadyEvent struct { // Player represents a participant in a Lobby. type Player struct { - // userSession uniquely identifies the player. - userSession uuid.UUID - ws *gws.Conn - // disconnectTime is used to kick a player in case the lobby doesn't have - // space for new players. The player with the oldest disconnect.Time will - // get kicked. - disconnectTime *time.Time - votedForKick map[uuid.UUID]bool - lastKnownAddress string - // Name is the players displayed name Name string `json:"name"` State PlayerState `json:"state"` @@ -236,6 +226,22 @@ type Player struct { Connected bool `json:"connected"` // ID uniquely identified the Player. ID uuid.UUID `json:"id"` + + // userSession uniquely identifies the player. + userSession uuid.UUID + ws *gws.Conn + // disconnectTime is used to kick a player in case the lobby doesn't have + // space for new players. The player with the oldest disconnect.Time will + // get kicked. + disconnectTime *time.Time + votedForKick map[uuid.UUID]bool + lastKnownAddress string +} + +type LobbySettings struct { + Wordpack string + ScoreCalculationIdentifier string `json:"scoreCalculationIdentifier"` + EditableLobbySettings } // EditableLobbySettings represents all lobby settings that are editable by diff --git a/internal/game/words.go b/internal/game/words.go index bdfca026..19bd8ce0 100644 --- a/internal/game/words.go +++ b/internal/game/words.go @@ -149,9 +149,9 @@ func popCustomWord(lobby *Lobby) string { // popCustomWords is, that the wordlist gets reset and reshuffeled once every // item has been popped. func popWordpackWord(lobby *Lobby, reloadWords func(lobby *Lobby) ([]string, error)) string { - if len(lobby.words) == 0 { + if len(lobby.Words) == 0 { var err error - lobby.words, err = reloadWords(lobby) + lobby.Words, err = reloadWords(lobby) if err != nil { // Since this list should've been successfully read once before, we // can "safely" panic if this happens, assuming that there's a @@ -159,9 +159,9 @@ func popWordpackWord(lobby *Lobby, reloadWords func(lobby *Lobby) ([]string, err panic(err) } } - lastIndex := len(lobby.words) - 1 - lastWord := lobby.words[lastIndex] - lobby.words = lobby.words[:lastIndex] + lastIndex := len(lobby.Words) - 1 + lastWord := lobby.Words[lastIndex] + lobby.Words = lobby.Words[:lastIndex] return lastWord } diff --git a/internal/game/words_test.go b/internal/game/words_test.go index 65aa7c94..a03d518c 100644 --- a/internal/game/words_test.go +++ b/internal/game/words_test.go @@ -84,14 +84,16 @@ func Test_getRandomWords(t *testing.T) { lobby := &Lobby{ CurrentWord: "", - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 0, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 0, + }, }, - words: []string{"a", "b", "c"}, + Words: []string{"a", "b", "c"}, } randomWords := GetRandomWords(3, lobby) - for _, lobbyWord := range lobby.words { + for _, lobbyWord := range lobby.Words { if !arrayContains(randomWords, lobbyWord) { t.Errorf("Random words %s, didn't contain lobbyWord %s", randomWords, lobbyWord) } @@ -103,16 +105,17 @@ func Test_getRandomWords(t *testing.T) { lobby := &Lobby{ CurrentWord: "", - words: []string{"a", "b", "c"}, - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 0, + Words: []string{"a", "b", "c"}, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 0, + }, }, - CustomWords: []string{"d", "e", "f"}, } randomWords := GetRandomWords(3, lobby) - for _, lobbyWord := range lobby.words { + for _, lobbyWord := range lobby.Words { if !arrayContains(randomWords, lobbyWord) { t.Errorf("Random words %s, didn't contain lobbyWord %s", randomWords, lobbyWord) } @@ -124,15 +127,17 @@ func Test_getRandomWords(t *testing.T) { lobby := &Lobby{ CurrentWord: "", - words: []string{"a", "b", "c"}, - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 3, + Words: []string{"a", "b", "c"}, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 3, + }, }, CustomWords: nil, } randomWords := GetRandomWords(3, lobby) - for _, lobbyWord := range lobby.words { + for _, lobbyWord := range lobby.Words { if !arrayContains(randomWords, lobbyWord) { t.Errorf("Random words %s, didn't contain lobbyWord %s", randomWords, lobbyWord) } @@ -144,9 +149,11 @@ func Test_getRandomWords(t *testing.T) { lobby := &Lobby{ CurrentWord: "", - words: []string{"a", "b", "c"}, - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 3, + Words: []string{"a", "b", "c"}, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 3, + }, }, CustomWords: []string{"d", "e", "f"}, } @@ -171,9 +178,11 @@ func Test_getRandomWordsReloading(t *testing.T) { t.Parallel() lobby := &Lobby{ - words: wordList, - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 0, + Words: wordList, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 0, + }, }, CustomWords: nil, } @@ -192,9 +201,11 @@ func Test_getRandomWordsReloading(t *testing.T) { t.Parallel() lobby := &Lobby{ - words: wordList, - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 3, + Words: wordList, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 3, + }, }, CustomWords: nil, } @@ -213,9 +224,11 @@ func Test_getRandomWordsReloading(t *testing.T) { t.Parallel() lobby := &Lobby{ - words: wordList, - EditableLobbySettings: EditableLobbySettings{ - CustomWordsPerTurn: 3, + Words: wordList, + LobbySettings: LobbySettings{ + EditableLobbySettings: EditableLobbySettings{ + CustomWordsPerTurn: 3, + }, }, CustomWords: []string{"a"}, } diff --git a/internal/state/lobbies.go b/internal/state/lobbies.go index 91284a69..6c6b1e0a 100644 --- a/internal/state/lobbies.go +++ b/internal/state/lobbies.go @@ -64,15 +64,36 @@ func AddLobby(lobby *game.Lobby) { globalStateMutex.Lock() defer globalStateMutex.Unlock() + addLobby(lobby) +} + +func addLobby(lobby *game.Lobby) { lobbies = append(lobbies, lobby) } +func ResurrectLobby(lobby *game.Lobby) bool { + globalStateMutex.RLock() + defer globalStateMutex.RUnlock() + + existingLobby := getLobby(lobby.LobbyID) + if existingLobby == nil { + addLobby(lobby) + return true + } + + return false +} + // GetLobby returns a Lobby that has a matching ID or no Lobby if none could // be found. func GetLobby(id string) *game.Lobby { globalStateMutex.RLock() defer globalStateMutex.RUnlock() + return getLobby(id) +} + +func getLobby(id string) *game.Lobby { for _, lobby := range lobbies { if lobby.LobbyID == id { return lobby @@ -99,6 +120,9 @@ func ShutdownLobbiesGracefully() { // Instead of removing one by one, we nil the array, since that's faster. lobbies = nil + + // Graceperiod to make sure sockets can flush everything. + time.Sleep(500 * time.Millisecond) } // GetActiveLobbyCount indicates how many activate lobby there are. This includes