Skip to content

Commit

Permalink
Improve "Close guess" checks
Browse files Browse the repository at this point in the history
* Now handles transpositions such as `bc ~ cb`, but not `abcd ~ dbca`
* Doesn't check further than the minimum required distance anymore
* Removed dep on levensthein lib
  • Loading branch information
Bios-Marcel committed Mar 2, 2024
1 parent e448efe commit d3dc356
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 12 deletions.
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
9 changes: 4 additions & 5 deletions internal/game/lobby.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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())

Expand All @@ -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
Expand Down
81 changes: 81 additions & 0 deletions internal/game/words.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"strings"
"unicode/utf8"

"golang.org/x/text/cases"
"golang.org/x/text/language"
Expand Down Expand Up @@ -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:]
}
}
153 changes: 153 additions & 0 deletions internal/game/words_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package game

import (
"bytes"
"fmt"
"strings"
"sync"
"testing"
Expand Down Expand Up @@ -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))
})
}
}

0 comments on commit d3dc356

Please sign in to comment.