diff --git a/go.mod b/go.mod index 5872d3b1..631c7c86 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,6 @@ go 1.21.3 require ( github.com/Bios-Marcel/discordemojimap/v2 v2.0.6-0.20231020161444-8c7f7fa6e5a6 github.com/Bios-Marcel/go-petname v0.0.1 - github.com/agnivade/levenshtein v1.1.1 github.com/caarlos0/env/v10 v10.0.0 github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 diff --git a/go.sum b/go.sum index 69c5ffc2..e67c7c0e 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,10 @@ github.com/Bios-Marcel/discordemojimap/v2 v2.0.6-0.20231020161444-8c7f7fa6e5a6 h github.com/Bios-Marcel/discordemojimap/v2 v2.0.6-0.20231020161444-8c7f7fa6e5a6/go.mod h1:HvXfXna44w4kQqzMl6HaFLI1eLJJxQfebKOs5kYE/Rs= github.com/Bios-Marcel/go-petname v0.0.1 h1:FELp77IS2bulz77kFXUOHqRJHXoOlL0lJUf6no5S0cQ= github.com/Bios-Marcel/go-petname v0.0.1/go.mod h1:67IdwdIEuQBRISkUQJd4b/DvOYscEo8dNpq0D2gPHoA= -github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= -github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= -github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/caarlos0/env/v10 v10.0.0 h1:yIHUBZGsyqCnpTkbjk8asUlx6RFhhEs+h7TOBdgdzXA= github.com/caarlos0/env/v10 v10.0.0/go.mod h1:ZfulV76NvVPw3tm591U4SwL3Xx9ldzBP9aGxzeN7G18= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= -github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= diff --git a/internal/game/lobby.go b/internal/game/lobby.go index afb7c795..4cdbc012 100644 --- a/internal/game/lobby.go +++ b/internal/game/lobby.go @@ -18,7 +18,6 @@ import ( discordemojimap "github.com/Bios-Marcel/discordemojimap/v2" petname "github.com/Bios-Marcel/go-petname" - "github.com/agnivade/levenshtein" "github.com/gofrs/uuid/v5" ) @@ -263,10 +262,10 @@ func handleMessage(message string, sender *Player, lobby *Lobby) { return } - // Since correct guess are probably the least common case, we'll always + // Since correct guesses are probably the least common case, we'll always // calculate the distance, as usually have to do it anyway. - switch levenshtein.ComputeDistance(normInput, normSearched) { - case 0: + switch CheckGuess(normInput, normSearched) { + case EqualGuess: { secondsLeft := int(lobby.roundEndTime/1000 - time.Now().UTC().Unix()) @@ -287,7 +286,7 @@ func handleMessage(message string, sender *Player, lobby *Lobby) { lobby.Broadcast(&Event{Type: EventTypeUpdatePlayers, Data: lobby.players}) } } - case 1: + case CloseGuess: { // In cases of a close guess, we still send the message to everyone. // This allows other players to guess the word by watching what the diff --git a/internal/game/words.go b/internal/game/words.go index b836c3cd..8a558cff 100644 --- a/internal/game/words.go +++ b/internal/game/words.go @@ -6,6 +6,7 @@ import ( "fmt" "math/rand" "strings" + "unicode/utf8" "golang.org/x/text/cases" "golang.org/x/text/language" @@ -158,3 +159,83 @@ func shuffleWordList(wordlist []string) { wordlist[a], wordlist[b] = wordlist[b], wordlist[a] }) } + +const ( + EqualGuess = 0 + CloseGuess = 1 + DistantGuess = 2 +) + +// CheckGuess compares the strings with eachother. Possible results: +// - EqualGuess (0) +// - CloseGuess (1) +// - DistantGuess (2) +// +// This works mostly like levensthein distance, but doesn't check further than +// to a distance of 2 and also handles transpositions where the runes are +// directly next to eachother. +func CheckGuess(a, b string) int { + // Simplify logic later on; FIXME Explain + if len(a) < len(b) { + a, b = b, a + } else if a == b { + return EqualGuess + } + + // We only want to indicate a close guess if: + // * 1 additional character is found (abc ~ abcd) + // * 1 character is missing (abc ~ ab) + // * 1 character is wrong (abc ~ adc) + // * 2 characters are swapped (abc ~ acb) + + if len(a)-len(b) > CloseGuess { + return DistantGuess + } + + var distance int + aBytes := []byte(a) + bBytes := []byte(b) + for { + aRune, aSize := utf8.DecodeRune(aBytes) + // If a eaches the end, then so does b, as we make sure a is longer at + // the top, therefore we can be sure no additional conflict diff occurs. + if aRune == utf8.RuneError { + return distance + } + bRune, bSize := utf8.DecodeRune(bBytes) + + // Either different runes, or b is empty, returning RuneError (65533). + if aRune != bRune { + // Check for transposition (abc ~ acb) + nextARune, nextASize := utf8.DecodeRune(aBytes[aSize:]) + if nextARune == bRune { + if nextARune != utf8.RuneError { + nextBRune, nextBSize := utf8.DecodeRune(bBytes[bSize:]) + if nextBRune == aRune { + distance++ + aBytes = aBytes[aSize+nextASize:] + bBytes = bBytes[bSize+nextBSize:] + continue + } + } + + // Make sure to not pop from b, so we can compare the rest, in + // case we are only missing one character for cases such as: + // abc ~ bc + // abcde ~ abde + bSize = 0 + } else { + // We'd reach a diff of 2 now. Needs to happen after transposition + // though, as transposition could still prove us wrong. + if distance == 1 { + return DistantGuess + } + } + + distance++ + } + + aBytes = aBytes[aSize:] + bBytes = bBytes[bSize:] + } +} diff --git a/internal/game/words_test.go b/internal/game/words_test.go index 73413aca..0300c54e 100644 --- a/internal/game/words_test.go +++ b/internal/game/words_test.go @@ -2,6 +2,7 @@ package game import ( "bytes" + "fmt" "strings" "sync" "testing" @@ -248,3 +249,155 @@ func arrayContains(array []string, item string) bool { return false } + +var poximityBenchCases = [][]string{ + {"", ""}, + {"a", "a"}, + {"ab", "ab"}, + {"abc", "abc"}, + {"abc", "abcde"}, + {"cde", "abcde"}, + {"a", "abcdefghijklmnop"}, + {"cde", "abcde"}, + {"cheese", "wheel"}, + {"abcdefg", "bcdefgh"}, +} + +func Benchmark_proximity_custom(b *testing.B) { + for _, benchCase := range poximityBenchCases { + b.Run(fmt.Sprint(benchCase[0], " ", benchCase[1]), func(b *testing.B) { + var sink int + for i := 0; i < b.N; i++ { + sink = CheckGuess(benchCase[0], benchCase[1]) + } + _ = sink + }) + } +} + +// We've replaced levensthein with the implementation from proximity_custom +// func Benchmark_proximity_levensthein(b *testing.B) { +// for _, benchCase := range poximityBenchCases { +// b.Run(fmt.Sprint(benchCase[0], " ", benchCase[1]), func(b *testing.B) { +// var sink int +// for i := 0; i < b.N; i++ { +// sink = levenshtein.ComputeDistance(benchCase[0], benchCase[1]) +// } +// _ = sink +// }) +// } +// } + +func Test_CheckGuess_Negative(t *testing.T) { + type testCase struct { + a, b string + } + + cases := []testCase{ + { + a: "abc", + b: "abcde", + }, + { + a: "abc", + b: "01abc", + }, + { + a: "abc", + b: "a", + }, + { + a: "abc", + b: "c", + }, + { + a: "abc", + b: "c", + }, + { + a: "hallo", + b: "welt", + }, + { + a: "abcd", + b: "badc", + }, + } + + for _, c := range cases { + t.Run(fmt.Sprintf("%s ~ %s", c.a, c.b), func(t *testing.T) { + assert.Equal(t, 2, CheckGuess(c.a, c.b)) + }) + } +} + +func Test_CheckGuess_Positive(t *testing.T) { + type testCase struct { + a, b string + dist int + } + + cases := []testCase{ + { + a: "abc", + b: "abc", + dist: EqualGuess, + }, + { + a: "abc", + b: "abcd", + dist: CloseGuess, + }, + { + a: "abc", + b: "ab", + dist: CloseGuess, + }, + { + a: "abc", + b: "bc", + dist: CloseGuess, + }, + { + a: "abcde", + b: "abde", + dist: CloseGuess, + }, + { + a: "abc", + b: "adc", + dist: CloseGuess, + }, + { + a: "abc", + b: "acb", + dist: CloseGuess, + }, + { + a: "abcd", + b: "acbd", + dist: CloseGuess, + }, + { + a: "abcd", + b: "bacd", + dist: CloseGuess, + }, + { + a: "cheese", + b: "wheel", + dist: DistantGuess, + }, + { + a: "a", + b: "bcdefg", + dist: DistantGuess, + }, + } + + for _, c := range cases { + t.Run(fmt.Sprintf("%s ~ %s", c.a, c.b), func(t *testing.T) { + assert.Equal(t, c.dist, CheckGuess(c.a, c.b)) + }) + } +}