diff --git a/internal/api/createparse.go b/internal/api/createparse.go index 66ed06d2..9dd309cf 100644 --- a/internal/api/createparse.go +++ b/internal/api/createparse.go @@ -36,6 +36,16 @@ func ParseLanguage(value string) (*game.LanguageData, string, error) { return nil, "", errors.New("the given language doesn't match any supported language") } +func ParseScoreCalculation(value string) (game.ScoreCalculation, error) { + toLower := strings.ToLower(strings.TrimSpace(value)) + switch toLower { + case "", "chill": + return &game.ChillScoring{}, nil + } + + return nil, errors.New("the given score calculation doesn't match any supported algorithm") +} + // ParseDrawingTime checks whether the given value is an integer between // the lower and upper bound of drawing time. All other invalid // input, including empty strings, will return an error. diff --git a/internal/api/v1.go b/internal/api/v1.go index 55b76003..f46d9ee8 100644 --- a/internal/api/v1.go +++ b/internal/api/v1.go @@ -104,6 +104,7 @@ func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Re } } + scoreCalculation, scoreCalculationInvalid := ParseScoreCalculation(request.Form.Get("score_calculation")) languageData, languageKey, languageInvalid := ParseLanguage(request.Form.Get("language")) drawingTime, drawingTimeInvalid := ParseDrawingTime(handler.cfg, request.Form.Get("drawing_time")) rounds, roundsInvalid := ParseRounds(handler.cfg, request.Form.Get("rounds")) @@ -120,6 +121,9 @@ func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Re } customWords, customWordsInvalid := ParseCustomWords(lowercaser, request.Form.Get("custom_words")) + if scoreCalculationInvalid != nil { + requestErrors = append(requestErrors, scoreCalculationInvalid.Error()) + } if languageInvalid != nil { requestErrors = append(requestErrors, languageInvalid.Error()) } @@ -153,7 +157,7 @@ func (handler *V1Handler) postLobby(writer http.ResponseWriter, request *http.Re playerName := GetPlayername(request) player, lobby, err := game.CreateLobby(desiredLobbyId, playerName, languageKey, publicLobby, drawingTime, rounds, maxPlayers, - customWordsPerTurn, clientsPerIPLimit, customWords) + customWordsPerTurn, clientsPerIPLimit, customWords, scoreCalculation) if err != nil { http.Error(writer, err.Error(), http.StatusBadRequest) return diff --git a/internal/config/config.go b/internal/config/config.go index 7f98e018..e4bd1d37 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,6 +22,7 @@ type LobbySettingDefaults struct { CustomWordsPerTurn string `env:"CUSTOM_WORDS_PER_TURN"` ClientsPerIPLimit string `env:"CLIENTS_PER_IP_LIMIT"` Language string `env:"LANGUAGE"` + ScoreCalculation string `env:"SCORE_CALCULATION"` } type CORS struct { @@ -70,6 +71,7 @@ var Default = Config{ CustomWordsPerTurn: "3", ClientsPerIPLimit: "2", Language: "english", + ScoreCalculation: "chill", }, LobbySettingBounds: game.SettingBounds{ MinDrawingTime: 60, diff --git a/internal/frontend/create.go b/internal/frontend/create.go index 84fc3629..e9b2c7cd 100644 --- a/internal/frontend/create.go +++ b/internal/frontend/create.go @@ -80,6 +80,7 @@ func (handler *SSRHandler) createDefaultLobbyCreatePageData() *LobbyCreatePageDa BasePageConfig: handler.basePageConfig, SettingBounds: handler.cfg.LobbySettingBounds, Languages: game.SupportedLanguages, + ScoreCalculations: game.SupportedScoreCalculations, LobbySettingDefaults: handler.cfg.LobbySettingDefaults, } } @@ -90,10 +91,11 @@ type LobbyCreatePageData struct { config.LobbySettingDefaults game.SettingBounds - Translation translations.Translation - Locale string - Errors []string - Languages map[string]string + Translation translations.Translation + Locale string + Errors []string + Languages map[string]string + ScoreCalculations []string } // ssrCreateLobby allows creating a lobby, optionally returning errors that @@ -104,6 +106,7 @@ func (handler *SSRHandler) ssrCreateLobby(writer http.ResponseWriter, request *h return } + scoreCalculation, scoreCalculationInvalid := api.ParseScoreCalculation(request.Form.Get("score_calculation")) languageData, languageKey, languageInvalid := api.ParseLanguage(request.Form.Get("language")) drawingTime, drawingTimeInvalid := api.ParseDrawingTime(handler.cfg, request.Form.Get("drawing_time")) rounds, roundsInvalid := api.ParseRounds(handler.cfg, request.Form.Get("rounds")) @@ -133,10 +136,14 @@ func (handler *SSRHandler) ssrCreateLobby(writer http.ResponseWriter, request *h CustomWordsPerTurn: request.Form.Get("custom_words_per_turn"), ClientsPerIPLimit: request.Form.Get("clients_per_ip_limit"), Language: request.Form.Get("language"), + ScoreCalculation: request.Form.Get("score_calculation"), }, Languages: game.SupportedLanguages, } + if scoreCalculationInvalid != nil { + pageData.Errors = append(pageData.Errors, scoreCalculationInvalid.Error()) + } if languageInvalid != nil { pageData.Errors = append(pageData.Errors, languageInvalid.Error()) } @@ -178,7 +185,7 @@ func (handler *SSRHandler) ssrCreateLobby(writer http.ResponseWriter, request *h player, lobby, err := game.CreateLobby(uuid.Nil, playerName, languageKey, publicLobby, drawingTime, rounds, maxPlayers, customWordsPerTurn, - clientsPerIPLimit, customWords) + clientsPerIPLimit, customWords, scoreCalculation) if err != nil { pageData.Errors = append(pageData.Errors, err.Error()) if err := pageTemplates.ExecuteTemplate(writer, "lobby-create-page", pageData); err != nil { diff --git a/internal/frontend/templates/lobby_create.html b/internal/frontend/templates/lobby_create.html index c91a5aba..0704d8a8 100644 --- a/internal/frontend/templates/lobby_create.html +++ b/internal/frontend/templates/lobby_create.html @@ -46,6 +46,17 @@ {{end}} + + diff --git a/internal/game/data.go b/internal/game/data.go index 019a80dd..b0967529 100644 --- a/internal/game/data.go +++ b/internal/game/data.go @@ -41,7 +41,8 @@ type Lobby struct { // creator is the player that opened a lobby. Initially creator and owner // are set to the same player. While the owner can change throughout the // game, the creator can't. - creator *Player + creator *Player + scoreCalculation ScoreCalculation // 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 diff --git a/internal/game/lobby.go b/internal/game/lobby.go index 1dc9d7e5..59ecdceb 100644 --- a/internal/game/lobby.go +++ b/internal/game/lobby.go @@ -20,6 +20,10 @@ import ( "github.com/gofrs/uuid/v5" ) +var SupportedScoreCalculations = []string{ + "chill", +} + var SupportedLanguages = map[string]string{ "english_gb": "English (GB)", "english": "English (US)", @@ -282,9 +286,7 @@ func handleMessage(message string, sender *Player, lobby *Lobby) { switch CheckGuess(normInput, normSearched) { case EqualGuess: { - secondsLeft := int(lobby.roundEndTime/1000 - time.Now().UTC().Unix()) - - sender.LastScore = calculateGuesserScore(lobby.hintCount, lobby.hintsLeft, secondsLeft, lobby.DrawingTime) + sender.LastScore = lobby.calculateGuesserScore() sender.Score += sender.LastScore sender.State = Standby @@ -321,24 +323,6 @@ func (lobby *Lobby) wasLastDrawEventFill() bool { return isFillEvent } -func calculateGuesserScore(hintCount, hintsLeft, secondsLeft, drawingTime int) int { - // The base score is based on the general time taken. - // The formula here represents an exponential decline based on the time taken. - // This way fast players get more points, however not a lot more. - // The bonus gained by guessing before hints are shown is therefore still somewhat relevant. - declineFactor := 1.0 / float64(drawingTime) - baseScore := int(maxBaseScore * math.Pow(1.0-declineFactor, float64(drawingTime-secondsLeft))) - - // Prevent zero division panic. This could happen with two letter words. - if hintCount <= 0 { - return baseScore - } - - // If all hints are shown, or the word is too short to show hints, the - // calculation will basically always be baseScore + 0. - return baseScore + hintsLeft*(maxHintBonusScore/hintCount) -} - func (lobby *Lobby) isAnyoneStillGuessing() bool { for _, otherPlayer := range lobby.players { if otherPlayer.State == Guessing && otherPlayer.Connected { @@ -597,34 +581,12 @@ func handleNameChangeEvent(caller *Player, lobby *Lobby, name string) { } } -func (lobby *Lobby) calculateDrawerScore() int { - // The drawer can get points even if disconnected. But if they are - // connected, we need to ignore them when calculating their score. - var ( - playerCount int - scoreSum int - ) - for _, player := range lobby.GetPlayers() { - if player.State != Drawing && - // Switch to spectating is only possible after score calculation, so - // this can't be used to manipulate score. - player.State != Spectating && - // If the player has guessed, we want to take them into account, - // even if they aren't connected anymore. If the player is - // connected, but hasn't guessed, it is still as well, as the - // drawing must've not been good enough to be guessable. - (player.Connected || player.LastScore > 0) { - scoreSum += player.LastScore - playerCount++ - } - } - - var averageScore int - if playerCount > 0 { - averageScore = scoreSum / playerCount - } +func (lobby *Lobby) calculateGuesserScore() int { + return lobby.scoreCalculation.CalculateGuesserScore(lobby) +} - return averageScore +func (lobby *Lobby) calculateDrawerScore() int { + return lobby.scoreCalculation.CalculateDrawerScore(lobby) } // advanceLobbyPredefineDrawer is required in cases where the drawer is removed @@ -946,6 +908,7 @@ func CreateLobby( publicLobby bool, drawingTime, rounds, maxPlayers, customWordsPerTurn, clientsPerIPLimit int, customWords []string, + scoringCalculation ScoreCalculation, ) (*Player, *Lobby, error) { if desiredLobbyId == uuid.Nil { desiredLobbyId = uuid.Must(uuid.NewV4()) @@ -960,9 +923,10 @@ func CreateLobby( ClientsPerIPLimit: clientsPerIPLimit, Public: publicLobby, }, - CustomWords: customWords, - currentDrawing: make([]any, 0), - State: Unstarted, + CustomWords: customWords, + currentDrawing: make([]any, 0), + State: Unstarted, + scoreCalculation: scoringCalculation, } if len(customWords) > 1 { @@ -1139,3 +1103,72 @@ func (lobby *Lobby) Shutdown() { lobby.Broadcast(&EventTypeOnly{Type: EventTypeShutdown}) } + +// ScoreCalculation allows having different scoring systems for +type ScoreCalculation interface { + Identifier() string + CalculateGuesserScore(*Lobby) int + CalculateDrawerScore(*Lobby) int +} + +type ChillScoring struct{} + +func (s *ChillScoring) Identifier() string { + return "chill" +} + +func (s *ChillScoring) CalculateGuesserScore(lobby *Lobby) int { + return s.calculateGuesserScore(lobby.hintCount, lobby.hintsLeft, lobby.DrawingTime, lobby.roundEndTime) +} + +func (s *ChillScoring) calculateGuesserScore( + hintCount, hintsLeft, drawingTime int, + roundEndTimeMillis int64, +) int { + secondsLeft := int(roundEndTimeMillis/1000 - time.Now().UTC().Unix()) + + // The base score is based on the general time taken. + // The formula here represents an exponential decline based on the time taken. + // This way fast players get more points, however not a lot more. + // The bonus gained by guessing before hints are shown is therefore still somewhat relevant. + declineFactor := 1.0 / float64(drawingTime) + baseScore := int(maxBaseScore * math.Pow(1.0-declineFactor, float64(drawingTime-secondsLeft))) + + // Prevent zero division panic. This could happen with two letter words. + if hintCount <= 0 { + return baseScore + } + + // If all hints are shown, or the word is too short to show hints, the + // calculation will basically always be baseScore + 0. + return baseScore + hintsLeft*(maxHintBonusScore/hintCount) +} + +func (s *ChillScoring) CalculateDrawerScore(lobby *Lobby) int { + // The drawer can get points even if disconnected. But if they are + // connected, we need to ignore them when calculating their score. + var ( + playerCount int + scoreSum int + ) + for _, player := range lobby.GetPlayers() { + if player.State != Drawing && + // Switch to spectating is only possible after score calculation, so + // this can't be used to manipulate score. + player.State != Spectating && + // If the player has guessed, we want to take them into account, + // even if they aren't connected anymore. If the player is + // connected, but hasn't guessed, it is still as well, as the + // drawing must've not been good enough to be guessable. + (player.Connected || player.LastScore > 0) { + scoreSum += player.LastScore + playerCount++ + } + } + + if playerCount > 0 { + return scoreSum / playerCount + } + + return 0 +} diff --git a/internal/game/lobby_test.go b/internal/game/lobby_test.go index da89525a..00182326 100644 --- a/internal/game/lobby_test.go +++ b/internal/game/lobby_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "reflect" "testing" + "time" "unsafe" "github.com/gofrs/uuid/v5" @@ -185,10 +186,11 @@ func Test_recalculateRanks(t *testing.T) { } } -func Test_calculateGuesserScore(t *testing.T) { +func Test_chillScoring_calculateGuesserScore(t *testing.T) { t.Parallel() - lastScore := calculateGuesserScore(0, 0, 115, 120) + var chillScoring ChillScoring + lastScore := chillScoring.calculateGuesserScore(0, 0, 120, time.Now().Add(115*time.Second).UnixMilli()) if lastScore >= maxBaseScore { t.Errorf("Score should have declined, but was bigger than or "+ "equal to the baseScore. (LastScore: %d; BaseScore: %d)", lastScore, maxBaseScore) @@ -196,7 +198,8 @@ func Test_calculateGuesserScore(t *testing.T) { lastDecline := -1 for secondsLeft := 105; secondsLeft >= 5; secondsLeft -= 10 { - newScore := calculateGuesserScore(0, 0, secondsLeft, 120) + roundEndTime := time.Now().Add(time.Duration(secondsLeft) * time.Second).UnixMilli() + newScore := chillScoring.calculateGuesserScore(0, 0, 120, roundEndTime) if newScore > lastScore { t.Errorf("Score with more time taken should be lower. (LastScore: %d; NewScore: %d)", lastScore, newScore) } @@ -334,7 +337,8 @@ func Test_kickDrawer(t *testing.T) { DrawingTime: 10, Rounds: 10, }, - words: []string{"a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a"}, + scoreCalculation: &ChillScoring{}, + words: []string{"a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a", "a"}, } lobby.WriteObject = noOpWriteObject lobby.WritePreparedMessage = noOpWritePreparedMessage @@ -386,7 +390,7 @@ func Test_kickDrawer(t *testing.T) { } } -func Test_calculateDrawerScore(t *testing.T) { +func Test_lobby_calculateDrawerScore(t *testing.T) { t.Parallel() t.Run("only disconnected players, with score", func(t *testing.T) { @@ -404,6 +408,7 @@ func Test_calculateDrawerScore(t *testing.T) { LastScore: 200, }, }, + scoreCalculation: &ChillScoring{}, } require.Equal(t, 150, lobby.calculateDrawerScore()) @@ -423,6 +428,7 @@ func Test_calculateDrawerScore(t *testing.T) { LastScore: 0, }, }, + scoreCalculation: &ChillScoring{}, } require.Equal(t, 0, lobby.calculateDrawerScore()) @@ -442,6 +448,7 @@ func Test_calculateDrawerScore(t *testing.T) { LastScore: 0, }, }, + scoreCalculation: &ChillScoring{}, } require.Equal(t, 0, lobby.calculateDrawerScore()) @@ -461,6 +468,7 @@ func Test_calculateDrawerScore(t *testing.T) { LastScore: 200, }, }, + scoreCalculation: &ChillScoring{}, } require.Equal(t, 150, lobby.calculateDrawerScore()) @@ -488,6 +496,7 @@ func Test_calculateDrawerScore(t *testing.T) { LastScore: 0, }, }, + scoreCalculation: &ChillScoring{}, } require.Equal(t, 100, lobby.calculateDrawerScore()) @@ -515,6 +524,7 @@ func Test_calculateDrawerScore(t *testing.T) { LastScore: 400, }, }, + scoreCalculation: &ChillScoring{}, } require.Equal(t, 250, lobby.calculateDrawerScore()) @@ -524,7 +534,7 @@ func Test_calculateDrawerScore(t *testing.T) { func Test_NoPrematureGameOver(t *testing.T) { t.Parallel() - player, lobby, err := CreateLobby(uuid.Nil, "test", "english", false, 120, 4, 4, 3, 1, nil) + player, lobby, err := CreateLobby(uuid.Nil, "test", "english", false, 120, 4, 4, 3, 1, nil, &ChillScoring{}) require.NoError(t, err) lobby.WriteObject = noOpWriteObject diff --git a/internal/state/lobbies_test.go b/internal/state/lobbies_test.go index 2280460f..99fb2310 100644 --- a/internal/state/lobbies_test.go +++ b/internal/state/lobbies_test.go @@ -14,7 +14,7 @@ func TestAddAndRemove(t *testing.T) { require.Empty(t, lobbies, "Lobbies should be empty when test starts") createLobby := func() *game.Lobby { - player, lobby, err := game.CreateLobby(uuid.Nil, "player", "dutch", true, 100, 10, 10, 3, 1, nil) + player, lobby, err := game.CreateLobby(uuid.Nil, "player", "dutch", true, 100, 10, 10, 3, 1, nil, &game.ChillScoring{}) require.NoError(t, err) lobby.OnPlayerDisconnect(player) return lobby diff --git a/internal/translations/en_us.go b/internal/translations/en_us.go index a76d657a..7fcfe553 100644 --- a/internal/translations/en_us.go +++ b/internal/translations/en_us.go @@ -49,6 +49,7 @@ func initEnglishTranslation() Translation { translation.put("change-lobby-settings-title", "Lobby settings") translation.put("lobby-settings-changed", "Lobby settings changed") translation.put("advanced-settings", "Advanced settings") + translation.put("score-calculation", "Scoring") translation.put("word-language", "Language") translation.put("drawing-time-setting", "Drawing Time") translation.put("rounds-setting", "Rounds")