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")