├── .gitignore ├── heroku.yml ├── resources ├── frontend │ ├── kick.png │ ├── plop.wav │ ├── end-turn.wav │ ├── favicon.png │ ├── vanilla-js.png │ ├── your-turn.wav │ ├── Montserrat-Bold.otf │ ├── Montserrat-Italic.otf │ ├── Montserrat-Medium.otf │ ├── Montserrat-Regular.otf │ ├── user.svg │ ├── error.css │ ├── no-sound.svg │ ├── lobby_create.css │ ├── sound.svg │ ├── rubber.svg │ ├── fill.svg │ ├── base.css │ ├── floodfill.js │ └── lobby.css └── words │ ├── nl │ └── fr ├── app.json ├── Makefile ├── templates ├── error.html ├── footer.html └── lobby_create.html ├── Dockerfile ├── communication ├── http.go ├── init.go ├── ws.go ├── publiccreate.go ├── lobbycreateparse_test.go ├── lobby.go └── create.go ├── .github └── workflows │ └── scribble-rs.yml ├── main.go ├── go.mod ├── game ├── lobby_test.go ├── rocketchat.go ├── words.go ├── words_test.go ├── data.go └── lobby.go ├── tools └── translate.sh ├── LICENSE ├── README.md └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | pkged.go 2 | scribblers 3 | .vscode/ 4 | *-packr.go -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile 4 | -------------------------------------------------------------------------------- /resources/frontend/kick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/kick.png -------------------------------------------------------------------------------- /resources/frontend/plop.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/plop.wav -------------------------------------------------------------------------------- /resources/frontend/end-turn.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/end-turn.wav -------------------------------------------------------------------------------- /resources/frontend/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/favicon.png -------------------------------------------------------------------------------- /resources/frontend/vanilla-js.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/vanilla-js.png -------------------------------------------------------------------------------- /resources/frontend/your-turn.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/your-turn.wav -------------------------------------------------------------------------------- /resources/frontend/Montserrat-Bold.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/Montserrat-Bold.otf -------------------------------------------------------------------------------- /resources/frontend/Montserrat-Italic.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/Montserrat-Italic.otf -------------------------------------------------------------------------------- /resources/frontend/Montserrat-Medium.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/Montserrat-Medium.otf -------------------------------------------------------------------------------- /resources/frontend/Montserrat-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shubham8550/scribble.rs/master/resources/frontend/Montserrat-Regular.otf -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Scribble.rs", 3 | "description": "A multiplayer drawing game for the browser", 4 | "repository": "https://github.com/scribble-rs/scribble.rs", 5 | "keywords": [ 6 | "game", 7 | "multiplayer", 8 | "ephemeral" 9 | ], 10 | "stack": "container" 11 | } -------------------------------------------------------------------------------- /resources/frontend/user.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/frontend/error.css: -------------------------------------------------------------------------------- 1 | .error-title, .error-message { 2 | display: block; 3 | } 4 | 5 | .error-title { 6 | font-size: 13rem; 7 | } 8 | 9 | .error-message { 10 | font-size: 4rem; 11 | margin: 2vw; 12 | } 13 | 14 | .go-back, .go-back:link, .go-back:visited { 15 | display: block; 16 | font-size: 3rem; 17 | color: rgb(248, 148, 164); 18 | text-align: center; 19 | } 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT: help 2 | .SILENT: 3 | SHELL=bash 4 | 5 | help: ## Display usage 6 | printf "\033[96mScribble.rs\033[0m\n\n" 7 | grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 8 | 9 | is-go-installed: 10 | which go >/dev/null 2>&1 || { echo >&2 "'go' is required.\nPlease install it."; exit 1; } 11 | 12 | build: is-go-installed ## Build binary file 13 | go run github.com/gobuffalo/packr/v2/packr2 14 | CGO_ENABLED=0 go build -ldflags="-w -s" -o scribblers . 15 | printf "\033[32mBuild done!\033[0m\n" 16 | -------------------------------------------------------------------------------- /templates/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Scribble.rs - Error 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

(◕︿◕✿)

15 |

{{.}}

16 | Click here to get back to the Homepage 17 |
18 | 19 | {{template "footer"}} 20 | 21 | 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # builder 2 | FROM golang:latest AS builder 3 | RUN mkdir /app 4 | ADD . /app/ 5 | WORKDIR /app 6 | 7 | RUN make build 8 | 9 | # certificates are required in case the Go binary do HTTPS calls 10 | # to read more about it: https://www.docker.com/blog/docker-golang/ "The special case of SSL certificates" 11 | FROM alpine:latest as certs 12 | RUN apk --no-cache add ca-certificates 13 | 14 | # runner 15 | FROM scratch 16 | #WORKDIR /app 17 | 18 | # For future implementation of SSL certificate support 19 | COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 20 | # Selfcontained executable used as entrypoint 21 | COPY --from=builder /app/scribblers /scribblers 22 | 23 | ENTRYPOINT ["/scribblers"] 24 | -------------------------------------------------------------------------------- /templates/footer.html: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 | 13 | {{end}} -------------------------------------------------------------------------------- /resources/frontend/no-sound.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /communication/http.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | //userFacingError will return the occurred error as a custom html page to the caller. 10 | func userFacingError(w http.ResponseWriter, errorMessage string) { 11 | err := errorPage.ExecuteTemplate(w, "error.html", errorMessage) 12 | //This should never happen, but if it does, something is very wrong. 13 | if err != nil { 14 | panic(err) 15 | } 16 | } 17 | 18 | func remoteAddressToSimpleIP(input string) string { 19 | address := input 20 | lastIndexOfDoubleColon := strings.LastIndex(address, ":") 21 | if lastIndexOfDoubleColon != -1 { 22 | address = address[:lastIndexOfDoubleColon] 23 | } 24 | 25 | return strings.TrimSuffix(strings.TrimPrefix(address, "["), "]") 26 | } 27 | 28 | func Serve(port int) error { 29 | return http.ListenAndServe(fmt.Sprintf(":%d", port), nil) 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/scribble-rs.yml: -------------------------------------------------------------------------------- 1 | name: Run scribble-rs tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | 7 | run-tests: 8 | 9 | strategy: 10 | matrix: 11 | go-version: [1.13.x, 1.14.x] 12 | platform: [ubuntu-latest, macos-latest, windows-latest] 13 | 14 | runs-on: ${{ matrix.platform }} 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | with: 19 | fetch: 1 20 | 21 | - name: Install Go 22 | uses: actions/setup-go@v1 23 | with: 24 | go-version: ${{ matrix.go-version }} 25 | 26 | - name: Test 27 | shell: bash 28 | run: | 29 | go test -race -coverprofile=profile.out -covermode=atomic ./... 30 | 31 | - name: Load on codecov 32 | if: matrix.platform == 'ubuntu-latest' && matrix.go-version == '1.14.x' 33 | uses: codecov/codecov-action@v1 34 | with: 35 | file: ./profile.out 36 | fail_ci_if_error: true 37 | -------------------------------------------------------------------------------- /resources/frontend/lobby_create.css: -------------------------------------------------------------------------------- 1 | .content-wrapper { 2 | padding-bottom: 2rem; 3 | } 4 | 5 | .center-container { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | justify-content: center; 10 | margin-top: 10px; 11 | margin-bottom: 10px; 12 | } 13 | 14 | .content-container { 15 | display: block; 16 | background-color: rgb(250, 192, 202); 17 | border-radius: 15px; 18 | padding: 20px; 19 | } 20 | 21 | .input-container { 22 | justify-content: center; 23 | align-items: center; 24 | display: inline-grid; 25 | grid-template-columns: auto auto; 26 | grid-column-gap: 20px; 27 | grid-row-gap: 10px; 28 | } 29 | 30 | .input-item { 31 | width: inherit; 32 | min-width: 300px; 33 | } 34 | 35 | .error-list { 36 | background-color: rgb(236, 93, 93); 37 | padding: 10px; 38 | border-radius: 15px; 39 | color: white; 40 | font-weight: bold; 41 | margin-bottom: 20px; 42 | grid-column-start: 1; 43 | grid-column-end: 3; 44 | } 45 | -------------------------------------------------------------------------------- /resources/frontend/sound.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /resources/frontend/rubber.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 10 | 12 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/scribble-rs/scribble.rs/communication" 12 | ) 13 | 14 | func main() { 15 | portHTTPFlag := flag.Int("portHTTP", -1, "defines the port to be used for http mode") 16 | flag.Parse() 17 | 18 | var portHTTP int 19 | if *portHTTPFlag != -1 { 20 | portHTTP = *portHTTPFlag 21 | log.Printf("Listening on port %d sourced from portHTTP flag.\n", portHTTP) 22 | } else { 23 | //Support for heroku, as heroku expects applications to use a specific port. 24 | envPort, _ := os.LookupEnv("PORT") 25 | parsed, parseError := strconv.ParseInt(envPort, 10, 16) 26 | if parseError == nil { 27 | portHTTP = int(parsed) 28 | log.Printf("Listening on port %d sourced from PORT environment variable\n", portHTTP) 29 | } else { 30 | portHTTP = 8080 31 | log.Printf("Listening on default port %d\n", portHTTP) 32 | } 33 | } 34 | 35 | //Setting the seed in order for the petnames to be random. 36 | rand.Seed(time.Now().UnixNano()) 37 | 38 | log.Println("Started.") 39 | 40 | //If this ever fails, it will return and print a fatal logger message 41 | log.Fatal(communication.Serve(portHTTP)) 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/scribble-rs/scribble.rs 2 | 3 | go 1.12 4 | 5 | require ( 6 | github.com/Bios-Marcel/cmdp v0.0.0-20190623190758-6760aca2c54e 7 | github.com/Bios-Marcel/discordemojimap v1.0.1 8 | github.com/agnivade/levenshtein v1.0.3 9 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 // indirect 10 | github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 11 | github.com/gobuffalo/packr/v2 v2.8.0 12 | github.com/gorilla/websocket v1.4.2 13 | github.com/karrick/godirwalk v1.15.6 // indirect 14 | github.com/kennygrant/sanitize v1.2.4 15 | github.com/kr/text v0.2.0 // indirect 16 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect 17 | github.com/satori/go.uuid v1.2.0 18 | github.com/sirupsen/logrus v1.5.0 // indirect 19 | github.com/spf13/cobra v1.0.0 // indirect 20 | github.com/spf13/pflag v1.0.5 // indirect 21 | golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 // indirect 22 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a // indirect 23 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 // indirect 24 | golang.org/x/tools v0.0.0-20200413161937-250b2131eb8b // indirect 25 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect 26 | gopkg.in/yaml.v2 v2.2.8 // indirect 27 | ) 28 | -------------------------------------------------------------------------------- /resources/frontend/fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | 12 | 13 | -------------------------------------------------------------------------------- /game/lobby_test.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func Test_CalculateVotesNeededToKick(t *testing.T) { 8 | t.Run("Check necessary kick vote amount for players", func(test *testing.T) { 9 | var expectedResults = map[int]int{ 10 | //Kinda irrelevant since you can't kick yourself, but who cares. 11 | 1: 1, 12 | 2: 1, 13 | 3: 2, 14 | 4: 2, 15 | 5: 3, 16 | 6: 3, 17 | 7: 4, 18 | 8: 4, 19 | 9: 5, 20 | 10: 5, 21 | } 22 | 23 | for k, v := range expectedResults { 24 | result := calculateVotesNeededToKick(k) 25 | if result != v { 26 | t.Errorf("Error. Necessary vote amount was %d, but should've been %d", result, v) 27 | } 28 | } 29 | }) 30 | } 31 | 32 | func Test_RemoveAccents(t *testing.T) { 33 | t.Run("Check removing accented characters", func(test *testing.T) { 34 | var expectedResults = map[string]string{ 35 | "é": "e", 36 | "É": "E", 37 | "à": "a", 38 | "À": "A", 39 | "ç": "c", 40 | "Ç": "C", 41 | "ö": "oe", 42 | "Ö": "OE", 43 | "œ": "oe", 44 | "\n": "\n", 45 | "\t": "\t", 46 | "\r": "\r", 47 | "": "", 48 | "·": "·", 49 | "?:!": "?:!", 50 | "ac-ab": "acab", 51 | "ac - _ab-- ": "acab", 52 | } 53 | 54 | for k, v := range expectedResults { 55 | result := removeAccents(k) 56 | if result != v { 57 | t.Errorf("Error. Char was %s, but should've been %s", result, v) 58 | } 59 | } 60 | }) 61 | } -------------------------------------------------------------------------------- /tools/translate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ -z ${FROM+x} ]; then echo "FROM is unset"; exit 1; fi 4 | if [ -z ${TO+x} ]; then echo "TO is unset"; exit 1; fi 5 | 6 | SOURCE_FILE="../resources/words/${FROM}" 7 | DESTINATION_FILE="../resources/words/${TO}" 8 | 9 | if [ ! -f ${SOURCE_FILE} ]; then echo "file: ${SOURCE_FILE} does not exist"; fi; 10 | if [ -f ${DESTINATION_FILE} ]; then 11 | echo "WARNING! file: ${DESTINATION_FILE} already exist!"; 12 | echo "Do you want to overide it? [yes/no]" 13 | read -r choice 14 | if [[ "${choice}" != "yes" ]]; then 15 | echo "Aborting" 16 | exit 1; 17 | fi 18 | 19 | rm -f ${DESTINATION_FILE} 20 | fi; 21 | 22 | touch ${DESTINATION_FILE} 23 | 24 | while read sourceWord; do 25 | cleanWord=$(echo "$sourceWord" | sed -En "s/(.*)\#.*/\1/p") 26 | tag=$(echo "$sourceWord" | sed -En "s/.*\#(.*)/\1/p") 27 | 28 | echo -n "Translating '${cleanWord}'... " 29 | 30 | # Wanne exclude some words based on a tag? 31 | # Just un-comment and edit the following lines 32 | if [[ ${tag} == "i" ]]; then 33 | echo "❌ Skipping due to tag setting." 34 | continue; 35 | fi 36 | 37 | # non-optimized AWS call 38 | # Must use a translation-job here 39 | translation=$(aws translate translate-text --text "${cleanWord}" --source-language-code "${FROM}" --target-language-code "${TO}" | jq -r .TranslatedText) 40 | 41 | echo "${translation}" >> ${DESTINATION_FILE} 42 | echo "✅" 43 | done <${SOURCE_FILE} 44 | 45 | echo "Done!" 46 | -------------------------------------------------------------------------------- /resources/frontend/base.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Montserrat'; 3 | font-weight: normal; 4 | src: local('Monserrat'), url(/resources/Montserrat-Regular.otf) format("opentype"); 5 | } 6 | 7 | @font-face { 8 | font-family: 'Montserrat'; 9 | font-weight: bold; 10 | src: local('Monserrat Bold'), url(/resources/Montserrat-Bold.otf) format("opentype"); 11 | } 12 | 13 | html { 14 | font-family: 'Montserrat'; 15 | } 16 | 17 | body { 18 | background-color: antiquewhite; 19 | margin: 0; 20 | position: relative; 21 | min-height: 100vh; 22 | } 23 | 24 | a { 25 | text-decoration: none; 26 | } 27 | 28 | a:link, a:visited { 29 | color: inherit; 30 | } 31 | 32 | a:hover { 33 | text-decoration: underline; 34 | } 35 | 36 | h1, h2 { 37 | margin: 0; 38 | text-align: center; 39 | color: rgb(248, 148, 164); 40 | } 41 | 42 | h1 { 43 | font-size: 6rem; 44 | } 45 | 46 | h2 { 47 | font-size: 4rem; 48 | } 49 | 50 | ul { 51 | margin: 0; 52 | } 53 | 54 | footer { 55 | display: flex; 56 | justify-content: center; 57 | align-items: center; 58 | position: absolute; 59 | bottom: 0; 60 | height: 2rem; 61 | width: 100%; 62 | background-color: lightslategray; 63 | color: white; 64 | } 65 | 66 | .footer-item + .footer-item { 67 | margin-left: 1rem; 68 | } 69 | 70 | .noscript { 71 | display: flex; 72 | font-size: 2.5rem; 73 | font-weight: bold; 74 | justify-content: center; 75 | border-bottom: 1rem solid black; 76 | padding: 10px; 77 | } 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2019, scribble-rs 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /game/rocketchat.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | type rocketChatPayload struct { 15 | Alias string `json:"alias"` 16 | Text string `json:"text"` 17 | } 18 | 19 | var ( 20 | // Go doesn't set timeouts by default 21 | netTransport = &http.Transport{ 22 | Dial: (&net.Dialer{ 23 | Timeout: 5 * time.Second, 24 | }).Dial, 25 | TLSHandshakeTimeout: 5 * time.Second, 26 | } 27 | 28 | netClient = &http.Client{ 29 | Timeout: time.Second * 10, 30 | Transport: netTransport, 31 | } 32 | 33 | rocketchatWebhook string 34 | scribbleURL string 35 | ) 36 | 37 | func init() { 38 | rocketchatWebhook, _ = os.LookupEnv("ROCKETCHAT_WEBHOOK") 39 | scribbleURL, _ = os.LookupEnv("SCRIBBLE_URL") 40 | } 41 | 42 | func updateRocketChat(lobby *Lobby, player *Player) { 43 | //This means scribble wasn't set up correctly for use with rocket chat. 44 | if rocketchatWebhook == "" || scribbleURL == "" { 45 | return 46 | } 47 | 48 | var count int 49 | // Only count connected players 50 | for _, p := range lobby.Players { 51 | if p.Connected { 52 | count++ 53 | } 54 | } 55 | 56 | var action string 57 | if !player.Connected { 58 | action = "disconnected" 59 | } else { 60 | action = "connected" 61 | } 62 | 63 | if count == 0 { 64 | sendRocketChatMessage(fmt.Sprintf("%v has %v. The game has ended.", player.Name, action)) 65 | } else { 66 | sendRocketChatMessage(fmt.Sprintf("%v has %v. There are %v players in the game. Join [here](%v/ssrEnterLobby?lobby_id=%v)", player.Name, action, count, scribbleURL, lobby.ID)) 67 | } 68 | } 69 | func sendRocketChatMessage(msg string) { 70 | payload := rocketChatPayload{ 71 | Alias: "Scribble Bot", 72 | Text: msg, 73 | } 74 | payloadByte, err := json.Marshal(payload) 75 | _, err = netClient.Post(rocketchatWebhook, "application/json", bytes.NewReader(payloadByte)) 76 | if err != nil { 77 | log.Println(err) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /communication/init.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "html/template" 5 | "net/http" 6 | 7 | "github.com/gobuffalo/packr/v2" 8 | ) 9 | 10 | var ( 11 | errorPage *template.Template 12 | lobbyCreatePage *template.Template 13 | lobbyPage *template.Template 14 | ) 15 | 16 | func findStringFromBox(box *packr.Box, name string) string { 17 | result, err := box.FindString(name) 18 | //Since this isn't a runtime error that should happen, we instantly panic. 19 | if err != nil { 20 | panic(errorPage) 21 | } 22 | 23 | return result 24 | } 25 | 26 | //In this init hook we initialize all templates that could at some point be 27 | //needed during the server runtime. If any of the templates can't be loaded, we 28 | //panic. 29 | func init() { 30 | templates := packr.New("templates", "../templates") 31 | var parseError error 32 | errorPage, parseError = template.New("error.html").Parse(findStringFromBox(templates, "error.html")) 33 | if parseError != nil { 34 | panic(parseError) 35 | } 36 | errorPage, parseError = errorPage.New("footer.html").Parse(findStringFromBox(templates, "footer.html")) 37 | if parseError != nil { 38 | panic(parseError) 39 | } 40 | 41 | lobbyCreatePage, parseError = template.New("lobby_create.html").Parse(findStringFromBox(templates, "lobby_create.html")) 42 | if parseError != nil { 43 | panic(parseError) 44 | } 45 | lobbyCreatePage, parseError = lobbyCreatePage.New("footer.html").Parse(findStringFromBox(templates, "footer.html")) 46 | if parseError != nil { 47 | panic(parseError) 48 | } 49 | 50 | lobbyPage, parseError = template.New("lobby.html").Parse(findStringFromBox(templates, "lobby.html")) 51 | if parseError != nil { 52 | panic(parseError) 53 | } 54 | lobbyPage, parseError = lobbyPage.New("footer.html").Parse(findStringFromBox(templates, "footer.html")) 55 | if parseError != nil { 56 | panic(parseError) 57 | } 58 | 59 | setupRoutes() 60 | } 61 | 62 | func setupRoutes() { 63 | frontendRessourcesBox := packr.New("frontend", "../resources/frontend") 64 | //Endpoints for official webclient 65 | http.Handle("/resources/", http.StripPrefix("/resources/", http.FileServer(frontendRessourcesBox))) 66 | http.HandleFunc("/", homePage) 67 | http.HandleFunc("/ssrEnterLobby", ssrEnterLobby) 68 | http.HandleFunc("/ssrCreateLobby", ssrCreateLobby) 69 | 70 | //The websocket is shared between the public API and the official client 71 | http.HandleFunc("/v1/ws", wsEndpoint) 72 | 73 | //These exist only for the public API. We version them in order to ensure 74 | //backwards compatibility as far as possible. 75 | http.HandleFunc("/v1/lobby", createLobby) 76 | http.HandleFunc("/v1/lobby/player", enterLobby) 77 | } 78 | -------------------------------------------------------------------------------- /templates/lobby_create.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Scribble.rs 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |

Scribble.rs

15 |

Create a lobby

16 | 17 |
18 |
19 | {{if .Errors}} 20 |
21 | Your input contains invalid data: 22 |
    23 | {{range .Errors}} 24 |
  • {{.}}
  • 25 | {{end}} 26 |
27 |
28 | Fix the input and try again. 29 |
30 | 31 | {{end}} 32 |
33 | Lobby-Language 34 | 40 | Drawing Time 41 | 43 | Rounds 44 | 46 | Maximum Players 47 | 49 | Custom Words 50 | 52 | Custom Words Chance 53 | 54 | Clients per IP Limit 55 | 57 | Enable Votekick 58 | 60 | 63 |
64 |
65 |
66 |
67 | 68 | {{template "footer"}} 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /game/words.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | "time" 7 | 8 | "github.com/gobuffalo/packr/v2" 9 | ) 10 | 11 | var ( 12 | wordListCache = make(map[string][]string) 13 | languageMap = map[string]string{ 14 | "english": "en", 15 | "italian": "it", 16 | "german": "de", 17 | "french": "fr", 18 | "dutch": "nl", 19 | } 20 | wordBox = packr.New("words", "../resources/words") 21 | ) 22 | 23 | func readWordList(chosenLanguage string) ([]string, error) { 24 | langFileName := languageMap[chosenLanguage] 25 | list, available := wordListCache[langFileName] 26 | if available { 27 | return list, nil 28 | } 29 | 30 | wordListFile, pkgerError := wordBox.FindString(langFileName) 31 | if pkgerError != nil { 32 | panic(pkgerError) 33 | } 34 | 35 | tempWords := strings.Split(wordListFile, "\n") 36 | var words []string 37 | for _, word := range tempWords { 38 | word = strings.TrimSpace(word) 39 | 40 | //Newlines will just be empty strings 41 | if word == "" { 42 | continue 43 | } 44 | 45 | //The "i" was "impossible", as in "impossible to draw", tag initially supplied. 46 | if strings.HasSuffix(word, "#i") { 47 | continue 48 | } 49 | 50 | //Since not all words use the tag system, we can just instantly return for words that don't use it. 51 | lastIndexNumberSign := strings.LastIndex(word, "#") 52 | if lastIndexNumberSign == -1 { 53 | words = append(words, word) 54 | } else { 55 | words = append(words, word[:lastIndexNumberSign]) 56 | } 57 | } 58 | 59 | wordListCache[langFileName] = words 60 | 61 | return words, nil 62 | } 63 | 64 | // GetRandomWords gets 3 random words for the passed Lobby. The words will be 65 | // chosen from the custom words and the default dictionary, depending on the 66 | // settings specified by the Lobby-Owner. 67 | func GetRandomWords(lobby *Lobby) []string { 68 | rand.Seed(time.Now().Unix()) 69 | wordsNotToPick := lobby.alreadyUsedWords 70 | wordOne := getRandomWordWithCustomWordChance(lobby, wordsNotToPick, lobby.CustomWords, lobby.CustomWordsChance) 71 | wordsNotToPick = append(wordsNotToPick, wordOne) 72 | wordTwo := getRandomWordWithCustomWordChance(lobby, wordsNotToPick, lobby.CustomWords, lobby.CustomWordsChance) 73 | wordsNotToPick = append(wordsNotToPick, wordTwo) 74 | wordThree := getRandomWordWithCustomWordChance(lobby, wordsNotToPick, lobby.CustomWords, lobby.CustomWordsChance) 75 | 76 | return []string{ 77 | wordOne, 78 | wordTwo, 79 | wordThree, 80 | } 81 | } 82 | 83 | func getRandomWordWithCustomWordChance(lobby *Lobby, wordsAlreadyUsed []string, customWords []string, customWordChance int) string { 84 | if len(lobby.CustomWords) > 0 && customWordChance > 0 && rand.Intn(100)+1 <= customWordChance { 85 | return getUnusedCustomWord(lobby, wordsAlreadyUsed, customWords) 86 | } 87 | 88 | return getUnusedRandomWord(lobby, wordsAlreadyUsed) 89 | } 90 | 91 | func getUnusedCustomWord(lobby *Lobby, wordsAlreadyUsed []string, customWords []string) string { 92 | OUTER_LOOP: 93 | for _, word := range customWords { 94 | for _, usedWord := range wordsAlreadyUsed { 95 | if usedWord == word { 96 | continue OUTER_LOOP 97 | } 98 | } 99 | 100 | return word 101 | } 102 | 103 | return getUnusedRandomWord(lobby, wordsAlreadyUsed) 104 | } 105 | 106 | func getUnusedRandomWord(lobby *Lobby, wordsAlreadyUsed []string) string { 107 | //We attempt to find a random word for a hundred times, afterwards we just use any. 108 | randomnessAttempts := 0 109 | var word string 110 | OUTER_LOOP: 111 | for { 112 | word = lobby.Words[rand.Int()%len(lobby.Words)] 113 | for _, usedWord := range wordsAlreadyUsed { 114 | if usedWord == word { 115 | if randomnessAttempts == 100 { 116 | break OUTER_LOOP 117 | } 118 | 119 | randomnessAttempts++ 120 | continue OUTER_LOOP 121 | } 122 | } 123 | break 124 | } 125 | 126 | return word 127 | } 128 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Scribble.rs

2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

17 | 18 | Scribble.rs is a clone of the web-based drawing game skribbl.io. In my opinion 19 | skribbl.io has several usability issues, which I'll address in this project. 20 | 21 | The site will not display any ads or share any data with third parties. 22 | 23 | ## Play now 24 | 25 | Feel free to play on any of these instances: 26 | 27 | * https://scribblers-official.herokuapp.com/ 28 | > Might not respond right-away, just wait some seconds 29 | * http://scribble.rs 30 | > No HTTPS! 31 | 32 | ### Hosting your own instance for free 33 | 34 | By using Heroku, you can deploy a temporary container that runs scribble.rs. 35 | The container will not have any cost and automatically suspend as soon as it 36 | stops receiving traffic for a while. 37 | 38 | Simply create an account at https://id.heroku.com/login and then click this link: 39 | 40 | https://heroku.com/deploy?template=https://github.com/scribble-rs/scribble.rs/tree/master 41 | 42 | ## Building / Running 43 | 44 | Run the following to build the application: 45 | 46 | ```shell 47 | git clone https://github.com/scribble-rs/scribble.rs.git 48 | cd scribble.rs 49 | ``` 50 | 51 | For -nix systems: 52 | ```shell 53 | # run `make` to see all availables commands 54 | make build 55 | ``` 56 | 57 | For Windows: 58 | ```shell 59 | go run github.com/gobuffalo/packr/v2/packr2 60 | go build -o scribblers . 61 | ``` 62 | 63 | This will produce a portable binary called `scribblers`. The binary doesn't 64 | have any dependencies and should run on every system as long as it has the 65 | same architecture and OS family as the system it was compiled on. 66 | 67 | The default port will be `8080`. The parameter `portHTTP` allows changing the 68 | port though. 69 | 70 | It should run on any system that go supports as a compilation target. 71 | 72 | This application uses go modules, therefore you need to make sure that you 73 | have go version `1.13` or higher. 74 | 75 | ## Docker 76 | 77 | Alternatively there's a docker container: 78 | 79 | ```shell 80 | docker pull biosmarcel/scribble.rs 81 | ``` 82 | 83 | ### Changing default port 84 | 85 | The default port is `8080`. To override it, run: 86 | ```shell 87 | docker run -p : biosmarcel/scribble.rs --portHTTP= 88 | ``` 89 | 90 | ## Contributing 91 | 92 | There are many ways you can contribute: 93 | 94 | * Update / Add documentation in the wiki of the GitHub repository 95 | * Extend this README 96 | * Create feature requests and bug reports 97 | * Solve issues by creating Pull Requests 98 | * Tell your friends about the project 99 | * Curating the word lists 100 | 101 | ## Credits 102 | 103 | These resources are by people unrelated to the project, whilst not every of these 104 | resources requires attribution as per license, we'll do it either way ;) 105 | 106 | If you happen to find a mistake here, please make a PR. If you are one of the 107 | authors and feel like we've wronged you, please reach out. 108 | 109 | * Favicon - [Fredy Sujono](https://www.iconfinder.com/freud) 110 | * Rubber Icon - Made by [Pixel Buddha](https://www.flaticon.com/authors/pixel-buddha) from [flaticon.com](https://flaticon.com) 111 | * Fill Bucket Icon - Made by [inipagistudio](https://www.flaticon.com/authors/inipagistudio) from [flaticon.com](https://flaticon.com) 112 | * Kicking Icon - [Kicking Icon #309402](https://icon-library.net/icon/kicking-icon-4.html) 113 | * Sound / No sound Icon - Made by Viktor Erikson (If this is you or you know who this is, send me a link to that persons Homepage) 114 | * Profile Icon - Made by [kumakamu](https://www.iconfinder.com/kumakamu) 115 | -------------------------------------------------------------------------------- /game/words_test.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | ) 8 | 9 | func Test_readWordList(t *testing.T) { 10 | t.Run("test invalid language file", func(t *testing.T) { 11 | //We expect a panic for invalid language files. 12 | defer func() { 13 | err := recover() 14 | if err == nil { 15 | panic(fmt.Sprintf("Test should've failed, but returned nil error")) 16 | } 17 | }() 18 | _, readError := readWordList("owO") 19 | if readError == nil { 20 | t.Errorf("Reading word list didn't return an error, even though the langauge doesn't exist.") 21 | } 22 | }) 23 | 24 | for language := range languageMap { 25 | t.Run(fmt.Sprintf("Testing language file for %s", language), func(t *testing.T) { 26 | //First run from box/drive 27 | testWordList(language, t) 28 | //Second run from in-memory cache 29 | testWordList(language, t) 30 | }) 31 | } 32 | } 33 | 34 | func testWordList(language string, t *testing.T) { 35 | words, readError := readWordList(language) 36 | if readError != nil { 37 | t.Errorf("Error reading language %s: %s", language, readError) 38 | } 39 | 40 | if len(words) == 0 { 41 | t.Errorf("Wordlist for language %s was empty.", language) 42 | } 43 | 44 | for _, word := range words { 45 | if word == "" { 46 | t.Errorf("Wordlist for language %s contained empty word", language) 47 | } 48 | 49 | if strings.HasPrefix(word, " ") || strings.HasSuffix(word, " ") { 50 | t.Errorf("Word has surrounding spaces: %s", word) 51 | } 52 | } 53 | } 54 | 55 | func Test_getRandomWords(t *testing.T) { 56 | t.Run("Test getRandomWords with 3 words in list", func(t *testing.T) { 57 | lobby := &Lobby{ 58 | CurrentWord: "", 59 | Words: []string{"a", "b", "c"}, 60 | } 61 | 62 | randomWords := GetRandomWords(lobby) 63 | for _, lobbyWord := range lobby.Words { 64 | if !arrayContains(randomWords, lobbyWord) { 65 | t.Errorf("Random words %s, didn't contain lobbyWord %s", randomWords, lobbyWord) 66 | } 67 | } 68 | }) 69 | 70 | t.Run("Test getRandomWords with 3 words in list and 3 more in custom word list, but with 0 chance", func(t *testing.T) { 71 | lobby := &Lobby{ 72 | CurrentWord: "", 73 | Words: []string{"a", "b", "c"}, 74 | CustomWordsChance: 0, 75 | CustomWords: []string{"d", "e", "f"}, 76 | } 77 | 78 | randomWords := GetRandomWords(lobby) 79 | for _, lobbyWord := range lobby.Words { 80 | if !arrayContains(randomWords, lobbyWord) { 81 | t.Errorf("Random words %s, didn't contain lobbyWord %s", randomWords, lobbyWord) 82 | } 83 | } 84 | }) 85 | 86 | t.Run("Test getRandomWords with 3 words in list and 100% custom word chance, but without custom words", func(t *testing.T) { 87 | lobby := &Lobby{ 88 | CurrentWord: "", 89 | Words: []string{"a", "b", "c"}, 90 | CustomWordsChance: 100, 91 | CustomWords: nil, 92 | } 93 | 94 | randomWords := GetRandomWords(lobby) 95 | for _, lobbyWord := range lobby.Words { 96 | if !arrayContains(randomWords, lobbyWord) { 97 | t.Errorf("Random words %s, didn't contain lobbyWord %s", randomWords, lobbyWord) 98 | } 99 | } 100 | }) 101 | 102 | t.Run("Test getRandomWords with 3 words in list and 100% custom word chance, with 3 custom words", func(t *testing.T) { 103 | lobby := &Lobby{ 104 | CurrentWord: "", 105 | Words: []string{"a", "b", "c"}, 106 | CustomWordsChance: 100, 107 | CustomWords: []string{"d", "e", "f"}, 108 | } 109 | 110 | for i := 0; i < 1000; i++ { 111 | randomWords := GetRandomWords(lobby) 112 | for _, customWord := range lobby.CustomWords { 113 | if !arrayContains(randomWords, customWord) { 114 | t.Errorf("Random words %s, didn't contain customWord %s", randomWords, customWord) 115 | } 116 | } 117 | } 118 | }) 119 | 120 | t.Run("Test getRandomWords with 3 words in list and 100% custom word chance, with 3 custom words and one of them on the used list", func(t *testing.T) { 121 | lobby := &Lobby{ 122 | CurrentWord: "", 123 | Words: []string{"a", "b", "c"}, 124 | CustomWordsChance: 100, 125 | CustomWords: []string{"d", "e", "f"}, 126 | alreadyUsedWords: []string{"f"}, 127 | } 128 | 129 | for i := 0; i < 1000; i++ { 130 | randomWords := GetRandomWords(lobby) 131 | if !arrayContains(randomWords, "d") { 132 | t.Errorf("Random words %s, didn't contain customWord d", randomWords) 133 | } 134 | 135 | if !arrayContains(randomWords, "e") { 136 | t.Errorf("Random words %s, didn't contain customWord e", randomWords) 137 | } 138 | 139 | if arrayContains(randomWords, "f") { 140 | t.Errorf("Random words %s, contained customWord f", randomWords) 141 | } 142 | } 143 | }) 144 | } 145 | 146 | func arrayContains(array []string, item string) bool { 147 | for _, arrayItem := range array { 148 | if arrayItem == item { 149 | return true 150 | } 151 | } 152 | 153 | return false 154 | } 155 | -------------------------------------------------------------------------------- /communication/ws.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "html" 8 | "log" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/gorilla/websocket" 13 | 14 | "github.com/scribble-rs/scribble.rs/game" 15 | ) 16 | 17 | var upgrader = websocket.Upgrader{ 18 | ReadBufferSize: 1024, 19 | WriteBufferSize: 1024, 20 | CheckOrigin: func(r *http.Request) bool { return true }, 21 | } 22 | 23 | func init() { 24 | game.TriggerComplexUpdateEvent = TriggerComplexUpdateEvent 25 | game.TriggerSimpleUpdateEvent = TriggerSimpleUpdateEvent 26 | game.SendDataToConnectedPlayers = SendDataToConnectedPlayers 27 | game.WriteAsJSON = WriteAsJSON 28 | game.WritePublicSystemMessage = WritePublicSystemMessage 29 | game.TriggerComplexUpdatePerPlayerEvent = TriggerComplexUpdatePerPlayerEvent 30 | } 31 | 32 | func wsEndpoint(w http.ResponseWriter, r *http.Request) { 33 | lobby, lobbyError := getLobby(r) 34 | if lobbyError != nil { 35 | http.Error(w, lobbyError.Error(), http.StatusNotFound) 36 | return 37 | } 38 | 39 | //This issue can happen if you illegally request a websocket connection without ever having had 40 | //a usersession or your client having deleted the usersession cookie. 41 | sessionCookie := getUserSession(r) 42 | if sessionCookie == "" { 43 | http.Error(w, "you don't have access to this lobby;usersession not set", http.StatusUnauthorized) 44 | return 45 | } 46 | 47 | player := lobby.GetPlayer(sessionCookie) 48 | if player == nil { 49 | http.Error(w, "you don't have access to this lobby;usersession invalid", http.StatusUnauthorized) 50 | return 51 | } 52 | 53 | ws, err := upgrader.Upgrade(w, r, nil) 54 | if err != nil { 55 | http.Error(w, err.Error(), http.StatusInternalServerError) 56 | return 57 | } 58 | 59 | log.Println(player.Name + " has connected") 60 | 61 | player.SetWebsocket(ws) 62 | game.OnConnected(lobby, player) 63 | 64 | ws.SetCloseHandler(func(code int, text string) error { 65 | game.OnDisconnected(lobby, player) 66 | return nil 67 | }) 68 | 69 | go wsListen(lobby, player, ws) 70 | } 71 | 72 | func wsListen(lobby *game.Lobby, player *game.Player, socket *websocket.Conn) { 73 | //Workaround to prevent crash 74 | defer func() { 75 | err := recover() 76 | if err != nil { 77 | game.OnDisconnected(lobby, player) 78 | log.Println("Error occurred in wsListen: ", err) 79 | } 80 | }() 81 | for { 82 | messageType, data, err := socket.ReadMessage() 83 | if err != nil { 84 | if websocket.IsCloseError(err) || websocket.IsUnexpectedCloseError(err) || 85 | //This happens when the server closes the connection. It will cause 1000 retries followed by a panic. 86 | strings.Contains(err.Error(), "use of closed network connection") { 87 | //Make sure that the sockethandler is called 88 | game.OnDisconnected(lobby, player) 89 | log.Println(player.Name + " disconnected.") 90 | return 91 | } 92 | 93 | log.Printf("Error reading from socket: %s\n", err) 94 | } else if messageType == websocket.TextMessage { 95 | received := &game.JSEvent{} 96 | err := json.Unmarshal(data, received) 97 | if err != nil { 98 | log.Printf("Error unmarshalling message: %s\n", err) 99 | sendError := WriteAsJSON(player, game.JSEvent{Type: "system-message", Data: fmt.Sprintf("An error occurred trying to read your request, please report the error via GitHub: %s!", err)}) 100 | if sendError != nil { 101 | log.Printf("Error sending errormessage: %s\n", sendError) 102 | } 103 | continue 104 | } 105 | 106 | handleError := game.HandleEvent(data, received, lobby, player) 107 | if handleError != nil { 108 | log.Printf("Error handling event: %s\n", handleError) 109 | } 110 | } 111 | } 112 | } 113 | 114 | func SendDataToConnectedPlayers(sender *game.Player, lobby *game.Lobby, data interface{}) { 115 | for _, otherPlayer := range lobby.Players { 116 | if otherPlayer != sender { 117 | WriteAsJSON(otherPlayer, data) 118 | } 119 | } 120 | } 121 | 122 | func TriggerSimpleUpdateEvent(eventType string, lobby *game.Lobby) { 123 | event := &game.JSEvent{Type: eventType} 124 | for _, otherPlayer := range lobby.Players { 125 | //FIXME Why did i use a goroutine here but not anywhere else? 126 | go func(player *game.Player) { 127 | WriteAsJSON(player, event) 128 | }(otherPlayer) 129 | } 130 | } 131 | 132 | func TriggerComplexUpdateEvent(eventType string, data interface{}, lobby *game.Lobby) { 133 | event := &game.JSEvent{Type: eventType, Data: data} 134 | for _, otherPlayer := range lobby.Players { 135 | WriteAsJSON(otherPlayer, event) 136 | } 137 | } 138 | 139 | func TriggerComplexUpdatePerPlayerEvent(eventType string, data func(*game.Player) interface{}, lobby *game.Lobby) { 140 | for _, otherPlayer := range lobby.Players { 141 | WriteAsJSON(otherPlayer, &game.JSEvent{Type: eventType, Data: data(otherPlayer)}) 142 | } 143 | } 144 | 145 | // WriteAsJSON marshals the given input into a JSON string and sends it to the 146 | // player using the currently established websocket connection. 147 | func WriteAsJSON(player *game.Player, object interface{}) error { 148 | player.GetWebsocketMutex().Lock() 149 | defer player.GetWebsocketMutex().Unlock() 150 | 151 | socket := player.GetWebsocket() 152 | if socket == nil || !player.Connected { 153 | return errors.New("player not connected") 154 | } 155 | 156 | return socket.WriteJSON(object) 157 | } 158 | 159 | func WritePublicSystemMessage(lobby *game.Lobby, text string) { 160 | playerHasBeenKickedMsg := &game.JSEvent{Type: "system-message", Data: html.EscapeString(text)} 161 | for _, otherPlayer := range lobby.Players { 162 | //In simple message events we ignore write failures. 163 | WriteAsJSON(otherPlayer, playerHasBeenKickedMsg) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /communication/publiccreate.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/scribble-rs/scribble.rs/game" 9 | ) 10 | 11 | //This file contains the API methods for the public API 12 | 13 | func enterLobby(w http.ResponseWriter, r *http.Request) { 14 | lobby, err := getLobby(r) 15 | if err != nil { 16 | if err == noLobbyIdSuppliedError { 17 | http.Error(w, err.Error(), http.StatusBadRequest) 18 | } else if err == lobbyNotExistentError { 19 | http.Error(w, err.Error(), http.StatusNotFound) 20 | } else { 21 | http.Error(w, err.Error(), http.StatusInternalServerError) 22 | } 23 | return 24 | } 25 | 26 | player := getPlayer(lobby, r) 27 | 28 | if player == nil { 29 | if len(lobby.Players) >= lobby.MaxPlayers { 30 | http.Error(w, "lobby already full", http.StatusUnauthorized) 31 | return 32 | } 33 | 34 | var clientsWithSameIP int 35 | requestAddress := getIPAddressFromRequest(r) 36 | for _, otherPlayer := range lobby.Players { 37 | if otherPlayer.GetLastKnownAddress() == requestAddress { 38 | clientsWithSameIP++ 39 | if clientsWithSameIP >= lobby.ClientsPerIPLimit { 40 | http.Error(w, "maximum amount of newPlayer per IP reached", http.StatusUnauthorized) 41 | return 42 | } 43 | } 44 | } 45 | 46 | newPlayer := lobby.JoinPlayer(getPlayername(r)) 47 | newPlayer.SetLastKnownAddress(getIPAddressFromRequest(r)) 48 | 49 | // Use the players generated usersession and pass it as a cookie. 50 | http.SetCookie(w, &http.Cookie{ 51 | Name: "usersession", 52 | Value: newPlayer.GetUserSession(), 53 | Path: "/", 54 | SameSite: http.SameSiteStrictMode, 55 | }) 56 | } else { 57 | player.SetLastKnownAddress(getIPAddressFromRequest(r)) 58 | } 59 | 60 | lobbyData := &LobbyData{ 61 | LobbyID: lobby.ID, 62 | DrawingBoardBaseWidth: DrawingBoardBaseWidth, 63 | DrawingBoardBaseHeight: DrawingBoardBaseHeight, 64 | } 65 | 66 | encodingError := json.NewEncoder(w).Encode(lobbyData) 67 | if encodingError != nil { 68 | http.Error(w, encodingError.Error(), http.StatusInternalServerError) 69 | } 70 | } 71 | 72 | func createLobby(w http.ResponseWriter, r *http.Request) { 73 | formParseError := r.ParseForm() 74 | if formParseError != nil { 75 | http.Error(w, formParseError.Error(), http.StatusBadRequest) 76 | return 77 | } 78 | 79 | language, languageInvalid := parseLanguage(r.Form.Get("language")) 80 | drawingTime, drawingTimeInvalid := parseDrawingTime(r.Form.Get("drawing_time")) 81 | rounds, roundsInvalid := parseRounds(r.Form.Get("rounds")) 82 | maxPlayers, maxPlayersInvalid := parseMaxPlayers(r.Form.Get("max_players")) 83 | customWords, customWordsInvalid := parseCustomWords(r.Form.Get("custom_words")) 84 | customWordChance, customWordChanceInvalid := parseCustomWordsChance(r.Form.Get("custom_words_chance")) 85 | clientsPerIPLimit, clientsPerIPLimitInvalid := parseClientsPerIPLimit(r.Form.Get("clients_per_ip_limit")) 86 | enableVotekick := r.Form.Get("enable_votekick") == "true" 87 | 88 | //Prevent resetting the form, since that would be annoying as hell. 89 | pageData := CreatePageData{ 90 | SettingBounds: game.LobbySettingBounds, 91 | Languages: game.SupportedLanguages, 92 | DrawingTime: r.Form.Get("drawing_time"), 93 | Rounds: r.Form.Get("rounds"), 94 | MaxPlayers: r.Form.Get("max_players"), 95 | CustomWords: r.Form.Get("custom_words"), 96 | CustomWordsChance: r.Form.Get("custom_words_chance"), 97 | ClientsPerIPLimit: r.Form.Get("clients_per_ip_limit"), 98 | EnableVotekick: r.Form.Get("enable_votekick"), 99 | Language: r.Form.Get("language"), 100 | } 101 | 102 | if languageInvalid != nil { 103 | pageData.Errors = append(pageData.Errors, languageInvalid.Error()) 104 | } 105 | if drawingTimeInvalid != nil { 106 | pageData.Errors = append(pageData.Errors, drawingTimeInvalid.Error()) 107 | } 108 | if roundsInvalid != nil { 109 | pageData.Errors = append(pageData.Errors, roundsInvalid.Error()) 110 | } 111 | if maxPlayersInvalid != nil { 112 | pageData.Errors = append(pageData.Errors, maxPlayersInvalid.Error()) 113 | } 114 | if customWordsInvalid != nil { 115 | pageData.Errors = append(pageData.Errors, customWordsInvalid.Error()) 116 | } 117 | if customWordChanceInvalid != nil { 118 | pageData.Errors = append(pageData.Errors, customWordChanceInvalid.Error()) 119 | } 120 | if clientsPerIPLimitInvalid != nil { 121 | pageData.Errors = append(pageData.Errors, clientsPerIPLimitInvalid.Error()) 122 | } 123 | 124 | if len(pageData.Errors) != 0 { 125 | http.Error(w, strings.Join(pageData.Errors, ";"), http.StatusBadRequest) 126 | return 127 | } 128 | 129 | var playerName = getPlayername(r) 130 | player, lobby, createError := game.CreateLobby(playerName, language, drawingTime, rounds, maxPlayers, customWordChance, clientsPerIPLimit, customWords, enableVotekick) 131 | if createError != nil { 132 | http.Error(w, createError.Error(), http.StatusBadRequest) 133 | return 134 | } 135 | 136 | player.SetLastKnownAddress(getIPAddressFromRequest(r)) 137 | 138 | // Use the players generated usersession and pass it as a cookie. 139 | http.SetCookie(w, &http.Cookie{ 140 | Name: "usersession", 141 | Value: player.GetUserSession(), 142 | Path: "/", 143 | SameSite: http.SameSiteStrictMode, 144 | }) 145 | 146 | lobbyData := &LobbyData{ 147 | LobbyID: lobby.ID, 148 | DrawingBoardBaseWidth: DrawingBoardBaseWidth, 149 | DrawingBoardBaseHeight: DrawingBoardBaseHeight, 150 | } 151 | 152 | encodingError := json.NewEncoder(w).Encode(lobbyData) 153 | if encodingError != nil { 154 | //If the encoding / transmitting fails, the creator will never know the 155 | //ID, therefore we can directly kill the lobby. 156 | game.RemoveLobby(lobby.ID) 157 | http.Error(w, encodingError.Error(), http.StatusInternalServerError) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /communication/lobbycreateparse_test.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func Test_parsePlayerName(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | value string 12 | want string 13 | wantErr bool 14 | }{ 15 | {"empty name", "", "", true}, 16 | {"blank name", " ", "", true}, 17 | {"one letter name", "a", "a", false}, 18 | {"normal name", "Scribble", "Scribble", false}, 19 | {"name with space in the middle", "Hello World", "Hello World", false}, 20 | } 21 | for _, tt := range tests { 22 | t.Run(tt.name, func(t *testing.T) { 23 | got, err := parsePlayerName(tt.value) 24 | if (err != nil) != tt.wantErr { 25 | t.Errorf("parsePlayerName() error = %v, wantErr %v", err, tt.wantErr) 26 | return 27 | } 28 | if got != tt.want { 29 | t.Errorf("parsePlayerName() = %v, want %v", got, tt.want) 30 | } 31 | }) 32 | } 33 | } 34 | 35 | func Test_parsePassword(t *testing.T) { 36 | tests := []struct { 37 | name string 38 | value string 39 | want string 40 | wantErr bool 41 | }{ 42 | {"empty password", "", "", false}, 43 | {"space as password", " ", " ", false}, 44 | {"word as password", "word", "word", false}, 45 | {"string with space in the middle", "Hello World", "Hello World", false}, 46 | } 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | got, err := parsePassword(tt.value) 50 | if (err != nil) != tt.wantErr { 51 | t.Errorf("parsePassword() error = %v, wantErr %v", err, tt.wantErr) 52 | return 53 | } 54 | if got != tt.want { 55 | t.Errorf("parsePassword() = %v, want %v", got, tt.want) 56 | } 57 | }) 58 | } 59 | } 60 | 61 | func Test_parseDrawingTime(t *testing.T) { 62 | tests := []struct { 63 | name string 64 | value string 65 | want int 66 | wantErr bool 67 | }{ 68 | {"empty value", "", 0, true}, 69 | {"space", " ", 0, true}, 70 | {"less than minimum", "59", 0, true}, 71 | {"more than maximum", "301", 0, true}, 72 | {"maximum", "300", 300, false}, 73 | {"minimum", "60", 60, false}, 74 | {"something valid", "150", 150, false}, 75 | } 76 | for _, tt := range tests { 77 | t.Run(tt.name, func(t *testing.T) { 78 | got, err := parseDrawingTime(tt.value) 79 | if (err != nil) != tt.wantErr { 80 | t.Errorf("parseDrawingTime() error = %v, wantErr %v", err, tt.wantErr) 81 | return 82 | } 83 | if got != tt.want { 84 | t.Errorf("parseDrawingTime() = %v, want %v", got, tt.want) 85 | } 86 | }) 87 | } 88 | } 89 | 90 | func Test_parseRounds(t *testing.T) { 91 | tests := []struct { 92 | name string 93 | value string 94 | want int 95 | wantErr bool 96 | }{ 97 | {"empty value", "", 0, true}, 98 | {"space", " ", 0, true}, 99 | {"less than minimum", "0", 0, true}, 100 | {"more than maximum", "21", 0, true}, 101 | {"maximum", "20", 20, false}, 102 | {"minimum", "1", 1, false}, 103 | {"something valid", "15", 15, false}, 104 | } 105 | for _, tt := range tests { 106 | t.Run(tt.name, func(t *testing.T) { 107 | got, err := parseRounds(tt.value) 108 | if (err != nil) != tt.wantErr { 109 | t.Errorf("parseRounds() error = %v, wantErr %v", err, tt.wantErr) 110 | return 111 | } 112 | if got != tt.want { 113 | t.Errorf("parseRounds() = %v, want %v", got, tt.want) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func Test_parseMaxPlayers(t *testing.T) { 120 | tests := []struct { 121 | name string 122 | value string 123 | want int 124 | wantErr bool 125 | }{ 126 | {"empty value", "", 0, true}, 127 | {"space", " ", 0, true}, 128 | {"less than minimum", "1", 0, true}, 129 | {"more than maximum", "25", 0, true}, 130 | {"maximum", "24", 24, false}, 131 | {"minimum", "2", 2, false}, 132 | {"something valid", "15", 15, false}, 133 | } 134 | for _, tt := range tests { 135 | t.Run(tt.name, func(t *testing.T) { 136 | got, err := parseMaxPlayers(tt.value) 137 | if (err != nil) != tt.wantErr { 138 | t.Errorf("parseMaxPlayers() error = %v, wantErr %v", err, tt.wantErr) 139 | return 140 | } 141 | if got != tt.want { 142 | t.Errorf("parseMaxPlayers() = %v, want %v", got, tt.want) 143 | } 144 | }) 145 | } 146 | } 147 | 148 | func Test_parseCustomWords(t *testing.T) { 149 | tests := []struct { 150 | name string 151 | value string 152 | want []string 153 | wantErr bool 154 | }{ 155 | {"emtpty", "", nil, false}, 156 | {"spaces", " ", nil, false}, 157 | {"spaces with comma in middle", " , ", nil, true}, 158 | {"single word", "hello", []string{"hello"}, false}, 159 | {"single word upper to lower", "HELLO", []string{"hello"}, false}, 160 | {"single word with spaces around", " hello ", []string{"hello"}, false}, 161 | {"two words", "hello,world", []string{"hello", "world"}, false}, 162 | {"two words with spaces around", " hello , world ", []string{"hello", "world"}, false}, 163 | {"sentence and word", "What a great day, hello ", []string{"what a great day", "hello"}, false}, 164 | } 165 | for _, tt := range tests { 166 | t.Run(tt.name, func(t *testing.T) { 167 | got, err := parseCustomWords(tt.value) 168 | if (err != nil) != tt.wantErr { 169 | t.Errorf("parseCustomWords() error = %v, wantErr %v", err, tt.wantErr) 170 | return 171 | } 172 | if !reflect.DeepEqual(got, tt.want) { 173 | t.Errorf("parseCustomWords() = %v, want %v", got, tt.want) 174 | } 175 | }) 176 | } 177 | } 178 | 179 | func Test_parseCustomWordChance(t *testing.T) { 180 | tests := []struct { 181 | name string 182 | value string 183 | want int 184 | wantErr bool 185 | }{ 186 | {"empty value", "", 0, true}, 187 | {"space", " ", 0, true}, 188 | {"less than minimum", "-1", 0, true}, 189 | {"more than maximum", "101", 0, true}, 190 | {"maximum", "100", 100, false}, 191 | {"minimum", "0", 0, false}, 192 | {"something valid", "60", 60, false}, 193 | } 194 | for _, tt := range tests { 195 | t.Run(tt.name, func(t *testing.T) { 196 | got, err := parseCustomWordsChance(tt.value) 197 | if (err != nil) != tt.wantErr { 198 | t.Errorf("parseCustomWordsChance() error = %v, wantErr %v", err, tt.wantErr) 199 | return 200 | } 201 | if got != tt.want { 202 | t.Errorf("parseCustomWordsChance() = %v, want %v", got, tt.want) 203 | } 204 | }) 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /resources/frontend/floodfill.js: -------------------------------------------------------------------------------- 1 | var floodfill = (function() { 2 | 3 | //Copyright(c) Max Irwin - 2011, 2015, 2016 4 | //MIT License 5 | 6 | function floodfill(data,x,y,fillcolor,tolerance,width,height) { 7 | 8 | var length = data.length; 9 | var Q = []; 10 | var i = (x+y*width)*4; 11 | var e = i, w = i, me, mw, w2 = width*4; 12 | 13 | var targetcolor = [data[i],data[i+1],data[i+2],data[i+3]]; 14 | 15 | if(!pixelCompare(i,targetcolor,fillcolor,data,length,tolerance)) { return false; } 16 | Q.push(i); 17 | while(Q.length) { 18 | i = Q.pop(); 19 | if(pixelCompareAndSet(i,targetcolor,fillcolor,data,length,tolerance)) { 20 | e = i; 21 | w = i; 22 | mw = parseInt(i/w2)*w2; //left bound 23 | me = mw+w2; //right bound 24 | while(mwe && me>(e+=4) && pixelCompareAndSet(e,targetcolor,fillcolor,data,length,tolerance)); //go right until edge hit 26 | for(var j=w+4;j=0 && pixelCompare(j-w2,targetcolor,fillcolor,data,length,tolerance)) Q.push(j-w2); //queue y-1 28 | if(j+w2=length) return false; //out of bounds 37 | if (data[i+3]===0 && fillcolor.a>0) return true; //surface is invisible and fill is visible 38 | 39 | if ( 40 | Math.abs(targetcolor[3] - fillcolor.a)<=tolerance && 41 | Math.abs(targetcolor[0] - fillcolor.r)<=tolerance && 42 | Math.abs(targetcolor[1] - fillcolor.g)<=tolerance && 43 | Math.abs(targetcolor[2] - fillcolor.b)<=tolerance 44 | ) return false; //target is same as fill 45 | 46 | if ( 47 | (targetcolor[3] === data[i+3]) && 48 | (targetcolor[0] === data[i] ) && 49 | (targetcolor[1] === data[i+1]) && 50 | (targetcolor[2] === data[i+2]) 51 | ) return true; //target matches surface 52 | 53 | if ( 54 | Math.abs(targetcolor[3] - data[i+3])<=(255-tolerance) && 55 | Math.abs(targetcolor[0] - data[i] )<=tolerance && 56 | Math.abs(targetcolor[1] - data[i+1])<=tolerance && 57 | Math.abs(targetcolor[2] - data[i+2])<=tolerance 58 | ) return true; //target to surface within tolerance 59 | 60 | return false; //no match 61 | }; 62 | 63 | function pixelCompareAndSet(i,targetcolor,fillcolor,data,length,tolerance) { 64 | if(pixelCompare(i,targetcolor,fillcolor,data,length,tolerance)) { 65 | //fill the color 66 | data[i] = fillcolor.r; 67 | data[i+1] = fillcolor.g; 68 | data[i+2] = fillcolor.b; 69 | data[i+3] = fillcolor.a; 70 | return true; 71 | } 72 | return false; 73 | }; 74 | 75 | function fillUint8ClampedArray(data,x,y,color,tolerance,width,height) { 76 | if (!data instanceof Uint8ClampedArray) throw new Error("data must be an instance of Uint8ClampedArray"); 77 | if (isNaN(width) || width<1) throw new Error("argument 'width' must be a positive integer"); 78 | if (isNaN(height) || height<1) throw new Error("argument 'height' must be a positive integer"); 79 | if (isNaN(x) || x<0) throw new Error("argument 'x' must be a positive integer"); 80 | if (isNaN(y) || y<0) throw new Error("argument 'y' must be a positive integer"); 81 | if (width*height*4!==data.length) throw new Error("width and height do not fit Uint8ClampedArray dimensions"); 82 | 83 | var xi = Math.floor(x); 84 | var yi = Math.floor(y); 85 | 86 | if (xi!==x) console.warn("x truncated from",x,"to",xi); 87 | if (yi!==y) console.warn("y truncated from",y,"to",yi); 88 | 89 | //Maximum tolerance of 254, Default to 0 90 | tolerance = (!isNaN(tolerance)) ? Math.min(Math.abs(Math.round(tolerance)),254) : 0; 91 | 92 | return floodfill(data,xi,yi,color,tolerance,width,height); 93 | }; 94 | 95 | var getComputedColor = function(c) { 96 | var temp = document.createElement("div"); 97 | var color = {r:0,g:0,b:0,a:0}; 98 | temp.style.color = c; 99 | temp.style.display = "none"; 100 | document.body.appendChild(temp); 101 | //Use native window.getComputedStyle to parse any CSS color pattern 102 | var style = window.getComputedStyle(temp,null).color; 103 | document.body.removeChild(temp); 104 | 105 | var recol = /([\.\d]+)/g; 106 | var vals = style.match(recol); 107 | if (vals && vals.length>2) { 108 | //Coerce the string value into an rgba object 109 | color.r = parseInt(vals[0])||0; 110 | color.g = parseInt(vals[1])||0; 111 | color.b = parseInt(vals[2])||0; 112 | color.a = Math.round((parseFloat(vals[3])||1.0)*255); 113 | } 114 | return color; 115 | }; 116 | 117 | function fillContext(x,y,tolerance,left,top,right,bottom) { 118 | var ctx = this; 119 | 120 | //Gets the rgba color from the context fillStyle 121 | var color = getComputedColor(this.fillStyle); 122 | 123 | //Defaults and type checks for image boundaries 124 | left = (isNaN(left)) ? 0 : left; 125 | top = (isNaN(top)) ? 0 : top; 126 | right = (!isNaN(right)&&right) ? Math.min(Math.abs(right),ctx.canvas.width) : ctx.canvas.width; 127 | bottom = (!isNaN(bottom)&&bottom) ? Math.min(Math.abs(bottom),ctx.canvas.height) : ctx.canvas.height; 128 | 129 | var image = ctx.getImageData(left,top,right,bottom); 130 | 131 | var data = image.data; 132 | var width = image.width; 133 | var height = image.height; 134 | 135 | if(width>0 && height>0) { 136 | fillUint8ClampedArray(data,x,y,color,tolerance,width,height); 137 | ctx.putImageData(image,left,top); 138 | } 139 | }; 140 | 141 | if (typeof CanvasRenderingContext2D != 'undefined') { 142 | CanvasRenderingContext2D.prototype.fillFlood = fillContext; 143 | }; 144 | 145 | return fillUint8ClampedArray; 146 | 147 | })(); 148 | -------------------------------------------------------------------------------- /resources/frontend/lobby.css: -------------------------------------------------------------------------------- 1 | #rounds { 2 | font-size: 1.7rem; 3 | flex: 1; 4 | } 5 | 6 | #time-left { 7 | font-size: 1.5rem; 8 | } 9 | 10 | #word-container { 11 | text-align: center; 12 | } 13 | 14 | .guess-letter-underline { 15 | text-transform: uppercase; 16 | border-bottom: 0.3rem black solid; 17 | } 18 | 19 | .guess-letter { 20 | font-family: monospace; 21 | font-weight: bold; 22 | font-size: 1.5rem; 23 | } 24 | 25 | .guess-letter + .guess-letter { 26 | margin-left: 0.5rem; 27 | } 28 | 29 | /* 30 | 31 | */ 32 | 33 | #lobby { 34 | padding: 5px; 35 | display: grid; 36 | grid-template-columns: 15rem auto 18rem; 37 | grid-template-rows: min-content auto auto min-content; 38 | grid-gap: 5px; 39 | } 40 | 41 | 42 | /* 43 | * These two ensure that the drawing board has an aspect ratio of 16/9. 44 | * Technically we could make this configurable by setting the padding via JS. 45 | */ 46 | #drawing-board-wrapper { 47 | width: 100%; 48 | height: 0; 49 | padding-bottom: 56.25%; 50 | position: relative; 51 | background-color: red; 52 | } 53 | 54 | #drawing-board-inner-wrapper { 55 | position: absolute; 56 | top: 0; 57 | right: 0; 58 | bottom: 0; 59 | left: 0; 60 | } 61 | 62 | #drawing-board { 63 | position: absolute; 64 | background-color: white; 65 | width: 100%; 66 | height: 100%; 67 | } 68 | 69 | #center-dialog { 70 | pointer-events: none; 71 | touch-action: none; 72 | position: absolute; 73 | width: 100%; 74 | height: 100%; 75 | z-index: 20; 76 | } 77 | 78 | .center-dialog-content { 79 | pointer-events: all; 80 | touch-action: auto; 81 | background-color: rgb(225, 221, 221); 82 | display: flex; 83 | flex-direction: column; 84 | justify-content: center; 85 | align-items: center; 86 | visibility: hidden; 87 | padding-top: 1rem; 88 | padding-bottom: 1rem; 89 | position: absolute; 90 | left: 50%; 91 | top: 50%; 92 | transform: translate(-50%, -50%); 93 | /* A dialog should never fully hide the canvas. */ 94 | max-width: 80%; 95 | max-height: 80%; 96 | } 97 | 98 | #chat { 99 | min-width: 15rem; 100 | height: 100%; 101 | display: grid; 102 | grid-template-rows: auto fit-content(100%); 103 | grid-column: 3; 104 | grid-row-start: 2; 105 | grid-row-end: 4; 106 | } 107 | 108 | #message-container { 109 | padding: 5px; 110 | overflow-y: scroll; 111 | word-break: break-all; 112 | background-color: white; 113 | } 114 | 115 | #word-container { 116 | justify-items: center; 117 | } 118 | 119 | .chat-name { 120 | font-weight: bold; 121 | } 122 | 123 | .chat-name:after { 124 | content: ":"; 125 | } 126 | 127 | .non-guessing-player-message { 128 | color: rgb(38, 187, 38); 129 | } 130 | 131 | .message-input-form { 132 | display: flex; 133 | } 134 | 135 | #message-input { 136 | flex: 1; 137 | border: 0; 138 | border-top: 3px solid black; 139 | padding: 5px; 140 | } 141 | 142 | #message-input:focus { 143 | outline: none; 144 | background-color: rgb(250, 232, 235); 145 | } 146 | 147 | .dialog-title { 148 | margin-bottom: 15px; 149 | font-size: 3rem; 150 | font-weight: bold; 151 | color: rgb(240, 105, 127); 152 | margin-left: 20px; 153 | margin-right: 20px; 154 | } 155 | 156 | .word-button-container { 157 | display: flex; 158 | flex-direction: row; 159 | margin-left: 20px; 160 | margin-right: 20px; 161 | } 162 | 163 | .dialog-button { 164 | border: none; 165 | background-color: white; 166 | padding: 0.5rem 1rem 0.5rem 1rem; 167 | } 168 | 169 | .dialog-button + .dialog-button { 170 | margin-left: 0.25rem; 171 | } 172 | 173 | .dialog-close-button { 174 | margin-top: 1rem; 175 | } 176 | 177 | button:hover, 178 | input[type="button"]:hover, 179 | .line-width-button-content:hover, 180 | #color-picker:hover { 181 | /** Important insures it won't be prevented by a 'transparent' main color*/ 182 | background-color: rgb(244, 183, 247) !important; 183 | } 184 | 185 | button:active, 186 | input[type="button"]:active, 187 | #color-picker:active { 188 | /** Important insures it won't be prevented by a 'transparent' main color*/ 189 | background-color: rgb(243, 132, 247) !important; 190 | } 191 | 192 | .header-button { 193 | padding: 0.2rem; 194 | background-color: transparent; 195 | } 196 | 197 | .header-button-image { 198 | width: 1.7rem; 199 | height: 1.7rem; 200 | } 201 | 202 | .custom-check-or-radio { 203 | /* Little hack in order to hide the original components of the check/radio button */ 204 | opacity: 0.0; 205 | position: absolute; 206 | } 207 | 208 | .dot { 209 | background-color: black; 210 | border-radius: 50%; 211 | } 212 | 213 | .line-width-button + .line-width-button-content { 214 | height: 50px; 215 | width: 50px; 216 | display: flex; 217 | align-items: center; 218 | justify-content: center; 219 | } 220 | 221 | .line-width-button-content { 222 | background-color: rgb(218, 218, 218); 223 | } 224 | 225 | .line-width-button:checked + .line-width-button-content { 226 | background-color: rgb(243, 132, 247); 227 | } 228 | 229 | #color-picker { 230 | border: 0; 231 | background-color: rgb(218, 218, 218); 232 | height: 50px; 233 | width: 50px; 234 | padding: 0px; 235 | } 236 | 237 | @-moz-document url-prefix() { 238 | #color-picker { 239 | padding: 5px; 240 | } 241 | } 242 | 243 | .canvas-button { 244 | height: 50px; 245 | width: 50px; 246 | border: 0; 247 | background-color: rgb(218, 218, 218); 248 | } 249 | 250 | .canvas-button::-moz-focus-inner { 251 | border: 0; 252 | } 253 | 254 | .color-button-container { 255 | border: 1px solid black; 256 | display: flex; 257 | flex-direction: column; 258 | } 259 | 260 | .color-button-row { 261 | display: flex; 262 | flex-direction: row; 263 | } 264 | 265 | .color-button { 266 | height: 24px; 267 | width: 24px; 268 | border: 0; 269 | } 270 | 271 | .color-button::-moz-focus-inner { 272 | border: 0; 273 | } 274 | 275 | .system-message { 276 | color: red; 277 | } 278 | 279 | #toolbox { 280 | grid-column-start: 2; 281 | grid-column-end: 3; 282 | display: flex; 283 | flex-direction: row; 284 | flex-wrap: wrap; 285 | } 286 | 287 | .toolbox-group { 288 | margin-bottom: 10px; 289 | margin-right: 10px; 290 | } 291 | 292 | .toolbox-group:last-child { 293 | margin-right: 0; 294 | } 295 | 296 | .pencil-sizes-container { 297 | display: flex; 298 | } 299 | 300 | #player-container { 301 | display: flex; 302 | flex-direction: column; 303 | grid-column: 1; 304 | grid-row-start: 2; 305 | grid-row-end: 4; 306 | } 307 | 308 | .player { 309 | background-color: rgb(245, 245, 245); 310 | padding: 0.2rem; 311 | display: grid; 312 | grid-template-columns: fit-content(100%) auto; 313 | grid-template-rows: 1fr 1fr; 314 | } 315 | 316 | .player + .player { 317 | margin-top: 5px; 318 | } 319 | 320 | .playername { 321 | text-overflow: ellipsis; 322 | white-space: nowrap; 323 | overflow: hidden; 324 | flex: 1; 325 | } 326 | 327 | .playername-self { 328 | font-weight: bold; 329 | } 330 | 331 | .player-done { 332 | background-color: rgb(141, 224, 15); 333 | } 334 | 335 | .rank { 336 | display: flex; 337 | grid-row-start: 1; 338 | grid-row-end: 3; 339 | justify-content: center; 340 | align-items: center; 341 | width: 2.5rem; 342 | font-size: 1.5rem; 343 | } 344 | 345 | .score-and-status { 346 | display: flex; 347 | flex-direction: row; 348 | justify-content: space-between; 349 | } 350 | 351 | .last-turn-score { 352 | font-size: 0.8rem; 353 | color: lightslategray; 354 | } 355 | 356 | #kick-dialog-players { 357 | width: 100%; 358 | overflow-x: hidden; 359 | overflow-y: auto; 360 | } 361 | 362 | .kick-player-button { 363 | width: 70%; 364 | border: none; 365 | margin-left: 15%; 366 | padding-top: 1rem; 367 | padding-bottom: 1rem; 368 | } 369 | 370 | .kick-player-button + .kick-player-button { 371 | margin-top: 0.5rem; 372 | } 373 | -------------------------------------------------------------------------------- /communication/lobby.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "html" 7 | "net/http" 8 | "strings" 9 | 10 | "github.com/scribble-rs/scribble.rs/game" 11 | ) 12 | 13 | var ( 14 | noLobbyIdSuppliedError = errors.New("please supply a lobby id via the 'lobby_id' query parameter") 15 | lobbyNotExistentError = errors.New("the requested lobby doesn't exist") 16 | ) 17 | 18 | func getLobby(r *http.Request) (*game.Lobby, error) { 19 | lobbyID := r.URL.Query().Get("lobby_id") 20 | if lobbyID == "" { 21 | return nil, noLobbyIdSuppliedError 22 | } 23 | 24 | lobby := game.GetLobby(lobbyID) 25 | 26 | if lobby == nil { 27 | return nil, lobbyNotExistentError 28 | } 29 | 30 | return lobby, nil 31 | } 32 | 33 | func getUserSession(r *http.Request) string { 34 | sessionCookie, noCookieError := r.Cookie("usersession") 35 | if noCookieError == nil && sessionCookie.Value != "" { 36 | return sessionCookie.Value 37 | } 38 | 39 | session, ok := r.Header["Usersession"] 40 | if ok { 41 | return session[0] 42 | } 43 | 44 | return "" 45 | } 46 | 47 | func getPlayer(lobby *game.Lobby, r *http.Request) *game.Player { 48 | return lobby.GetPlayer(getUserSession(r)) 49 | } 50 | 51 | func getPlayername(r *http.Request) string { 52 | usernameCookie, noCookieError := r.Cookie("username") 53 | if noCookieError == nil { 54 | username := html.EscapeString(strings.TrimSpace(usernameCookie.Value)) 55 | if username != "" { 56 | return trimDownTo(username, 30) 57 | } 58 | } 59 | 60 | parseError := r.ParseForm() 61 | if parseError == nil { 62 | username := r.Form.Get("username") 63 | if username != "" { 64 | return trimDownTo(username, 30) 65 | } 66 | } 67 | 68 | return game.GeneratePlayerName() 69 | } 70 | 71 | func trimDownTo(text string, size int) string { 72 | if len(text) <= size { 73 | return text 74 | } 75 | 76 | return text[:size] 77 | } 78 | 79 | // GetPlayers returns divs for all players in the lobby to the calling client. 80 | func GetPlayers(w http.ResponseWriter, r *http.Request) { 81 | lobby, err := getLobby(r) 82 | if err != nil { 83 | http.Error(w, err.Error(), http.StatusNotFound) 84 | return 85 | } 86 | 87 | if getPlayer(lobby, r) == nil { 88 | http.Error(w, "you aren't part of this lobby", http.StatusUnauthorized) 89 | return 90 | } 91 | 92 | w.Header().Set("Content-Type", "application/json") 93 | err = json.NewEncoder(w).Encode(lobby.Players) 94 | if err != nil { 95 | http.Error(w, err.Error(), http.StatusInternalServerError) 96 | } 97 | } 98 | 99 | //GetRounds returns the html structure and data for the current round info. 100 | func GetRounds(w http.ResponseWriter, r *http.Request) { 101 | lobby, err := getLobby(r) 102 | if err != nil { 103 | http.Error(w, err.Error(), http.StatusNotFound) 104 | return 105 | } 106 | 107 | if getPlayer(lobby, r) == nil { 108 | http.Error(w, "you aren't part of this lobby", http.StatusUnauthorized) 109 | return 110 | } 111 | 112 | w.Header().Set("Content-Type", "application/json") 113 | err = json.NewEncoder(w).Encode(game.Rounds{Round: lobby.Round, MaxRounds: lobby.MaxRounds}) 114 | if err != nil { 115 | http.Error(w, err.Error(), http.StatusInternalServerError) 116 | } 117 | } 118 | 119 | // GetWordHint returns the html structure and data for the current word hint. 120 | func GetWordHint(w http.ResponseWriter, r *http.Request) { 121 | lobby, err := getLobby(r) 122 | if err != nil { 123 | http.Error(w, err.Error(), http.StatusNotFound) 124 | return 125 | } 126 | 127 | player := getPlayer(lobby, r) 128 | if player == nil { 129 | http.Error(w, "you aren't part of this lobby", http.StatusUnauthorized) 130 | return 131 | } 132 | 133 | w.Header().Set("Content-Type", "application/json") 134 | err = json.NewEncoder(w).Encode(lobby.GetAvailableWordHints(player)) 135 | if err != nil { 136 | http.Error(w, err.Error(), http.StatusInternalServerError) 137 | } 138 | } 139 | 140 | const ( 141 | DrawingBoardBaseWidth = 1600 142 | DrawingBoardBaseHeight = 900 143 | ) 144 | 145 | // LobbyData is the data necessary for initially displaying all data of 146 | // the lobbies webpage. 147 | type LobbyData struct { 148 | LobbyID string `json:"lobbyId"` 149 | DrawingBoardBaseWidth int `json:"drawingBoardBaseWidth"` 150 | DrawingBoardBaseHeight int `json:"drawingBoardBaseHeight"` 151 | } 152 | 153 | // ssrEnterLobby opens a lobby, either opening it directly or asking for a lobby. 154 | func ssrEnterLobby(w http.ResponseWriter, r *http.Request) { 155 | lobby, err := getLobby(r) 156 | if err != nil { 157 | userFacingError(w, err.Error()) 158 | return 159 | } 160 | 161 | // TODO Improve this. Return metadata or so instead. 162 | userAgent := strings.ToLower(r.UserAgent()) 163 | if !(strings.Contains(userAgent, "gecko") || strings.Contains(userAgent, "chrom") || strings.Contains(userAgent, "opera") || strings.Contains(userAgent, "safari")) { 164 | userFacingError(w, "Sorry, no robots allowed.") 165 | return 166 | } 167 | 168 | //FIXME Temporary 169 | if strings.Contains(userAgent, "iphone") || strings.Contains(userAgent, "android") { 170 | userFacingError(w, "Sorry, mobile is currently not supported.") 171 | return 172 | } 173 | 174 | player := getPlayer(lobby, r) 175 | 176 | pageData := &LobbyData{ 177 | LobbyID: lobby.ID, 178 | DrawingBoardBaseWidth: DrawingBoardBaseWidth, 179 | DrawingBoardBaseHeight: DrawingBoardBaseHeight, 180 | } 181 | 182 | var templateError error 183 | 184 | if player == nil { 185 | if len(lobby.Players) >= lobby.MaxPlayers { 186 | userFacingError(w, "Sorry, but the lobby is full.") 187 | return 188 | } 189 | 190 | var clientsWithSameIP int 191 | requestAddress := getIPAddressFromRequest(r) 192 | for _, otherPlayer := range lobby.Players { 193 | if otherPlayer.GetLastKnownAddress() == requestAddress { 194 | clientsWithSameIP++ 195 | if clientsWithSameIP >= lobby.ClientsPerIPLimit { 196 | userFacingError(w, "Sorry, but you have exceeded the maximum number of clients per IP.") 197 | return 198 | } 199 | } 200 | } 201 | 202 | newPlayer := lobby.JoinPlayer(getPlayername(r)) 203 | 204 | // Use the players generated usersession and pass it as a cookie. 205 | http.SetCookie(w, &http.Cookie{ 206 | Name: "usersession", 207 | Value: newPlayer.GetUserSession(), 208 | Path: "/", 209 | SameSite: http.SameSiteStrictMode, 210 | }) 211 | } else { 212 | if player.Connected && player.GetWebsocket() != nil { 213 | userFacingError(w, "It appears you already have an open tab for this lobby.") 214 | return 215 | } 216 | player.SetLastKnownAddress(getIPAddressFromRequest(r)) 217 | } 218 | 219 | templateError = lobbyPage.ExecuteTemplate(w, "lobby.html", pageData) 220 | if templateError != nil { 221 | panic(templateError) 222 | } 223 | } 224 | 225 | func getIPAddressFromRequest(r *http.Request) string { 226 | //See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For 227 | //See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded 228 | 229 | //The following logic has been implemented according to the spec, therefore please 230 | //refer to the spec if you have a question. 231 | 232 | forwardedAddress := r.Header.Get("X-Forwarded-For") 233 | if forwardedAddress != "" { 234 | //Since the field may contain multiple addresses separated by commas, we use the first 235 | //one, which according to the docs is supposed to be the client address. 236 | clientAddress := strings.TrimSpace(strings.Split(forwardedAddress, ",")[0]) 237 | return remoteAddressToSimpleIP(clientAddress) 238 | } 239 | 240 | standardForwardedHeader := r.Header.Get("Forwarded") 241 | if standardForwardedHeader != "" { 242 | targetPrefix := "for=" 243 | //Since forwarded can contain more than one field, we search for one specific field. 244 | for _, part := range strings.Split(standardForwardedHeader, ";") { 245 | trimmed := strings.TrimSpace(part) 246 | if strings.HasPrefix(trimmed, targetPrefix) { 247 | //FIXME Maybe checking for a valid IP-Address would make sense here, not sure tho. 248 | address := remoteAddressToSimpleIP(strings.TrimPrefix(trimmed, targetPrefix)) 249 | //Since the documentation doesn't mention which quotes are used, I just remove all ;) 250 | return strings.NewReplacer("`", "", "'", "", "\"", "", "[", "", "]", "").Replace(address) 251 | } 252 | } 253 | } 254 | 255 | return remoteAddressToSimpleIP(r.RemoteAddr) 256 | } 257 | -------------------------------------------------------------------------------- /communication/create.go: -------------------------------------------------------------------------------- 1 | package communication 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/scribble-rs/scribble.rs/game" 11 | ) 12 | 13 | //This file contains the API for the official web client. 14 | 15 | // homePage servers the default page for scribble.rs, which is the page to 16 | // create a new lobby. 17 | func homePage(w http.ResponseWriter, r *http.Request) { 18 | err := lobbyCreatePage.ExecuteTemplate(w, "lobby_create.html", createDefaultLobbyCreatePageData()) 19 | if err != nil { 20 | http.Error(w, err.Error(), http.StatusInternalServerError) 21 | } 22 | } 23 | 24 | func createDefaultLobbyCreatePageData() *CreatePageData { 25 | 26 | return &CreatePageData{ 27 | SettingBounds: game.LobbySettingBounds, 28 | Languages: game.SupportedLanguages, 29 | DrawingTime: "120", 30 | Rounds: "4", 31 | MaxPlayers: "120", 32 | CustomWordsChance: "50", 33 | ClientsPerIPLimit: "1", 34 | EnableVotekick: "true", 35 | Language: "english", 36 | } 37 | } 38 | 39 | // CreatePageData defines all non-static data for the lobby create page. 40 | type CreatePageData struct { 41 | *game.SettingBounds 42 | Errors []string 43 | Languages map[string]string 44 | DrawingTime string 45 | Rounds string 46 | MaxPlayers string 47 | CustomWords string 48 | CustomWordsChance string 49 | ClientsPerIPLimit string 50 | EnableVotekick string 51 | Language string 52 | } 53 | 54 | // ssrCreateLobby allows creating a lobby, optionally returning errors that 55 | // occurred during creation. 56 | func ssrCreateLobby(w http.ResponseWriter, r *http.Request) { 57 | formParseError := r.ParseForm() 58 | if formParseError != nil { 59 | http.Error(w, formParseError.Error(), http.StatusBadRequest) 60 | return 61 | } 62 | 63 | language, languageInvalid := parseLanguage(r.Form.Get("language")) 64 | drawingTime, drawingTimeInvalid := parseDrawingTime(r.Form.Get("drawing_time")) 65 | rounds, roundsInvalid := parseRounds(r.Form.Get("rounds")) 66 | maxPlayers, maxPlayersInvalid := parseMaxPlayers(r.Form.Get("max_players")) 67 | customWords, customWordsInvalid := parseCustomWords(r.Form.Get("custom_words")) 68 | customWordChance, customWordChanceInvalid := parseCustomWordsChance(r.Form.Get("custom_words_chance")) 69 | clientsPerIPLimit, clientsPerIPLimitInvalid := parseClientsPerIPLimit(r.Form.Get("clients_per_ip_limit")) 70 | enableVotekick := r.Form.Get("enable_votekick") == "true" 71 | 72 | //Prevent resetting the form, since that would be annoying as hell. 73 | pageData := CreatePageData{ 74 | SettingBounds: game.LobbySettingBounds, 75 | Languages: game.SupportedLanguages, 76 | DrawingTime: r.Form.Get("drawing_time"), 77 | Rounds: r.Form.Get("rounds"), 78 | MaxPlayers: r.Form.Get("max_players"), 79 | CustomWords: r.Form.Get("custom_words"), 80 | CustomWordsChance: r.Form.Get("custom_words_chance"), 81 | ClientsPerIPLimit: r.Form.Get("clients_per_ip_limit"), 82 | EnableVotekick: r.Form.Get("enable_votekick"), 83 | Language: r.Form.Get("language"), 84 | } 85 | 86 | if languageInvalid != nil { 87 | pageData.Errors = append(pageData.Errors, languageInvalid.Error()) 88 | } 89 | if drawingTimeInvalid != nil { 90 | pageData.Errors = append(pageData.Errors, drawingTimeInvalid.Error()) 91 | } 92 | if roundsInvalid != nil { 93 | pageData.Errors = append(pageData.Errors, roundsInvalid.Error()) 94 | } 95 | if maxPlayersInvalid != nil { 96 | pageData.Errors = append(pageData.Errors, maxPlayersInvalid.Error()) 97 | } 98 | if customWordsInvalid != nil { 99 | pageData.Errors = append(pageData.Errors, customWordsInvalid.Error()) 100 | } 101 | if customWordChanceInvalid != nil { 102 | pageData.Errors = append(pageData.Errors, customWordChanceInvalid.Error()) 103 | } 104 | if clientsPerIPLimitInvalid != nil { 105 | pageData.Errors = append(pageData.Errors, clientsPerIPLimitInvalid.Error()) 106 | } 107 | 108 | if len(pageData.Errors) != 0 { 109 | err := lobbyCreatePage.ExecuteTemplate(w, "lobby_create.html", pageData) 110 | if err != nil { 111 | http.Error(w, err.Error(), http.StatusInternalServerError) 112 | } 113 | return 114 | } 115 | 116 | var playerName = getPlayername(r) 117 | 118 | player, lobby, createError := game.CreateLobby(playerName, language, drawingTime, rounds, maxPlayers, customWordChance, clientsPerIPLimit, customWords, enableVotekick) 119 | if createError != nil { 120 | pageData.Errors = append(pageData.Errors, createError.Error()) 121 | templateError := lobbyCreatePage.ExecuteTemplate(w, "lobby_create.html", pageData) 122 | if templateError != nil { 123 | userFacingError(w, templateError.Error()) 124 | } 125 | 126 | return 127 | } 128 | 129 | player.SetLastKnownAddress(getIPAddressFromRequest(r)) 130 | 131 | // Use the players generated usersession and pass it as a cookie. 132 | http.SetCookie(w, &http.Cookie{ 133 | Name: "usersession", 134 | Value: player.GetUserSession(), 135 | Path: "/", 136 | SameSite: http.SameSiteStrictMode, 137 | }) 138 | 139 | http.Redirect(w, r, "/ssrEnterLobby?lobby_id="+lobby.ID, http.StatusFound) 140 | } 141 | 142 | func parsePlayerName(value string) (string, error) { 143 | trimmed := strings.TrimSpace(value) 144 | if trimmed == "" { 145 | return trimmed, errors.New("the player name must not be empty") 146 | } 147 | 148 | return trimmed, nil 149 | } 150 | 151 | func parsePassword(value string) (string, error) { 152 | return value, nil 153 | } 154 | 155 | func parseLanguage(value string) (string, error) { 156 | toLower := strings.ToLower(strings.TrimSpace(value)) 157 | for languageKey := range game.SupportedLanguages { 158 | if toLower == languageKey { 159 | return languageKey, nil 160 | } 161 | } 162 | 163 | return "", errors.New("the given language doesn't match any supported language") 164 | } 165 | 166 | func parseDrawingTime(value string) (int, error) { 167 | result, parseErr := strconv.ParseInt(value, 10, 64) 168 | if parseErr != nil { 169 | return 0, errors.New("the drawing time must be numeric") 170 | } 171 | 172 | if result < game.LobbySettingBounds.MinDrawingTime { 173 | return 0, fmt.Errorf("drawing time must not be smaller than %d", game.LobbySettingBounds.MinDrawingTime) 174 | } 175 | 176 | if result > game.LobbySettingBounds.MaxDrawingTime { 177 | return 0, fmt.Errorf("drawing time must not be greater than %d", game.LobbySettingBounds.MaxDrawingTime) 178 | } 179 | 180 | return int(result), nil 181 | } 182 | 183 | func parseRounds(value string) (int, error) { 184 | result, parseErr := strconv.ParseInt(value, 10, 64) 185 | if parseErr != nil { 186 | return 0, errors.New("the rounds amount must be numeric") 187 | } 188 | 189 | if result < game.LobbySettingBounds.MinRounds { 190 | return 0, fmt.Errorf("rounds must not be smaller than %d", game.LobbySettingBounds.MinRounds) 191 | } 192 | 193 | if result > game.LobbySettingBounds.MaxRounds { 194 | return 0, fmt.Errorf("rounds must not be greater than %d", game.LobbySettingBounds.MaxRounds) 195 | } 196 | 197 | return int(result), nil 198 | } 199 | 200 | func parseMaxPlayers(value string) (int, error) { 201 | result, parseErr := strconv.ParseInt(value, 10, 64) 202 | if parseErr != nil { 203 | return 0, errors.New("the max players amount must be numeric") 204 | } 205 | 206 | if result < game.LobbySettingBounds.MinMaxPlayers { 207 | return 0, fmt.Errorf("maximum players must not be smaller than %d", game.LobbySettingBounds.MinMaxPlayers) 208 | } 209 | 210 | if result > game.LobbySettingBounds.MaxMaxPlayers { 211 | return 0, fmt.Errorf("maximum players must not be greater than %d", game.LobbySettingBounds.MaxMaxPlayers) 212 | } 213 | 214 | return int(result), nil 215 | } 216 | 217 | func parseCustomWords(value string) ([]string, error) { 218 | trimmedValue := strings.TrimSpace(value) 219 | if trimmedValue == "" { 220 | return nil, nil 221 | } 222 | 223 | result := strings.Split(trimmedValue, ",") 224 | for index, item := range result { 225 | trimmedItem := strings.ToLower(strings.TrimSpace(item)) 226 | if trimmedItem == "" { 227 | return nil, errors.New("custom words must not be empty") 228 | } 229 | result[index] = trimmedItem 230 | } 231 | 232 | return result, nil 233 | } 234 | 235 | func parseClientsPerIPLimit(value string) (int, error) { 236 | result, parseErr := strconv.ParseInt(value, 10, 64) 237 | if parseErr != nil { 238 | return 0, errors.New("the clients per IP limit must be numeric") 239 | } 240 | 241 | if result < game.LobbySettingBounds.MinClientsPerIPLimit { 242 | return 0, fmt.Errorf("the clients per IP limit must not be lower than %d", game.LobbySettingBounds.MinClientsPerIPLimit) 243 | } 244 | 245 | if result > game.LobbySettingBounds.MaxClientsPerIPLimit { 246 | return 0, fmt.Errorf("the clients per IP limit must not be higher than %d", game.LobbySettingBounds.MaxClientsPerIPLimit) 247 | } 248 | 249 | return int(result), nil 250 | } 251 | 252 | func parseCustomWordsChance(value string) (int, error) { 253 | result, parseErr := strconv.ParseInt(value, 10, 64) 254 | if parseErr != nil { 255 | return 0, errors.New("the custom word chance must be numeric") 256 | } 257 | 258 | if result < 0 { 259 | return 0, errors.New("custom word chance must not be lower than 0") 260 | } 261 | 262 | if result > 100 { 263 | return 0, errors.New("custom word chance must not be higher than 100") 264 | } 265 | 266 | return int(result), nil 267 | } 268 | -------------------------------------------------------------------------------- /game/data.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "math/rand" 5 | "sync" 6 | "time" 7 | 8 | "github.com/gorilla/websocket" 9 | uuid "github.com/satori/go.uuid" 10 | ) 11 | 12 | // Lobby represents a game session. 13 | type Lobby struct { 14 | // ID uniquely identified the Lobby. 15 | ID string 16 | 17 | // DrawingTime is the amount of seconds that each player has available to 18 | // finish their drawing. 19 | DrawingTime int 20 | // MaxRounds defines how many iterations a lobby does before the game ends. 21 | // One iteration means every participant does one drawing. 22 | MaxRounds int 23 | // MaxPlayers defines the maximum amount of players in a single lobby. 24 | MaxPlayers int 25 | // CustomWords are additional words that will be used in addition to the 26 | // predefined words. 27 | CustomWords []string 28 | Words []string 29 | 30 | // Players references all participants of the Lobby. 31 | Players []*Player 32 | 33 | // Drawer references the Player that is currently drawing. 34 | Drawer *Player 35 | // Owner references the Player that created the lobby. 36 | Owner *Player 37 | // CurrentWord represents the word that was last selected. If no word has 38 | // been selected yet or the round is already over, this should be empty. 39 | CurrentWord string 40 | // WordHints for the current word. 41 | WordHints []*WordHint 42 | // WordHintsShown are the same as WordHints with characters visible. 43 | WordHintsShown []*WordHint 44 | // Round is the round that the Lobby is currently in. This is a number 45 | // between 0 and MaxRounds. 0 indicates that it hasn't started yet. 46 | Round int 47 | // WordChoice represents the current choice of words. 48 | WordChoice []string 49 | // RoundEndTime represents the time at which the current round will end. 50 | // This is a UTC unix-timestamp in milliseconds. 51 | RoundEndTime int64 52 | 53 | timeLeftTicker *time.Ticker 54 | scoreEarnedByGuessers int 55 | alreadyUsedWords []string 56 | CustomWordsChance int 57 | ClientsPerIPLimit int 58 | // CurrentDrawing represents the state of the current canvas. The elements 59 | // consist of LineEvent and FillEvent. Please do not modify the contents 60 | // of this array an only move AppendLine and AppendFill on the respective 61 | // lobby object. 62 | CurrentDrawing []interface{} 63 | EnableVotekick bool 64 | } 65 | 66 | // WordHint describes a character of the word that is to be guessed, whether 67 | // the character should be shown and whether it should be underlined on the 68 | // UI. 69 | type WordHint struct { 70 | Character rune `json:"character"` 71 | Underline bool `json:"underline"` 72 | } 73 | 74 | // Line is the struct that a client send when drawing 75 | type Line struct { 76 | FromX float32 `json:"fromX"` 77 | FromY float32 `json:"fromY"` 78 | ToX float32 `json:"toX"` 79 | ToY float32 `json:"toY"` 80 | Color string `json:"color"` 81 | LineWidth float32 `json:"lineWidth"` 82 | } 83 | 84 | // Fill represents the usage of the fill bucket. 85 | type Fill struct { 86 | X float32 `json:"x"` 87 | Y float32 `json:"y"` 88 | Color string `json:"color"` 89 | } 90 | 91 | // Player represents a participant in a Lobby. 92 | type Player struct { 93 | // userSession uniquely identifies the player. 94 | userSession string 95 | ws *websocket.Conn 96 | socketMutex *sync.Mutex 97 | lastKnownAddress string 98 | 99 | votedForKick map[string]bool 100 | 101 | // ID uniquely identified the Player. 102 | ID string `json:"id"` 103 | // Name is the players displayed name 104 | Name string `json:"name"` 105 | // Score is the points that the player got in the current Lobby. 106 | Score int `json:"score"` 107 | // Connected defines whether the players websocket connection is currently 108 | // established. This has previously been in state but has been moved out 109 | // in order to avoid losing the state on refreshing the page. 110 | // While checking the websocket against nil would be enough, we still need 111 | // this field for sending it via the APIs. 112 | Connected bool `json:"connected"` 113 | // Rank is the current ranking of the player in his Lobby 114 | LastScore int `json:"lastScore"` 115 | Rank int `json:"rank"` 116 | State PlayerState `json:"state"` 117 | } 118 | 119 | // GetLastKnownAddress returns the last known IP-Address used for an HTTP request. 120 | func (player *Player) GetLastKnownAddress() string { 121 | return player.lastKnownAddress 122 | } 123 | 124 | // SetLastKnownAddress sets the last known IP-Address used for an HTTP request. 125 | // Can be retrieved via GetLastKnownAddress(). 126 | func (player *Player) SetLastKnownAddress(address string) { 127 | player.lastKnownAddress = address 128 | } 129 | 130 | // GetWebsocket simply returns the players websocket connection. This method 131 | // exists to encapsulate the websocket field and prevent accidental sending 132 | // the websocket data via the network. 133 | func (player *Player) GetWebsocket() *websocket.Conn { 134 | return player.ws 135 | } 136 | 137 | // SetWebsocket sets the given connection as the players websocket connection. 138 | func (player *Player) SetWebsocket(socket *websocket.Conn) { 139 | player.ws = socket 140 | } 141 | 142 | // GetWebsocketMutex returns a mutex for locking the websocket connection. 143 | // Since gorilla websockets shits it self when two calls happen at 144 | // the same time, we need a mutex per player, since each player has their 145 | // own socket. This getter extends to prevent accidentally sending the mutex 146 | // via the network. 147 | func (player *Player) GetWebsocketMutex() *sync.Mutex { 148 | return player.socketMutex 149 | } 150 | 151 | // GetUserSession returns the players current user session. 152 | func (player *Player) GetUserSession() string { 153 | return player.userSession 154 | } 155 | 156 | type PlayerState int 157 | 158 | const ( 159 | Guessing PlayerState = 0 160 | Drawing PlayerState = 1 161 | Standby PlayerState = 2 162 | ) 163 | 164 | // GetPlayer searches for a player, identifying them by usersession. 165 | func (lobby *Lobby) GetPlayer(userSession string) *Player { 166 | for _, player := range lobby.Players { 167 | if player.userSession == userSession { 168 | return player 169 | } 170 | } 171 | 172 | return nil 173 | } 174 | 175 | func (lobby *Lobby) ClearDrawing() { 176 | lobby.CurrentDrawing = make([]interface{}, 0, 0) 177 | } 178 | 179 | // AppendLine adds a line direction to the current drawing. This exists in order 180 | // to prevent adding arbitrary elements to the drawing, as the backing array is 181 | // an empty interface type. 182 | func (lobby *Lobby) AppendLine(line *LineEvent) { 183 | lobby.CurrentDrawing = append(lobby.CurrentDrawing, line) 184 | } 185 | 186 | // AppendFill adds a fill direction to the current drawing. This exists in order 187 | // to prevent adding arbitrary elements to the drawing, as the backing array is 188 | // an empty interface type. 189 | func (lobby *Lobby) AppendFill(fill *FillEvent) { 190 | lobby.CurrentDrawing = append(lobby.CurrentDrawing, fill) 191 | } 192 | 193 | // GetLobby returns a Lobby that has a matching ID or no Lobby if none could 194 | // be found. 195 | func GetLobby(id string) *Lobby { 196 | for _, l := range lobbies { 197 | if l.ID == id { 198 | return l 199 | } 200 | } 201 | 202 | return nil 203 | } 204 | 205 | // RemoveLobby deletes a lobby, not allowing anyone to connect to it again. 206 | func RemoveLobby(id string) { 207 | indexToDelete := -1 208 | for index, l := range lobbies { 209 | if l.ID == id { 210 | indexToDelete = index 211 | break 212 | } 213 | } 214 | 215 | if indexToDelete != -1 { 216 | lobbies = append(lobbies[:indexToDelete], lobbies[indexToDelete+1:]...) 217 | } 218 | } 219 | 220 | func createPlayer(name string) *Player { 221 | return &Player{ 222 | Name: name, 223 | ID: uuid.NewV4().String(), 224 | userSession: uuid.NewV4().String(), 225 | Score: 0, 226 | LastScore: 0, 227 | Rank: 1, 228 | votedForKick: make(map[string]bool), 229 | socketMutex: &sync.Mutex{}, 230 | State: Guessing, 231 | Connected: false, 232 | } 233 | } 234 | 235 | func createLobby( 236 | drawingTime int, 237 | rounds int, 238 | maxPlayers int, 239 | customWords []string, 240 | customWordsChance int, 241 | clientsPerIPLimit int, 242 | enableVotekick bool) *Lobby { 243 | 244 | createDeleteMutex.Lock() 245 | 246 | lobby := &Lobby{ 247 | ID: uuid.NewV4().String(), 248 | DrawingTime: drawingTime, 249 | MaxRounds: rounds, 250 | MaxPlayers: maxPlayers, 251 | CustomWords: customWords, 252 | CustomWordsChance: customWordsChance, 253 | ClientsPerIPLimit: clientsPerIPLimit, 254 | EnableVotekick: enableVotekick, 255 | CurrentDrawing: make([]interface{}, 0, 0), 256 | } 257 | 258 | if len(customWords) > 1 { 259 | rand.Shuffle(len(lobby.CustomWords), func(i, j int) { 260 | lobby.CustomWords[i], lobby.CustomWords[j] = lobby.CustomWords[j], lobby.CustomWords[i] 261 | }) 262 | } 263 | 264 | lobbies = append(lobbies, lobby) 265 | 266 | createDeleteMutex.Unlock() 267 | 268 | return lobby 269 | } 270 | 271 | // JSEvent contains an eventtype and optionally any data. 272 | type JSEvent struct { 273 | Type string `json:"type"` 274 | Data interface{} `json:"data"` 275 | } 276 | 277 | func (lobby *Lobby) HasConnectedPlayers() bool { 278 | for _, otherPlayer := range lobby.Players { 279 | if otherPlayer.Connected { 280 | return true 281 | } 282 | } 283 | 284 | return false 285 | } 286 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | github.com/Bios-Marcel/cmdp v0.0.0-20190623190758-6760aca2c54e h1:0mIenEIycuRSzIFsawubRbCF2ikRNFSzZeN3VcCRsw8= 3 | github.com/Bios-Marcel/cmdp v0.0.0-20190623190758-6760aca2c54e/go.mod h1:cn7xf68lOu3CA6FyLYXjn9I2YNb9Xa9ZHvXh2Gq9SHA= 4 | github.com/Bios-Marcel/discordemojimap v1.0.1 h1:b3UYPO7+h1+ciStkwU/KQCerOmpUNPHsBf4a7EjMdys= 5 | github.com/Bios-Marcel/discordemojimap v1.0.1/go.mod h1:AoHIpUwf3EVCAAUmk+keXjb9khyZcFnW84/rhJd4IkU= 6 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 7 | github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= 8 | github.com/agnivade/levenshtein v1.0.3 h1:M5ZnqLOoZR8ygVq0FfkXsNOKzMCk0xRiow0R5+5VkQ0= 9 | github.com/agnivade/levenshtein v1.0.3/go.mod h1:4SFRZbbXWLF4MU1T9Qg0pGgH3Pjs+t6ie5efyrwRJXs= 10 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 11 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 12 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= 13 | github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= 14 | github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= 15 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 16 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 17 | github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= 18 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 19 | github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= 20 | github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= 21 | github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= 22 | github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 23 | github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= 24 | github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 25 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 26 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 28 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 29 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 30 | github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= 31 | github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c h1:TUuUh0Xgj97tLMNtWtNvI9mIV6isjEb9lBMNv+77IGM= 32 | github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 33 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g= 34 | github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= 35 | github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 h1:90Ly+6UfUypEF6vvvW5rQIv9opIL8CbmW9FT20LDQoY= 36 | github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0/go.mod h1:V+Qd57rJe8gd4eiGzZyg4h54VLHmYVVw54iMnlAMrF8= 37 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 38 | github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= 39 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 40 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 41 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 42 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 43 | github.com/gobuffalo/logger v1.0.3 h1:YaXOTHNPCvkqqA7w05A4v0k2tCdpr+sgFlgINbQ6gqc= 44 | github.com/gobuffalo/logger v1.0.3/go.mod h1:SoeejUwldiS7ZsyCBphOGURmWdwUFXs0J7TCjEhjKxM= 45 | github.com/gobuffalo/packd v1.0.0 h1:6ERZvJHfe24rfFmA9OaoKBdC7+c9sydrytMg8SdFGBM= 46 | github.com/gobuffalo/packd v1.0.0/go.mod h1:6VTc4htmJRFB7u1m/4LeMTWjFoYrUiBkU9Fdec9hrhI= 47 | github.com/gobuffalo/packr v1.30.1 h1:hu1fuVR3fXEZR7rXNW3h8rqSML8EVAf6KNm0NKO/wKg= 48 | github.com/gobuffalo/packr/v2 v2.8.0 h1:IULGd15bQL59ijXLxEvA5wlMxsmx/ZkQv9T282zNVIY= 49 | github.com/gobuffalo/packr/v2 v2.8.0/go.mod h1:PDk2k3vGevNE3SwVyVRgQCCXETC9SaONCNSXT1Q8M1g= 50 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 51 | github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= 52 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 53 | github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 54 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 55 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 56 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 57 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 58 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 59 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 60 | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 61 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 62 | github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= 63 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= 64 | github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= 65 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 66 | github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= 67 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 68 | github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= 69 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 70 | github.com/karrick/godirwalk v1.15.3 h1:0a2pXOgtB16CqIqXTiT7+K9L73f74n/aNQUnH6Ortew= 71 | github.com/karrick/godirwalk v1.15.3/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= 72 | github.com/karrick/godirwalk v1.15.6 h1:Yf2mmR8TJy+8Fa0SuQVto5SYap6IF7lNVX4Jdl8G1qA= 73 | github.com/karrick/godirwalk v1.15.6/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= 74 | github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o= 75 | github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak= 76 | github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= 77 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 78 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 79 | github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= 80 | github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 81 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 82 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 83 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 84 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 85 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 86 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 87 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 88 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 89 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 90 | github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= 91 | github.com/markbates/errx v1.1.0/go.mod h1:PLa46Oex9KNbVDZhKel8v1OT7hD5JZ2eI7AHhA0wswc= 92 | github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= 93 | github.com/markbates/oncer v1.0.0/go.mod h1:Z59JA581E9GP6w96jai+TGqafHPW+cPfRxz2aSZ0mcI= 94 | github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= 95 | github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= 96 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 97 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 98 | github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 99 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 100 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 101 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 102 | github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= 103 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 104 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 105 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 106 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 107 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 108 | github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso= 109 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 110 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 111 | github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= 112 | github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 113 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 114 | github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 115 | github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= 116 | github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= 117 | github.com/rogpeppe/go-internal v1.5.2 h1:qLvObTrvO/XRCqmkKxUlOBc48bI3efyDuAZe25QiF0w= 118 | github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 119 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 120 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 121 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 122 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 123 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 124 | github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= 125 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 126 | github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q= 127 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= 128 | github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= 129 | github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 130 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 131 | github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= 132 | github.com/spf13/cobra v0.0.6 h1:breEStsVwemnKh2/s6gMvSdMEkwW0sK8vGStnlVBMCs= 133 | github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= 134 | github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8= 135 | github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE= 136 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 137 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 138 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 139 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 140 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 141 | github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= 142 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 143 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 144 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 145 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 146 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 147 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 148 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= 149 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 150 | github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= 151 | github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= 152 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= 153 | github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= 154 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 155 | go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= 156 | go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= 157 | go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 158 | go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= 159 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 160 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 161 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 162 | golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4= 163 | golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 164 | golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 h1:DOmugCavvUtnUD114C1Wh+UgTgQZ4pMLzXxi1pSt+/Y= 165 | golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 166 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 167 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 168 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 169 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 170 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 171 | golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 172 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 173 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 174 | golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 175 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 176 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b h1:0mm1VjtFUOIlE1SbDlwjYaDxZVDP2S5ou6y0gSgXHu8= 177 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 178 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 179 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 180 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 181 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 182 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 183 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= 184 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 185 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= 186 | golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 187 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 188 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 189 | golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 190 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 191 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 192 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 193 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= 194 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 195 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY= 196 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 197 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 198 | golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 199 | golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 200 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 201 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 202 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 203 | golang.org/x/tools v0.0.0-20200308013534-11ec41452d41 h1:9Di9iYgOt9ThCipBxChBVhgNipDoE5mxO84rQV7D0FE= 204 | golang.org/x/tools v0.0.0-20200308013534-11ec41452d41/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= 205 | golang.org/x/tools v0.0.0-20200413161937-250b2131eb8b h1:FvD0+J5ZtXZrrc2bVxQaUSnJYUhSNlB1P3XHuZohH9I= 206 | golang.org/x/tools v0.0.0-20200413161937-250b2131eb8b/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 207 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 208 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 209 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 210 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 211 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 212 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 213 | google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= 214 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 215 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 216 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 217 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 218 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 219 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 220 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 221 | gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= 222 | gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= 223 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 224 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 225 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 226 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 227 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 228 | -------------------------------------------------------------------------------- /resources/words/nl: -------------------------------------------------------------------------------- 1 | aanbieding 2 | aandrijving 3 | aangename 4 | aankondiging 5 | aantrekken 6 | aanval 7 | aanwezig 8 | aanwijzing 9 | aap 10 | aardappel 11 | aardbei 12 | aardbeving 13 | aardewerk 14 | abortus 15 | academie 16 | accountant 17 | achter 18 | achtergrond 19 | achteruit 20 | achtervolgen 21 | adelaar 22 | ademen 23 | adres 24 | advocaat 25 | afbeelding 26 | afbeeldingen 27 | afdaling 28 | affaire 29 | afgelopen 30 | afgestudeerde 31 | aflevering 32 | afluisteren 33 | afname 34 | afspraak 35 | afstand 36 | afval 37 | afvoer 38 | agent 39 | alarm 40 | AlbertHeijn 41 | album 42 | alcohol 43 | altaar 44 | ambacht 45 | amber 46 | ambulance 47 | amputeren 48 | analyse 49 | angst 50 | annuleer 51 | anoniem 52 | anticipatie 53 | appel 54 | applaudisseren 55 | aquarium 56 | archief 57 | arena 58 | arm 59 | arrest 60 | art 61 | artikel 62 | artistiek 63 | as 64 | asiel 65 | aspect 66 | atleet 67 | atoom 68 | automatisch 69 | avond 70 | award 71 | baan 72 | baard 73 | baby 74 | bad 75 | bak 76 | bal 77 | balkon 78 | ballet 79 | ballon 80 | banaan 81 | band 82 | bang 83 | bank 84 | banner 85 | barrière 86 | basis 87 | basketbal 88 | batterij 89 | bean 90 | bed 91 | bedreig 92 | bedreiging 93 | bedrijf 94 | bedrijfstak 95 | been 96 | beer 97 | begraaf 98 | begraafplaats 99 | begrafenis 100 | begroet 101 | behandeling 102 | beheerder 103 | beker 104 | bekijk 105 | bekken 106 | bel 107 | belofte 108 | bemanning 109 | beperking 110 | bereik 111 | berekening 112 | berg 113 | bericht 114 | berijder 115 | beroerte 116 | bes 117 | bescherm 118 | bescherming 119 | bespaar 120 | bespreek 121 | bestand 122 | bestrating 123 | bestuurder 124 | betaal 125 | betaling 126 | beton 127 | bevredigend 128 | bevriezen 129 | bevroren 130 | bewaker 131 | bewijs 132 | bezienswaardigheden 133 | bibliotheek 134 | bid 135 | bier 136 | bij 137 | bijbel 138 | bijt 139 | bin 140 | binding 141 | binnen 142 | biografie 143 | biologie 144 | bioscoop 145 | bisschop 146 | bitter 147 | blad 148 | blanco 149 | blauw 150 | blijven 151 | blind 152 | bloed 153 | bloeden 154 | bloedvergieten 155 | bloem 156 | blok 157 | blonde 158 | bocht 159 | boek 160 | boem 161 | boer 162 | boerderij 163 | bol 164 | bom 165 | bommenwerper 166 | bondgenoot 167 | boog 168 | boom 169 | boor 170 | boos 171 | boot 172 | bord 173 | borgtocht 174 | borst 175 | borstel 176 | bos 177 | bot 178 | boter 179 | bout 180 | branden 181 | brandstof 182 | brandweerman 183 | breedte 184 | breng 185 | briefkaart 186 | bril 187 | broccoli 188 | broek 189 | broer 190 | brok 191 | bron 192 | brons 193 | brood 194 | brouwerij 195 | brug 196 | bruid 197 | bruiloft 198 | bruin 199 | brullen 200 | bubbel 201 | buffet 202 | buigen 203 | buik 204 | buis 205 | buiten 206 | buitenaards 207 | buitenlander 208 | buitenwijk 209 | bundel 210 | bureau 211 | burgemeester 212 | burger 213 | bus 214 | buur 215 | buurt 216 | café 217 | cake 218 | camera 219 | canvas 220 | cap 221 | carrière 222 | carve 223 | cassette 224 | cat 225 | cel 226 | censuur 227 | ceremonie 228 | certificaat 229 | champagne 230 | chaos 231 | charme 232 | chemie 233 | chemische 234 | chip 235 | chirurg 236 | chocolade 237 | chronische 238 | cilinder 239 | circulatie 240 | cirkel 241 | citaat 242 | citeer 243 | citroen 244 | claim 245 | club 246 | coach 247 | code 248 | collectie 249 | collega 250 | college 251 | coma 252 | combinatie 253 | combineren 254 | commando 255 | commentaar 256 | communicatie 257 | computer 258 | concentraat 259 | concert 260 | concurreren 261 | conferentie 262 | conflict 263 | consensus 264 | construct 265 | contact 266 | contant 267 | contract 268 | controleer 269 | conventie 270 | correctie 271 | crack 272 | crash 273 | creatie 274 | creditcard 275 | crème 276 | cricket 277 | crimineel 278 | curriculum 279 | cursus 280 | curve 281 | cyclus 282 | dacht 283 | dag 284 | daglicht 285 | dak 286 | daling 287 | dame 288 | dans 289 | dappere 290 | darm 291 | datum 292 | deal 293 | dealer 294 | deeg 295 | deel 296 | deeltje 297 | deining 298 | dekking 299 | deksel 300 | demonstratie 301 | denk 302 | depressie 303 | detective 304 | detector 305 | deur 306 | diagnose 307 | diagram 308 | diamant 309 | diameter 310 | dicteer 311 | diefstal 312 | dienaar 313 | diep 314 | dier 315 | digitale 316 | dik 317 | diner 318 | dip 319 | directory 320 | dirigent 321 | disco 322 | discrete 323 | divisie 324 | dochter 325 | document 326 | dodelijke 327 | doel 328 | doelman 329 | dokter 330 | dolfijn 331 | dollar 332 | doneer 333 | donker 334 | dood 335 | doof 336 | doolhof 337 | dorp 338 | dosis 339 | douche 340 | draad 341 | draag 342 | draai 343 | draak 344 | dragen 345 | driehoek 346 | drink 347 | droog 348 | droom 349 | drug 350 | druk 351 | drum 352 | dubbel 353 | duif 354 | duik 355 | duivin 356 | dump 357 | dun 358 | dutje 359 | duur 360 | dwaas 361 | echo 362 | echtgenoot 363 | echtscheiding 364 | eenheid 365 | eenzaam 366 | eerbetoon 367 | eerlijke 368 | eerste 369 | eet 370 | eetlust 371 | ei 372 | eigendom 373 | eikel 374 | eiken 375 | eiland 376 | einde 377 | elektriciteit 378 | elektron 379 | elektronica 380 | elektronische 381 | element 382 | elimineren 383 | elleboog 384 | emmer 385 | emotie 386 | energie 387 | engel 388 | enkel 389 | enkele 390 | enorme 391 | enthousiasme 392 | envelop 393 | Europa 394 | evenredig 395 | evolutie 396 | excuses 397 | exemplaar 398 | experiment 399 | explosie 400 | export 401 | extensie 402 | fabricage 403 | fabriek 404 | faciliteit 405 | falen 406 | familie 407 | fantasie 408 | fase 409 | fauteuil 410 | fax 411 | fee 412 | feest 413 | festival 414 | fictie 415 | fiets 416 | film 417 | filosoof 418 | filter 419 | financiële 420 | financiën 421 | finish 422 | fitness 423 | fix 424 | fles 425 | flits 426 | fluisteren 427 | flush 428 | folder 429 | fontein 430 | formele 431 | formule 432 | fortuin 433 | fossiel 434 | foto 435 | fotograaf 436 | fotografie 437 | fotokopie 438 | fout 439 | fragment 440 | franchise 441 | fraude 442 | frequentie 443 | fruit 444 | ga 445 | Galaxy 446 | galerij 447 | gallon 448 | gang 449 | gangpad 450 | garage 451 | gast 452 | gat 453 | gazon 454 | gebaar 455 | gebed 456 | gebouw 457 | gebroken 458 | gebruiker 459 | gedenkteken 460 | gedicht 461 | geest 462 | geeuw 463 | gegijzeld 464 | geheugen 465 | gehoorzamen 466 | geit 467 | gekwalificeerd 468 | gekwetst 469 | geld 470 | gelei 471 | geleidelijke 472 | gelijkspel 473 | geluid 474 | geluk 475 | gelukkig 476 | gemalen 477 | gemeenschap 478 | gemiddeld 479 | geneeskunde 480 | genie 481 | geografie 482 | geologische 483 | gereedschap 484 | gerelateerde 485 | geschenk 486 | geschreven 487 | geslacht 488 | gesloten 489 | gesprek 490 | getrouwd 491 | getuige 492 | geur 493 | gevaar 494 | gevangene 495 | gevangenis 496 | gevel 497 | geweer 498 | gewelddadige 499 | geweten 500 | gewicht 501 | gezamenlijke 502 | gezicht 503 | gezond 504 | gezondheid 505 | giet 506 | giftig 507 | gitaar 508 | gladde 509 | glans 510 | glas 511 | gletsjer 512 | glijden 513 | glimlach 514 | gloed 515 | god 516 | goed 517 | goedkoop 518 | golf 519 | gooi 520 | goot 521 | gordijn 522 | goud 523 | graad 524 | graaf 525 | graan 526 | graf 527 | grafiek 528 | grap 529 | grappig 530 | gras 531 | gratis 532 | grens 533 | griep 534 | grijns 535 | grimas 536 | grind 537 | grip 538 | groeien 539 | groen 540 | groente 541 | groet 542 | grond 543 | grootmoeder 544 | grootvader 545 | grot 546 | grote 547 | haai 548 | haak 549 | haar 550 | haat 551 | habitat 552 | hak 553 | hal 554 | halfrond 555 | hamer 556 | hand 557 | handdoek 558 | handel 559 | handelaar 560 | handelen 561 | handicap 562 | handleiding 563 | handschoen 564 | handtekening 565 | hang 566 | hard 567 | hardware 568 | harmonie 569 | hart 570 | haven 571 | hedge 572 | heersen 573 | heet 574 | heilige 575 | hek 576 | heks 577 | hel 578 | held 579 | hele 580 | helikopter 581 | helm 582 | hemel 583 | herhaal 584 | herhaling 585 | herinneren 586 | herleven 587 | heroïne 588 | heropleving 589 | hersenen 590 | herstel 591 | hert 592 | heup 593 | heuvel 594 | historisch 595 | hoek 596 | hoest 597 | homoseksueel 598 | hond 599 | honkbal 600 | hoofd 601 | hoofdkantoor 602 | hoofdletter 603 | hoofdstuk 604 | hoog 605 | hoogte 606 | hooi 607 | hoor 608 | hoorn 609 | horizon 610 | horizontaal 611 | horror 612 | hotel 613 | houd 614 | hout 615 | huidige 616 | huilen 617 | huis 618 | huisdier 619 | huisje 620 | huisvrouw 621 | hurken 622 | hut 623 | huur 624 | huurder 625 | huwelijk 626 | hypnotiseren 627 | idee 628 | identificatie 629 | identificeer 630 | identiteit 631 | ijs 632 | ijzer 633 | illegaal 634 | immigrant 635 | immigratie 636 | importeer 637 | impuls 638 | incident 639 | indeling 640 | individuele 641 | industrieel 642 | infecteren 643 | infectie 644 | informatie 645 | ingenieur 646 | ingrediënt 647 | injecteer 648 | injectie 649 | inkomen 650 | inschrijving 651 | insect 652 | inspecteur 653 | instal 654 | instorten 655 | instructie 656 | instrument 657 | integratie 658 | intelligentie 659 | internationaal 660 | interview 661 | invasie 662 | inwoner 663 | inzet 664 | isolatie 665 | item 666 | jaar 667 | jaarlijks 668 | jacht 669 | jager 670 | jam 671 | jammer 672 | jas 673 | jazz 674 | jet 675 | jeugd 676 | jockey 677 | jonge 678 | jongen 679 | juist 680 | jungle 681 | junior 682 | jurk 683 | jury 684 | juweel 685 | kaak 686 | kaal 687 | kaars 688 | kaart 689 | kaas 690 | kabel 691 | kalender 692 | kalf 693 | kalkoen 694 | kamer 695 | kamerplant 696 | kamp 697 | kampioen 698 | kanaal 699 | kandidaat 700 | kanker 701 | kant 702 | kantoor 703 | kapitein 704 | kapsel 705 | karakter 706 | kast 707 | kasteel 708 | kathedraal 709 | katoen 710 | kauw 711 | keel 712 | kelder 713 | kerk 714 | kern 715 | kers 716 | ketting 717 | keuken 718 | kick 719 | kies 720 | kijk 721 | kikker 722 | kin 723 | kind 724 | kindertijd 725 | kip 726 | kist 727 | klagen 728 | klant 729 | klap 730 | klaslokaal 731 | klasse 732 | klassiek 733 | kleerkast 734 | klei 735 | kleren 736 | kleur 737 | kleurrijk 738 | kleverige 739 | klim 740 | kliniek 741 | klok 742 | kloof 743 | klop 744 | knal 745 | knie 746 | kniel 747 | knijp 748 | knik 749 | knoflook 750 | knoop 751 | knop 752 | koe 753 | koekje 754 | koelkast 755 | koepel 756 | koffie 757 | kolen 758 | kolom 759 | kolonie 760 | kom 761 | komedie 762 | komeet 763 | komkommer 764 | konijn 765 | koning 766 | koningin 767 | koninkrijk 768 | kooi 769 | kook 770 | koop 771 | koorts 772 | kop 773 | koper 774 | kopieer 775 | koppel 776 | korte 777 | korting 778 | kostuum 779 | koud 780 | kraam 781 | kras 782 | krijger 783 | krijt 784 | krimpen 785 | kristal 786 | kroon 787 | kruip 788 | kruis 789 | kruising 790 | kruk 791 | krul 792 | kudde 793 | kunstenaar 794 | kunstmatige 795 | kussen 796 | kust 797 | kwaliteit 798 | kwart 799 | kwekerij 800 | laat 801 | laatste 802 | label 803 | laboratorium 804 | lach 805 | ladder 806 | lade 807 | lading 808 | lagere 809 | lam 810 | lamp 811 | land 812 | landeigenaar 813 | landschap 814 | lang 815 | lange 816 | langzaam 817 | laser 818 | last 819 | ledemaat 820 | leef 821 | leeftijd 822 | leeg 823 | leer 824 | leerling 825 | lees 826 | leeuw 827 | leg 828 | legende 829 | leger 830 | leiderschap 831 | lek 832 | lekker 833 | lelie 834 | lelijk 835 | lengte 836 | lente 837 | lepel 838 | leraar 839 | les 840 | letsel 841 | letter 842 | leugen 843 | leuk 844 | lever 845 | leveren 846 | levering 847 | lezer 848 | lezing 849 | licentie 850 | lichaam 851 | licht 852 | lid 853 | lidmaatschap 854 | liefdadigheid 855 | liefde 856 | lift 857 | lijm 858 | lijn 859 | lijst 860 | lik 861 | limiet 862 | link 863 | links 864 | lint 865 | lip 866 | literatuur 867 | lobby 868 | locatie 869 | lodge 870 | log 871 | long 872 | lopen 873 | loper 874 | losse 875 | lounge 876 | lucht 877 | luchthaven 878 | luchtvaart 879 | luchtvaartmaatschappij 880 | luid 881 | luister 882 | lunch 883 | lus 884 | maag 885 | maagd 886 | maaltijd 887 | maan 888 | maand 889 | maart 890 | maat 891 | machine 892 | magere 893 | magnetische 894 | mail 895 | mainstream 896 | maïs 897 | make-up 898 | management 899 | mand 900 | manier 901 | marathon 902 | marine 903 | markeer 904 | marketing 905 | marmer 906 | mars 907 | marteling 908 | masker 909 | massa 910 | materiaal 911 | matroos 912 | mechanisch 913 | medaille 914 | meer 915 | meerdere 916 | meester 917 | meid 918 | meisje 919 | melk 920 | meneer 921 | menigte 922 | mens 923 | mensen 924 | mensheid 925 | menu 926 | merk 927 | mes 928 | metaal 929 | meubels 930 | microfoon 931 | middag 932 | middeleeuws 933 | midden 934 | middernacht 935 | mier 936 | mijl 937 | mijnwerker 938 | militaire 939 | mineraal 940 | minimaal 941 | minimaliseren 942 | minor 943 | minuut 944 | misbruik 945 | misdaad 946 | mislukking 947 | mobiel 948 | modder 949 | model 950 | moe 951 | moeder 952 | moer 953 | moeras 954 | mok 955 | mol 956 | moleculair 957 | mond 958 | monnik 959 | monopolie 960 | monster 961 | monsterlijke 962 | moord 963 | moordenaar 964 | morsen 965 | moskee 966 | motor 967 | motorwagen 968 | mouw 969 | mug 970 | muis 971 | multimedia 972 | munt 973 | museum 974 | muur 975 | muziek 976 | muzikant 977 | mythe 978 | naald 979 | naam 980 | nacht 981 | nachtmerrie 982 | nagel 983 | nakomelingen 984 | nat 985 | natuur 986 | natuurkunde 987 | nauwkeurige 988 | nederlaag 989 | neef 990 | nek 991 | nest 992 | netto 993 | netwerk 994 | neus 995 | nier 996 | nieuws 997 | niveau 998 | non 999 | nood 1000 | noord 1001 | notitieboek 1002 | nucleair 1003 | nul 1004 | nummer 1005 | observatie 1006 | obstakel 1007 | oceaan 1008 | ochtend 1009 | offer 1010 | officier 1011 | olie 1012 | olifant 1013 | omgeving 1014 | omissie 1015 | onderdrukken 1016 | onderhoud 1017 | onderkant 1018 | ondersteuning 1019 | onderstreep 1020 | onderwijs 1021 | onderzoek 1022 | onderzoeker 1023 | ondiep 1024 | oneindig 1025 | oneven 1026 | ongemakkelijk 1027 | ongeval 1028 | onschuldige 1029 | ontbijtgranen 1030 | ontbinden 1031 | ontdekking 1032 | onthoof 1033 | ontmoet 1034 | ontploffen 1035 | ontploffing 1036 | ontsnappen 1037 | ontvangst 1038 | ontwerp 1039 | onzin 1040 | oog 1041 | oogst 1042 | oor 1043 | oordeel 1044 | oorlog 1045 | oorzaak 1046 | oost 1047 | opdracht 1048 | open 1049 | opera 1050 | operatie 1051 | opgewonden 1052 | oplossing 1053 | opmerking 1054 | opname 1055 | opperhoofd 1056 | oppervlakte 1057 | opslag 1058 | opsommingsteken 1059 | optie 1060 | oraal 1061 | oranje 1062 | orgel 1063 | origineel 1064 | orkest 1065 | oude 1066 | ouder 1067 | outfit 1068 | oven 1069 | overdracht 1070 | overeenkomst 1071 | overheersing 1072 | overheid 1073 | overstroming 1074 | overstuur 1075 | overval 1076 | overweeg 1077 | overwinning 1078 | overzicht 1079 | paar 1080 | paard 1081 | pad 1082 | paddenstoel 1083 | pagina 1084 | pak 1085 | pakket 1086 | paleis 1087 | palm 1088 | pan 1089 | papier 1090 | parade 1091 | paragraaf 1092 | parallel 1093 | paraplu 1094 | park 1095 | parkeren 1096 | partij 1097 | partner 1098 | partnerschap 1099 | paspoort 1100 | passage 1101 | passagier 1102 | past 1103 | patent 1104 | patroon 1105 | pauze 1106 | pen 1107 | penny 1108 | peper 1109 | perforeer 1110 | periode 1111 | personeel 1112 | persoon 1113 | piano 1114 | pier 1115 | pijl 1116 | pijn 1117 | pijp 1118 | pil 1119 | piloot 1120 | pinda 1121 | pionier 1122 | piramide 1123 | pistool 1124 | plaat 1125 | plaats 1126 | plafond 1127 | plak 1128 | plan 1129 | planeet 1130 | plank 1131 | plant 1132 | plastic 1133 | platform 1134 | plek 1135 | ploeg 1136 | plug 1137 | poeder 1138 | poëzie 1139 | politiek 1140 | poll 1141 | pols 1142 | pomp 1143 | pompoen 1144 | pony 1145 | pool 1146 | poort 1147 | pop 1148 | portret 1149 | positieve 1150 | post 1151 | pot 1152 | potlood 1153 | presentatie 1154 | president 1155 | prijs 1156 | prins 1157 | prinses 1158 | print 1159 | printer 1160 | privacy 1161 | privilege 1162 | procent 1163 | producent 1164 | produceren 1165 | product 1166 | productie 1167 | productieve 1168 | proef 1169 | professor 1170 | profiel 1171 | programma 1172 | projectie 1173 | prooi 1174 | propaganda 1175 | proteïne 1176 | protest 1177 | psycholoog 1178 | pub 1179 | publiceer 1180 | publiek 1181 | pudding 1182 | punch 1183 | punt 1184 | put 1185 | puzzel 1186 | race 1187 | racisme 1188 | radio 1189 | raid 1190 | raket 1191 | rally 1192 | rand 1193 | rangschikking 1194 | rapport 1195 | ras 1196 | rat 1197 | reactie 1198 | reactor 1199 | rebel 1200 | receptie 1201 | rechte 1202 | rechter 1203 | rechtvaardigheid 1204 | reclame 1205 | record 1206 | recycle 1207 | redding 1208 | reductie 1209 | redundantie 1210 | reeks 1211 | reflectie 1212 | regen 1213 | regenboog 1214 | regio 1215 | regisseur 1216 | register 1217 | rehabilitatie 1218 | reis 1219 | rek 1220 | rel 1221 | relatie 1222 | relax 1223 | religieuze 1224 | rem 1225 | ren 1226 | repetitie 1227 | reproductie 1228 | reptiel 1229 | restaurant 1230 | restjes 1231 | resultaat 1232 | reus 1233 | rib 1234 | richting 1235 | riem 1236 | rij 1237 | rijk 1238 | rijkdom 1239 | rijst 1240 | ring 1241 | ritueel 1242 | rivier 1243 | rob 1244 | robot 1245 | rock 1246 | roem 1247 | rol 1248 | roman 1249 | romantisch 1250 | rood 1251 | rook 1252 | royalty 1253 | rug 1254 | rugby 1255 | ruimte 1256 | rundvlees 1257 | rust 1258 | ruwe 1259 | saaie 1260 | salade 1261 | saldo 1262 | samenvatting 1263 | samenzwering 1264 | sandaal 1265 | sap 1266 | satelliet 1267 | saus 1268 | scan 1269 | scène 1270 | schade 1271 | schaduw 1272 | schakelaar 1273 | schapen 1274 | schat 1275 | schattig 1276 | schedel 1277 | scheiding 1278 | schema 1279 | scheren 1280 | scherm 1281 | scherpe 1282 | schets 1283 | scheur 1284 | schiet 1285 | schikking 1286 | schild 1287 | schilder 1288 | schip 1289 | schoen 1290 | schok 1291 | schommel 1292 | school 1293 | schoon 1294 | schoorsteen 1295 | schoot 1296 | schors 1297 | schot 1298 | schotel 1299 | schouder 1300 | schouderophalen 1301 | schreeuw 1302 | schrijf 1303 | schrijver 1304 | schuld 1305 | score 1306 | script 1307 | sculptuur 1308 | secretaris 1309 | secundair 1310 | seizoen 1311 | seizoensgebonden 1312 | seminar 1313 | senior 1314 | serie 1315 | serveren 1316 | service 1317 | shell 1318 | shift 1319 | shirt 1320 | sigaret 1321 | sin 1322 | site 1323 | skate 1324 | ski 1325 | slaaf 1326 | slaap 1327 | slaapkamer 1328 | slachtoffer 1329 | slagveld 1330 | slak 1331 | slam 1332 | slang 1333 | slechte 1334 | sleep 1335 | sleutel 1336 | slijm 1337 | slimme 1338 | slip 1339 | slopen 1340 | slot 1341 | sluier 1342 | sluiten 1343 | smakelijke 1344 | smal 1345 | smash 1346 | smeken 1347 | sneeuw 1348 | snel 1349 | snelheid 1350 | snelweg 1351 | snijd 1352 | snijden 1353 | snoer 1354 | snuif 1355 | soep 1356 | software 1357 | sok 1358 | soldaat 1359 | solide 1360 | solo 1361 | som 1362 | somberheid 1363 | soort 1364 | soortgelijke 1365 | speel 1366 | speelgoed 1367 | spek 1368 | spel 1369 | spell 1370 | spiegel 1371 | spier 1372 | spin 1373 | spinazie 1374 | spion 1375 | split 1376 | spoorweg 1377 | sport 1378 | spray 1379 | spreiding 1380 | spreker 1381 | sproet 1382 | sprong 1383 | spuug 1384 | staan​​ 1385 | staart 1386 | stad 1387 | staking 1388 | stam 1389 | standbeeld 1390 | stap 1391 | stapel 1392 | start 1393 | station 1394 | steak 1395 | steeg 1396 | steek 1397 | steen 1398 | stem 1399 | stempel 1400 | sterk 1401 | sterkte 1402 | sterrenbeeld 1403 | sterven 1404 | stichting 1405 | stick 1406 | stijgen 1407 | stijl 1408 | stikken 1409 | stilte 1410 | stoel 1411 | stof 1412 | stok 1413 | stom 1414 | stoom 1415 | stop 1416 | storm 1417 | storting 1418 | straal 1419 | straat 1420 | strak 1421 | straling 1422 | strand 1423 | stretch 1424 | strijd 1425 | string 1426 | stro 1427 | struik 1428 | student 1429 | studio 1430 | stuiteren 1431 | stuk 1432 | subjectief 1433 | succesvol 1434 | suiker 1435 | suite 1436 | superieur 1437 | supermarkt 1438 | supervisor 1439 | surround 1440 | symmetrie 1441 | systeem 1442 | t-shirt 1443 | taal 1444 | taart 1445 | tablet 1446 | tafel 1447 | taille 1448 | tand 1449 | tandarts 1450 | tank 1451 | tape 1452 | tapijt 1453 | tarief 1454 | tarwe 1455 | taxi 1456 | team 1457 | techniek 1458 | technische 1459 | technologie 1460 | teef 1461 | teen 1462 | tegel 1463 | tegen 1464 | tegenover 1465 | tegenstander 1466 | tegenstrijdigheid 1467 | tegenzin 1468 | tegoed 1469 | tegoedbon 1470 | teken 1471 | tekening 1472 | tekort 1473 | tekst 1474 | tel 1475 | telefoon 1476 | televisie 1477 | teller 1478 | tempel 1479 | temperatuur 1480 | tennis 1481 | tent 1482 | terminal 1483 | terras 1484 | terrorist 1485 | terug 1486 | test 1487 | theater 1488 | therapeut 1489 | therapie 1490 | thumb 1491 | tick 1492 | ticket 1493 | tiener 1494 | tij 1495 | tijd 1496 | tijdschema 1497 | tijdschrift 1498 | tijger 1499 | tin 1500 | tip 1501 | titel 1502 | toast 1503 | toepassing 1504 | toerisme 1505 | toernooi 1506 | toespraak 1507 | toevoeging 1508 | tomaat 1509 | ton 1510 | tong 1511 | toon 1512 | top 1513 | toren 1514 | totaal 1515 | touw 1516 | trace 1517 | track 1518 | trainer 1519 | training 1520 | transparant 1521 | trap 1522 | trein 1523 | trek 1524 | trend 1525 | trip 1526 | troep 1527 | trolley 1528 | troon 1529 | tropische 1530 | truc 1531 | trui 1532 | tuimelen 1533 | tuin 1534 | tumor 1535 | tunnel 1536 | tweede 1537 | twin 1538 | tycoon 1539 | ui 1540 | uil 1541 | uitbreiden 1542 | uitbreiding 1543 | uitdrukking 1544 | uitgang 1545 | uitgestorven 1546 | uitlaat 1547 | uitnodigen 1548 | uitnodiging 1549 | uitrusting 1550 | uitvoerder 1551 | uitvoering 1552 | uitzending 1553 | uniek 1554 | uniform 1555 | universiteit 1556 | update 1557 | urban 1558 | urine 1559 | uur 1560 | vacht 1561 | vacuüm 1562 | vader 1563 | vak 1564 | vakantie 1565 | vakman 1566 | vallei 1567 | vallen 1568 | valuta 1569 | van 1570 | vang 1571 | variant 1572 | varken 1573 | vat 1574 | vechten 1575 | veeg 1576 | veel 1577 | veer 1578 | veerboot 1579 | vegetatie 1580 | veilig 1581 | veilige 1582 | veiling 1583 | veld 1584 | venster 1585 | Venus 1586 | ver 1587 | verander 1588 | verbaal 1589 | verbeelding 1590 | verbergen 1591 | verbied 1592 | verbinding 1593 | verbrijzelen 1594 | verdachte 1595 | verdedig 1596 | verdeel 1597 | verdieping 1598 | verdomde 1599 | verdrinken 1600 | verdwijnen 1601 | verf 1602 | vergadering 1603 | vergelijk 1604 | vergelijking 1605 | vergif 1606 | vergoeding 1607 | vergroten 1608 | verhouding 1609 | verhuurder 1610 | verjaardag 1611 | verkeer 1612 | verkeerd 1613 | verkenning 1614 | verkiezing 1615 | verkoop 1616 | verkoper 1617 | verkrijg 1618 | verlegen 1619 | verliezen 1620 | vermenigvuldig 1621 | vermogen 1622 | vernietiging 1623 | veroordeelde 1624 | verplaatsen 1625 | verpleegster 1626 | verrassing 1627 | verre 1628 | verschijnen 1629 | verschil 1630 | verschillen 1631 | verschuldigd 1632 | verse 1633 | versie 1634 | verslaafd 1635 | verslaan 1636 | verslaggever 1637 | verspreid 1638 | verticaal 1639 | vertrek 1640 | vervang 1641 | vervanging 1642 | vervoer 1643 | vervuiling 1644 | verwarring 1645 | verzamel 1646 | vestiging 1647 | veteraan 1648 | vezel 1649 | video 1650 | viering 1651 | vierkant 1652 | vijand 1653 | villa 1654 | vind 1655 | vinger 1656 | vis 1657 | visie 1658 | visser 1659 | vitamine 1660 | vlag 1661 | vleermuis 1662 | vlees 1663 | vlek 1664 | vleugel 1665 | vlieg 1666 | vlieger 1667 | vliegtuig 1668 | vliegtuigen 1669 | vlinder 1670 | vloeistof 1671 | vloot 1672 | vlucht 1673 | voedsel 1674 | voertuig 1675 | voet 1676 | voetbal 1677 | voetgangers 1678 | vogel 1679 | voltooi 1680 | volume 1681 | volwassen 1682 | voor 1683 | voorganger 1684 | voorhoofd 1685 | voorraad 1686 | voorspelbare 1687 | vooruit 1688 | vooruitzichten 1689 | voorzitterschap 1690 | vork 1691 | vorm 1692 | vos 1693 | vouw 1694 | vraag 1695 | vracht 1696 | vrachtwagen 1697 | vragenlijst 1698 | vreedzaam 1699 | vreugde 1700 | vriend 1701 | vriendschap 1702 | vroege 1703 | vrouw 1704 | vuile 1705 | vuilnis 1706 | vuist 1707 | vul 1708 | vulkaan 1709 | vuur 1710 | waardevolle 1711 | waarnemer 1712 | waarschuwing 1713 | wachtrij 1714 | wachtwoord 1715 | wagen 1716 | wakker 1717 | walvis 1718 | wandeling 1719 | wang 1720 | wapen 1721 | warm 1722 | warmte 1723 | was 1724 | water 1725 | waterkoker 1726 | waterval 1727 | wederzijdse 1728 | wedstrijd 1729 | weduwe 1730 | weefsel 1731 | week 1732 | weekend 1733 | weer 1734 | weergeven 1735 | weerspiegelen 1736 | weg 1737 | wekelijks 1738 | wenkbrauw 1739 | wereld 1740 | west 1741 | wetenschap 1742 | wetenschapper 1743 | weven 1744 | whisky 1745 | wiel 1746 | wiet 1747 | wijn 1748 | wilde 1749 | wildernis 1750 | willekeurige 1751 | win 1752 | wind 1753 | winkel 1754 | winkelen 1755 | winkelwagen 1756 | winnaar 1757 | winter 1758 | wiskunde 1759 | wiskundig 1760 | wit 1761 | woede 1762 | woestijn 1763 | wol 1764 | wolf 1765 | wolk 1766 | wond 1767 | woord 1768 | woordenboek 1769 | worm 1770 | worst 1771 | worstelen 1772 | wortel 1773 | wraak 1774 | wrak 1775 | wrap 1776 | x-ray 1777 | yard 1778 | zaad 1779 | zaak 1780 | zachte 1781 | zak 1782 | zakenman 1783 | zaklamp 1784 | zalm 1785 | zanger 1786 | zee 1787 | zeep 1788 | zeg 1789 | zegel 1790 | zegen 1791 | zeil 1792 | zelfmoord 1793 | zelfs 1794 | zelfverzekerd 1795 | zet 1796 | zicht 1797 | ziek 1798 | ziekenhuis 1799 | ziekte 1800 | ziel 1801 | zijde 1802 | zilver 1803 | zing 1804 | zinken 1805 | zit 1806 | zoek 1807 | zoete 1808 | zolder 1809 | zomer 1810 | zon 1811 | zone 1812 | zonne 1813 | zonneschijn 1814 | zonsopgang 1815 | zorgen 1816 | zout 1817 | zuid 1818 | zuivel 1819 | zus 1820 | zuur 1821 | zuurstof 1822 | zwaar 1823 | zwaard 1824 | zwaarlijvig 1825 | zwaartekracht 1826 | zwak 1827 | zwakte 1828 | zwanger 1829 | zwart 1830 | zweep 1831 | zweer 1832 | zweet 1833 | zwembad 1834 | zwemmen -------------------------------------------------------------------------------- /game/lobby.go: -------------------------------------------------------------------------------- 1 | package game 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html" 7 | "log" 8 | "math" 9 | "math/rand" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | commands "github.com/Bios-Marcel/cmdp" 16 | "github.com/Bios-Marcel/discordemojimap" 17 | "github.com/agnivade/levenshtein" 18 | petname "github.com/dustinkirkland/golang-petname" 19 | "github.com/kennygrant/sanitize" 20 | ) 21 | 22 | var ( 23 | createDeleteMutex = &sync.Mutex{} 24 | lobbies []*Lobby = nil 25 | ) 26 | 27 | var ( 28 | LobbySettingBounds = &SettingBounds{ 29 | MinDrawingTime: 60, 30 | MaxDrawingTime: 300, 31 | MinRounds: 1, 32 | MaxRounds: 20, 33 | MinMaxPlayers: 2, 34 | MaxMaxPlayers: 150, 35 | MinClientsPerIPLimit: 1, 36 | MaxClientsPerIPLimit: 150, 37 | } 38 | SupportedLanguages = map[string]string{ 39 | "english": "English", 40 | "italian": "Italian", 41 | "german": "German", 42 | "french": "French", 43 | "dutch": "Dutch", 44 | } 45 | ) 46 | 47 | // SettingBounds defines the lower and upper bounds for the user-specified 48 | // lobby creation input. 49 | type SettingBounds struct { 50 | MinDrawingTime int64 51 | MaxDrawingTime int64 52 | MinRounds int64 53 | MaxRounds int64 54 | MinMaxPlayers int64 55 | MaxMaxPlayers int64 56 | MinClientsPerIPLimit int64 57 | MaxClientsPerIPLimit int64 58 | } 59 | 60 | // LineEvent is basically the same as JSEvent, but with a specific Data type. 61 | // We use this for reparsing as soon as we know that the type is right. It's 62 | // a bit unperformant, but will do for now. 63 | type LineEvent struct { 64 | Type string `json:"type"` 65 | Data *Line `json:"data"` 66 | } 67 | 68 | // LineEvent is basically the same as JSEvent, but with a specific Data type. 69 | // We use this for reparsing as soon as we know that the type is right. It's 70 | // a bit unperformant, but will do for now. 71 | type FillEvent struct { 72 | Type string `json:"type"` 73 | Data *Fill `json:"data"` 74 | } 75 | 76 | func HandleEvent(raw []byte, received *JSEvent, lobby *Lobby, player *Player) error { 77 | if received.Type == "message" { 78 | dataAsString, isString := (received.Data).(string) 79 | if !isString { 80 | return fmt.Errorf("invalid data received: '%s'", received.Data) 81 | } 82 | 83 | if strings.HasPrefix(dataAsString, "!") { 84 | handleCommand(dataAsString[1:], player, lobby) 85 | } else { 86 | handleMessage(dataAsString, player, lobby) 87 | } 88 | } else if received.Type == "line" { 89 | if lobby.canDraw(player) { 90 | line := &LineEvent{} 91 | jsonError := json.Unmarshal(raw, line) 92 | if jsonError != nil { 93 | return fmt.Errorf("error decoding data: %s", jsonError) 94 | } 95 | lobby.AppendLine(line) 96 | 97 | //We directly forward the event, as it seems to be valid. 98 | SendDataToConnectedPlayers(player, lobby, received) 99 | } 100 | } else if received.Type == "fill" { 101 | if lobby.canDraw(player) { 102 | fill := &FillEvent{} 103 | jsonError := json.Unmarshal(raw, fill) 104 | if jsonError != nil { 105 | return fmt.Errorf("error decoding data: %s", jsonError) 106 | } 107 | lobby.AppendFill(fill) 108 | 109 | //We directly forward the event, as it seems to be valid. 110 | SendDataToConnectedPlayers(player, lobby, received) 111 | } 112 | } else if received.Type == "clear-drawing-board" { 113 | if lobby.canDraw(player) && len(lobby.CurrentDrawing) > 0 { 114 | lobby.ClearDrawing() 115 | SendDataToConnectedPlayers(player, lobby, received) 116 | } 117 | } else if received.Type == "choose-word" { 118 | chosenIndex, isInt := (received.Data).(int) 119 | if !isInt { 120 | asFloat, isFloat32 := (received.Data).(float64) 121 | if isFloat32 && asFloat < 4 { 122 | chosenIndex = int(asFloat) 123 | } else { 124 | return fmt.Errorf("invalid data in choose-word event: %v", received.Data) 125 | } 126 | } 127 | 128 | drawer := lobby.Drawer 129 | if player == drawer && len(lobby.WordChoice) > 0 && chosenIndex >= 0 && chosenIndex <= 2 { 130 | lobby.CurrentWord = lobby.WordChoice[chosenIndex] 131 | lobby.WordChoice = nil 132 | lobby.WordHints = createWordHintFor(lobby.CurrentWord, false) 133 | lobby.WordHintsShown = createWordHintFor(lobby.CurrentWord, true) 134 | triggerWordHintUpdate(lobby) 135 | } 136 | } else if received.Type == "kick-vote" { 137 | if !lobby.EnableVotekick { 138 | // Votekicking is disabled in the lobby 139 | // We tell the user and do not continue with the event 140 | WriteAsJSON(player, JSEvent{Type: "system-message", Data: "Votekick is disabled in this lobby!"}) 141 | } else { 142 | toKickID, isString := (received.Data).(string) 143 | if !isString { 144 | return fmt.Errorf("invalid data in kick-vote event: %v", received.Data) 145 | } 146 | 147 | handleKickEvent(lobby, player, toKickID) 148 | } 149 | } else if received.Type == "start" { 150 | if lobby.Round == 0 && player == lobby.Owner { 151 | //We are reseting each players score, since players could 152 | //technically be player a second game after the last one 153 | //has already ended. 154 | for _, otherPlayer := range lobby.Players { 155 | otherPlayer.Score = 0 156 | otherPlayer.LastScore = 0 157 | //Since nobody has any points in the beginning, everyone has practically 158 | //the same rank, therefore y'll winners for now. 159 | otherPlayer.Rank = 1 160 | } 161 | 162 | advanceLobby(lobby) 163 | } 164 | } else if received.Type == "name-change" { 165 | newName, isString := (received.Data).(string) 166 | if !isString { 167 | return fmt.Errorf("invalid data in name-change event: %v", received.Data) 168 | } 169 | commandNick(player, lobby, newName) 170 | } 171 | 172 | return nil 173 | } 174 | 175 | func handleMessage(input string, sender *Player, lobby *Lobby) { 176 | trimmed := strings.TrimSpace(input) 177 | if trimmed == "" { 178 | return 179 | } 180 | 181 | if lobby.CurrentWord == "" { 182 | sendMessageToAll(trimmed, sender, lobby) 183 | return 184 | } 185 | 186 | if sender.State == Drawing || sender.State == Standby { 187 | sendMessageToAllNonGuessing(trimmed, sender, lobby) 188 | } else if sender.State == Guessing { 189 | lowerCasedInput := strings.ToLower(trimmed) 190 | lowerCasedSearched := strings.ToLower(lobby.CurrentWord) 191 | 192 | normInput := removeAccents(lowerCasedInput) 193 | normSearched := removeAccents(lowerCasedSearched) 194 | 195 | if normSearched == normInput { 196 | secondsLeft := lobby.RoundEndTime/1000 - time.Now().UTC().UnixNano()/1000000000 197 | sender.LastScore = int(math.Ceil(math.Pow(math.Max(float64(secondsLeft), 1), 1.3) * 2)) 198 | sender.Score += sender.LastScore 199 | lobby.scoreEarnedByGuessers += sender.LastScore 200 | sender.State = Standby 201 | WriteAsJSON(sender, JSEvent{Type: "system-message", Data: "You have correctly guessed the word."}) 202 | 203 | if !lobby.isAnyoneStillGuessing() { 204 | endTurn(lobby) 205 | } else { 206 | //Since the word has been guessed correctly, we reveal it. 207 | WriteAsJSON(sender, JSEvent{Type: "update-wordhint", Data: lobby.WordHintsShown}) 208 | recalculateRanks(lobby) 209 | triggerCorrectGuessEvent(lobby) 210 | triggerPlayersUpdate(lobby) 211 | } 212 | 213 | return 214 | } else if levenshtein.ComputeDistance(normInput, normSearched) == 1 { 215 | WriteAsJSON(sender, JSEvent{Type: "system-message", Data: fmt.Sprintf("'%s' is very close.", trimmed)}) 216 | } 217 | 218 | sendMessageToAll(trimmed, sender, lobby) 219 | } 220 | } 221 | 222 | func (lobby *Lobby) isAnyoneStillGuessing() bool { 223 | for _, otherPlayer := range lobby.Players { 224 | if otherPlayer.State == Guessing && otherPlayer.Connected { 225 | return true 226 | } 227 | } 228 | 229 | return false 230 | } 231 | 232 | func sendMessageToAll(message string, sender *Player, lobby *Lobby) { 233 | escaped := html.EscapeString(discordemojimap.Replace(message)) 234 | for _, target := range lobby.Players { 235 | WriteAsJSON(target, JSEvent{Type: "message", Data: Message{ 236 | Author: html.EscapeString(sender.Name), 237 | Content: escaped, 238 | }}) 239 | } 240 | } 241 | 242 | func sendMessageToAllNonGuessing(message string, sender *Player, lobby *Lobby) { 243 | escaped := html.EscapeString(discordemojimap.Replace(message)) 244 | for _, target := range lobby.Players { 245 | if target.State != Guessing { 246 | WriteAsJSON(target, JSEvent{Type: "non-guessing-player-message", Data: Message{ 247 | Author: html.EscapeString(sender.Name), 248 | Content: escaped, 249 | }}) 250 | } 251 | } 252 | } 253 | 254 | func handleKickEvent(lobby *Lobby, player *Player, toKickID string) { 255 | //Kicking yourself isn't allowed 256 | if toKickID == player.ID { 257 | return 258 | } 259 | 260 | //A player can't vote twice to kick someone 261 | if player.votedForKick[toKickID] { 262 | return 263 | } 264 | 265 | toKick := -1 266 | for index, otherPlayer := range lobby.Players { 267 | if otherPlayer.ID == toKickID { 268 | toKick = index 269 | break 270 | } 271 | } 272 | 273 | //If we haven't found the player, we can't kick him/her. 274 | if toKick != -1 { 275 | player.votedForKick[toKickID] = true 276 | playerToKick := lobby.Players[toKick] 277 | 278 | var voteKickCount int 279 | for _, otherPlayer := range lobby.Players { 280 | if otherPlayer.votedForKick[toKickID] == true { 281 | voteKickCount++ 282 | } 283 | } 284 | 285 | votesNeeded := calculateVotesNeededToKick(len(lobby.Players)) 286 | 287 | WritePublicSystemMessage(lobby, fmt.Sprintf("(%d/%d) players voted to kick %s", voteKickCount, votesNeeded, playerToKick.Name)) 288 | 289 | if voteKickCount >= votesNeeded { 290 | //Since the player is already kicked, we first clean up the kicking information related to that player 291 | for _, otherPlayer := range lobby.Players { 292 | if otherPlayer.votedForKick[toKickID] == true { 293 | delete(player.votedForKick, toKickID) 294 | break 295 | } 296 | } 297 | 298 | WritePublicSystemMessage(lobby, fmt.Sprintf("%s has been kicked from the lobby", playerToKick.Name)) 299 | 300 | if lobby.Drawer == playerToKick { 301 | WritePublicSystemMessage(lobby, "Since the kicked player has been drawing, none of you will get any points this round.") 302 | //Since the drawing person has been kicked, that probably means that he/she was trolling, therefore 303 | //we redact everyones last earned score. 304 | for _, otherPlayer := range lobby.Players { 305 | otherPlayer.Score -= otherPlayer.LastScore 306 | otherPlayer.LastScore = 0 307 | } 308 | lobby.scoreEarnedByGuessers = 0 309 | //We must absolutely not set lobby.Drawer to nil, since this would cause the drawing order to be ruined. 310 | } 311 | 312 | if playerToKick.ws != nil { 313 | playerToKick.ws.Close() 314 | } 315 | lobby.Players = append(lobby.Players[:toKick], lobby.Players[toKick+1:]...) 316 | 317 | recalculateRanks(lobby) 318 | 319 | //If the owner is kicked, we choose the next best person as the owner. 320 | if lobby.Owner == playerToKick { 321 | for _, otherPlayer := range lobby.Players { 322 | potentialOwner := otherPlayer 323 | if potentialOwner.Connected { 324 | lobby.Owner = potentialOwner 325 | WritePublicSystemMessage(lobby, fmt.Sprintf("%s is the new lobby owner.", potentialOwner.Name)) 326 | break 327 | } 328 | } 329 | } 330 | 331 | triggerPlayersUpdate(lobby) 332 | 333 | if lobby.Drawer == playerToKick || !lobby.isAnyoneStillGuessing() { 334 | endTurn(lobby) 335 | } 336 | } 337 | } 338 | } 339 | 340 | func calculateVotesNeededToKick(amountOfPlayers int) int { 341 | //If the amount of players equals an even number, such as 6, we will always 342 | //need half of that. If the amount is uneven, we'll get a floored result. 343 | //therefore we always add one to the amount. 344 | //examples: 345 | // (6+1)/2 = 3 346 | // (5+1)/2 = 3 347 | //Therefore it'll never be possible for a minority to kick a player. 348 | return (amountOfPlayers + 1) / 2 349 | } 350 | 351 | func handleCommand(commandString string, caller *Player, lobby *Lobby) { 352 | command := commands.ParseCommand(commandString) 353 | if len(command) >= 1 { 354 | switch strings.ToLower(command[0]) { 355 | case "setmp": 356 | commandSetMP(caller, lobby, command) 357 | case "help": 358 | //TODO 359 | } 360 | } 361 | } 362 | 363 | func commandNick(caller *Player, lobby *Lobby, name string) { 364 | newName := html.EscapeString(strings.TrimSpace(name)) 365 | 366 | //We don't want super-long names 367 | if len(newName) > 30 { 368 | newName = newName[:31] 369 | } 370 | 371 | if newName == "" { 372 | caller.Name = GeneratePlayerName() 373 | } else { 374 | caller.Name = newName 375 | } 376 | 377 | fmt.Printf("%s is now %s\n", caller.Name, newName) 378 | 379 | triggerPlayersUpdate(lobby) 380 | } 381 | 382 | func commandSetMP(caller *Player, lobby *Lobby, args []string) { 383 | if caller == lobby.Owner { 384 | if len(args) < 2 { 385 | return 386 | } 387 | 388 | newMaxPlayersValue := strings.TrimSpace(args[1]) 389 | newMaxPlayersValueInt, err := strconv.ParseInt(newMaxPlayersValue, 10, 64) 390 | if err == nil { 391 | if int(newMaxPlayersValueInt) >= len(lobby.Players) && newMaxPlayersValueInt <= LobbySettingBounds.MaxMaxPlayers && newMaxPlayersValueInt >= LobbySettingBounds.MinMaxPlayers { 392 | lobby.MaxPlayers = int(newMaxPlayersValueInt) 393 | 394 | WritePublicSystemMessage(lobby, fmt.Sprintf("MaxPlayers value has been changed to %d", lobby.MaxPlayers)) 395 | } else { 396 | if len(lobby.Players) > int(LobbySettingBounds.MinMaxPlayers) { 397 | WriteAsJSON(caller, JSEvent{Type: "system-message", Data: fmt.Sprintf("MaxPlayers value should be between %d and %d.", len(lobby.Players), LobbySettingBounds.MaxMaxPlayers)}) 398 | } else { 399 | WriteAsJSON(caller, JSEvent{Type: "system-message", Data: fmt.Sprintf("MaxPlayers value should be between %d and %d.", LobbySettingBounds.MinMaxPlayers, LobbySettingBounds.MaxMaxPlayers)}) 400 | } 401 | } 402 | } else { 403 | WriteAsJSON(caller, JSEvent{Type: "system-message", Data: fmt.Sprintf("MaxPlayers value must be numeric.")}) 404 | } 405 | } else { 406 | WriteAsJSON(caller, JSEvent{Type: "system-message", Data: fmt.Sprintf("Only the lobby owner can change MaxPlayers setting.")}) 407 | } 408 | } 409 | 410 | func endTurn(lobby *Lobby) { 411 | if lobby.timeLeftTicker != nil { 412 | lobby.timeLeftTicker.Stop() 413 | lobby.timeLeftTicker = nil 414 | } 415 | 416 | var roundOverMessage string 417 | if lobby.CurrentWord == "" { 418 | roundOverMessage = "Round over. No word was chosen." 419 | } else { 420 | roundOverMessage = fmt.Sprintf("Round over. The word was '%s'", lobby.CurrentWord) 421 | } 422 | 423 | //The drawer can potentially be null if he's kicked, in that case we proceed with the round if anyone has already 424 | drawer := lobby.Drawer 425 | if drawer != nil && lobby.scoreEarnedByGuessers > 0 { 426 | averageScore := float64(lobby.scoreEarnedByGuessers) / float64(len(lobby.Players)-1) 427 | if averageScore > 0 { 428 | drawer.LastScore = int(averageScore * 1.1) 429 | drawer.Score += drawer.LastScore 430 | } 431 | } 432 | 433 | lobby.scoreEarnedByGuessers = 0 434 | lobby.alreadyUsedWords = append(lobby.alreadyUsedWords, lobby.CurrentWord) 435 | lobby.CurrentWord = "" 436 | lobby.WordHints = nil 437 | 438 | //If the round ends and people still have guessing, that means the "Last" value 439 | ////for the next turn has to be "no score earned". 440 | for _, otherPlayer := range lobby.Players { 441 | if otherPlayer.State == Guessing { 442 | otherPlayer.LastScore = 0 443 | } 444 | } 445 | 446 | WritePublicSystemMessage(lobby, roundOverMessage) 447 | 448 | advanceLobby(lobby) 449 | } 450 | 451 | // advanceLobby will either start the game or jump over to the next turn. 452 | func advanceLobby(lobby *Lobby) { 453 | for _, otherPlayer := range lobby.Players { 454 | otherPlayer.State = Guessing 455 | otherPlayer.votedForKick = make(map[string]bool) 456 | } 457 | 458 | lobby.ClearDrawing() 459 | 460 | newDrawer, roundOver := selectNextDrawer(lobby) 461 | if roundOver { 462 | if lobby.Round == lobby.MaxRounds { 463 | endGame(lobby) 464 | return 465 | } 466 | 467 | lobby.Round++ 468 | } 469 | 470 | lobby.Drawer = newDrawer 471 | lobby.Drawer.State = Drawing 472 | lobby.WordChoice = GetRandomWords(lobby) 473 | 474 | recalculateRanks(lobby) 475 | 476 | //We use milliseconds for higher accuracy 477 | lobby.RoundEndTime = time.Now().UTC().UnixNano()/1000000 + int64(lobby.DrawingTime)*1000 478 | lobby.timeLeftTicker = time.NewTicker(1 * time.Second) 479 | go roundTimerTicker(lobby) 480 | 481 | TriggerComplexUpdateEvent("next-turn", &NextTurn{ 482 | Round: lobby.Round, 483 | Players: lobby.Players, 484 | RoundEndTime: int(lobby.RoundEndTime - getTimeAsMillis()), 485 | }, lobby) 486 | 487 | WriteAsJSON(lobby.Drawer, &JSEvent{Type: "your-turn", Data: lobby.WordChoice}) 488 | } 489 | 490 | func endGame(lobby *Lobby) { 491 | lobby.Drawer = nil 492 | lobby.Round = 0 493 | 494 | recalculateRanks(lobby) 495 | triggerPlayersUpdate(lobby) 496 | 497 | WritePublicSystemMessage(lobby, "Game over. Type !start again to start a new round.") 498 | } 499 | 500 | // selectNextDrawer returns the next person that's supposed to be drawing, but 501 | // doesn't tell the lobby yet. The boolean signals whether the current round is 502 | // over. 503 | func selectNextDrawer(lobby *Lobby) (*Player, bool) { 504 | for index, otherPlayer := range lobby.Players { 505 | if otherPlayer == lobby.Drawer { 506 | //If we have someone that's drawing, take the next one 507 | for i := index + 1; i < len(lobby.Players); i++ { 508 | player := lobby.Players[i] 509 | if player.Connected { 510 | return player, false 511 | } 512 | } 513 | } 514 | } 515 | 516 | return lobby.Players[0], true 517 | } 518 | 519 | func roundTimerTicker(lobby *Lobby) { 520 | hintsLeft := 2 521 | revealHintAtMillisecondsLeft := lobby.DrawingTime * 1000 / 3 522 | 523 | for { 524 | ticker := lobby.timeLeftTicker 525 | if ticker == nil { 526 | return 527 | } 528 | 529 | select { 530 | case <-ticker.C: 531 | currentTime := getTimeAsMillis() 532 | if currentTime >= lobby.RoundEndTime { 533 | go endTurn(lobby) 534 | } 535 | 536 | if hintsLeft > 0 && lobby.WordHints != nil { 537 | timeLeft := lobby.RoundEndTime - currentTime 538 | if timeLeft <= int64(revealHintAtMillisecondsLeft*hintsLeft) { 539 | hintsLeft-- 540 | 541 | for { 542 | randomIndex := rand.Int() % len(lobby.WordHints) 543 | if lobby.WordHints[randomIndex].Character == 0 { 544 | lobby.WordHints[randomIndex].Character = []rune(lobby.CurrentWord)[randomIndex] 545 | triggerWordHintUpdate(lobby) 546 | break 547 | } 548 | } 549 | } 550 | } 551 | } 552 | } 553 | } 554 | 555 | func getTimeAsMillis() int64 { 556 | return time.Now().UTC().UnixNano() / 1000000 557 | } 558 | 559 | // NextTurn represents the data necessary for displaying the lobby state right 560 | // after a new turn started. Meaning that no word has been chosen yet and 561 | // therefore there are no wordhints and no current drawing instructions. 562 | type NextTurn struct { 563 | Round int `json:"round"` 564 | Players []*Player `json:"players"` 565 | RoundEndTime int `json:"roundEndTime"` 566 | } 567 | 568 | // recalculateRanks will assign each player his respective rank in the lobby 569 | // according to everyones current score. This will not trigger any events. 570 | func recalculateRanks(lobby *Lobby) { 571 | for _, a := range lobby.Players { 572 | if !a.Connected { 573 | continue 574 | } 575 | playersThatAreHigher := 0 576 | for _, b := range lobby.Players { 577 | if !b.Connected { 578 | continue 579 | } 580 | if b.Score > a.Score { 581 | playersThatAreHigher++ 582 | } 583 | } 584 | 585 | a.Rank = playersThatAreHigher + 1 586 | } 587 | } 588 | 589 | func createWordHintFor(word string, showAll bool) []*WordHint { 590 | wordHints := make([]*WordHint, 0, len(word)) 591 | for _, char := range word { 592 | irrelevantChar := char == ' ' || char == '_' || char == '-' 593 | if showAll { 594 | wordHints = append(wordHints, &WordHint{ 595 | Character: char, 596 | Underline: !irrelevantChar, 597 | }) 598 | } else { 599 | if irrelevantChar { 600 | wordHints = append(wordHints, &WordHint{ 601 | Character: char, 602 | Underline: !irrelevantChar, 603 | }) 604 | } else { 605 | wordHints = append(wordHints, &WordHint{ 606 | Underline: !irrelevantChar, 607 | }) 608 | } 609 | } 610 | } 611 | 612 | return wordHints 613 | } 614 | 615 | var TriggerSimpleUpdateEvent func(eventType string, lobby *Lobby) 616 | var TriggerComplexUpdatePerPlayerEvent func(eventType string, data func(*Player) interface{}, lobby *Lobby) 617 | var TriggerComplexUpdateEvent func(eventType string, data interface{}, lobby *Lobby) 618 | var SendDataToConnectedPlayers func(sender *Player, lobby *Lobby, data interface{}) 619 | var WriteAsJSON func(player *Player, object interface{}) error 620 | var WritePublicSystemMessage func(lobby *Lobby, text string) 621 | 622 | func triggerPlayersUpdate(lobby *Lobby) { 623 | TriggerComplexUpdateEvent("update-players", lobby.Players, lobby) 624 | } 625 | 626 | func triggerCorrectGuessEvent(lobby *Lobby) { 627 | TriggerSimpleUpdateEvent("correct-guess", lobby) 628 | } 629 | 630 | func triggerWordHintUpdate(lobby *Lobby) { 631 | if lobby.CurrentWord == "" { 632 | return 633 | } 634 | 635 | TriggerComplexUpdatePerPlayerEvent("update-wordhint", func(player *Player) interface{} { 636 | return lobby.GetAvailableWordHints(player) 637 | }, lobby) 638 | } 639 | 640 | type Rounds struct { 641 | Round int `json:"round"` 642 | MaxRounds int `json:"maxRounds"` 643 | } 644 | 645 | // CreateLobby allows creating a lobby, optionally returning errors that 646 | // occurred during creation. 647 | func CreateLobby(playerName, language string, drawingTime, rounds, maxPlayers, customWordChance, clientsPerIPLimit int, customWords []string, enableVotekick bool) (*Player, *Lobby, error) { 648 | lobby := createLobby(drawingTime, rounds, maxPlayers, customWords, customWordChance, clientsPerIPLimit, enableVotekick) 649 | player := createPlayer(playerName) 650 | 651 | lobby.Players = append(lobby.Players, player) 652 | lobby.Owner = player 653 | 654 | // Read wordlist according to the chosen language 655 | words, err := readWordList(language) 656 | if err != nil { 657 | RemoveLobby(lobby.ID) 658 | return nil, nil, err 659 | } 660 | 661 | lobby.Words = words 662 | 663 | return player, lobby, nil 664 | } 665 | 666 | // GeneratePlayerName creates a new playername. A so called petname. It consists 667 | // of an adverb, an adjective and a animal name. The result can generally be 668 | // trusted to be sane. 669 | func GeneratePlayerName() string { 670 | adjective := strings.Title(petname.Adjective()) 671 | adverb := strings.Title(petname.Adverb()) 672 | name := strings.Title(petname.Name()) 673 | return adverb + adjective + name 674 | } 675 | 676 | // Message represents a message in the chatroom. 677 | type Message struct { 678 | // Author is the player / thing that wrote the message 679 | Author string `json:"author"` 680 | // Content is the actual message text. 681 | Content string `json:"content"` 682 | } 683 | 684 | // Ready represents the initial state that a user needs upon connection. 685 | // This includes all the necessary things for properly running a client 686 | // without receiving any more data. 687 | type Ready struct { 688 | PlayerID string `json:"playerId"` 689 | PlayerName string `json:"playerName"` 690 | Drawing bool `json:"drawing"` 691 | 692 | OwnerID string `json:"ownerId"` 693 | Round int `json:"round"` 694 | MaxRound int `json:"maxRounds"` 695 | RoundEndTime int `json:"roundEndTime"` 696 | WordHints []*WordHint `json:"wordHints"` 697 | Players []*Player `json:"players"` 698 | CurrentDrawing []interface{} `json:"currentDrawing"` 699 | } 700 | 701 | func OnConnected(lobby *Lobby, player *Player) { 702 | player.Connected = true 703 | WriteAsJSON(player, JSEvent{Type: "ready", Data: &Ready{ 704 | PlayerID: player.ID, 705 | Drawing: player.State == Drawing, 706 | PlayerName: player.Name, 707 | 708 | OwnerID: lobby.Owner.ID, 709 | Round: lobby.Round, 710 | MaxRound: lobby.MaxRounds, 711 | RoundEndTime: int(lobby.RoundEndTime - getTimeAsMillis()), 712 | WordHints: lobby.GetAvailableWordHints(player), 713 | Players: lobby.Players, 714 | CurrentDrawing: lobby.CurrentDrawing, 715 | }}) 716 | 717 | //This state is reached when the player refreshes before having chosen a word. 718 | if lobby.Drawer == player && lobby.CurrentWord == "" { 719 | WriteAsJSON(lobby.Drawer, &JSEvent{Type: "your-turn", Data: lobby.WordChoice}) 720 | } 721 | 722 | updateRocketChat(lobby, player) 723 | 724 | //TODO Only send to everyone except for the new player, since it's part of the ready event. 725 | triggerPlayersUpdate(lobby) 726 | } 727 | 728 | func OnDisconnected(lobby *Lobby, player *Player) { 729 | //We want to avoid calling the handler twice. 730 | if player.ws == nil { 731 | return 732 | } 733 | 734 | player.Connected = false 735 | player.ws = nil 736 | 737 | updateRocketChat(lobby, player) 738 | 739 | if !lobby.HasConnectedPlayers() { 740 | RemoveLobby(lobby.ID) 741 | log.Printf("Closing lobby %s. There are currently %d open lobbies left.\n", lobby.ID, len(lobbies)) 742 | } else { 743 | triggerPlayersUpdate(lobby) 744 | } 745 | } 746 | 747 | func (lobby *Lobby) GetAvailableWordHints(player *Player) []*WordHint { 748 | //The draw simple gets every character as a word-hint. We basically abuse 749 | //the hints for displaying the word, instead of having yet another GUI 750 | //element that wastes space. 751 | if player.State == Drawing || player.State == Standby { 752 | return lobby.WordHintsShown 753 | } else { 754 | return lobby.WordHints 755 | } 756 | } 757 | 758 | func (lobby *Lobby) JoinPlayer(playerName string) *Player { 759 | player := createPlayer(playerName) 760 | 761 | //FIXME Make a dedicated method that uses a mutex? 762 | lobby.Players = append(lobby.Players, player) 763 | recalculateRanks(lobby) 764 | triggerPlayersUpdate(lobby) 765 | 766 | return player 767 | } 768 | 769 | func (lobby *Lobby) canDraw(player *Player) bool { 770 | return lobby.Drawer == player && lobby.CurrentWord != "" 771 | } 772 | 773 | func removeAccents(s string) string { 774 | return strings. 775 | NewReplacer(" ", "", "-", "", "_", ""). 776 | Replace(sanitize.Accents(s)) 777 | } 778 | -------------------------------------------------------------------------------- /resources/words/fr: -------------------------------------------------------------------------------- 1 | capacité 2 | avortement 3 | abus 4 | académie 5 | accident 6 | comptable 7 | acide 8 | acte 9 | ajouter 10 | accro 11 | ajout 12 | adresse 13 | administrateur 14 | adulte 15 | publicité 16 | liaison 17 | effrayé 18 | après-midi 19 | âge 20 | agence 21 | agent 22 | accord 23 | aérer 24 | aéronef 25 | compagnie aérienne 26 | aéroport 27 | allée 28 | alarme 29 | album 30 | alcool 31 | allocation 32 | allié 33 | autel 34 | ambre 35 | ambulance 36 | amputer 37 | analyse 38 | ange 39 | colère 40 | angle 41 | fâché 42 | animal 43 | cheville 44 | annonce 45 | annuel 46 | anonyme 47 | fourmi 48 | anticipation 49 | anxiété 50 | Excusez-moi 51 | apparaître 52 | appétit 53 | applaudir 54 | pomme 55 | application 56 | nomination 57 | approuver 58 | aquarium 59 | arche 60 | archiver 61 | arène 62 | bras 63 | fauteuil 64 | armée 65 | arrestation 66 | flèche 67 | art 68 | article 69 | artificiel 70 | artiste 71 | artistique 72 | cendre 73 | endormi 74 | aspect 75 | assaut 76 | atout 77 | affectation 78 | asile 79 | athlète 80 | atome 81 | attaque 82 | grenier 83 | attirer 84 | enchères 85 | auditoire 86 | automatique 87 | moyenne 88 | aviation 89 | éveillé 90 | prix 91 | axe 92 | bébé 93 | dos 94 | arrière-plan 95 | lardon 96 | sac 97 | caution 98 | cuire 99 | équilibre 100 | balcon 101 | chauve 102 | balle 103 | ballet 104 | ballon 105 | banane 106 | bande 107 | détonation 108 | banque 109 | bannière 110 | bar 111 | écorce 112 | baril 113 | barrière 114 | base 115 | baseball 116 | bassin 117 | panier 118 | basket-ball 119 | batte 120 | bain 121 | batterie 122 | bataille 123 | champ de bataille 124 | baie 125 | plage 126 | faisceau 127 | haricot 128 | ours 129 | barbe 130 | battre 131 | lit 132 | chambre 133 | abeille 134 | bœuf 135 | bière 136 | supplier 137 | décapiter 138 | cloche 139 | ventre 140 | ceinture 141 | banc 142 | virage 143 | baie 144 | pari 145 | Bible 146 | vélo 147 | poubelle 148 | biographie 149 | biologie 150 | oiseau 151 | anniversaire 152 | biscuit 153 | évêque 154 | chienne 155 | morsure 156 | amer 157 | noir 158 | lame 159 | vierge 160 | explosion 161 | saigner 162 | bénir 163 | aveugle 164 | bloc 165 | Blonde 166 | sang 167 | carnage 168 | sanglante 169 | souffler 170 | bleu 171 | planche 172 | bateau 173 | corps 174 | bouillir 175 | boulon 176 | bombe 177 | bombardier 178 | lien 179 | os 180 | livre 181 | boom 182 | botte 183 | frontière 184 | déranger 185 | bouteille 186 | bas 187 | rebondir 188 | arc 189 | entrailles 190 | bol 191 | boîte 192 | garçon 193 | cerveau 194 | frein 195 | succursale 196 | marque 197 | courageux 198 | pain 199 | pause 200 | poitrine 201 | respirer 202 | race 203 | brise 204 | brasserie 205 | brique 206 | mariée 207 | pont 208 | apporter 209 | diffuser 210 | brocoli 211 | cassé 212 | bronze 213 | frère 214 | brun 215 | brosse 216 | bulle 217 | seau 218 | buffet 219 | bâtiment 220 | bulbe 221 | balle 222 | faisceau 223 | inhumation 224 | brûler 225 | enterrer 226 | bus 227 | brousse 228 | entreprise 229 | businessman 230 | beurre 231 | papillon 232 | bouton 233 | acheter 234 | cabine 235 | cabinet 236 | câble 237 | café 238 | cage 239 | gâteau 240 | calcul 241 | calendrier 242 | veau 243 | appeler 244 | caméra 245 | camp 246 | annuler 247 | cancer 248 | candidat 249 | bougie 250 | canne 251 | toile 252 | bouchon 253 | capital 254 | capitaine 255 | capture 256 | voiture 257 | carte 258 | carrière 259 | tapis 260 | carrosse 261 | carotte 262 | porter 263 | chariot 264 | tailler 265 | cas 266 | espèces 267 | cassette 268 | château 269 | chat 270 | attraper 271 | cathédrale 272 | cause 273 | grotte 274 | plafond 275 | célébration 276 | cellule 277 | cave 278 | cimetière 279 | censure 280 | centre 281 | céréale 282 | cérémonie 283 | certificat 284 | chaîne 285 | chaise 286 | craie 287 | champagne 288 | champion 289 | changement 290 | chaîne 291 | chaos 292 | chapitre 293 | caractère 294 | charge 295 | charité 296 | charme 297 | graphe 298 | radin 299 | vérifier 300 | joue 301 | fromage 302 | chimique 303 | chimie 304 | cerise 305 | poitrine 306 | mâcher 307 | poulet 308 | chef 309 | enfant 310 | enfance 311 | cheminée 312 | menton 313 | croustille 314 | chocolat 315 | étouffer 316 | hacher 317 | chronique 318 | église 319 | cigarette 320 | cinéma 321 | cercle 322 | circulation 323 | citoyen 324 | ville 325 | civil 326 | réclamation 327 | classe 328 | classique 329 | salle de classe 330 | argile 331 | nettoyer 332 | grimper 333 | clinique 334 | horloge 335 | fermer 336 | fermé 337 | vêtements 338 | nuage 339 | club 340 | indice 341 | autocar 342 | charbon 343 | côte 344 | manteau 345 | code 346 | café 347 | cercueil 348 | pièce 349 | froid 350 | effondrement 351 | collègue 352 | recueillir 353 | collection 354 | collège 355 | colonie 356 | Couleur 357 | Coloré 358 | colonne 359 | coma 360 | combinaison 361 | combiner 362 | comédie 363 | comète 364 | commande 365 | commentaire 366 | communication 367 | communauté 368 | compagnie 369 | comparer 370 | rivaliser 371 | se plaindre 372 | complète 373 | ordinateur 374 | concentrer 375 | concert 376 | béton 377 | conducteur 378 | conférence 379 | confiant 380 | conflit 381 | confusion 382 | connexion 383 | conscience 384 | consensus 385 | envisager 386 | conspiration 387 | constellation 388 | contrainte 389 | construire 390 | contact 391 | concours 392 | contrat 393 | contradiction 394 | convention 395 | conversation 396 | convertir 397 | bagnard 398 | cuire 399 | cuivre 400 | copie 401 | corde 402 | noyau 403 | cor 404 | coin 405 | correction 406 | costume 407 | cottage 408 | coton 409 | toux 410 | compter 411 | compteur 412 | pays 413 | couple 414 | cours 415 | couvrir 416 | vache 417 | crack 418 | métier 419 | artisan 420 | crash 421 | crème 422 | création 423 | carte de crédit 424 | crédit 425 | ramper 426 | équipage 427 | cricket 428 | crime 429 | criminel 430 | croix 431 | croisement 432 | accroupis 433 | foule 434 | couronne 435 | pleurer 436 | cristal 437 | concombre 438 | tasse 439 | placard 440 | boucle 441 | monnaie 442 | actuel 443 | cursus 444 | rideau 445 | courbe 446 | coussin 447 | client 448 | couper 449 | mignon 450 | découpage 451 | cycle 452 | cylindre 453 | laitière 454 | dommage 455 | danse 456 | danger 457 | sombre 458 | date 459 | fille 460 | jour 461 | jour 462 | morts 463 | mortel 464 | sourd 465 | traiter 466 | dealer 467 | mort 468 | dette 469 | diminuer 470 | profond 471 | cerf 472 | défaite 473 | défendre 474 | degré 475 | livrer 476 | livraison 477 | démolir 478 | démonstration 479 | dentiste 480 | quitter 481 | départ 482 | dépôt 483 | dépression 484 | descente 485 | désert 486 | conception 487 | bureau 488 | destruction 489 | détective 490 | détecteur 491 | diagnostic 492 | diagramme 493 | diamètre 494 | diamant 495 | dicter 496 | dictionnaire 497 | mourir 498 | différer 499 | différence 500 | creuser 501 | numérique 502 | dîner 503 | tremper 504 | direction 505 | directeur 506 | répertoire 507 | sale 508 | invalidité 509 | disparaître 510 | discothèque 511 | rabais 512 | découverte 513 | discret 514 | discuter 515 | maladie 516 | plat 517 | antipathie 518 | afficher 519 | dissoudre 520 | distance 521 | lointain 522 | plongée 523 | diviser 524 | division 525 | divorce 526 | médecin 527 | document 528 | chien 529 | poupée 530 | dollar 531 | dauphin 532 | dôme 533 | domination 534 | donner 535 | porte 536 | dose 537 | double 538 | pâte 539 | traîner 540 | dragon 541 | drain 542 | dessiner 543 | tiroir 544 | dessin 545 | rêve 546 | robe 547 | perceuse 548 | boire 549 | lecteur 550 | conducteur 551 | goutte 552 | noyer 553 | drogue 554 | tambour 555 | sec 556 | canard 557 | due 558 | terne 559 | larguer 560 | durée 561 | aigle 562 | oreille 563 | précoce 564 | séisme 565 | orient 566 | manger 567 | Eavesdrop 568 | écho 569 | bord 570 | éducation 571 | oeuf 572 | coude 573 | élection 574 | électricité 575 | électron 576 | électronique 577 | électronique 578 | élément 579 | éléphant 580 | éliminer 581 | urgence 582 | émotion 583 | empire 584 | vide 585 | fin 586 | ennemi 587 | énergie 588 | moteur 589 | ingénieur 590 | agrandir 591 | entrer 592 | enthousiasme 593 | entrée 594 | enveloppe 595 | environnement 596 | épisode 597 | égal 598 | équation 599 | équipement 600 | erreur 601 | échapper 602 | Europe 603 | même 604 | soir 605 | évolution 606 | dépasser 607 | excité 608 | exécution 609 | sortie 610 | agrandir 611 | expansion 612 | coûteux 613 | expérimenter 614 | exploser 615 | exploration 616 | explosion 617 | exporter 618 | expression 619 | extension 620 | éteint 621 | extraterrestre 622 | œil 623 | sourcil 624 | façade 625 | visage 626 | installation 627 | usine 628 | échouer 629 | échec 630 | faible 631 | équitable 632 | fée 633 | chute 634 | gloire 635 | famille 636 | ventilateur 637 | fantasme 638 | loin 639 | ferme 640 | agriculteur 641 | rapide 642 | graisse 643 | père 644 | télécopieur 645 | crainte 646 | festin 647 | plume 648 | honoraires 649 | clôture 650 | ferry 651 | festival 652 | fièvre 653 | fibre 654 | fiction 655 | champ 656 | lutte 657 | fichier 658 | remplir 659 | pellicule 660 | filtre 661 | financer 662 | financier 663 | trouver 664 | doigt 665 | finir 666 | licencier 667 | pompier 668 | premier 669 | poissons 670 | pêcheur 671 | poing 672 | apte 673 | aptitude 674 | fixer 675 | drapeau 676 | éclair 677 | flotte 678 | chair 679 | vol 680 | passade 681 | inondation 682 | plancher 683 | farine 684 | fleur 685 | grippe 686 | fluide 687 | flush 688 | voler 689 | plier 690 | nourriture 691 | imbécile 692 | pied 693 | football 694 | interdire 695 | front 696 | étranger 697 | forêt 698 | fourche 699 | formel 700 | formule 701 | fortune 702 | avancer 703 | fossile 704 | fondation 705 | fontaine 706 | renard 707 | fragment 708 | franchise 709 | fraude 710 | tache de rousseur 711 | libre 712 | geler 713 | fret 714 | fréquence 715 | fraîche 716 | frigo 717 | ami 718 | amitié 719 | grenouille 720 | devant 721 | congelé 722 | fruit 723 | carburant 724 | amusant 725 | funérailles 726 | drôle 727 | fourrure 728 | mobilier 729 | galaxie 730 | galerie 731 | gallon 732 | jeu 733 | écart 734 | garage 735 | ordures 736 | jardin 737 | ail 738 | gaz 739 | porte 740 | génie 741 | gentilhomme 742 | géographie 743 | géologique 744 | geste 745 | fantôme 746 | géant 747 | cadeau 748 | fille 749 | donner 750 | glacier 751 | verre 752 | lunettes 753 | glisser 754 | morosité 755 | gant 756 | lueur 757 | colle 758 | aller 759 | objectif 760 | gardien 761 | chèvre 762 | Dieu 763 | or 764 | golf 765 | bonne 766 | gouvernement 767 | progressive 768 | diplômé 769 | grain 770 | grand-père 771 | grand-mère 772 | graphique 773 | graphisme 774 | herbe 775 | grave 776 | graviers 777 | gravité 778 | vert 779 | saluer 780 | salutation 781 | grimace 782 | sourire 783 | poignée 784 | sol 785 | croître 786 | croissance 787 | garde 788 | invité 789 | culpabilité 790 | guitare 791 | pistolet 792 | caniveau 793 | habitat 794 | cheveux 795 | coupe 796 | moitié 797 | salle 798 | couloir 799 | marteau 800 | main 801 | handicap 802 | accrocher 803 | heureux 804 | port 805 | dur 806 | matériel 807 | dommage 808 | harmonie 809 | récolte 810 | chapeau 811 | détester 812 | hanter 813 | foin 814 | tête 815 | manchette 816 | quartier général 817 | santé 818 | sain 819 | entendre 820 | cœur 821 | chaleur 822 | ciel 823 | lourd 824 | haie 825 | hauteur 826 | hélicoptère 827 | enfer 828 | casque 829 | hémisphère 830 | poule 831 | troupeau 832 | héros 833 | héroïne 834 | cacher 835 | élevé 836 | souligner 837 | randonnée 838 | colline 839 | hanche 840 | historique 841 | frapper 842 | tenir 843 | trou 844 | vacances 845 | foyer 846 | homosexuelle 847 | miel 848 | crochet 849 | horizon 850 | horizontal 851 | corne 852 | horreur 853 | cheval 854 | hôpital 855 | otage 856 | chaud 857 | hôtel 858 | heure 859 | abriter 860 | plante d'intérieur 861 | femme au foyer 862 | planer 863 | énorme 864 | homme 865 | humanité 866 | chasseur 867 | chasse 868 | blesser 869 | mari 870 | hutte 871 | hypnotiser 872 | crème glacée 873 | glace 874 | idée 875 | identification 876 | identifier 877 | identité 878 | illégal 879 | maladie 880 | image 881 | imagination 882 | imaginer 883 | immigrant 884 | immigration 885 | importer 886 | impulsion 887 | incident 888 | revenu 889 | individuel 890 | intérieur 891 | industrielle 892 | industrie 893 | infecter 894 | infection 895 | infini 896 | gonfler 897 | informations 898 | ingrédient 899 | habitant 900 | injecter 901 | injection 902 | blessure 903 | innocent 904 | insecte 905 | insérer 906 | intérieur 907 | Inspecteur 908 | Instal 909 | instruction 910 | instrument 911 | intégration 912 | intelligence 913 | international 914 | entrevue 915 | invasion 916 | enquêteur 917 | invitation 918 | inviter 919 | fer 920 | île 921 | isolement 922 | point 923 | veste 924 | prison 925 | confiture 926 | bocal 927 | mâchoire 928 | jazz 929 | gelée 930 | secousse 931 | jet 932 | joyau 933 | emploi 934 | jockey 935 | articulation 936 | blague 937 | joie 938 | juge 939 | jugement 940 | jus 941 | sauter 942 | jungle 943 | junior 944 | jury 945 | justice 946 | bouilloire 947 | clé 948 | botter 949 | gamin 950 | rein 951 | tuer 952 | tueur 953 | genre 954 | roi 955 | royaume 956 | baiser 957 | cuisine 958 | cerf-volant 959 | genou 960 | agenouiller 961 | couteau 962 | frapper 963 | nœud 964 | étiquette 965 | laboratoire 966 | dentelle 967 | échelle 968 | dame 969 | lac 970 | agneau 971 | lampe 972 | terre 973 | propriétaire 974 | propriétaire foncier 975 | paysage 976 | langue 977 | genoux 978 | grand 979 | laser 980 | dernier 981 | les plus récents 982 | rire 983 | lancement 984 | blanchisserie 985 | gazon 986 | avocat 987 | pondre 988 | disposition 989 | leadership 990 | feuille 991 | dépliant 992 | fuite 993 | maigre 994 | apprendre 995 | cuir 996 | congé 997 | conférence 998 | gauche 999 | restes 1000 | jambe 1001 | légende 1002 | citron 1003 | longueur 1004 | leçon 1005 | lettre 1006 | niveau 1007 | bibliothèque 1008 | licence 1009 | lécher 1010 | couvercle 1011 | mensonge 1012 | ascenseur 1013 | lumière 1014 | lys 1015 | membre 1016 | limite 1017 | ligne 1018 | lien 1019 | lion 1020 | lèvre 1021 | liquide 1022 | liste 1023 | écouter 1024 | littérature 1025 | vivre 1026 | foie 1027 | lobby 1028 | localiser 1029 | emplacement 1030 | verrouiller 1031 | loge 1032 | bûche 1033 | seul 1034 | long 1035 | regarder 1036 | boucle 1037 | lâche 1038 | perdre 1039 | lot 1040 | bruyant 1041 | salon 1042 | amour 1043 | abaisser 1044 | chanceux 1045 | bosse 1046 | déjeuner 1047 | poumon 1048 | machine 1049 | magazine 1050 | magnétique 1051 | servante 1052 | courrier 1053 | commercial 1054 | entretien 1055 | majeur 1056 | maquillage 1057 | mec 1058 | gestion 1059 | manuel 1060 | fabriquer 1061 | carte 1062 | marathon 1063 | marbre 1064 | marche 1065 | marin 1066 | marketing 1067 | mariage 1068 | marié 1069 | Mars 1070 | marais 1071 | masque 1072 | masse 1073 | maître 1074 | match 1075 | matériau 1076 | mathématique 1077 | mathématiques 1078 | maximum 1079 | maire 1080 | dédale 1081 | repas 1082 | mesurer 1083 | viande 1084 | mécanique 1085 | médaille 1086 | médecine 1087 | médiéval 1088 | moyen 1089 | rencontrer 1090 | réunion 1091 | membre 1092 | adhésion 1093 | mémorial 1094 | mémoire 1095 | menu 1096 | marchand 1097 | message 1098 | métal 1099 | microphone 1100 | milieu 1101 | minuit 1102 | mille 1103 | armée 1104 | lait 1105 | esprit 1106 | mineur 1107 | minéral 1108 | minimiser 1109 | minimum 1110 | mineur 1111 | minute 1112 | miroir 1113 | fausse couche 1114 | manquer 1115 | missile 1116 | mobile 1117 | modèle 1118 | taupe 1119 | moléculaire 1120 | argent 1121 | moine 1122 | singe 1123 | monopole 1124 | monstre 1125 | monstrueux 1126 | mois 1127 | lune 1128 | matin 1129 | mosquée 1130 | moustique 1131 | mère 1132 | autoroute 1133 | montagne 1134 | souris 1135 | bouche 1136 | déplacer 1137 | émouvant 1138 | boue 1139 | mug 1140 | multimédia 1141 | multiple 1142 | multiplier 1143 | meurtre 1144 | muscle 1145 | musée 1146 | champignon 1147 | musique 1148 | musicien 1149 | mutuel 1150 | mythe 1151 | ongle 1152 | nom 1153 | sieste 1154 | étroit 1155 | nature 1156 | marine 1157 | cou 1158 | aiguille 1159 | voisin 1160 | quartier 1161 | neveu 1162 | nid 1163 | net 1164 | réseau 1165 | nouvelles 1166 | nuit 1167 | cauchemar 1168 | hochement 1169 | bruit 1170 | nord 1171 | nez 1172 | note 1173 | cahier 1174 | roman 1175 | nucléaire 1176 | nombre 1177 | nonne 1178 | infirmière 1179 | pépinière 1180 | écrou 1181 | chêne 1182 | obèse 1183 | obéir 1184 | observation 1185 | observateur 1186 | obstacle 1187 | obtenir 1188 | océan 1189 | impair 1190 | offrir 1191 | bureau 1192 | officier 1193 | progéniture 1194 | huile 1195 | vieux 1196 | omission 1197 | oignon 1198 | ouvrir 1199 | opéra 1200 | opération 1201 | adversaire 1202 | s'opposer 1203 | opposé 1204 | option 1205 | oral 1206 | orange 1207 | orbite 1208 | orchestre 1209 | organe 1210 | original 1211 | tenue 1212 | exutoire 1213 | esquisse 1214 | vue 1215 | extérieur 1216 | four 1217 | hibou 1218 | propriété 1219 | oxygène 1220 | paquet 1221 | paquet 1222 | page 1223 | douleur 1224 | peinture 1225 | peintre 1226 | paire 1227 | palais 1228 | paume 1229 | poêle 1230 | papier 1231 | parade 1232 | paragraphe 1233 | parallèle 1234 | parent 1235 | parc 1236 | parking 1237 | particule 1238 | partenaire 1239 | partenariat 1240 | partie 1241 | passage 1242 | passager 1243 | passeport 1244 | mot de passe 1245 | passé 1246 | tapoter 1247 | brevet 1248 | chemin 1249 | modèle 1250 | pause 1251 | chaussée 1252 | payer 1253 | paiement 1254 | paisible 1255 | arachide 1256 | piétonne 1257 | stylo 1258 | crayon 1259 | penny 1260 | gens 1261 | poivre 1262 | pourcent 1263 | perforé 1264 | interprète 1265 | période 1266 | personne 1267 | animal de compagnie 1268 | philosophe 1269 | photocopie 1270 | photo 1271 | photographe 1272 | photographie 1273 | physique 1274 | piano 1275 | Choisis 1276 | photo 1277 | tarte 1278 | pièce 1279 | jetée 1280 | cochon 1281 | pigeon 1282 | pile 1283 | pilule 1284 | oreiller 1285 | pilote 1286 | épingle 1287 | pionnier 1288 | tuyau 1289 | fosse 1290 | plan 1291 | avion 1292 | planète 1293 | plante 1294 | plastique 1295 | plate-forme 1296 | jouer 1297 | Agréable 1298 | gage 1299 | brancher 1300 | poche 1301 | poème 1302 | poésie 1303 | point 1304 | Du poison 1305 | perche 1306 | politique 1307 | sondage 1308 | pollution 1309 | poney 1310 | piscine 1311 | Pauvre 1312 | portrait 1313 | positif 1314 | poste 1315 | carte postale 1316 | pot 1317 | pomme de terre 1318 | poterie 1319 | verser 1320 | poudre 1321 | puissance 1322 | Priez 1323 | Prière 1324 | précis 1325 | prédécesseur 1326 | prévisible 1327 | enceinte 1328 | présent 1329 | présentation 1330 | présidence 1331 | Président 1332 | presse 1333 | pression 1334 | proie 1335 | prix 1336 | prince 1337 | princesse 1338 | impression 1339 | imprimante 1340 | prison 1341 | prisonnier 1342 | protection de la vie privée 1343 | privilège 1344 | prix 1345 | produire 1346 | producteur 1347 | produit 1348 | production 1349 | productives 1350 | professeur 1351 | profil 1352 | programme 1353 | projection 1354 | preuve 1355 | propagande 1356 | propriété 1357 | proportion 1358 | proportionnelle 1359 | protéger 1360 | protection 1361 | protéines 1362 | protestation 1363 | psychologue 1364 | pub 1365 | publier 1366 | Pudding 1367 | Tirez 1368 | pompe 1369 | citrouille 1370 | coup de poing 1371 | élève 1372 | Poussez 1373 | puzzle 1374 | pyramide 1375 | qualifié 1376 | qualité 1377 | quart 1378 | Reine 1379 | question 1380 | questionnaire 1381 | file d'attente 1382 | Arrêtez 1383 | citation 1384 | citation 1385 | Lapin 1386 | course 1387 | racisme 1388 | rack 1389 | rayonnement 1390 | radio 1391 | fureur 1392 | raid 1393 | wagon 1394 | chemin de fer 1395 | pluie 1396 | arc-en-ciel 1397 | rassemblement 1398 | aléatoire 1399 | rang 1400 | rat 1401 | taux 1402 | ratio 1403 | cru 1404 | atteindre 1405 | réaction 1406 | réacteur 1407 | lire 1408 | lecteur 1409 | arrière 1410 | rebelle 1411 | reçu 1412 | réception 1413 | enregistrement 1414 | enregistrement 1415 | récupération 1416 | recycler 1417 | rouge 1418 | réduction 1419 | redondance 1420 | réfléchir 1421 | réflexion 1422 | région 1423 | inscrivez-vous 1424 | réadaptation 1425 | répétition 1426 | règne 1427 | liés 1428 | relation 1429 | Détends-toi 1430 | relâche 1431 | religieux 1432 | réticence 1433 | restent 1434 | remarque 1435 | Rappelle-toi 1436 | loyer 1437 | répéter 1438 | répétition 1439 | remplacer 1440 | remplacement 1441 | rapport 1442 | reporter 1443 | reproduction 1444 | reptile 1445 | recherche 1446 | chercheur 1447 | résident 1448 | station 1449 | ressource 1450 | Repose-toi 1451 | restaurant 1452 | restriction 1453 | résultat 1454 | vengeance 1455 | inverse 1456 | renouveau 1457 | revivre 1458 | côte 1459 | ruban 1460 | riz 1461 | riche 1462 | cavalier 1463 | fusil 1464 | droite 1465 | sonnerie 1466 | émeute 1467 | élévation 1468 | rituel 1469 | rivière 1470 | route 1471 | rugissement 1472 | Rob 1473 | cambriolage 1474 | robot 1475 | rocher 1476 | fusée 1477 | rôle 1478 | Rouler 1479 | romantique 1480 | toit 1481 | chambre 1482 | corde 1483 | Rose 1484 | ligne 1485 | royauté 1486 | Bêtises 1487 | rugby 1488 | Courez 1489 | coureur 1490 | sacré 1491 | sacrifice 1492 | Voile 1493 | marin 1494 | salade 1495 | vente 1496 | saumon 1497 | sel 1498 | salut 1499 | sable 1500 | sandale 1501 | satellite 1502 | satisfaisant 1503 | sauce 1504 | saucisse 1505 | Sauvegarder 1506 | Dites 1507 | scanner 1508 | disperser 1509 | scène 1510 | calendrier 1511 | école 1512 | science 1513 | scientifique 1514 | score 1515 | gratter 1516 | crie 1517 | écran 1518 | script 1519 | sculpture 1520 | mer 1521 | sceller 1522 | recherche 1523 | saison 1524 | saisonnière 1525 | siège 1526 | seconde 1527 | secondaire 1528 | Secrétaire 1529 | Sécurisé 1530 | voir 1531 | semence 1532 | vendre 1533 | vendeur 1534 | séminaire 1535 | Senior 1536 | séparation 1537 | séquence 1538 | série 1539 | serviteur 1540 | servir 1541 | service 1542 | règlement 1543 | sexe 1544 | ombre 1545 | ombre 1546 | peu profond 1547 | forme 1548 | partager 1549 | requin 1550 | pointu 1551 | brousiller 1552 | Rase-toi 1553 | moutons 1554 | feuille 1555 | étagère 1556 | coquille 1557 | bouclier 1558 | Déplacement 1559 | Briller 1560 | chemise 1561 | choc 1562 | chaussure 1563 | Tirez 1564 | boutique 1565 | shopping 1566 | court 1567 | pénurie 1568 | Short 1569 | Tir 1570 | épaule 1571 | crie 1572 | montrer 1573 | douche 1574 | Rétrécir 1575 | hausser les épaules 1576 | timide 1577 | malade 1578 | maladie 1579 | vue 1580 | signe 1581 | signature 1582 | silence 1583 | soie 1584 | argent 1585 | similaire 1586 | similitude 1587 | péché 1588 | Chante 1589 | chanteur 1590 | seul 1591 | évier 1592 | Ma sœur 1593 | Asseyez-vous 1594 | site 1595 | taille 1596 | Patins 1597 | croquis 1598 | ski 1599 | Crâne 1600 | ciel 1601 | dalle 1602 | Slam 1603 | gifle 1604 | esclave 1605 | Dormez 1606 | manche 1607 | Tranche 1608 | Sale 1609 | glisser 1610 | glissante 1611 | fente 1612 | Doucement 1613 | Intelligent 1614 | Frappe 1615 | odeur 1616 | Souriez 1617 | Fumer 1618 | escargot 1619 | serpent 1620 | renifler 1621 | neige 1622 | blottis 1623 | savon 1624 | foot 1625 | chaussette 1626 | Doux 1627 | logiciel 1628 | sol 1629 | solaire 1630 | soldat 1631 | solide 1632 | solo 1633 | solution 1634 | résoudre 1635 | âme 1636 | son 1637 | soupe 1638 | sud 1639 | espace 1640 | orateur 1641 | spécimen 1642 | discours 1643 | vitesse 1644 | épeler 1645 | sphère 1646 | Araignée 1647 | répandre 1648 | Tournez 1649 | épinards 1650 | colonne vertébrale 1651 | esprit 1652 | crache 1653 | Split 1654 | cuillère 1655 | sport 1656 | spot 1657 | pulvérisation 1658 | propagation 1659 | printemps 1660 | espionne 1661 | escouade 1662 | carré 1663 | Pressez 1664 | poignarder 1665 | personnel 1666 | scène 1667 | tache 1668 | escalier 1669 | décrochage 1670 | timbre 1671 | Tiens-toi 1672 | étoile 1673 | station 1674 | statue 1675 | steak 1676 | vapeur 1677 | tige 1678 | pas 1679 | collante 1680 | piqûre 1681 | point 1682 | stock 1683 | estomac 1684 | pierre 1685 | tabouret 1686 | stockage 1687 | magasin 1688 | tempête 1689 | Droit 1690 | paille 1691 | fraise 1692 | rue 1693 | force 1694 | étirer 1695 | grève 1696 | chaîne 1697 | AVC 1698 | fort 1699 | étudiant 1700 | studio 1701 | C'est stupide 1702 | style 1703 | subjective 1704 | substance 1705 | banlieue 1706 | réussi 1707 | sucre 1708 | suicide 1709 | costume 1710 | suite 1711 | somme 1712 | résumé 1713 | été 1714 | Soleil 1715 | lever du soleil 1716 | soleil 1717 | supérieur 1718 | supermarché 1719 | superviseur 1720 | soutien 1721 | supprimer 1722 | surface 1723 | chirurgien 1724 | chirurgie 1725 | surprise 1726 | Entourer 1727 | enquête 1728 | Suspect 1729 | Jure 1730 | sueur 1731 | Pull 1732 | Balayage 1733 | C'est gentil 1734 | Goule 1735 | nager 1736 | Balançoire 1737 | Balayez 1738 | interrupteur 1739 | épée 1740 | symétrie 1741 | système 1742 | T-Shirt 1743 | table 1744 | tablette 1745 | queue 1746 | Grand 1747 | réservoir 1748 | Touchez 1749 | cassette 1750 | cible 1751 | savoureux 1752 | taxi 1753 | thé 1754 | Professeur 1755 | équipe 1756 | déchirure 1757 | technique 1758 | technique 1759 | technologie 1760 | adolescent 1761 | téléphone 1762 | télévision 1763 | température 1764 | temple 1765 | locataire 1766 | tendre 1767 | tennis 1768 | tente 1769 | Terminal 1770 | terrasse 1771 | terroriste 1772 | test 1773 | texte 1774 | théâtre 1775 | vol 1776 | thérapeute 1777 | thérapie 1778 | épais 1779 | mince 1780 | Réfléchis 1781 | Pensée 1782 | menace 1783 | menacer 1784 | gorge 1785 | trône 1786 | lancer 1787 | pouce 1788 | billet 1789 | marée 1790 | cravate 1791 | Tigre 1792 | serré 1793 | tuile 1794 | temps 1795 | calendrier 1796 | étain 1797 | pourboire 1798 | fatigué 1799 | tissu 1800 | titre 1801 | toast 1802 | orteil 1803 | tomate 1804 | tonne 1805 | langue 1806 | outil 1807 | dent 1808 | haut 1809 | torche 1810 | torture 1811 | Jetons 1812 | total 1813 | Touchez 1814 | tourisme 1815 | tournoi 1816 | serviette 1817 | tour 1818 | ville 1819 | toxique 1820 | jouet 1821 | trace 1822 | piste 1823 | commerce 1824 | trafic 1825 | train 1826 | formateur 1827 | formation 1828 | transfert 1829 | transparent 1830 | transport 1831 | piège 1832 | trésor 1833 | traitement 1834 | arbre 1835 | tendance 1836 | procès 1837 | triangle 1838 | tribu 1839 | hommage 1840 | voyage 1841 | chariot 1842 | troupe 1843 | tropicale 1844 | pantalon 1845 | camion 1846 | coffre 1847 | tube 1848 | Tumble 1849 | tumeur 1850 | tunnel 1851 | dinde 1852 | Tournez 1853 | jumeau 1854 | tordre 1855 | magnat 1856 | pneu 1857 | parapluie 1858 | mal à l'aise 1859 | soulignent 1860 | uniforme 1861 | unique 1862 | unité 1863 | unité 1864 | université 1865 | mise à jour 1866 | contrarié 1867 | urbain 1868 | urine 1869 | utilisateur 1870 | vide 1871 | vallée 1872 | précieux 1873 | camionnette 1874 | variante 1875 | légume 1876 | végétation 1877 | véhicule 1878 | voile 1879 | Vénus 1880 | verbale 1881 | version 1882 | verticale 1883 | vaisseau 1884 | vétéran 1885 | victime 1886 | victoire 1887 | vidéo 1888 | voir 1889 | villa 1890 | village 1891 | violent 1892 | vierge 1893 | vision 1894 | vitamine 1895 | voix 1896 | volcan 1897 | volume 1898 | bon d'achat 1899 | voyage 1900 | wagon 1901 | taille 1902 | Réveille-toi 1903 | Marchez 1904 | mur 1905 | guerre 1906 | garde-robe 1907 | chaud 1908 | avertissement 1909 | guerrier 1910 | Laver 1911 | gaspillage 1912 | Regardez 1913 | eau 1914 | cascade 1915 | vague 1916 | faiblesse 1917 | richesse 1918 | arme 1919 | porter 1920 | météo 1921 | tissage 1922 | mariage 1923 | De l'herbe 1924 | semaine 1925 | week-end 1926 | hebdomadaire 1927 | poids 1928 | Eh bien 1929 | Ouest 1930 | mouillé 1931 | baleine 1932 | blé 1933 | Roue 1934 | fouet 1935 | whisky 1936 | murmure 1937 | blanc 1938 | entières 1939 | veuve 1940 | largeur 1941 | femme 1942 | sauvage 1943 | nature sauvage 1944 | faune 1945 | gagner 1946 | vent 1947 | fenêtre 1948 | vin 1949 | aile 1950 | gagnant 1951 | hiver 1952 | Essuyez 1953 | fil 1954 | sorcière 1955 | témoin 1956 | loup 1957 | femme 1958 | bois 1959 | laine 1960 | mot 1961 | monde 1962 | ver 1963 | vous inquiétez 1964 | Blessure 1965 | Emballage 1966 | épave 1967 | lutter 1968 | poignet 1969 | écrire 1970 | écrivain 1971 | écrit 1972 | radiographie 1973 | yacht 1974 | cour 1975 | bâiller 1976 | année 1977 | jeune 1978 | jeunesse 1979 | zéro 1980 | --------------------------------------------------------------------------------