Skip to content

Commit

Permalink
Prepare backend and GUI for multiple scoring algorithms
Browse files Browse the repository at this point in the history
  • Loading branch information
Bios-Marcel committed Nov 30, 2024
1 parent 242c800 commit b0a5a46
Show file tree
Hide file tree
Showing 10 changed files with 144 additions and 65 deletions.
10 changes: 10 additions & 0 deletions internal/api/createparse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion internal/api/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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())
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -70,6 +71,7 @@ var Default = Config{
CustomWordsPerTurn: "3",
ClientsPerIPLimit: "2",
Language: "english",
ScoreCalculation: "chill",
},
LobbySettingBounds: game.SettingBounds{
MinDrawingTime: 60,
Expand Down
17 changes: 12 additions & 5 deletions internal/frontend/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand All @@ -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
Expand All @@ -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"))
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions internal/frontend/templates/lobby_create.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@
<option value="{{$k}}" {{if eq $k $language}}selected="selected" {{end}}>{{$v}}</option>
{{end}}
</select>
<label class="lobby-create-label" for="score_calculation">
{{.Translation.Get "score-calculation"}}
</label>
<select class="input-item" name="score_calculation" id="score_calculation" placeholder="Choose
how player scores are determined">
{{$scoreCalculation := .ScoreCalculation}}
{{range .ScoreCalculations}}
<option value="{{.}}" {{if eq . $scoreCalculation}}selected="selected" {{end}}>{{ . }}
</option>
{{end}}
</select>
<label class="lobby-create-label" for="drawing_time">
{{.Translation.Get "drawing-time-setting"}}
</label>
Expand Down
3 changes: 2 additions & 1 deletion internal/game/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 84 additions & 51 deletions internal/game/lobby.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
Loading

0 comments on commit b0a5a46

Please sign in to comment.