Initial commit
|
@ -0,0 +1,32 @@
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Project binaries
|
||||||
|
codies
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
|
||||||
|
.git/
|
||||||
|
config/
|
||||||
|
|
||||||
|
# common benchstat filenames
|
||||||
|
old.txt
|
||||||
|
new.txt
|
||||||
|
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/build/
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Project binaries
|
||||||
|
codies
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
|
@ -0,0 +1,2 @@
|
||||||
|
**/build/**
|
||||||
|
**/node_modules/**
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.yml", "*.yaml"],
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
FROM node:14 AS JS_BUILD
|
||||||
|
WORKDIR /frontend
|
||||||
|
COPY ./frontend/package.json ./frontend/yarn.lock ./
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
COPY ./frontend ./
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM golang:1.14 as GO_BUILD
|
||||||
|
WORKDIR /codies
|
||||||
|
COPY ./go.mod ./go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
# Manually copying the required files to make this image's cache only include Go code.
|
||||||
|
COPY ./main.go ./main.go
|
||||||
|
COPY ./internal ./internal
|
||||||
|
RUN go build .
|
||||||
|
|
||||||
|
# TODO: Use distroless/static and statically compile above. (https://golang.org/issue/26492)
|
||||||
|
FROM gcr.io/distroless/base:nonroot
|
||||||
|
WORKDIR /codies
|
||||||
|
COPY --from=GO_BUILD /codies/codies ./codies
|
||||||
|
COPY --from=JS_BUILD /frontend/build ./frontend/build
|
||||||
|
ENTRYPOINT [ "/codies/codies" ]
|
||||||
|
EXPOSE 5000
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 zikaeroh
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
|
@ -0,0 +1,17 @@
|
||||||
|
# codies
|
||||||
|
|
||||||
|
Yet another Codenames webapp. Featuring:
|
||||||
|
|
||||||
|
- Custom word packs
|
||||||
|
- Timed mode
|
||||||
|
- Quick room joining
|
||||||
|
- Dark/light mode
|
||||||
|
- Responsiveness for mobile play
|
||||||
|
- And more!
|
||||||
|
|
||||||
|
This is entirely inspired by the wonderful [codenames.plus](https://github.com/Joooop/codenames.plus),
|
||||||
|
which works very well, but hasn't been scaling too well recently. I wanted an opportunity
|
||||||
|
to learn TypeScript and React, and figured I could make something that worked just as well
|
||||||
|
with a few extra niceties (and a more stable backend).
|
||||||
|
|
||||||
|
![Game board](/docs/screenshot1.png?raw=true 'Game board')
|
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[markdown]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[yaml]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[html]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll.eslint": true
|
||||||
|
},
|
||||||
|
"files.exclude": {
|
||||||
|
"**/node_modules": true
|
||||||
|
},
|
||||||
|
"typescript.tsdk": "frontend/node_modules/typescript/lib"
|
||||||
|
}
|
||||||
|
}
|
After Width: | Height: | Size: 56 KiB |
|
@ -0,0 +1,2 @@
|
||||||
|
**/build/**
|
||||||
|
**/node_modules/**
|
|
@ -0,0 +1,37 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"prettier",
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react-hooks/recommended"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 6,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": ["@typescript-eslint/eslint-plugin", "simple-import-sort"],
|
||||||
|
"rules": {
|
||||||
|
"eqeqeq": "error",
|
||||||
|
"no-undef": 0,
|
||||||
|
"simple-import-sort/sort": "warn",
|
||||||
|
"@typescript-eslint/explicit-function-return-type": 0,
|
||||||
|
"@typescript-eslint/no-empty-interface": 0,
|
||||||
|
"@typescript-eslint/no-empty-function": 0,
|
||||||
|
"@typescript-eslint/no-explicit-any": 0,
|
||||||
|
"@typescript-eslint/no-namespace": 0,
|
||||||
|
"@typescript-eslint/no-use-before-define": 0,
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/explicit-member-accessibility": "warn"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"react": {
|
||||||
|
"version": "detect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
|
@ -0,0 +1,66 @@
|
||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@material-ui/core": "^4.9.13",
|
||||||
|
"@material-ui/icons": "^4.9.1",
|
||||||
|
"@rehooks/local-storage": "^2.4.0",
|
||||||
|
"@testing-library/jest-dom": "^5.7.0",
|
||||||
|
"@testing-library/react": "^10.0.4",
|
||||||
|
"@testing-library/user-event": "^10.1.1",
|
||||||
|
"@types/jest": "^25.2.1",
|
||||||
|
"@types/lodash-es": "^4.17.3",
|
||||||
|
"@types/node": "^14.0.1",
|
||||||
|
"@types/react": "^16.9.0",
|
||||||
|
"@types/react-dom": "^16.9.7",
|
||||||
|
"@types/uuid": "^8.0.0",
|
||||||
|
"clipboard-copy": "^3.1.0",
|
||||||
|
"fireworks": "^2.2.5",
|
||||||
|
"lodash": "^4.17.15",
|
||||||
|
"material-ui-dropzone": "^3.0.0",
|
||||||
|
"myzod": "^1.0.0-alpha.9",
|
||||||
|
"react": "^16.13.1",
|
||||||
|
"react-dom": "^16.13.1",
|
||||||
|
"react-hook-form": "^5.6.3",
|
||||||
|
"react-scripts": "^3.4.1",
|
||||||
|
"react-use-websocket": "^2.0.1",
|
||||||
|
"typeface-roboto": "^0.0.75",
|
||||||
|
"typescript": "^3.9.2",
|
||||||
|
"uuid": "^8.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^2.31.0",
|
||||||
|
"@typescript-eslint/parser": "^2.31.0",
|
||||||
|
"eslint": "^6.8.0",
|
||||||
|
"eslint-config-prettier": "^6.11.0",
|
||||||
|
"eslint-plugin-react": "^7.19.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.0.0",
|
||||||
|
"eslint-plugin-simple-import-sort": "^5.0.3",
|
||||||
|
"mutationobserver-shim": "^0.3.5",
|
||||||
|
"prettier": "2.0.5",
|
||||||
|
"source-map-explorer": "^2.4.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"analyze": "source-map-explorer 'build/static/js/*.js'",
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
"defaults",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"proxy": "http://localhost:5000"
|
||||||
|
}
|
After Width: | Height: | Size: 6.5 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 6.0 KiB |
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<browserconfig>
|
||||||
|
<msapplication>
|
||||||
|
<tile>
|
||||||
|
<square150x150logo src="/mstile-150x150.png"/>
|
||||||
|
<TileColor>#00aba9</TileColor>
|
||||||
|
</tile>
|
||||||
|
</msapplication>
|
||||||
|
</browserconfig>
|
After Width: | Height: | Size: 843 B |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 15 KiB |
|
@ -0,0 +1,23 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="Codenames, on the web." />
|
||||||
|
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/site.webmanifest" />
|
||||||
|
<link rel="mask-icon" href="%PUBLIC_URL%/safari-pinned-tab.svg" color="#5bbad5" />
|
||||||
|
<meta name="msapplication-TileColor" content="#00aba9" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
|
||||||
|
<title>Codies</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 3.5 KiB |
|
@ -0,0 +1,3 @@
|
||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
<metadata>
|
||||||
|
Created by potrace 1.11, written by Peter Selinger 2001-2013
|
||||||
|
</metadata>
|
||||||
|
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M4065 9463 c-46 -10 -156 -56 -197 -83 -65 -43 -126 -113 -246 -283
|
||||||
|
-34 -48 -62 -92 -62 -97 0 -6 -4 -10 -10 -10 -5 0 -10 -5 -10 -11 0 -6 -10
|
||||||
|
-25 -22 -42 -75 -104 -263 -483 -323 -652 -10 -27 -22 -54 -25 -60 -7 -10 -81
|
||||||
|
-229 -84 -250 -1 -5 -4 -14 -7 -20 -3 -5 -12 -31 -18 -57 -13 -45 -15 -46 -60
|
||||||
|
-54 -25 -4 -50 -8 -56 -10 -5 -1 -26 -5 -45 -8 -19 -4 -118 -27 -220 -52 -545
|
||||||
|
-135 -809 -287 -816 -472 -4 -129 99 -231 347 -341 106 -47 333 -119 457 -146
|
||||||
|
9 -2 53 -13 97 -24 44 -11 95 -23 113 -26 19 -3 39 -7 45 -8 20 -4 24 -16 26
|
||||||
|
-90 1 -40 4 -83 5 -97 40 -303 94 -495 209 -730 33 -69 66 -137 73 -151 l12
|
||||||
|
-26 -507 0 c-444 0 -513 -2 -557 -17 -105 -34 -177 -126 -179 -229 -2 -47 5
|
||||||
|
-77 35 -150 65 -159 144 -352 279 -676 72 -172 128 -317 126 -321 -3 -5 -38
|
||||||
|
-30 -78 -56 -193 -127 -422 -337 -547 -503 -14 -18 -30 -38 -36 -44 -28 -31
|
||||||
|
-156 -233 -203 -321 -103 -193 -183 -410 -216 -584 -2 -13 -6 -31 -9 -40 -10
|
||||||
|
-35 -23 -142 -33 -272 -8 -117 -9 -924 -1 -1000 24 -214 95 -343 287 -520 48
|
||||||
|
-45 166 -103 271 -133 l75 -22 3165 0 3165 0 80 23 c306 89 515 334 553 647
|
||||||
|
14 124 5 1040 -12 1131 -2 10 -7 46 -10 79 -12 104 -83 362 -126 460 -10 22
|
||||||
|
-31 69 -46 105 -15 36 -38 82 -51 102 -13 21 -23 41 -23 46 0 20 -198 318
|
||||||
|
-245 368 -5 6 -34 38 -63 72 -71 81 -194 200 -262 253 -30 24 -57 46 -60 49
|
||||||
|
-3 4 -43 33 -90 65 -47 32 -87 59 -88 61 -4 3 6 32 88 239 28 72 87 225 131
|
||||||
|
340 44 116 84 219 90 230 18 41 82 212 98 265 41 135 -35 286 -169 334 -46 17
|
||||||
|
-95 18 -569 19 -472 0 -519 1 -512 16 3 9 28 58 54 108 143 280 216 546 233
|
||||||
|
848 6 98 11 116 36 122 17 3 97 21 123 27 11 2 40 9 65 16 25 7 56 14 70 16
|
||||||
|
14 3 34 8 45 11 11 4 25 8 30 9 55 11 252 75 340 111 251 102 375 212 381 337
|
||||||
|
9 178 -216 327 -696 460 -52 15 -106 29 -120 32 -48 11 -115 25 -140 30 -13 3
|
||||||
|
-62 14 -107 25 -46 10 -92 19 -102 19 -17 0 -24 13 -37 65 -6 27 -79 246 -94
|
||||||
|
285 -10 25 -35 88 -56 140 -141 360 -419 823 -593 989 -102 98 -249 148 -404
|
||||||
|
138 -101 -6 -114 -11 -464 -184 -298 -148 -343 -163 -463 -158 -106 5 -145 19
|
||||||
|
-424 158 -349 174 -380 186 -500 185 -50 -1 -100 -3 -111 -5z m-485 -2807 c36
|
||||||
|
-4 76 -8 90 -11 14 -2 50 -7 80 -10 30 -3 69 -7 85 -9 17 -3 68 -7 115 -11 90
|
||||||
|
-7 110 -9 285 -20 61 -4 128 -8 150 -10 278 -23 1357 -23 1495 0 14 2 81 6
|
||||||
|
150 10 69 3 136 7 150 9 24 4 156 17 210 21 50 4 166 16 190 21 14 2 52 7 84
|
||||||
|
10 33 3 64 6 70 7 7 1 12 -33 14 -94 l3 -96 -31 -13 c-31 -13 -62 -49 -73 -85
|
||||||
|
-20 -71 -96 -254 -149 -359 -68 -136 -159 -234 -264 -285 -73 -36 -69 -34
|
||||||
|
-164 -56 -69 -16 -177 -13 -227 6 -15 6 -28 10 -28 9 0 -8 -78 22 -154 59
|
||||||
|
-168 83 -292 232 -372 448 -41 111 -80 143 -172 143 -60 0 -108 -26 -133 -70
|
||||||
|
-23 -43 -54 -113 -54 -124 0 -15 -106 -198 -132 -227 -104 -118 -157 -161
|
||||||
|
-262 -210 -73 -34 -138 -47 -222 -43 -48 2 -130 12 -154 19 -54 15 -99 31
|
||||||
|
-115 42 -11 7 -25 13 -32 13 -16 0 -143 79 -143 88 0 4 -13 21 -28 37 -15 17
|
||||||
|
-31 37 -35 45 -4 8 -16 29 -26 45 -11 17 -34 59 -51 95 -17 36 -36 74 -42 85
|
||||||
|
-16 29 -98 247 -98 261 0 14 -75 84 -90 84 -6 0 -10 36 -10 88 0 84 4 107 19
|
||||||
|
98 3 -3 35 -7 71 -10z m61 -2111 c10 -5 70 -44 134 -85 210 -136 309 -200 390
|
||||||
|
-251 44 -28 124 -79 178 -114 l97 -63 203 -338 c192 -321 202 -341 195 -374
|
||||||
|
-10 -41 -10 -41 -38 -145 -12 -44 -23 -87 -25 -95 -2 -8 -27 -100 -55 -205
|
||||||
|
-28 -104 -53 -197 -55 -205 -4 -20 -51 -196 -128 -485 -61 -228 -79 -297 -78
|
||||||
|
-302 0 -2 -5 -17 -10 -35 -6 -18 -41 -146 -77 -284 l-67 -252 -43 202 c-24
|
||||||
|
110 -50 228 -57 261 -22 95 -66 298 -71 325 -5 25 -116 535 -180 825 -20 88
|
||||||
|
-38 169 -40 180 -2 11 -15 72 -29 135 -20 90 -88 399 -110 501 -2 9 -13 61
|
||||||
|
-25 115 -12 55 -24 110 -26 124 -3 13 -14 61 -24 106 -11 45 -22 97 -26 116
|
||||||
|
-3 18 -7 38 -8 43 -1 6 -6 26 -10 45 -4 19 -16 71 -26 115 -10 44 -21 92 -24
|
||||||
|
108 -3 15 -8 37 -12 50 -6 20 -5 21 11 7 9 -8 25 -19 36 -25z m2982 -77 c-30
|
||||||
|
-140 -27 -126 -53 -244 -12 -55 -24 -110 -26 -124 -3 -14 -11 -52 -19 -85 -13
|
||||||
|
-54 -39 -172 -51 -234 -3 -12 -12 -53 -20 -90 -9 -36 -18 -77 -21 -91 -6 -37
|
||||||
|
-92 -431 -100 -460 -3 -14 -8 -34 -10 -45 -3 -18 -14 -68 -73 -335 -12 -52
|
||||||
|
-23 -105 -26 -118 -3 -13 -16 -76 -30 -140 -14 -64 -27 -124 -29 -133 -2 -8
|
||||||
|
-26 -121 -55 -250 -28 -129 -54 -245 -56 -259 -3 -14 -10 -45 -15 -70 -6 -25
|
||||||
|
-13 -56 -15 -70 -3 -14 -11 -53 -19 -86 -8 -34 -16 -74 -19 -88 -3 -14 -7 -34
|
||||||
|
-10 -44 -3 -9 -7 -28 -10 -42 -3 -14 -11 -54 -18 -90 -11 -57 -13 -61 -19 -35
|
||||||
|
-3 17 -16 66 -28 110 -12 44 -24 87 -26 95 -2 8 -13 50 -24 92 -12 43 -30 113
|
||||||
|
-41 155 -12 43 -25 92 -31 109 -5 17 -11 41 -14 52 -3 20 -13 56 -70 267 -19
|
||||||
|
67 -35 131 -40 158 -2 10 -8 32 -14 50 -12 37 -28 97 -70 257 -17 63 -49 185
|
||||||
|
-72 270 -22 85 -43 162 -45 170 -2 8 -16 64 -32 124 l-29 109 126 211 c70 116
|
||||||
|
162 270 205 341 l79 130 304 195 c231 148 485 313 539 349 0 1 -10 -50 -23
|
||||||
|
-111z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 5.0 KiB |
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { App } from './app';
|
||||||
|
|
||||||
|
test('renders codies name', () => {
|
||||||
|
const { getByText } = render(<App />);
|
||||||
|
const element = getByText(/codies/i);
|
||||||
|
expect(element).toBeInTheDocument();
|
||||||
|
});
|
|
@ -0,0 +1,32 @@
|
||||||
|
import querystring from 'querystring';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { ServerTimeProvider } from './hooks/useServerTime';
|
||||||
|
import { Game, GameProps } from './pages/game';
|
||||||
|
import { Login } from './pages/login';
|
||||||
|
import { StaticView } from './pages/staticView';
|
||||||
|
|
||||||
|
export const App = (_props: {}) => {
|
||||||
|
const [gameProps, setGameProps] = React.useState<GameProps | undefined>();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const query = querystring.parse(window.location.search.substring(1));
|
||||||
|
if (query.static !== undefined) {
|
||||||
|
return <StaticView />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameProps) {
|
||||||
|
return (
|
||||||
|
<ServerTimeProvider>
|
||||||
|
<Game {...gameProps} />
|
||||||
|
</ServerTimeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Login
|
||||||
|
onLogin={(roomID, nickname) => setGameProps({ roomID, nickname, leave: () => setGameProps(undefined) })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { fail } from 'assert';
|
||||||
|
|
||||||
|
export function noop() {}
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
export function websocketUrl(path: string): string {
|
||||||
|
const loc = window.location;
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
// react-scripts does not properly proxy websocket requests, so manually select the URL here.
|
||||||
|
return `ws://${loc.hostname}:5000${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${loc.protocol === 'https:' ? 'wss:' : 'ws:'}//${loc.host}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNever(x: never): never {
|
||||||
|
throw new Error('Unexpected object: ' + x);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDefined<T>(x: T | undefined | null): x is T {
|
||||||
|
return x !== undefined && x !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertIsDefined<T>(val: T): asserts val is NonNullable<T> {
|
||||||
|
if (val === undefined || val === null) {
|
||||||
|
fail(`Expected 'val' to be defined, but received ${val}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nameofFactory = <T>() => (name: keyof T) => name;
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Backdrop, Button, createStyles, Fade, makeStyles, Modal, Paper, Theme } from '@material-ui/core';
|
||||||
|
import { Help } from '@material-ui/icons';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
modal: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
paper: {
|
||||||
|
border: '2px solid #000',
|
||||||
|
boxShadow: theme.shadows[5],
|
||||||
|
padding: theme.spacing(2, 4, 3),
|
||||||
|
maxWidth: '500px',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AboutButton = (props: { style?: React.CSSProperties }) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span style={props.style}>
|
||||||
|
<Button type="button" startIcon={<Help />} onClick={handleOpen}>
|
||||||
|
About
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
className={classes.modal}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
closeAfterTransition
|
||||||
|
BackdropComponent={Backdrop}
|
||||||
|
BackdropProps={{
|
||||||
|
timeout: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Fade in={open}>
|
||||||
|
<Paper className={classes.paper}>
|
||||||
|
<h2>How to play</h2>
|
||||||
|
<p>
|
||||||
|
In Codenames, spymasters give one word clues pointing to multiple words on the board, as
|
||||||
|
well as the number of words corresponding to that clue (for example, "animal 3").
|
||||||
|
Their teammates then try to guess the words while avoiding the opposing team's, and may
|
||||||
|
guess as many times as the words the spymaster gave in their clue, plus an additional guess.
|
||||||
|
</p>
|
||||||
|
</Paper>
|
||||||
|
</Fade>
|
||||||
|
</Modal>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,20 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface AspectDivProps {
|
||||||
|
aspectRatio: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AspectDiv = (props: React.PropsWithChildren<AspectDivProps>) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: 0,
|
||||||
|
paddingBottom: props.aspectRatio,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,177 @@
|
||||||
|
import { Button, createStyles, makeStyles, Theme, Typography } from '@material-ui/core';
|
||||||
|
import { grey, orange, red } from '@material-ui/core/colors';
|
||||||
|
import { Fireworks } from 'fireworks/lib/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { isDefined } from '../common';
|
||||||
|
import { StateBoard, StateTile } from '../protocol';
|
||||||
|
import { TeamHue, teamSpecs } from '../teams';
|
||||||
|
import { AspectDiv } from './aspectDiv';
|
||||||
|
|
||||||
|
function neutralStyle(revealed: boolean, spymaster: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
color: revealed ? 'white' : 'black',
|
||||||
|
backgroundColor: grey[revealed ? 500 : 200],
|
||||||
|
fontWeight: spymaster ? 'bold' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bombStyle(revealed: boolean, spymaster: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
color: revealed ? 'white' : grey[900],
|
||||||
|
backgroundColor: grey[revealed ? 900 : 700],
|
||||||
|
fontWeight: spymaster ? 'bold' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamStyle(teamHue: TeamHue, revealed: boolean, spymaster: boolean): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
color: revealed ? 'white' : teamHue[900],
|
||||||
|
backgroundColor: teamHue[revealed ? 600 : 200],
|
||||||
|
fontWeight: spymaster ? 'bold' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileStyle(tile: StateTile, spymaster: boolean): React.CSSProperties {
|
||||||
|
if (!isDefined(tile.view) || tile.view.neutral) {
|
||||||
|
return neutralStyle(tile.revealed, spymaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tile.view.bomb) {
|
||||||
|
return bombStyle(tile.revealed, spymaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamHue = teamSpecs[tile.view.team].hue;
|
||||||
|
return teamStyle(teamHue, tile.revealed, spymaster);
|
||||||
|
}
|
||||||
|
|
||||||
|
const useTileStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
button: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
padding: '6px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typo: {
|
||||||
|
wordWrap: 'break-word',
|
||||||
|
width: '100%',
|
||||||
|
fontSize: theme.typography.h6.fontSize,
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
fontSize: theme.typography.button.fontSize,
|
||||||
|
lineHeight: '1rem',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
explosionWrapper: {
|
||||||
|
zIndex: 100,
|
||||||
|
position: 'absolute',
|
||||||
|
margin: 'auto',
|
||||||
|
height: 0,
|
||||||
|
width: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
},
|
||||||
|
explosion: {
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
interface TileProps {
|
||||||
|
tile: StateTile;
|
||||||
|
onClick: () => void;
|
||||||
|
spymaster: boolean;
|
||||||
|
myTurn: boolean;
|
||||||
|
winner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tile = ({ tile, onClick, spymaster, myTurn, winner }: TileProps) => {
|
||||||
|
const classes = useTileStyles();
|
||||||
|
|
||||||
|
const bombRevealed = !!(tile.revealed && tile.view?.bomb);
|
||||||
|
const alreadyExploded = React.useRef(bombRevealed);
|
||||||
|
const explode = bombRevealed && !alreadyExploded.current;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AspectDiv aspectRatio="75%">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="contained"
|
||||||
|
className={classes.button}
|
||||||
|
onClick={onClick}
|
||||||
|
style={tileStyle(tile, spymaster)}
|
||||||
|
disabled={spymaster || !myTurn || winner}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" className={classes.typo}>
|
||||||
|
{tile.word}
|
||||||
|
</Typography>
|
||||||
|
</Button>
|
||||||
|
{explode ? (
|
||||||
|
<div className={classes.explosionWrapper}>
|
||||||
|
<div className={classes.explosion}>
|
||||||
|
<Fireworks
|
||||||
|
{...{
|
||||||
|
interval: 0,
|
||||||
|
colors: [red[700], orange[800], grey[500]],
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AspectDiv>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BoardProps {
|
||||||
|
words: StateBoard;
|
||||||
|
spymaster: boolean;
|
||||||
|
myTurn: boolean;
|
||||||
|
winner: boolean;
|
||||||
|
onClick: (row: number, col: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
display: 'grid',
|
||||||
|
gridGap: theme.spacing(0.5),
|
||||||
|
[theme.breakpoints.up('lg')]: {
|
||||||
|
gridGap: theme.spacing(1),
|
||||||
|
},
|
||||||
|
gridTemplateRows: (props: BoardProps) => `repeat(${props.words.length}, 1fr)`,
|
||||||
|
gridTemplateColumns: (props: BoardProps) => `repeat(${props.words[0].length}, 1fr)`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Board = (props: BoardProps) => {
|
||||||
|
const classes = useStyles(props);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
{props.words.map((arr, row) =>
|
||||||
|
arr.map((tile, col) => (
|
||||||
|
<div key={row * props.words.length + col}>
|
||||||
|
<Tile
|
||||||
|
tile={tile}
|
||||||
|
onClick={() => props.onClick(row, col)}
|
||||||
|
spymaster={props.spymaster}
|
||||||
|
myTurn={props.myTurn}
|
||||||
|
winner={props.winner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Button, Tooltip } from '@material-ui/core';
|
||||||
|
import copy from 'clipboard-copy';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface ClipboardButtonProps {
|
||||||
|
buttonText: string;
|
||||||
|
toCopy: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClipboardButton = (props: ClipboardButtonProps) => {
|
||||||
|
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
open={showTooltip}
|
||||||
|
title="Copied to clipboard."
|
||||||
|
leaveDelay={2000}
|
||||||
|
onClose={() => setShowTooltip(false)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copy(props.toCopy);
|
||||||
|
setShowTooltip(true);
|
||||||
|
}}
|
||||||
|
startIcon={props.icon}
|
||||||
|
>
|
||||||
|
{props.buttonText}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,130 @@
|
||||||
|
import { Button, ButtonGroup, createStyles, makeStyles, TextField, Theme } from '@material-ui/core';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
|
||||||
|
import { nameofFactory } from '../common';
|
||||||
|
|
||||||
|
export interface LoginFormData {
|
||||||
|
nickname: string;
|
||||||
|
roomName: string;
|
||||||
|
roomPass: string;
|
||||||
|
create: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formName = nameofFactory<LoginFormData>();
|
||||||
|
|
||||||
|
const noComplete = {
|
||||||
|
autoComplete: 'off',
|
||||||
|
'data-lpignore': 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
padBottom: {
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface LoginFormProps {
|
||||||
|
existingRoom: boolean;
|
||||||
|
onSubmit: (data: LoginFormData) => Promise<void>;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm(props: LoginFormProps) {
|
||||||
|
const classes = useStyles();
|
||||||
|
const { control, handleSubmit, errors, setValue, register } = useForm<LoginFormData>({});
|
||||||
|
React.useEffect(() => register({ name: formName('create') }), [register]);
|
||||||
|
const doSubmit = handleSubmit(props.onSubmit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
|
{props.existingRoom ? (
|
||||||
|
<div>
|
||||||
|
<em>Joining existing game; please choose a nickname.</em>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={props.existingRoom ? classes.padBottom : undefined}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
as={TextField}
|
||||||
|
name={formName('nickname')}
|
||||||
|
label="Nickname"
|
||||||
|
defaultValue=""
|
||||||
|
error={!!errors.nickname}
|
||||||
|
rules={{ required: true, minLength: 3, maxLength: 16 }}
|
||||||
|
fullWidth={true}
|
||||||
|
inputProps={noComplete}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.existingRoom ? null : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
as={TextField}
|
||||||
|
name={formName('roomName')}
|
||||||
|
label="Room name"
|
||||||
|
defaultValue=""
|
||||||
|
error={!!errors.roomName}
|
||||||
|
rules={{ required: true, minLength: 3, maxLength: 16 }}
|
||||||
|
fullWidth={true}
|
||||||
|
inputProps={noComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={classes.padBottom}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
as={TextField}
|
||||||
|
name={formName('roomPass')}
|
||||||
|
label="Password"
|
||||||
|
defaultValue=""
|
||||||
|
type="password"
|
||||||
|
error={!!errors.roomPass}
|
||||||
|
rules={{ required: true, minLength: 1 }}
|
||||||
|
fullWidth={true}
|
||||||
|
inputProps={noComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{props.errorMessage && (
|
||||||
|
<div className={classes.padBottom}>
|
||||||
|
<em>{props.errorMessage}</em>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ButtonGroup variant="contained">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
onClick={() => {
|
||||||
|
setValue(formName('create'), false);
|
||||||
|
doSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join game
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{props.existingRoom ? null : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setValue(formName('create'), true);
|
||||||
|
doSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create new game
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface ServerTime {
|
||||||
|
setOffset: (v: number) => void;
|
||||||
|
now: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Context = React.createContext<ServerTime>(Object.seal({ setOffset: () => {}, now: Date.now }));
|
||||||
|
|
||||||
|
export const ServerTimeProvider = (props: React.PropsWithChildren<{}>) => {
|
||||||
|
const [offset, setOffset] = React.useState(0);
|
||||||
|
const value = React.useMemo(() => Object.seal({ setOffset, now: () => Date.now() + offset }), [offset, setOffset]);
|
||||||
|
return <Context.Provider value={value}>{props.children}</Context.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useServerTime() {
|
||||||
|
return React.useContext(Context);
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import 'typeface-roboto';
|
||||||
|
|
||||||
|
import { createMuiTheme, CssBaseline, IconButton, responsiveFontSizes, ThemeProvider } from '@material-ui/core';
|
||||||
|
import { Brightness4, Brightness7 } from '@material-ui/icons';
|
||||||
|
import { useLocalStorage } from '@rehooks/local-storage';
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ReactDOM from 'react-dom';
|
||||||
|
|
||||||
|
import { App } from './app';
|
||||||
|
import { AboutButton } from './components/about';
|
||||||
|
|
||||||
|
function useTheme() {
|
||||||
|
const [themeName, setThemeName] = useLocalStorage<'light' | 'dark'>('themeName', 'dark');
|
||||||
|
|
||||||
|
// Workaround for https://github.com/mui-org/material-ui/issues/20708.
|
||||||
|
//
|
||||||
|
// When in strict mode (development only), this is required to properly allow
|
||||||
|
// the theme to be changed, as unused styles are not cleaned up. Create a new theme
|
||||||
|
// each time, so that they're forced to be injected again at the end of the existing
|
||||||
|
// block of stylesheets (as the styling library will see them as "new" styles, rather than
|
||||||
|
// assuming they can just be reused).
|
||||||
|
//
|
||||||
|
// This is gross, as it means every time the button is clicked it's a slew of extra
|
||||||
|
// stylesheets (each overriding the previous), but in production the cleanup works
|
||||||
|
// so this extra work is "only" a performance hit. If the bug is ever fixed, we can
|
||||||
|
// simply store two global themes and swap between them.
|
||||||
|
const theme = responsiveFontSizes(
|
||||||
|
createMuiTheme({
|
||||||
|
palette: {
|
||||||
|
type: themeName,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
if (themeName === 'light') {
|
||||||
|
setThemeName('dark');
|
||||||
|
} else {
|
||||||
|
setThemeName('light');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { theme, toggleTheme, isDark: themeName === 'dark' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Root = (_props: {}) => {
|
||||||
|
const { theme, toggleTheme, isDark } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
margin: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AboutButton style={{ marginRight: '0.5rem' }} />
|
||||||
|
<IconButton size="small" onClick={toggleTheme}>
|
||||||
|
{isDark ? <Brightness7 /> : <Brightness4 />}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ReactDOM.render(<Root />, document.getElementById('root'));
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { fail } from 'assert';
|
||||||
|
import * as React from 'react';
|
||||||
|
import useWebSocket from 'react-use-websocket';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import { assertIsDefined, assertNever, noop, websocketUrl } from '../common';
|
||||||
|
import { useServerTime } from '../hooks/useServerTime';
|
||||||
|
import { ClientNote, ServerNote, State, StatePlayer, TimeResponse, WordPack } from '../protocol';
|
||||||
|
import { GameView, Sender } from './gameView';
|
||||||
|
import { Loading } from './loading';
|
||||||
|
|
||||||
|
const socketUrl = websocketUrl('/api/ws');
|
||||||
|
|
||||||
|
function useSender(sendNote: (r: ClientNote) => void, version: number): Sender {
|
||||||
|
return React.useMemo<Sender>(() => {
|
||||||
|
return {
|
||||||
|
reveal: (row: number, col: number) =>
|
||||||
|
sendNote({
|
||||||
|
method: 'reveal',
|
||||||
|
version,
|
||||||
|
params: {
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
newGame: () => sendNote({ method: 'newGame', version, params: {} }),
|
||||||
|
endTurn: () => sendNote({ method: 'endTurn', version, params: {} }),
|
||||||
|
changeNickname: (nickname: string) => sendNote({ method: 'changeNickname', version, params: { nickname } }),
|
||||||
|
changeRole: (spymaster: boolean) => sendNote({ method: 'changeRole', version, params: { spymaster } }),
|
||||||
|
changeTeam: (team: number) => sendNote({ method: 'changeTeam', version, params: { team } }),
|
||||||
|
randomizeTeams: () => sendNote({ method: 'randomizeTeams', version, params: {} }),
|
||||||
|
changePack: (num: number, enable: boolean) =>
|
||||||
|
sendNote({ method: 'changePack', version, params: { num, enable } }),
|
||||||
|
changeTurnMode: (timed: boolean) => sendNote({ method: 'changeTurnMode', version, params: { timed } }),
|
||||||
|
changeTurnTime: (seconds: number) => sendNote({ method: 'changeTurnTime', version, params: { seconds } }),
|
||||||
|
addPacks: (packs: WordPack[]) => sendNote({ method: 'addPacks', version, params: { packs } }),
|
||||||
|
removePack: (num: number) => sendNote({ method: 'removePack', version, params: { num } }),
|
||||||
|
};
|
||||||
|
}, [sendNote, version]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePlayer(playerID: string, state?: State): { pState: StatePlayer; pTeam: number } | undefined {
|
||||||
|
return React.useMemo(() => {
|
||||||
|
if (!state) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < state.teams.length; i++) {
|
||||||
|
const pState = state.teams[i].find((p) => p.playerID === playerID);
|
||||||
|
if (pState) {
|
||||||
|
return { pState, pTeam: i };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fail('Player not found in any team');
|
||||||
|
}, [playerID, state]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reconnectAttempts = 5;
|
||||||
|
|
||||||
|
function useWS(roomID: string, playerID: string, nickname: string, dead: () => void, onOpen: () => void) {
|
||||||
|
const didUnmount = React.useRef(false);
|
||||||
|
const retry = React.useRef(0);
|
||||||
|
|
||||||
|
return useWebSocket(socketUrl, {
|
||||||
|
queryParams: { roomID, playerID, nickname },
|
||||||
|
reconnectAttempts,
|
||||||
|
onMessage: () => {
|
||||||
|
retry.current = 0;
|
||||||
|
},
|
||||||
|
onOpen,
|
||||||
|
shouldReconnect: () => {
|
||||||
|
if (didUnmount.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
retry.current++;
|
||||||
|
|
||||||
|
if (retry.current >= reconnectAttempts) {
|
||||||
|
dead();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTime(setOffset: (offset: number) => void) {
|
||||||
|
const fn = async () => {
|
||||||
|
let bestRTT: number | undefined;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const before = Date.now();
|
||||||
|
const resp = await fetch('/api/time');
|
||||||
|
const after = Date.now();
|
||||||
|
|
||||||
|
const body = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
const rtt = (after - before) / 2;
|
||||||
|
|
||||||
|
if (bestRTT !== undefined && rtt > bestRTT) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bestRTT = rtt;
|
||||||
|
|
||||||
|
const t = TimeResponse.parse(body);
|
||||||
|
const serverTime = t.time.getTime() + rtt;
|
||||||
|
offset = serverTime - Date.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOffset(offset);
|
||||||
|
};
|
||||||
|
fn().catch(noop);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameProps {
|
||||||
|
roomID: string;
|
||||||
|
nickname: string;
|
||||||
|
leave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Game = (props: GameProps) => {
|
||||||
|
const [playerID] = React.useState(v4);
|
||||||
|
const nickname = React.useRef(props.nickname); // Preserve a nickname for use in reconnects.
|
||||||
|
const [state, setState] = React.useState<State | undefined>();
|
||||||
|
const { setOffset } = useServerTime();
|
||||||
|
|
||||||
|
const { sendJsonMessage, lastJsonMessage } = useWS(props.roomID, playerID, nickname.current, props.leave, () =>
|
||||||
|
syncTime(setOffset)
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
syncTime(setOffset);
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [setOffset]);
|
||||||
|
|
||||||
|
const send = useSender(sendJsonMessage, state?.version ?? 0);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!lastJsonMessage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const note = ServerNote.parse(lastJsonMessage);
|
||||||
|
|
||||||
|
switch (note.method) {
|
||||||
|
case 'state':
|
||||||
|
setState(note.params);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
assertNever(note.method);
|
||||||
|
}
|
||||||
|
}, [lastJsonMessage]);
|
||||||
|
|
||||||
|
const player = usePlayer(playerID, state);
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertIsDefined(player);
|
||||||
|
nickname.current = player.pState.nickname;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameView
|
||||||
|
roomID={props.roomID}
|
||||||
|
leave={props.leave}
|
||||||
|
send={send}
|
||||||
|
state={state}
|
||||||
|
pState={player.pState}
|
||||||
|
pTeam={player.pTeam}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,539 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
createStyles,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
makeStyles,
|
||||||
|
Paper,
|
||||||
|
Slider,
|
||||||
|
Theme,
|
||||||
|
Typography,
|
||||||
|
} from '@material-ui/core';
|
||||||
|
import { green, orange } from '@material-ui/core/colors';
|
||||||
|
import { Add, ArrowBack, Delete, Link, Person, Search, Timer, TimerOff } from '@material-ui/icons';
|
||||||
|
import { ok as assertTrue } from 'assert';
|
||||||
|
import isArray from 'lodash/isArray';
|
||||||
|
import range from 'lodash/range';
|
||||||
|
import { DropzoneDialog } from 'material-ui-dropzone';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { isDefined } from '../common';
|
||||||
|
import { Board } from '../components/board';
|
||||||
|
import { ClipboardButton } from '../components/clipboard';
|
||||||
|
import { useServerTime } from '../hooks/useServerTime';
|
||||||
|
import { State, StatePlayer, StateTimer, WordPack } from '../protocol';
|
||||||
|
import { teamSpecs } from '../teams';
|
||||||
|
|
||||||
|
export interface Sender {
|
||||||
|
reveal: (row: number, col: number) => void;
|
||||||
|
newGame: () => void;
|
||||||
|
endTurn: () => void;
|
||||||
|
changeNickname: (nickname: string) => void;
|
||||||
|
changeRole: (spymaster: boolean) => void;
|
||||||
|
changeTeam: (team: number) => void;
|
||||||
|
randomizeTeams: () => void;
|
||||||
|
changePack: (num: number, enable: boolean) => void;
|
||||||
|
changeTurnMode: (timed: boolean) => void;
|
||||||
|
changeTurnTime: (seconds: number) => void;
|
||||||
|
addPacks: (packs: { name: string; words: string[] }[]) => void;
|
||||||
|
removePack: (num: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameViewProps {
|
||||||
|
roomID: string;
|
||||||
|
leave: () => void;
|
||||||
|
send: Sender;
|
||||||
|
state: State;
|
||||||
|
pState: StatePlayer;
|
||||||
|
pTeam: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCenterStyles = makeStyles((_theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
blink: {
|
||||||
|
animation: '$blinker 0.5s cubic-bezier(.5, 0, 1, 1) infinite alternate',
|
||||||
|
},
|
||||||
|
'@keyframes blinker': {
|
||||||
|
to: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const CenterText = ({ winner, timer, turn }: State) => {
|
||||||
|
const classes = useCenterStyles();
|
||||||
|
const [countdown, setCountdown] = React.useState<number | undefined>();
|
||||||
|
const { now } = useServerTime();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const updateCountdown = () => {
|
||||||
|
if (isDefined(winner)) {
|
||||||
|
setCountdown(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDefined(timer)) {
|
||||||
|
if (countdown !== undefined) {
|
||||||
|
setCountdown(undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = timer.turnEnd;
|
||||||
|
const diff = deadline.getTime() - now();
|
||||||
|
|
||||||
|
const between = Math.floor(diff / 1000);
|
||||||
|
if (between < 0) {
|
||||||
|
if (countdown === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCountdown(0);
|
||||||
|
} else {
|
||||||
|
setCountdown(between);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updateCountdown();
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
updateCountdown();
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [countdown, setCountdown, winner, timer, now]);
|
||||||
|
|
||||||
|
const centerText = React.useMemo(() => {
|
||||||
|
const text = isDefined(winner) ? `${teamSpecs[winner].name} wins!` : `${teamSpecs[turn].name}'s turn`;
|
||||||
|
|
||||||
|
if (!isDefined(countdown) || isDefined(winner)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${text} [${countdown}s]`;
|
||||||
|
}, [winner, turn, countdown]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<h1
|
||||||
|
style={{ color: teamSpecs[winner ?? turn].hue[600] }}
|
||||||
|
className={isDefined(countdown) && countdown < 10 ? classes.blink : undefined}
|
||||||
|
>
|
||||||
|
{centerText}
|
||||||
|
</h1>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Header = ({ send, state, pState, pTeam }: GameViewProps) => {
|
||||||
|
const myTurn = state.turn === pTeam;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container direction="row" justify="space-between" alignItems="center" spacing={2}>
|
||||||
|
<Grid item xs style={{ textAlign: 'left' }}>
|
||||||
|
<h1>
|
||||||
|
{state.wordsLeft.map((n, team) => {
|
||||||
|
return (
|
||||||
|
<span key={team}>
|
||||||
|
{team !== 0 ? <span> - </span> : null}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: teamSpecs[team].hue[600],
|
||||||
|
fontWeight: state.turn === team ? 'bold' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</h1>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs style={{ textAlign: 'center' }}>
|
||||||
|
<CenterText {...state} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs style={{ textAlign: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => myTurn && !pState.spymaster && send.endTurn()}
|
||||||
|
disabled={!myTurn || pState.spymaster || isDefined(state.winner)}
|
||||||
|
>
|
||||||
|
End turn
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sliderMarks = range(30, 301, 30).map((v) => ({ value: v }));
|
||||||
|
|
||||||
|
interface TimerSliderProps {
|
||||||
|
id: string;
|
||||||
|
timer: StateTimer;
|
||||||
|
onCommit: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimerSlider = ({ timer, onCommit, id }: TimerSliderProps) => {
|
||||||
|
// Keep around the original value when this component is created.
|
||||||
|
// This prevents React from complaining about the defaultValue
|
||||||
|
// changing when the overall state changes.
|
||||||
|
const defaultValue = React.useRef(timer.turnTime);
|
||||||
|
const [value, setValue] = React.useState(timer.turnTime);
|
||||||
|
|
||||||
|
const valueStr = React.useMemo(() => {
|
||||||
|
switch (value) {
|
||||||
|
case 30:
|
||||||
|
return '30 seconds';
|
||||||
|
case 60:
|
||||||
|
return '60 seconds';
|
||||||
|
default:
|
||||||
|
if (value % 60 === 0) {
|
||||||
|
return `${value / 60} minutes`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${(value / 60).toFixed(1)} minutes`;
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography id={id} gutterBottom>
|
||||||
|
Timer: {valueStr}
|
||||||
|
</Typography>
|
||||||
|
<Slider
|
||||||
|
style={{ color: orange[500] }}
|
||||||
|
aria-labelledby={id}
|
||||||
|
marks={sliderMarks}
|
||||||
|
defaultValue={defaultValue.current}
|
||||||
|
step={null}
|
||||||
|
min={sliderMarks[0].value}
|
||||||
|
max={sliderMarks[sliderMarks.length - 1].value}
|
||||||
|
onChange={(_e, v) => {
|
||||||
|
assertTrue(!isArray(v));
|
||||||
|
if (v !== value) {
|
||||||
|
setValue(v);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onChangeCommitted={(_e, v) => {
|
||||||
|
assertTrue(!isArray(v));
|
||||||
|
onCommit(v);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSidebarStyles = makeStyles((_theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
dropzone: {
|
||||||
|
backgroundColor: 'initial',
|
||||||
|
},
|
||||||
|
previewGrid: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const Sidebar = ({ send, state, pState, pTeam }: GameViewProps) => {
|
||||||
|
const classes = useSidebarStyles();
|
||||||
|
|
||||||
|
const teams = state.teams;
|
||||||
|
const lists = state.lists;
|
||||||
|
|
||||||
|
const wordCount = React.useMemo(
|
||||||
|
() =>
|
||||||
|
lists.reduce((curr, l) => {
|
||||||
|
if (l.enabled) {
|
||||||
|
return curr + l.count;
|
||||||
|
}
|
||||||
|
return curr;
|
||||||
|
}, 0),
|
||||||
|
[lists]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [uploadOpen, setUploadOpen] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>Teams</h2>
|
||||||
|
<Paper style={{ padding: '0.5rem' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridGap: '0.5rem',
|
||||||
|
gridTemplateColumns: `repeat(${teams.length}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{teams.map((team, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
gridRow: 1,
|
||||||
|
gridColumn: i + 1,
|
||||||
|
width: '100%',
|
||||||
|
color: 'white',
|
||||||
|
backgroundColor: teamSpecs[i].hue[600],
|
||||||
|
}}
|
||||||
|
disabled={pTeam === i}
|
||||||
|
onClick={() => send.changeTeam(i)}
|
||||||
|
>
|
||||||
|
Join {teamSpecs[i].name}
|
||||||
|
</Button>
|
||||||
|
{team.map((member, j) => (
|
||||||
|
<span
|
||||||
|
key={`member-${j}`}
|
||||||
|
style={{
|
||||||
|
gridRow: j + 2,
|
||||||
|
gridColumn: i + 1,
|
||||||
|
color: teamSpecs[i].hue[500],
|
||||||
|
fontStyle: member.playerID === pState.playerID ? 'italic' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{member.spymaster ? `[${member.nickname}]` : member.nickname}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%', marginTop: '0.5rem' }}
|
||||||
|
onClick={send.randomizeTeams}
|
||||||
|
>
|
||||||
|
Randomize teams
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<h2>Packs</h2>
|
||||||
|
<p style={{ fontStyle: 'italic' }}>{wordCount} words in the selected packs.</p>
|
||||||
|
<div style={{ display: 'grid', gridGap: '0.5rem' }}>
|
||||||
|
{lists.map((pack, i) => (
|
||||||
|
<div key={i} style={{ gridRow: i + 1 }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pack.enabled ? 'contained' : 'outlined'}
|
||||||
|
size="small"
|
||||||
|
style={{ width: pack.custom && !pack.enabled ? '90%' : '100%' }}
|
||||||
|
onClick={() => send.changePack(i, !pack.enabled)}
|
||||||
|
>
|
||||||
|
{pack.custom ? `Custom: ${pack.name}` : pack.name}
|
||||||
|
</Button>
|
||||||
|
{pack.custom && !pack.enabled ? (
|
||||||
|
<IconButton size="small" style={{ width: '10%' }} onClick={() => send.removePack(i)}>
|
||||||
|
<Delete />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{lists.length >= 10 ? null : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="small"
|
||||||
|
startIcon={<Add />}
|
||||||
|
style={{ width: '100%', gridRow: lists.length + 2 }}
|
||||||
|
onClick={() => setUploadOpen(true)}
|
||||||
|
>
|
||||||
|
Upload packs
|
||||||
|
</Button>
|
||||||
|
<DropzoneDialog
|
||||||
|
acceptedFiles={['.txt']}
|
||||||
|
cancelButtonText={'cancel'}
|
||||||
|
submitButtonText={'submit'}
|
||||||
|
dropzoneClass={classes.dropzone}
|
||||||
|
dropzoneText={'Text files, one word per line. Click or drag to upload.'}
|
||||||
|
previewGridClasses={{ container: classes.previewGrid }}
|
||||||
|
previewText={'Files:'}
|
||||||
|
maxFileSize={1000000}
|
||||||
|
open={uploadOpen}
|
||||||
|
onClose={() => setUploadOpen(false)}
|
||||||
|
onSave={async (files) => {
|
||||||
|
setUploadOpen(false);
|
||||||
|
|
||||||
|
const packs: WordPack[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
const name = file.name.substring(0, file.name.lastIndexOf('.')) || file.name;
|
||||||
|
const text = (await file.text()).trim();
|
||||||
|
packs.push({ name, words: text.split('\n') });
|
||||||
|
}
|
||||||
|
|
||||||
|
send.addPacks(packs);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isDefined(state.timer) ? null : (
|
||||||
|
<div style={{ textAlign: 'left', marginTop: '1rem' }}>
|
||||||
|
<TimerSlider id="timer-slider" timer={state.timer} onCommit={send.changeTurnTime} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Board2 = ({ send, state, pState, pTeam }: GameViewProps) => {
|
||||||
|
const myTurn = state.turn === pTeam;
|
||||||
|
return (
|
||||||
|
<Board
|
||||||
|
words={state.board}
|
||||||
|
onClick={(row, col) => myTurn && !pState.spymaster && send.reveal(row, col)}
|
||||||
|
spymaster={pState.spymaster}
|
||||||
|
myTurn={myTurn}
|
||||||
|
winner={isDefined(state.winner)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Footer = ({ send, state, pState }: GameViewProps) => {
|
||||||
|
const end = isDefined(state.winner);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container direction="row" justify="space-between" alignItems="flex-start" spacing={2}>
|
||||||
|
<Grid item xs style={{ textAlign: 'left' }}>
|
||||||
|
<ButtonGroup
|
||||||
|
variant="outlined"
|
||||||
|
style={{ marginBottom: '0.5rem', marginRight: '0.5rem', display: 'inline' }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pState.spymaster ? undefined : 'contained'}
|
||||||
|
onClick={() => send.changeRole(false)}
|
||||||
|
startIcon={<Search />}
|
||||||
|
disabled={end}
|
||||||
|
>
|
||||||
|
Guesser
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pState.spymaster ? 'contained' : undefined}
|
||||||
|
onClick={() => send.changeRole(true)}
|
||||||
|
startIcon={<Person />}
|
||||||
|
disabled={end}
|
||||||
|
>
|
||||||
|
Spymaster
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<ButtonGroup
|
||||||
|
variant="outlined"
|
||||||
|
style={{ marginBottom: '0.5rem', marginRight: '0.5rem', display: 'inline' }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isDefined(state.timer) ? undefined : 'contained'}
|
||||||
|
onClick={() => send.changeTurnMode(false)}
|
||||||
|
>
|
||||||
|
<TimerOff />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isDefined(state.timer) ? 'contained' : undefined}
|
||||||
|
onClick={() => send.changeTurnMode(true)}
|
||||||
|
>
|
||||||
|
<Timer />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs style={{ textAlign: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={end ? 'contained' : 'outlined'}
|
||||||
|
color={end ? undefined : 'secondary'}
|
||||||
|
style={end ? { color: 'white', backgroundColor: green[500] } : undefined}
|
||||||
|
onClick={send.newGame}
|
||||||
|
>
|
||||||
|
New game
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
},
|
||||||
|
wrapper: {
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingLeft: theme.spacing(2),
|
||||||
|
paddingRight: theme.spacing(2),
|
||||||
|
// Emulate the MUI Container component.
|
||||||
|
maxWidth: `1560px`, // TODO: Surely this shouldn't be hardcoded.
|
||||||
|
margin: 'auto',
|
||||||
|
// marginRight: 'auto',
|
||||||
|
display: 'grid',
|
||||||
|
gridGap: theme.spacing(2),
|
||||||
|
gridTemplateAreas: '"header" "board" "footer" "sidebar"',
|
||||||
|
[theme.breakpoints.down('lg')]: {
|
||||||
|
paddingTop: theme.spacing(5),
|
||||||
|
},
|
||||||
|
[theme.breakpoints.up('lg')]: {
|
||||||
|
gridTemplateColumns: '1fr 4fr 1fr',
|
||||||
|
gridTemplateRows: '1fr auto 1fr',
|
||||||
|
gridTemplateAreas: '". header ." "sidebar board ." ". footer ."',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
gridArea: 'header',
|
||||||
|
},
|
||||||
|
board: {
|
||||||
|
gridArea: 'board',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
gridArea: 'footer',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
gridArea: 'sidebar',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GameView = (props: GameViewProps) => {
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
margin: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="button" onClick={props.leave} startIcon={<ArrowBack />} style={{ marginRight: '0.5rem' }}>
|
||||||
|
Leave
|
||||||
|
</Button>
|
||||||
|
<ClipboardButton
|
||||||
|
buttonText="Copy Room URL"
|
||||||
|
toCopy={`${window.location.origin}/?roomID=${props.roomID}`}
|
||||||
|
icon={<Link />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<div className={classes.header}>
|
||||||
|
<Header {...props} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.board}>
|
||||||
|
<Board2 {...props} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.footer}>
|
||||||
|
<Footer {...props} />
|
||||||
|
</div>
|
||||||
|
<div className={classes.sidebar}>
|
||||||
|
<Sidebar {...props} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Container, Grid } from '@material-ui/core';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export const Loading = (_props: {}) => {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Grid
|
||||||
|
container
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
alignItems: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<Grid item xs>
|
||||||
|
<h2>Loading....</h2>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { createStyles, makeStyles, Paper, Theme, Typography } from '@material-ui/core';
|
||||||
|
import isArray from 'lodash/isArray';
|
||||||
|
import querystring from 'querystring';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { assertIsDefined, isDefined } from '../common';
|
||||||
|
import { LoginForm, LoginFormData } from '../components/loginForm';
|
||||||
|
import { RoomResponse } from '../protocol';
|
||||||
|
|
||||||
|
export interface LoginProps {
|
||||||
|
onLogin: (roomID: string, nickname: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
|
createStyles({
|
||||||
|
root: {
|
||||||
|
height: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
paper: {
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export const Login = (props: LoginProps) => {
|
||||||
|
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
|
const [roomID, setRoomID] = React.useState<string | undefined>();
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
const location = window.location;
|
||||||
|
if (location && location.search) {
|
||||||
|
const query = querystring.parse(location.search.substring(1));
|
||||||
|
let parsed = query.roomID;
|
||||||
|
if (parsed === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isArray(parsed)) {
|
||||||
|
parsed = parsed[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoomID(parsed);
|
||||||
|
|
||||||
|
delete query.roomID;
|
||||||
|
|
||||||
|
const newQuery = querystring.stringify(query);
|
||||||
|
const path = location.pathname + (newQuery ? '?' + newQuery : '');
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', path);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.root}>
|
||||||
|
<Paper className={classes.paper}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Codies
|
||||||
|
</Typography>
|
||||||
|
<LoginForm
|
||||||
|
existingRoom={!!roomID}
|
||||||
|
onSubmit={async (d: LoginFormData) => {
|
||||||
|
let id = roomID;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const query = querystring.stringify({
|
||||||
|
roomID: id,
|
||||||
|
});
|
||||||
|
const response = await fetch('/api/exists?' + query);
|
||||||
|
await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
setErrorMessage('Room does not exist.');
|
||||||
|
setRoomID(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let response: Response | undefined = undefined;
|
||||||
|
let resp: RoomResponse | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reqBody = JSON.stringify({
|
||||||
|
roomName: d.roomName,
|
||||||
|
roomPass: d.roomPass,
|
||||||
|
create: d.create,
|
||||||
|
});
|
||||||
|
response = await fetch('/api/room', { method: 'POST', body: reqBody });
|
||||||
|
const body = await response.json();
|
||||||
|
resp = RoomResponse.parse(body);
|
||||||
|
// eslint-disable-next-line no-empty
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
assertIsDefined(response);
|
||||||
|
|
||||||
|
if (!isDefined(resp) || !response.ok || !resp.id) {
|
||||||
|
setErrorMessage(resp?.error || 'An unknown error occurred.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
id = resp.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrorMessage(undefined);
|
||||||
|
props.onLogin(id, d.nickname);
|
||||||
|
}}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,331 @@
|
||||||
|
import { noop } from '../common';
|
||||||
|
import { GameView, Sender } from './gameView';
|
||||||
|
|
||||||
|
const send: Sender = new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: () => noop,
|
||||||
|
}
|
||||||
|
) as Sender;
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
state: {
|
||||||
|
version: 14,
|
||||||
|
roomID: '1Jx7enoG',
|
||||||
|
teams: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
playerID: '71355427-904b-4582-b609-3420539ac389',
|
||||||
|
nickname: 'foobar',
|
||||||
|
spymaster: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
playerID: '6349237e-d6dc-4fa5-a7a3-5017b619c8e2',
|
||||||
|
nickname: 'whatsup',
|
||||||
|
spymaster: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
playerID: 'bdaa9928-c393-4c1f-b627-19a406622c67',
|
||||||
|
nickname: 'hello there',
|
||||||
|
spymaster: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
playerID: '07693c3e-e340-4c36-8a56-c8aa10f35408',
|
||||||
|
nickname: 'I LOVE WORDS',
|
||||||
|
spymaster: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
playerID: 'acb830de-80e2-4eba-9b56-81b089fd3f12',
|
||||||
|
nickname: 'Player 0',
|
||||||
|
spymaster: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
turn: 1,
|
||||||
|
winner: null,
|
||||||
|
board: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
word: 'SINK',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'SHARK',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'FILE',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'CAT',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'DRILL',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
word: 'WAVE',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'VET',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'DWARF',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'NET',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'BEAR',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
word: 'MAPLE',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'HOOD',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'SHAKESPEARE',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'ROME',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'LION',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
word: 'STETHOSCOPE',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'KIWI',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'POINT',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'SPOT',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'SCUBA DIVER',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
word: 'ALIEN',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'NINJA',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: true,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'WELL',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'MILLIONAIRE',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 1,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
word: 'LAB',
|
||||||
|
revealed: false,
|
||||||
|
view: {
|
||||||
|
team: 0,
|
||||||
|
neutral: false,
|
||||||
|
bomb: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
wordsLeft: [8, 9],
|
||||||
|
lists: [
|
||||||
|
{
|
||||||
|
name: 'Base',
|
||||||
|
count: 404,
|
||||||
|
custom: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Duet',
|
||||||
|
count: 409,
|
||||||
|
custom: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Undercover',
|
||||||
|
count: 390,
|
||||||
|
custom: false,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cool words',
|
||||||
|
count: 500,
|
||||||
|
custom: true,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'also cool',
|
||||||
|
count: 490,
|
||||||
|
custom: true,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
turnTime: 0,
|
||||||
|
turnEnd: null,
|
||||||
|
},
|
||||||
|
pState: {
|
||||||
|
playerID: 'acb830de-80e2-4eba-9b56-81b089fd3f12',
|
||||||
|
nickname: 'Player 0',
|
||||||
|
spymaster: true,
|
||||||
|
},
|
||||||
|
pTeam: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static game page for testing.
|
||||||
|
export const StaticView = (_props: {}) =>
|
||||||
|
process.env.NODE_ENV === 'development' ? GameView({ ...props, send, roomID: 'fakeRoomID', leave: noop }) : null;
|
|
@ -0,0 +1,140 @@
|
||||||
|
import myzod, { Infer } from 'myzod';
|
||||||
|
|
||||||
|
// See protocol.go.
|
||||||
|
|
||||||
|
export type RoomResponse = Infer<typeof RoomResponse>;
|
||||||
|
export const RoomResponse = myzod.object({
|
||||||
|
id: myzod.string().optional().nullable(),
|
||||||
|
error: myzod.string().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TimeResponse = Infer<typeof TimeResponse>;
|
||||||
|
export const TimeResponse = myzod.object({
|
||||||
|
time: myzod.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type WordPack = Infer<typeof WordPack>;
|
||||||
|
const WordPack = myzod.object({
|
||||||
|
name: myzod.string(),
|
||||||
|
words: myzod.array(myzod.string()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ClientNote = Infer<typeof ClientNote>;
|
||||||
|
export const ClientNote = myzod
|
||||||
|
.object({
|
||||||
|
version: myzod.number(),
|
||||||
|
})
|
||||||
|
.and(
|
||||||
|
myzod.union([
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('newGame'),
|
||||||
|
params: myzod.object({}),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('endTurn'),
|
||||||
|
params: myzod.object({}),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('randomizeTeams'),
|
||||||
|
params: myzod.object({}),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('reveal'),
|
||||||
|
params: myzod.object({ row: myzod.number(), col: myzod.number() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('changeTeam'),
|
||||||
|
params: myzod.object({ team: myzod.number() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('changeNickname'),
|
||||||
|
params: myzod.object({ nickname: myzod.string() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('changeRole'),
|
||||||
|
params: myzod.object({ spymaster: myzod.boolean() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('changePack'),
|
||||||
|
params: myzod.object({ num: myzod.number(), enable: myzod.boolean() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('changeTurnMode'),
|
||||||
|
params: myzod.object({ timed: myzod.boolean() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('changeTurnTime'),
|
||||||
|
params: myzod.object({ seconds: myzod.number() }),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('addPacks'),
|
||||||
|
params: myzod.object({
|
||||||
|
packs: myzod.array(WordPack),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('removePack'),
|
||||||
|
params: myzod.object({ num: myzod.number() }),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
export type StateTile = Infer<typeof StateTile>;
|
||||||
|
const StateTile = myzod.object({
|
||||||
|
word: myzod.string(),
|
||||||
|
revealed: myzod.boolean(),
|
||||||
|
view: myzod
|
||||||
|
.object({
|
||||||
|
team: myzod.number(),
|
||||||
|
neutral: myzod.boolean(),
|
||||||
|
bomb: myzod.boolean(),
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StateBoard = Infer<typeof StateBoard>;
|
||||||
|
const StateBoard = myzod.array(myzod.array(StateTile));
|
||||||
|
|
||||||
|
export type StatePlayer = Infer<typeof StatePlayer>;
|
||||||
|
const StatePlayer = myzod.object({
|
||||||
|
playerID: myzod.string(),
|
||||||
|
nickname: myzod.string(),
|
||||||
|
spymaster: myzod.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StateTeams = Infer<typeof StateTeams>;
|
||||||
|
const StateTeams = myzod.array(myzod.array(StatePlayer));
|
||||||
|
|
||||||
|
export type StateTimer = Infer<typeof StateTimer>;
|
||||||
|
const StateTimer = myzod.object({
|
||||||
|
turnTime: myzod.number(),
|
||||||
|
turnEnd: myzod.date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type State = Infer<typeof State>;
|
||||||
|
export const State = myzod.object({
|
||||||
|
version: myzod.number(),
|
||||||
|
teams: StateTeams,
|
||||||
|
turn: myzod.number(),
|
||||||
|
winner: myzod.number().optional().nullable(),
|
||||||
|
board: StateBoard,
|
||||||
|
wordsLeft: myzod.array(myzod.number()),
|
||||||
|
lists: myzod.array(
|
||||||
|
myzod.object({
|
||||||
|
name: myzod.string(),
|
||||||
|
count: myzod.number(),
|
||||||
|
custom: myzod.boolean(),
|
||||||
|
enabled: myzod.boolean(),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
timer: StateTimer.optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ServerNote = Infer<typeof ServerNote>;
|
||||||
|
export const ServerNote = myzod.union([
|
||||||
|
myzod.object({
|
||||||
|
method: myzod.literal('state'),
|
||||||
|
params: State,
|
||||||
|
}),
|
||||||
|
]);
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="react-scripts" />
|
|
@ -0,0 +1,7 @@
|
||||||
|
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||||
|
// allows you to do things like:
|
||||||
|
// expect(element).toHaveTextContent(/react/i)
|
||||||
|
// learn more: https://github.com/testing-library/jest-dom
|
||||||
|
import '@testing-library/jest-dom/extend-expect';
|
||||||
|
// Required to make react-hook-form not break in tests.
|
||||||
|
import 'mutationobserver-shim';
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { blue, red } from '@material-ui/core/colors';
|
||||||
|
|
||||||
|
export type TeamHue = { [x in keyof typeof red]: string };
|
||||||
|
|
||||||
|
export interface TeamSpec {
|
||||||
|
name: string;
|
||||||
|
hue: TeamHue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teamSpecs: TeamSpec[] = [
|
||||||
|
{ name: 'Red', hue: red },
|
||||||
|
{ name: 'Blue', hue: blue },
|
||||||
|
];
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es6",
|
||||||
|
"module": "esnext",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
module github.com/zikaeroh/codies
|
||||||
|
|
||||||
|
go 1.14
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi v4.1.1+incompatible
|
||||||
|
github.com/gofrs/uuid v3.3.0+incompatible
|
||||||
|
github.com/jessevdk/go-flags v1.4.1-0.20181221193153-c0795c8afcf4
|
||||||
|
github.com/mailru/easyjson v0.7.1
|
||||||
|
github.com/mjibson/esc v0.2.0
|
||||||
|
github.com/posener/ctxutil v1.0.0
|
||||||
|
github.com/speps/go-hashids v2.0.0+incompatible
|
||||||
|
github.com/tomwright/queryparam/v4 v4.1.0
|
||||||
|
go.uber.org/atomic v1.6.0
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a
|
||||||
|
gotest.tools/v3 v3.0.2
|
||||||
|
nhooyr.io/websocket v1.8.6
|
||||||
|
)
|
|
@ -0,0 +1,112 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
|
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
|
||||||
|
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
|
||||||
|
github.com/go-chi/chi v4.1.1+incompatible h1:MmTgB0R8Bt/jccxp+t6S/1VGIKdJw5J74CK/c9tTfA4=
|
||||||
|
github.com/go-chi/chi v4.1.1+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
|
||||||
|
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
|
||||||
|
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
|
||||||
|
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
|
||||||
|
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
|
||||||
|
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
|
||||||
|
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
|
||||||
|
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||||
|
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
|
||||||
|
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
|
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
|
||||||
|
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||||
|
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
|
||||||
|
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
|
||||||
|
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/jessevdk/go-flags v1.4.1-0.20181221193153-c0795c8afcf4 h1:xKkUL6QBojwguhKKetf1SocCAKqc6W7S/mGm9xEGllo=
|
||||||
|
github.com/jessevdk/go-flags v1.4.1-0.20181221193153-c0795c8afcf4/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
|
github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
|
||||||
|
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
|
||||||
|
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
|
||||||
|
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
|
||||||
|
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
|
||||||
|
github.com/mailru/easyjson v0.7.1 h1:mdxE1MF9o53iCb2Ghj1VfWvh7ZOwHpnVG/xwXrV90U8=
|
||||||
|
github.com/mailru/easyjson v0.7.1/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs=
|
||||||
|
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||||
|
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||||
|
github.com/mjibson/esc v0.2.0 h1:k96hdaR9Z+nMcnDwNrOvhdBqtjyMrbVyxLpsRCdP2mA=
|
||||||
|
github.com/mjibson/esc v0.2.0/go.mod h1:9Hw9gxxfHulMF5OJKCyhYD7PzlSdhzXyaGEBRPH1OPs=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/posener/ctxutil v1.0.0 h1:APsSe4cGwZuER2veF2cv7vhauQgifRIAYxuGAjtvxoY=
|
||||||
|
github.com/posener/ctxutil v1.0.0/go.mod h1:3tPqqDAPFdtyAfY1WrxcIPFNhp/CuDXxeqyADkFVSLo=
|
||||||
|
github.com/speps/go-hashids v2.0.0+incompatible h1:kSfxGfESueJKTx0mpER9Y/1XHl+FVQjtCqRyYcviFbw=
|
||||||
|
github.com/speps/go-hashids v2.0.0+incompatible/go.mod h1:P7hqPzMdnZOfyIk+xrlG1QaSMw+gCBdHKsBDnhpaZvc=
|
||||||
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/tomwright/queryparam/v4 v4.1.0 h1:gbJpCDgBwLuONFPiyLocEmnSKK4ZXxr210u/SBdRTig=
|
||||||
|
github.com/tomwright/queryparam/v4 v4.1.0/go.mod h1:3sUgX1Kc0ABRc/7Q2LPKJyyYshm9P7VJPYTfvUbiatA=
|
||||||
|
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||||
|
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||||
|
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
|
||||||
|
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||||
|
go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk=
|
||||||
|
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
|
||||||
|
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4 h1:1mMox4TgefDwqluYCv677yNXwlfTkija4owZve/jr78=
|
||||||
|
golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c h1:IGkKhmfzcztjm6gYkykvu/NiS8kaqbCWAEWWAyf8J5U=
|
||||||
|
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gotest.tools/v3 v3.0.2 h1:kG1BFyqVHuQoVQiR1bWGnfz/fmHvvuiSPIV7rvl360E=
|
||||||
|
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
|
||||||
|
nhooyr.io/websocket v1.8.6 h1:s+C3xAMLwGmlI31Nyn/eAehUlZPwfYZu2JXM621Q5/k=
|
||||||
|
nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=
|
|
@ -0,0 +1,115 @@
|
||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/zikaeroh/codies/internal/words"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Team number, starting at zero.
|
||||||
|
type Team int
|
||||||
|
|
||||||
|
func (t Team) next(numTeams int) Team {
|
||||||
|
return (t + 1) % Team(numTeams)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tile struct {
|
||||||
|
// Immutable
|
||||||
|
Word string
|
||||||
|
Team Team
|
||||||
|
Neutral bool
|
||||||
|
Bomb bool
|
||||||
|
|
||||||
|
// Mutable
|
||||||
|
Revealed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Board struct {
|
||||||
|
Rows, Cols int
|
||||||
|
WordCounts []int
|
||||||
|
tiles []*Tile // len(items)=rows*cols, access via items[row*rows + col]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBoard(rows, cols int, words words.List, startingTeam Team, numTeams int, rand Rand) *Board {
|
||||||
|
if startingTeam < 0 || int(startingTeam) >= numTeams {
|
||||||
|
panic("invalid starting team")
|
||||||
|
}
|
||||||
|
|
||||||
|
n := rows * cols
|
||||||
|
layout, ok := layouts[layoutKey{boardSize: n, numTeams: numTeams}]
|
||||||
|
if !ok {
|
||||||
|
panic("invalid board dimension")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy and rotate teams to give the first team the most words.
|
||||||
|
old := layout.teams
|
||||||
|
layout.teams = append([]int(nil), old[startingTeam:]...)
|
||||||
|
layout.teams = append(layout.teams, old[:startingTeam]...)
|
||||||
|
wordCounts := append([]int(nil), layout.teams...)
|
||||||
|
|
||||||
|
items := make([]*Tile, n)
|
||||||
|
seen := make(map[int]struct{}, n)
|
||||||
|
|
||||||
|
for i := range items {
|
||||||
|
var w string
|
||||||
|
for {
|
||||||
|
j := rand.Intn(words.Len())
|
||||||
|
if _, ok := seen[j]; !ok {
|
||||||
|
seen[j] = struct{}{}
|
||||||
|
w = words.Get(j)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &Tile{Word: w}
|
||||||
|
|
||||||
|
ItemSwitch:
|
||||||
|
switch {
|
||||||
|
case layout.bomb > 0:
|
||||||
|
layout.bomb--
|
||||||
|
item.Bomb = true
|
||||||
|
|
||||||
|
case layout.neutral > 0:
|
||||||
|
layout.neutral--
|
||||||
|
item.Neutral = true
|
||||||
|
|
||||||
|
default:
|
||||||
|
for t, c := range layout.teams {
|
||||||
|
if c == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.teams[t]--
|
||||||
|
item.Team = Team(t)
|
||||||
|
break ItemSwitch
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("unreachable")
|
||||||
|
}
|
||||||
|
|
||||||
|
items[i] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
rand.Shuffle(len(items), func(i, j int) {
|
||||||
|
items[i], items[j] = items[j], items[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Board{
|
||||||
|
Rows: rows,
|
||||||
|
Cols: cols,
|
||||||
|
WordCounts: wordCounts,
|
||||||
|
tiles: items,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Board) Get(row, col int) *Tile {
|
||||||
|
switch {
|
||||||
|
case row < 0:
|
||||||
|
case col < 0:
|
||||||
|
case row >= b.Rows:
|
||||||
|
case col >= b.Cols:
|
||||||
|
default:
|
||||||
|
i := row*b.Rows + col
|
||||||
|
return b.tiles[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package game
|
||||||
|
|
||||||
|
type layoutKey struct {
|
||||||
|
boardSize int
|
||||||
|
numTeams int
|
||||||
|
}
|
||||||
|
|
||||||
|
var layouts = map[layoutKey]struct {
|
||||||
|
bomb int
|
||||||
|
neutral int
|
||||||
|
teams []int
|
||||||
|
}{
|
||||||
|
{25, 2}: {1, 7, []int{9, 8}},
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gotest.tools/v3/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLayouts(t *testing.T) {
|
||||||
|
for key, layout := range layouts {
|
||||||
|
assert.Equal(t, len(layout.teams), key.numTeams)
|
||||||
|
|
||||||
|
sum := layout.bomb + layout.neutral
|
||||||
|
for _, x := range layout.teams {
|
||||||
|
sum += x
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, sum, key.boardSize)
|
||||||
|
|
||||||
|
assert.Assert(t, sort.SliceIsSorted(layout.teams, func(i, j int) bool {
|
||||||
|
return layout.teams[i] >= layout.teams[j]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package game
|
||||||
|
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
type Rand interface {
|
||||||
|
Intn(n int) int
|
||||||
|
Shuffle(n int, swap func(i, j int))
|
||||||
|
}
|
||||||
|
|
||||||
|
type globalRand struct{}
|
||||||
|
|
||||||
|
var _ Rand = globalRand{}
|
||||||
|
|
||||||
|
func (globalRand) Intn(n int) int {
|
||||||
|
return rand.Intn(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (globalRand) Shuffle(n int, swap func(i, j int)) {
|
||||||
|
rand.Shuffle(n, swap)
|
||||||
|
}
|
|
@ -0,0 +1,363 @@
|
||||||
|
package game
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/zikaeroh/codies/internal/words"
|
||||||
|
"github.com/zikaeroh/codies/internal/words/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PlayerID = uuid.UUID
|
||||||
|
|
||||||
|
type WordList struct {
|
||||||
|
Name string
|
||||||
|
Custom bool
|
||||||
|
List words.List
|
||||||
|
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultWords() []*WordList {
|
||||||
|
return []*WordList{
|
||||||
|
{
|
||||||
|
Name: "Base",
|
||||||
|
List: static.Default,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Duet",
|
||||||
|
List: static.Duet,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Undercover",
|
||||||
|
List: static.Undercover,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
rand Rand
|
||||||
|
|
||||||
|
// Configuration for the next new game.
|
||||||
|
Rows, Cols int
|
||||||
|
|
||||||
|
Version int
|
||||||
|
Board *Board
|
||||||
|
Turn Team
|
||||||
|
Winner *Team
|
||||||
|
Players map[PlayerID]*Player
|
||||||
|
Teams [][]PlayerID // To preserve the ordering of teams.
|
||||||
|
WordLists []*WordList
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoom(rand Rand) *Room {
|
||||||
|
if rand == nil {
|
||||||
|
rand = globalRand{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Room{
|
||||||
|
rand: rand,
|
||||||
|
Rows: 5,
|
||||||
|
Cols: 5,
|
||||||
|
Players: make(map[PlayerID]*Player),
|
||||||
|
Teams: make([][]PlayerID, 2), // TODO: support more than 2 teams
|
||||||
|
WordLists: defaultWords(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Player struct {
|
||||||
|
ID PlayerID
|
||||||
|
Nickname string
|
||||||
|
Team Team
|
||||||
|
Spymaster bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) AddPlayer(id PlayerID, nickname string) {
|
||||||
|
if p, ok := r.Players[id]; ok {
|
||||||
|
if p.Nickname == nickname {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Nickname = nickname
|
||||||
|
r.Version++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
team := r.smallestTeam()
|
||||||
|
p := &Player{
|
||||||
|
ID: id,
|
||||||
|
Nickname: nickname,
|
||||||
|
Team: team,
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Players[id] = p
|
||||||
|
r.Teams[team] = append(r.Teams[team], id)
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) smallestTeam() Team {
|
||||||
|
min := Team(0)
|
||||||
|
minLen := len(r.Teams[0])
|
||||||
|
|
||||||
|
for tInt, team := range r.Teams {
|
||||||
|
if len(team) < minLen {
|
||||||
|
min = Team(tInt)
|
||||||
|
minLen = len(team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) words() (list words.List) {
|
||||||
|
for _, w := range r.WordLists {
|
||||||
|
if w.Enabled {
|
||||||
|
list = list.Concat(w.List)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) NewGame() {
|
||||||
|
words := r.words()
|
||||||
|
|
||||||
|
if r.Rows*r.Cols > words.Len() {
|
||||||
|
panic("not enough words")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Version++
|
||||||
|
r.Winner = nil
|
||||||
|
r.Turn = Team(r.rand.Intn(len(r.Teams)))
|
||||||
|
r.Board = newBoard(r.Rows, r.Cols, words, r.Turn, len(r.Teams), r.rand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) EndTurn(id PlayerID) {
|
||||||
|
if r.Winner != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := r.Players[id]
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Team != r.Turn || p.Spymaster {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.ForceEndTurn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) nextTeam() Team {
|
||||||
|
return r.Turn.next(len(r.Teams))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) nextTurn() {
|
||||||
|
r.Turn = r.nextTeam()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ForceEndTurn() {
|
||||||
|
r.Version++
|
||||||
|
r.nextTurn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) RemovePlayer(id PlayerID) {
|
||||||
|
p := r.Players[id]
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Version++
|
||||||
|
delete(r.Players, id)
|
||||||
|
|
||||||
|
r.Teams[p.Team] = removePlayer(r.Teams[p.Team], id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) Reveal(id PlayerID, row, col int) {
|
||||||
|
if r.Winner != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := r.Players[id]
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Spymaster || p.Team != r.Turn {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tile := r.Board.Get(row, col)
|
||||||
|
if tile == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tile.Revealed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tile.Revealed = true
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case tile.Neutral:
|
||||||
|
r.nextTurn()
|
||||||
|
case tile.Bomb:
|
||||||
|
// TODO: Who wins when there's more than one team?
|
||||||
|
// Maybe eliminate the team who clicked?
|
||||||
|
winner := r.nextTeam()
|
||||||
|
r.Winner = &winner
|
||||||
|
default:
|
||||||
|
r.Board.WordCounts[tile.Team]--
|
||||||
|
if r.Board.WordCounts[tile.Team] == 0 {
|
||||||
|
winner := tile.Team
|
||||||
|
r.Winner = &winner
|
||||||
|
} else if tile.Team != p.Team {
|
||||||
|
r.nextTurn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ChangeRole(id PlayerID, spymaster bool) {
|
||||||
|
if r.Winner != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := r.Players[id]
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Spymaster == spymaster {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Spymaster = spymaster
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ChangeTeam(id PlayerID, team Team) {
|
||||||
|
if team < 0 || int(team) >= len(r.Teams) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
p := r.Players[id]
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Team == team {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Teams[p.Team] = removePlayer(r.Teams[p.Team], id)
|
||||||
|
r.Teams[team] = append(r.Teams[team], id)
|
||||||
|
p.Team = team
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePlayer(team []PlayerID, remove PlayerID) []PlayerID {
|
||||||
|
newTeam := make([]PlayerID, 0, len(team)-1)
|
||||||
|
for _, id := range team {
|
||||||
|
if id != remove {
|
||||||
|
newTeam = append(newTeam, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newTeam
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) RandomizeTeams() {
|
||||||
|
players := make([]PlayerID, 0, len(r.Players))
|
||||||
|
for id := range r.Players {
|
||||||
|
players = append(players, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.rand.Shuffle(len(players), func(i, j int) {
|
||||||
|
players[i], players[j] = players[j], players[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
numTeams := len(r.Teams)
|
||||||
|
newTeams := make([][]PlayerID, numTeams)
|
||||||
|
for i := range newTeams {
|
||||||
|
newTeams[i] = make([]PlayerID, 0, len(players)/numTeams)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, id := range players {
|
||||||
|
team := i % numTeams
|
||||||
|
newTeams[team] = append(newTeams[team], id)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.rand.Shuffle(numTeams, func(i, j int) {
|
||||||
|
newTeams[i], newTeams[j] = newTeams[j], newTeams[i]
|
||||||
|
})
|
||||||
|
|
||||||
|
for team, players := range newTeams {
|
||||||
|
for _, id := range players {
|
||||||
|
r.Players[id].Team = Team(team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Teams = newTeams
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) ChangePack(num int, enable bool) {
|
||||||
|
if num < 0 || num >= len(r.WordLists) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pack := r.WordLists[num]
|
||||||
|
|
||||||
|
if pack.Enabled == enable {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !enable {
|
||||||
|
total := 0
|
||||||
|
for _, p := range r.WordLists {
|
||||||
|
if p.Enabled {
|
||||||
|
total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pack.Enabled = enable
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) AddPack(name string, wds []string) {
|
||||||
|
if len(r.WordLists) >= 10 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := &WordList{
|
||||||
|
Name: name,
|
||||||
|
Custom: true,
|
||||||
|
List: words.NewList(wds),
|
||||||
|
}
|
||||||
|
r.WordLists = append(r.WordLists, list)
|
||||||
|
r.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) RemovePack(num int) {
|
||||||
|
if num < 0 || num >= len(r.WordLists) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if pack := r.WordLists[num]; !pack.Custom || pack.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://github.com/golang/go/wiki/SliceTricks
|
||||||
|
lists := r.WordLists
|
||||||
|
copy(lists[num:], lists[num+1:])
|
||||||
|
lists[len(lists)-1] = nil
|
||||||
|
lists = lists[:len(lists)-1]
|
||||||
|
r.WordLists = lists
|
||||||
|
|
||||||
|
r.Version++
|
||||||
|
}
|
|
@ -0,0 +1,229 @@
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/mailru/easyjson"
|
||||||
|
"github.com/zikaeroh/codies/internal/game"
|
||||||
|
)
|
||||||
|
|
||||||
|
// See protocol/index.ts.
|
||||||
|
|
||||||
|
//go:generate go run github.com/mailru/easyjson/easyjson -disallow_unknown_fields protocol.go
|
||||||
|
|
||||||
|
type ExistsQuery struct {
|
||||||
|
RoomID string `queryparam:"roomID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type RoomRequest struct {
|
||||||
|
RoomName string `json:"roomName"`
|
||||||
|
RoomPass string `json:"roomPass"`
|
||||||
|
Create bool `json:"create"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RoomRequest) Valid() bool {
|
||||||
|
if len(r.RoomName) < 3 || len(r.RoomName) > 16 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.RoomPass) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type RoomResponse struct {
|
||||||
|
ID *string `json:"id,omitempty"`
|
||||||
|
Error *string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type TimeResponse struct {
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type StatsResponse struct {
|
||||||
|
Rooms int `json:"rooms"`
|
||||||
|
Clients int `json:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSQuery struct {
|
||||||
|
RoomID string `queryparam:"roomID"`
|
||||||
|
PlayerID uuid.UUID `queryparam:"playerID"`
|
||||||
|
Nickname string `queryparam:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WSQuery) Valid() bool {
|
||||||
|
if w.RoomID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.PlayerID == uuid.Nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(w.Nickname) < 3 || len(w.Nickname) > 16 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ClientNote struct {
|
||||||
|
Method ClientMethod `json:"method"`
|
||||||
|
Version int `json:"version"`
|
||||||
|
Params easyjson.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientMethod string
|
||||||
|
|
||||||
|
const NewGameMethod = ClientMethod("newGame")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type NewGameParams struct{}
|
||||||
|
|
||||||
|
const EndTurnMethod = ClientMethod("endTurn")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type EndTurnParams struct{}
|
||||||
|
|
||||||
|
const RandomizeTeamsMethod = ClientMethod("randomizeTeams")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type RandomizeTeamsParams struct{}
|
||||||
|
|
||||||
|
const RevealMethod = ClientMethod("reveal")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type RevealParams struct {
|
||||||
|
Row int `json:"row"`
|
||||||
|
Col int `json:"col"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeTeamMethod = ClientMethod("changeTeam")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ChangeTeamParams struct {
|
||||||
|
Team game.Team `json:"team"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeNicknameMethod = ClientMethod("changeNickname")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ChangeNicknameParams struct {
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeRoleMethod = ClientMethod("changeRole")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ChangeRoleParams struct {
|
||||||
|
Spymaster bool `json:"spymaster"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangePackMethod = ClientMethod("changePack")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ChangePackParams struct {
|
||||||
|
Num int `json:"num"`
|
||||||
|
Enable bool `json:"enable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeTurnModeMethod = ClientMethod("changeTurnMode")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ChangeTurnModeParams struct {
|
||||||
|
Timed bool `json:"timed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeTurnTimeMethod = ClientMethod("changeTurnTime")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ChangeTurnTimeParams struct {
|
||||||
|
Seconds int `json:"seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddPacksMethod = ClientMethod("addPacks")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type AddPacksParams struct {
|
||||||
|
Packs []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Words []string `json:"words"`
|
||||||
|
} `json:"packs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const RemovePackMethod = ClientMethod("removePack")
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type RemovePackParams struct {
|
||||||
|
Num int `json:"num"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerMethod string
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type ServerNote struct {
|
||||||
|
Method ServerMethod `json:"method"`
|
||||||
|
Params interface{} `json:"params"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func StateNote(s *State) ServerNote {
|
||||||
|
return ServerNote{
|
||||||
|
Method: "state",
|
||||||
|
Params: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type State struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Teams [][]*StatePlayer `json:"teams"`
|
||||||
|
Turn game.Team `json:"turn"`
|
||||||
|
Winner *game.Team `json:"winner"`
|
||||||
|
Board [][]*StateTile `json:"board"`
|
||||||
|
WordsLeft []int `json:"wordsLeft"`
|
||||||
|
Lists []*StateWordList `json:"lists"`
|
||||||
|
Timer *StateTimer `json:"timer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type StatePlayer struct {
|
||||||
|
PlayerID game.PlayerID `json:"playerID"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Spymaster bool `json:"spymaster"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type StateTile struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Revealed bool `json:"revealed"`
|
||||||
|
View *StateView `json:"view"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type StateView struct {
|
||||||
|
Team game.Team `json:"team"`
|
||||||
|
Neutral bool `json:"neutral"`
|
||||||
|
Bomb bool `json:"bomb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type StateWordList struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Custom bool `json:"custom"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//easyjson:json
|
||||||
|
type StateTimer struct {
|
||||||
|
TurnTime int `json:"turnTime"`
|
||||||
|
TurnEnd time.Time `json:"turnEnd"`
|
||||||
|
}
|
|
@ -0,0 +1,606 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/speps/go-hashids"
|
||||||
|
"github.com/zikaeroh/codies/internal/game"
|
||||||
|
"github.com/zikaeroh/codies/internal/protocol"
|
||||||
|
"go.uber.org/atomic"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
"nhooyr.io/websocket/wsjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxRooms = 1000
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrRoomExists = errors.New("server: rooms exist")
|
||||||
|
ErrTooManyRooms = errors.New("server: too many rooms")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
clientCount atomic.Int64
|
||||||
|
roomCount atomic.Int64
|
||||||
|
doPrune chan struct{}
|
||||||
|
ready chan struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
rooms map[string]*Room
|
||||||
|
roomIDs map[string]*Room
|
||||||
|
|
||||||
|
hid *hashids.HashID
|
||||||
|
nextID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer() *Server {
|
||||||
|
hd := hashids.NewData()
|
||||||
|
hd.MinLength = 8
|
||||||
|
hd.Salt = uuid.Must(uuid.NewV4()).String() // IDs are only valid for this server instance; ok to randomize salt.
|
||||||
|
hid, err := hashids.NewWithData(hd)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
ready: make(chan struct{}),
|
||||||
|
doPrune: make(chan struct{}, 1),
|
||||||
|
rooms: make(map[string]*Room),
|
||||||
|
roomIDs: make(map[string]*Room),
|
||||||
|
hid: hid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Run(ctx context.Context) error {
|
||||||
|
s.ctx = ctx
|
||||||
|
|
||||||
|
close(s.ready)
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
|
||||||
|
case <-s.doPrune:
|
||||||
|
s.prune()
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
s.prune()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) FindRoom(name string) *Room {
|
||||||
|
<-s.ready
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.rooms[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) FindRoomByID(id string) *Room {
|
||||||
|
<-s.ready
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.roomIDs[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) CreateRoom(name, password string) (*Room, error) {
|
||||||
|
<-s.ready
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
room := s.rooms[name]
|
||||||
|
if room != nil {
|
||||||
|
return nil, ErrRoomExists
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.rooms) >= maxRooms {
|
||||||
|
return nil, ErrTooManyRooms
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.hid.EncodeInt64([]int64{s.nextID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.nextID++
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(s.ctx)
|
||||||
|
|
||||||
|
room = &Room{
|
||||||
|
Name: name,
|
||||||
|
Password: password,
|
||||||
|
ID: id,
|
||||||
|
clientCount: &s.clientCount,
|
||||||
|
roomCount: &s.roomCount,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
room: game.NewRoom(nil),
|
||||||
|
players: make(map[game.PlayerID]noteSender),
|
||||||
|
turnSeconds: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
room.lastSeen.Store(time.Now())
|
||||||
|
|
||||||
|
room.room.NewGame()
|
||||||
|
|
||||||
|
s.rooms[name] = room
|
||||||
|
s.roomIDs[room.ID] = room
|
||||||
|
s.roomCount.Inc()
|
||||||
|
|
||||||
|
log.Printf("created new room '%s' (%s)", name, room.ID)
|
||||||
|
|
||||||
|
if s.nextID%100 == 0 {
|
||||||
|
s.triggerPrune()
|
||||||
|
}
|
||||||
|
|
||||||
|
return room, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) triggerPrune() {
|
||||||
|
select {
|
||||||
|
case s.doPrune <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) prune() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
toRemove := make([]string, 0, 1)
|
||||||
|
|
||||||
|
for name, room := range s.rooms {
|
||||||
|
lastSeen := room.lastSeen.Load().(time.Time)
|
||||||
|
if time.Since(lastSeen) > 10*time.Minute {
|
||||||
|
toRemove = append(toRemove, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(toRemove) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range toRemove {
|
||||||
|
room := s.rooms[name]
|
||||||
|
room.mu.Lock()
|
||||||
|
room.stopTimer()
|
||||||
|
room.mu.Unlock()
|
||||||
|
|
||||||
|
room.cancel()
|
||||||
|
delete(s.rooms, name)
|
||||||
|
delete(s.roomIDs, room.ID)
|
||||||
|
s.roomCount.Dec()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("pruned %d rooms", len(toRemove))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Stats() (rooms, clients int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return len(s.rooms), int(s.clientCount.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
type Room struct {
|
||||||
|
Name string
|
||||||
|
Password string
|
||||||
|
ID string
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
clientCount *atomic.Int64
|
||||||
|
roomCount *atomic.Int64
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
room *game.Room
|
||||||
|
players map[game.PlayerID]noteSender
|
||||||
|
state *stateCache
|
||||||
|
lastSeen atomic.Value
|
||||||
|
|
||||||
|
timed bool
|
||||||
|
turnSeconds int
|
||||||
|
turnDeadline *time.Time
|
||||||
|
turnTimer *time.Timer
|
||||||
|
}
|
||||||
|
|
||||||
|
type noteSender func(protocol.ServerNote)
|
||||||
|
|
||||||
|
func (r *Room) HandleConn(playerID uuid.UUID, nickname string, c *websocket.Conn) {
|
||||||
|
clientCount := r.clientCount.Inc()
|
||||||
|
log.Printf("client connected to room '%s' (%s); %v clients currently connected to %v rooms", r.Name, r.ID, clientCount, r.roomCount.Load())
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
clientCount := r.clientCount.Dec()
|
||||||
|
log.Printf("client disconnected from room '%s' (%s); %v clients currently connected to %v rooms", r.Name, r.ID, clientCount, r.roomCount.Load())
|
||||||
|
}()
|
||||||
|
|
||||||
|
defer c.Close(websocket.StatusGoingAway, "going away")
|
||||||
|
g, ctx := errgroup.WithContext(r.ctx)
|
||||||
|
|
||||||
|
r.mu.Lock()
|
||||||
|
r.players[playerID] = func(s protocol.ServerNote) {
|
||||||
|
g.Go(func() error {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return wsjson.Write(ctx, c, &s)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.room.AddPlayer(playerID, nickname)
|
||||||
|
r.sendAll()
|
||||||
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.players, playerID)
|
||||||
|
r.room.RemovePlayer(playerID)
|
||||||
|
r.sendAll()
|
||||||
|
}()
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Ping(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.lastSeen.Store(time.Now())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
for {
|
||||||
|
var note protocol.ClientNote
|
||||||
|
|
||||||
|
if err := wsjson.Read(ctx, c, ¬e); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.lastSeen.Store(time.Now())
|
||||||
|
|
||||||
|
if err := r.handleNote(playerID, ¬e); err != nil {
|
||||||
|
log.Println("error handling note:", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = g.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) handleNote(playerID game.PlayerID, note *protocol.ClientNote) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// The client's version was wrong; reject and send them the current state.
|
||||||
|
if note.Version != r.room.Version {
|
||||||
|
r.sendOne(playerID, r.players[playerID])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
before := r.room.Version
|
||||||
|
resetTimer := false
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r.room.Version != before {
|
||||||
|
if r.timed && resetTimer {
|
||||||
|
r.startTimer()
|
||||||
|
}
|
||||||
|
r.sendAll()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
switch note.Method {
|
||||||
|
case protocol.RevealMethod:
|
||||||
|
var params protocol.RevealParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resetTimer = true
|
||||||
|
r.room.Reveal(playerID, params.Row, params.Col)
|
||||||
|
|
||||||
|
case protocol.NewGameMethod:
|
||||||
|
var params protocol.NewGameParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resetTimer = true
|
||||||
|
r.room.NewGame()
|
||||||
|
|
||||||
|
case protocol.EndTurnMethod:
|
||||||
|
var params protocol.EndTurnParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resetTimer = true
|
||||||
|
r.room.EndTurn(playerID)
|
||||||
|
|
||||||
|
case protocol.RandomizeTeamsMethod:
|
||||||
|
var params protocol.RandomizeTeamsParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.room.RandomizeTeams()
|
||||||
|
|
||||||
|
case protocol.ChangeTeamMethod:
|
||||||
|
var params protocol.ChangeTeamParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.room.ChangeTeam(playerID, params.Team)
|
||||||
|
|
||||||
|
case protocol.ChangeNicknameMethod:
|
||||||
|
var params protocol.ChangeNicknameParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync with protocol.go's validation method.
|
||||||
|
if len(params.Nickname) < 3 || len(params.Nickname) > 16 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.room.AddPlayer(playerID, params.Nickname)
|
||||||
|
|
||||||
|
case protocol.ChangeRoleMethod:
|
||||||
|
var params protocol.ChangeRoleParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.room.ChangeRole(playerID, params.Spymaster)
|
||||||
|
|
||||||
|
case protocol.ChangePackMethod:
|
||||||
|
var params protocol.ChangePackParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.room.ChangePack(params.Num, params.Enable)
|
||||||
|
|
||||||
|
case protocol.ChangeTurnModeMethod:
|
||||||
|
var params protocol.ChangeTurnModeParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.changeTurnMode(params.Timed)
|
||||||
|
|
||||||
|
case protocol.ChangeTurnTimeMethod:
|
||||||
|
var params protocol.ChangeTurnTimeParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.changeTurnTime(params.Seconds)
|
||||||
|
|
||||||
|
case protocol.AddPacksMethod:
|
||||||
|
var params protocol.AddPacksParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, p := range params.Packs {
|
||||||
|
r.room.AddPack(p.Name, p.Words)
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocol.RemovePackMethod:
|
||||||
|
var params protocol.RemovePackParams
|
||||||
|
if err := json.Unmarshal(note.Params, ¶ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.room.RemovePack(params.Num)
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Printf("unhandled method: %s", note.Method)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) sendAll() {
|
||||||
|
for playerID, sender := range r.players {
|
||||||
|
r.sendOne(playerID, sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) sendOne(playerID game.PlayerID, sender noteSender) {
|
||||||
|
state := r.createStateFor(playerID)
|
||||||
|
note := protocol.StateNote(state)
|
||||||
|
sender(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) createStateFor(playerID game.PlayerID) *protocol.State {
|
||||||
|
if r.state == nil || r.state.version != r.room.Version {
|
||||||
|
r.state = r.createStateCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.room.Players[playerID].Spymaster {
|
||||||
|
return r.state.spymaster
|
||||||
|
}
|
||||||
|
return r.state.guesser
|
||||||
|
}
|
||||||
|
|
||||||
|
type stateCache struct {
|
||||||
|
version int
|
||||||
|
guesser *protocol.State
|
||||||
|
spymaster *protocol.State
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) createStateCache() *stateCache {
|
||||||
|
return &stateCache{
|
||||||
|
version: r.room.Version,
|
||||||
|
guesser: r.createRoomState(false),
|
||||||
|
spymaster: r.createRoomState(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) createRoomState(spymaster bool) *protocol.State {
|
||||||
|
room := r.room
|
||||||
|
|
||||||
|
s := &protocol.State{
|
||||||
|
Version: room.Version,
|
||||||
|
Teams: make([][]*protocol.StatePlayer, len(room.Teams)),
|
||||||
|
Turn: room.Turn,
|
||||||
|
Winner: room.Winner,
|
||||||
|
Board: make([][]*protocol.StateTile, room.Board.Rows),
|
||||||
|
WordsLeft: room.Board.WordCounts,
|
||||||
|
Lists: make([]*protocol.StateWordList, len(room.WordLists)),
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.turnDeadline != nil {
|
||||||
|
s.Timer = &protocol.StateTimer{
|
||||||
|
TurnTime: r.turnSeconds,
|
||||||
|
TurnEnd: *r.turnDeadline,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for team, members := range room.Teams {
|
||||||
|
for _, id := range members {
|
||||||
|
p := room.Players[id]
|
||||||
|
s.Teams[team] = append(s.Teams[team], &protocol.StatePlayer{
|
||||||
|
PlayerID: id,
|
||||||
|
Nickname: p.Nickname,
|
||||||
|
Spymaster: p.Spymaster,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Teams[team] == nil {
|
||||||
|
s.Teams[team] = []*protocol.StatePlayer{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row := range s.Board {
|
||||||
|
tiles := make([]*protocol.StateTile, room.Board.Cols)
|
||||||
|
for col := range tiles {
|
||||||
|
tile := room.Board.Get(row, col)
|
||||||
|
sTile := &protocol.StateTile{
|
||||||
|
Word: tile.Word,
|
||||||
|
Revealed: tile.Revealed,
|
||||||
|
}
|
||||||
|
|
||||||
|
if spymaster || tile.Revealed || room.Winner != nil {
|
||||||
|
sTile.View = &protocol.StateView{
|
||||||
|
Team: tile.Team,
|
||||||
|
Neutral: tile.Neutral,
|
||||||
|
Bomb: tile.Bomb,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tiles[col] = sTile
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Board[row] = tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, wl := range room.WordLists {
|
||||||
|
s.Lists[i] = &protocol.StateWordList{
|
||||||
|
Name: wl.Name,
|
||||||
|
Count: wl.List.Len(),
|
||||||
|
Custom: wl.Custom,
|
||||||
|
Enabled: wl.Enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) changeTurnMode(timed bool) {
|
||||||
|
if r.timed == timed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.timed = timed
|
||||||
|
|
||||||
|
if timed {
|
||||||
|
r.startTimer()
|
||||||
|
} else {
|
||||||
|
r.stopTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.room.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) changeTurnTime(seconds int) {
|
||||||
|
if seconds <= 0 || r.turnSeconds == seconds {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.turnSeconds = seconds
|
||||||
|
|
||||||
|
if r.timed {
|
||||||
|
r.startTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
r.room.Version++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room) timerEndTurn() {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
stopped := r.stopTimer()
|
||||||
|
if !stopped {
|
||||||
|
// Room was pruned.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.turnTimer = nil
|
||||||
|
r.turnDeadline = nil
|
||||||
|
|
||||||
|
if r.room.Winner != nil || r.turnSeconds == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.room.ForceEndTurn()
|
||||||
|
r.startTimer()
|
||||||
|
r.sendAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) stopTimer() (stopped bool) {
|
||||||
|
if r.turnTimer != nil {
|
||||||
|
r.turnTimer.Stop()
|
||||||
|
stopped = true
|
||||||
|
}
|
||||||
|
r.turnTimer = nil
|
||||||
|
r.turnDeadline = nil
|
||||||
|
return stopped
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be called with r.mu locked.
|
||||||
|
func (r *Room) startTimer() {
|
||||||
|
if !r.timed {
|
||||||
|
panic("startTimer called on non-timed room")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.turnTimer != nil {
|
||||||
|
r.turnTimer.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
dur := time.Second * time.Duration(r.turnSeconds)
|
||||||
|
deadline := time.Now().Add(dur)
|
||||||
|
r.turnDeadline = &deadline
|
||||||
|
r.turnTimer = time.AfterFunc(dur, r.timerEndTurn)
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
These files are sourced from: https://www.boardgamegeek.com/filepage/136292/codenames-word-list
|
|
@ -0,0 +1,404 @@
|
||||||
|
Hollywood
|
||||||
|
Well
|
||||||
|
Foot
|
||||||
|
New
|
||||||
|
York
|
||||||
|
Spring
|
||||||
|
Court
|
||||||
|
Tube
|
||||||
|
Point
|
||||||
|
Tablet
|
||||||
|
Slip
|
||||||
|
Date
|
||||||
|
Drill
|
||||||
|
Lemon
|
||||||
|
Bell
|
||||||
|
Screen
|
||||||
|
Fair
|
||||||
|
Torch
|
||||||
|
State
|
||||||
|
Match
|
||||||
|
Iron
|
||||||
|
Block
|
||||||
|
France
|
||||||
|
Australia
|
||||||
|
Limousine
|
||||||
|
Stream
|
||||||
|
Glove
|
||||||
|
Nurse
|
||||||
|
Leprechaun
|
||||||
|
Play
|
||||||
|
Tooth
|
||||||
|
Arm
|
||||||
|
Bermuda
|
||||||
|
Diamond
|
||||||
|
Whale
|
||||||
|
Comic
|
||||||
|
Mammoth
|
||||||
|
Green
|
||||||
|
Pass
|
||||||
|
Missile
|
||||||
|
Paste
|
||||||
|
Drop
|
||||||
|
Pheonix
|
||||||
|
Marble
|
||||||
|
Staff
|
||||||
|
Figure
|
||||||
|
Park
|
||||||
|
Centaur
|
||||||
|
Shadow
|
||||||
|
Fish
|
||||||
|
Cotton
|
||||||
|
Egypt
|
||||||
|
Theater
|
||||||
|
Scale
|
||||||
|
Fall
|
||||||
|
Track
|
||||||
|
Force
|
||||||
|
Dinosaur
|
||||||
|
Bill
|
||||||
|
Mine
|
||||||
|
Turkey
|
||||||
|
March
|
||||||
|
Contract
|
||||||
|
Bridge
|
||||||
|
Robin
|
||||||
|
Line
|
||||||
|
Plate
|
||||||
|
Band
|
||||||
|
Fire
|
||||||
|
Bank
|
||||||
|
Boom
|
||||||
|
Cat
|
||||||
|
Shot
|
||||||
|
Suit
|
||||||
|
Chocolate
|
||||||
|
Roulette
|
||||||
|
Mercury
|
||||||
|
Moon
|
||||||
|
Net
|
||||||
|
Lawyer
|
||||||
|
Satellite
|
||||||
|
Angel
|
||||||
|
Spider
|
||||||
|
Germany
|
||||||
|
Fork
|
||||||
|
Pitch
|
||||||
|
King
|
||||||
|
Crane
|
||||||
|
Trip
|
||||||
|
Dog
|
||||||
|
Conductor
|
||||||
|
Part
|
||||||
|
Bugle
|
||||||
|
Witch
|
||||||
|
Ketchup
|
||||||
|
Press
|
||||||
|
Spine
|
||||||
|
Worm
|
||||||
|
Alps
|
||||||
|
Bond
|
||||||
|
Pan
|
||||||
|
Beijing
|
||||||
|
Racket
|
||||||
|
Cross
|
||||||
|
Seal
|
||||||
|
Aztec
|
||||||
|
Maple
|
||||||
|
Parachute
|
||||||
|
Hotel
|
||||||
|
Berry
|
||||||
|
Soldier
|
||||||
|
Ray
|
||||||
|
Post
|
||||||
|
Greece
|
||||||
|
Square
|
||||||
|
Mass
|
||||||
|
Bat
|
||||||
|
Wave
|
||||||
|
Car
|
||||||
|
Smuggler
|
||||||
|
England
|
||||||
|
Crash
|
||||||
|
Tail
|
||||||
|
Card
|
||||||
|
Horn
|
||||||
|
Capital
|
||||||
|
Fence
|
||||||
|
Deck
|
||||||
|
Buffalo
|
||||||
|
Microscope
|
||||||
|
Jet
|
||||||
|
Duck
|
||||||
|
Ring
|
||||||
|
Train
|
||||||
|
Field
|
||||||
|
Gold
|
||||||
|
Tick
|
||||||
|
Check
|
||||||
|
Queen
|
||||||
|
Strike
|
||||||
|
Kangaroo
|
||||||
|
Spike
|
||||||
|
Scientist
|
||||||
|
Engine
|
||||||
|
Shakespeare
|
||||||
|
Wind
|
||||||
|
Kid
|
||||||
|
Embassy
|
||||||
|
Robot
|
||||||
|
Note
|
||||||
|
Ground
|
||||||
|
Draft
|
||||||
|
Ham
|
||||||
|
War
|
||||||
|
Mouse
|
||||||
|
Center
|
||||||
|
Chick
|
||||||
|
China
|
||||||
|
Bolt
|
||||||
|
Spot
|
||||||
|
Piano
|
||||||
|
Pupil
|
||||||
|
Plot
|
||||||
|
Lion
|
||||||
|
Police
|
||||||
|
Head
|
||||||
|
Litter
|
||||||
|
Concert
|
||||||
|
Mug
|
||||||
|
Vacuum
|
||||||
|
Atlantis
|
||||||
|
Straw
|
||||||
|
Switch
|
||||||
|
Skyscraper
|
||||||
|
Laser
|
||||||
|
Scuba
|
||||||
|
Diver
|
||||||
|
Africa
|
||||||
|
Plastic
|
||||||
|
Dwarf
|
||||||
|
Lap
|
||||||
|
Life
|
||||||
|
Honey
|
||||||
|
Horseshoe
|
||||||
|
Unicorn
|
||||||
|
Spy
|
||||||
|
Pants
|
||||||
|
Wall
|
||||||
|
Paper
|
||||||
|
Sound
|
||||||
|
Ice
|
||||||
|
Tag
|
||||||
|
Web
|
||||||
|
Fan
|
||||||
|
Orange
|
||||||
|
Temple
|
||||||
|
Canada
|
||||||
|
Scorpion
|
||||||
|
Undertaker
|
||||||
|
Mail
|
||||||
|
Europe
|
||||||
|
Soul
|
||||||
|
Apple
|
||||||
|
Pole
|
||||||
|
Tap
|
||||||
|
Mouth
|
||||||
|
Ambulance
|
||||||
|
Dress
|
||||||
|
Ice
|
||||||
|
Cream
|
||||||
|
Rabbit
|
||||||
|
Buck
|
||||||
|
Agent
|
||||||
|
Sock
|
||||||
|
Nut
|
||||||
|
Boot
|
||||||
|
Ghost
|
||||||
|
Oil
|
||||||
|
Superhero
|
||||||
|
Code
|
||||||
|
Kiwi
|
||||||
|
Hospital
|
||||||
|
Saturn
|
||||||
|
Film
|
||||||
|
Button
|
||||||
|
Snowman
|
||||||
|
Helicopter
|
||||||
|
Loch
|
||||||
|
Ness
|
||||||
|
Log
|
||||||
|
Princess
|
||||||
|
Time
|
||||||
|
Cook
|
||||||
|
Revolution
|
||||||
|
Shoe
|
||||||
|
Mole
|
||||||
|
Spell
|
||||||
|
Grass
|
||||||
|
Washer
|
||||||
|
Game
|
||||||
|
Beat
|
||||||
|
Hole
|
||||||
|
Horse
|
||||||
|
Pirate
|
||||||
|
Link
|
||||||
|
Dance
|
||||||
|
Fly
|
||||||
|
Pit
|
||||||
|
Server
|
||||||
|
School
|
||||||
|
Lock
|
||||||
|
Brush
|
||||||
|
Pool
|
||||||
|
Star
|
||||||
|
Jam
|
||||||
|
Organ
|
||||||
|
Berlin
|
||||||
|
Face
|
||||||
|
Luck
|
||||||
|
Amazon
|
||||||
|
Cast
|
||||||
|
Gas
|
||||||
|
Club
|
||||||
|
Sink
|
||||||
|
Water
|
||||||
|
Chair
|
||||||
|
Shark
|
||||||
|
Jupiter
|
||||||
|
Copper
|
||||||
|
Jack
|
||||||
|
Platypus
|
||||||
|
Stick
|
||||||
|
Olive
|
||||||
|
Grace
|
||||||
|
Bear
|
||||||
|
Glass
|
||||||
|
Row
|
||||||
|
Pistol
|
||||||
|
London
|
||||||
|
Rock
|
||||||
|
Van
|
||||||
|
Vet
|
||||||
|
Beach
|
||||||
|
Charge
|
||||||
|
Port
|
||||||
|
Disease
|
||||||
|
Palm
|
||||||
|
Moscow
|
||||||
|
Pin
|
||||||
|
Washington
|
||||||
|
Pyramid
|
||||||
|
Opera
|
||||||
|
Casino
|
||||||
|
Pilot
|
||||||
|
String
|
||||||
|
Night
|
||||||
|
Chest
|
||||||
|
Yard
|
||||||
|
Teacher
|
||||||
|
Pumpkin
|
||||||
|
Thief
|
||||||
|
Bark
|
||||||
|
Bug
|
||||||
|
Mint
|
||||||
|
Cycle
|
||||||
|
Telescope
|
||||||
|
Calf
|
||||||
|
Air
|
||||||
|
Box
|
||||||
|
Mount
|
||||||
|
Thumb
|
||||||
|
Antarctica
|
||||||
|
Trunk
|
||||||
|
Snow
|
||||||
|
Penguin
|
||||||
|
Root
|
||||||
|
Bar
|
||||||
|
File
|
||||||
|
Hawk
|
||||||
|
Battery
|
||||||
|
Compound
|
||||||
|
Slug
|
||||||
|
Octopus
|
||||||
|
Whip
|
||||||
|
America
|
||||||
|
Ivory
|
||||||
|
Pound
|
||||||
|
Sub
|
||||||
|
Cliff
|
||||||
|
Lab
|
||||||
|
Eagle
|
||||||
|
Genius
|
||||||
|
Ship
|
||||||
|
Dice
|
||||||
|
Hood
|
||||||
|
Heart
|
||||||
|
Novel
|
||||||
|
Pipe
|
||||||
|
Himalayas
|
||||||
|
Crown
|
||||||
|
Round
|
||||||
|
India
|
||||||
|
Needle
|
||||||
|
Shop
|
||||||
|
Watch
|
||||||
|
Lead
|
||||||
|
Tie
|
||||||
|
Table
|
||||||
|
Cell
|
||||||
|
Cover
|
||||||
|
Czech
|
||||||
|
Back
|
||||||
|
Bomb
|
||||||
|
Ruler
|
||||||
|
Forest
|
||||||
|
Bottle
|
||||||
|
Space
|
||||||
|
Hook
|
||||||
|
Doctor
|
||||||
|
Ball
|
||||||
|
Bow
|
||||||
|
Degree
|
||||||
|
Rome
|
||||||
|
Plane
|
||||||
|
Giant
|
||||||
|
Nail
|
||||||
|
Dragon
|
||||||
|
Stadium
|
||||||
|
Flute
|
||||||
|
Carrot
|
||||||
|
Wake
|
||||||
|
Fighter
|
||||||
|
Model
|
||||||
|
Tokyo
|
||||||
|
Eye
|
||||||
|
Mexico
|
||||||
|
Hand
|
||||||
|
Swing
|
||||||
|
Key
|
||||||
|
Alien
|
||||||
|
Tower
|
||||||
|
Poison
|
||||||
|
Cricket
|
||||||
|
Cold
|
||||||
|
Knife
|
||||||
|
Church
|
||||||
|
Board
|
||||||
|
Cloak
|
||||||
|
Ninja
|
||||||
|
Olympus
|
||||||
|
Belt
|
||||||
|
Light
|
||||||
|
Death
|
||||||
|
Stock
|
||||||
|
Millionaire
|
||||||
|
Day
|
||||||
|
Knight
|
||||||
|
Pie
|
||||||
|
Bed
|
||||||
|
Circle
|
||||||
|
Rose
|
||||||
|
Change
|
||||||
|
Cap
|
||||||
|
Triangle
|
|
@ -0,0 +1,409 @@
|
||||||
|
Drum
|
||||||
|
Bride
|
||||||
|
Wagon
|
||||||
|
Univerity
|
||||||
|
Hit
|
||||||
|
Ash
|
||||||
|
Bass
|
||||||
|
Astronaut
|
||||||
|
Doll
|
||||||
|
Nerve
|
||||||
|
Coach
|
||||||
|
Beam
|
||||||
|
Spoon
|
||||||
|
Country
|
||||||
|
Nose
|
||||||
|
King
|
||||||
|
Arthur
|
||||||
|
Stamp
|
||||||
|
Camp
|
||||||
|
Brain
|
||||||
|
Leaf
|
||||||
|
Tutu
|
||||||
|
Coast
|
||||||
|
Lunch
|
||||||
|
Thunder
|
||||||
|
Potato
|
||||||
|
Desk
|
||||||
|
Onion
|
||||||
|
Elephant
|
||||||
|
Anchor
|
||||||
|
Cowboy
|
||||||
|
Flood
|
||||||
|
Mohawk
|
||||||
|
Santa
|
||||||
|
Pitcher
|
||||||
|
Barbecue
|
||||||
|
Leather
|
||||||
|
Skates
|
||||||
|
Musketeer
|
||||||
|
Snap
|
||||||
|
Saddle
|
||||||
|
Genie
|
||||||
|
Mark
|
||||||
|
Shoulder
|
||||||
|
Governor
|
||||||
|
Manicure
|
||||||
|
Anthem
|
||||||
|
Halloween
|
||||||
|
Newton
|
||||||
|
Balloon
|
||||||
|
Fiddle
|
||||||
|
Craft
|
||||||
|
Glacier
|
||||||
|
Cake
|
||||||
|
Rat
|
||||||
|
Tank
|
||||||
|
Blind
|
||||||
|
Spirit
|
||||||
|
Cable
|
||||||
|
Swamp
|
||||||
|
Einstein
|
||||||
|
Hide
|
||||||
|
Crystal
|
||||||
|
Gear
|
||||||
|
Kiss
|
||||||
|
Pew
|
||||||
|
Powder
|
||||||
|
Turtle
|
||||||
|
Bacon
|
||||||
|
Sherlock
|
||||||
|
Squash
|
||||||
|
Book
|
||||||
|
Razor
|
||||||
|
Dressing
|
||||||
|
Brick
|
||||||
|
Brazil
|
||||||
|
Tear
|
||||||
|
Stable
|
||||||
|
Bikini
|
||||||
|
Pen
|
||||||
|
Roll
|
||||||
|
Christmas
|
||||||
|
Rubber
|
||||||
|
Bay
|
||||||
|
Mother
|
||||||
|
Kick
|
||||||
|
Fog
|
||||||
|
Radio
|
||||||
|
Crab
|
||||||
|
Cone
|
||||||
|
Skull
|
||||||
|
Wheelchair
|
||||||
|
Egg
|
||||||
|
Butter
|
||||||
|
Werewolf
|
||||||
|
Cherry
|
||||||
|
Patient
|
||||||
|
Dryer
|
||||||
|
Drawing
|
||||||
|
Boss
|
||||||
|
Fever
|
||||||
|
Banana
|
||||||
|
Polish
|
||||||
|
Knot
|
||||||
|
Paint
|
||||||
|
Storm
|
||||||
|
Goldilocks
|
||||||
|
Pillow
|
||||||
|
Chain
|
||||||
|
Moses
|
||||||
|
Saw
|
||||||
|
Brother
|
||||||
|
Rail
|
||||||
|
Rope
|
||||||
|
Street
|
||||||
|
Pad
|
||||||
|
Captain
|
||||||
|
Wish
|
||||||
|
Axe
|
||||||
|
Shorts
|
||||||
|
Popcorn
|
||||||
|
Castle
|
||||||
|
Second
|
||||||
|
Team
|
||||||
|
Oasis
|
||||||
|
Mess
|
||||||
|
Miss
|
||||||
|
Avalanche
|
||||||
|
Texas
|
||||||
|
Sun
|
||||||
|
Letter
|
||||||
|
Rust
|
||||||
|
Wing
|
||||||
|
Steel
|
||||||
|
Ear
|
||||||
|
Scroll
|
||||||
|
Bunk
|
||||||
|
Cane
|
||||||
|
Venus
|
||||||
|
Ladder
|
||||||
|
Purse
|
||||||
|
Sheet
|
||||||
|
Napoleon
|
||||||
|
Sugar
|
||||||
|
Director
|
||||||
|
Ace
|
||||||
|
Scratch
|
||||||
|
Bucket
|
||||||
|
Caesar
|
||||||
|
Disk
|
||||||
|
Beard
|
||||||
|
Bulb
|
||||||
|
Bench
|
||||||
|
Scarecrow
|
||||||
|
Igloo
|
||||||
|
Tuxedo
|
||||||
|
Earth
|
||||||
|
Ram
|
||||||
|
Sister
|
||||||
|
Bread
|
||||||
|
Record
|
||||||
|
Dash
|
||||||
|
Greenhouse
|
||||||
|
Drone
|
||||||
|
Steam
|
||||||
|
Biscuit
|
||||||
|
Rip
|
||||||
|
Notre
|
||||||
|
Dame
|
||||||
|
Lip
|
||||||
|
Shampoo
|
||||||
|
Cheese
|
||||||
|
Sack
|
||||||
|
Mountie
|
||||||
|
Sumo
|
||||||
|
Sahara
|
||||||
|
Walrus
|
||||||
|
Dust
|
||||||
|
Hammer
|
||||||
|
Cloud
|
||||||
|
Spray
|
||||||
|
St.Patrick
|
||||||
|
Kilt
|
||||||
|
Monkey
|
||||||
|
Frog
|
||||||
|
Dentist
|
||||||
|
Rainbow
|
||||||
|
Whistle
|
||||||
|
Reindeer
|
||||||
|
Kitchen
|
||||||
|
Lemonade
|
||||||
|
Slipper
|
||||||
|
Floor
|
||||||
|
Valentine
|
||||||
|
Pepper
|
||||||
|
Road
|
||||||
|
Shed
|
||||||
|
Bowler
|
||||||
|
Milk
|
||||||
|
Wheel
|
||||||
|
Magazine
|
||||||
|
Brass
|
||||||
|
Tea
|
||||||
|
Helmet
|
||||||
|
Flag
|
||||||
|
Troll
|
||||||
|
Jail
|
||||||
|
Sticker
|
||||||
|
Puppet
|
||||||
|
Chalk
|
||||||
|
Bonsai
|
||||||
|
Sweat
|
||||||
|
Gangster
|
||||||
|
Butterfly
|
||||||
|
Story
|
||||||
|
Salad
|
||||||
|
Armor
|
||||||
|
Smoke
|
||||||
|
Cave
|
||||||
|
Quack
|
||||||
|
Break
|
||||||
|
Snake
|
||||||
|
Mill
|
||||||
|
Gymnast
|
||||||
|
Wonderland
|
||||||
|
Driver
|
||||||
|
Spurs
|
||||||
|
Zombie
|
||||||
|
Pig
|
||||||
|
Cleopatra
|
||||||
|
Toast
|
||||||
|
Penny
|
||||||
|
Ant
|
||||||
|
Volume
|
||||||
|
Lace
|
||||||
|
Battleship
|
||||||
|
Maracas
|
||||||
|
Meter
|
||||||
|
Sling
|
||||||
|
Delta
|
||||||
|
Step
|
||||||
|
Joan
|
||||||
|
of
|
||||||
|
Arc
|
||||||
|
Comet
|
||||||
|
Bath
|
||||||
|
Polo
|
||||||
|
Gum
|
||||||
|
Vampire
|
||||||
|
Ski
|
||||||
|
Pocket
|
||||||
|
Battle
|
||||||
|
Foam
|
||||||
|
Rodeo
|
||||||
|
Squirrel
|
||||||
|
Salt
|
||||||
|
Mummy
|
||||||
|
Blacksmith
|
||||||
|
Chip
|
||||||
|
Goat
|
||||||
|
Laundry
|
||||||
|
Bee
|
||||||
|
Tattoo
|
||||||
|
Russia
|
||||||
|
Tin
|
||||||
|
Map
|
||||||
|
Yellowstone
|
||||||
|
Silk
|
||||||
|
Hose
|
||||||
|
Sloth
|
||||||
|
Kung
|
||||||
|
Fu
|
||||||
|
Clock
|
||||||
|
Bean
|
||||||
|
Lightning
|
||||||
|
Bowl
|
||||||
|
Guitar
|
||||||
|
Ranch
|
||||||
|
Pearl
|
||||||
|
Flat
|
||||||
|
Virus
|
||||||
|
Ice
|
||||||
|
Age
|
||||||
|
Coffee
|
||||||
|
Marathon
|
||||||
|
Attic
|
||||||
|
Wedding
|
||||||
|
Columbus
|
||||||
|
Pop
|
||||||
|
Sherwood
|
||||||
|
Trick
|
||||||
|
Nylon
|
||||||
|
Locust
|
||||||
|
Pacific
|
||||||
|
Cuckoo
|
||||||
|
Tornado
|
||||||
|
Memory
|
||||||
|
Jockey
|
||||||
|
Minotaur
|
||||||
|
Big
|
||||||
|
Bang
|
||||||
|
Page
|
||||||
|
Sphinx
|
||||||
|
Crusader
|
||||||
|
Volcano
|
||||||
|
Rifle
|
||||||
|
Boil
|
||||||
|
Hair
|
||||||
|
Bicycle
|
||||||
|
Jumper
|
||||||
|
Smoothie
|
||||||
|
Sleep
|
||||||
|
Pentagon
|
||||||
|
Groom
|
||||||
|
River
|
||||||
|
Farm
|
||||||
|
Judge
|
||||||
|
Viking
|
||||||
|
Easter
|
||||||
|
Mud
|
||||||
|
Parrot
|
||||||
|
Comb
|
||||||
|
Salsa
|
||||||
|
Eden
|
||||||
|
Army
|
||||||
|
Paddle
|
||||||
|
Saloon
|
||||||
|
Mona
|
||||||
|
Lisa
|
||||||
|
Mile
|
||||||
|
Blizzard
|
||||||
|
Quarter
|
||||||
|
Jeweler
|
||||||
|
Hamburger
|
||||||
|
Glasses
|
||||||
|
Sail
|
||||||
|
Boxer
|
||||||
|
Rice
|
||||||
|
Mirror
|
||||||
|
Ink
|
||||||
|
Beer
|
||||||
|
Tipi
|
||||||
|
Makeup
|
||||||
|
Microwave
|
||||||
|
Hercules
|
||||||
|
Sign
|
||||||
|
Pizza
|
||||||
|
Wool
|
||||||
|
Homer
|
||||||
|
Minute
|
||||||
|
Sword
|
||||||
|
Soup
|
||||||
|
Alaska
|
||||||
|
Baby
|
||||||
|
Potter
|
||||||
|
Shower
|
||||||
|
Blade
|
||||||
|
Noah
|
||||||
|
Soap
|
||||||
|
Tunnel
|
||||||
|
Peach
|
||||||
|
Dollar
|
||||||
|
Tip
|
||||||
|
Love
|
||||||
|
Jellyfish
|
||||||
|
Stethoscope
|
||||||
|
Taste
|
||||||
|
Fuel
|
||||||
|
Mosquito
|
||||||
|
Wizard
|
||||||
|
Big
|
||||||
|
Ben
|
||||||
|
Garden
|
||||||
|
Waitress
|
||||||
|
Shoot
|
||||||
|
Shell
|
||||||
|
Lumberjack
|
||||||
|
Medic
|
||||||
|
Dream
|
||||||
|
Blues
|
||||||
|
Earthquake
|
||||||
|
Pea
|
||||||
|
Parade
|
||||||
|
Sled
|
||||||
|
Smell
|
||||||
|
Computer
|
||||||
|
Cow
|
||||||
|
Peanut
|
||||||
|
Window
|
||||||
|
Mustard
|
||||||
|
Sand
|
||||||
|
Golf
|
||||||
|
Crow
|
||||||
|
Iceland
|
||||||
|
Apron
|
||||||
|
Violet
|
||||||
|
Door
|
||||||
|
Tiger
|
||||||
|
Joker
|
||||||
|
House
|
||||||
|
Collar
|
||||||
|
Hawaii
|
||||||
|
Dwarf
|
||||||
|
Pine
|
||||||
|
Magician
|
||||||
|
Frost
|
||||||
|
Curry
|
||||||
|
Bubble
|
||||||
|
Wood
|
|
@ -0,0 +1,390 @@
|
||||||
|
Horse
|
||||||
|
Sauna
|
||||||
|
Hooker
|
||||||
|
Stool
|
||||||
|
Mouth
|
||||||
|
Touchdown
|
||||||
|
Snake
|
||||||
|
Whiskey
|
||||||
|
Pickle
|
||||||
|
Hose
|
||||||
|
Legend
|
||||||
|
Blush
|
||||||
|
Dick
|
||||||
|
Cock
|
||||||
|
Alcohol
|
||||||
|
Sausage
|
||||||
|
Pecker
|
||||||
|
Straight
|
||||||
|
Sore
|
||||||
|
Toy
|
||||||
|
Black
|
||||||
|
White
|
||||||
|
Period
|
||||||
|
Couch
|
||||||
|
Juice
|
||||||
|
Bra
|
||||||
|
Dame
|
||||||
|
Chick
|
||||||
|
Bitch
|
||||||
|
Score
|
||||||
|
Sheep
|
||||||
|
Strap
|
||||||
|
Mattress
|
||||||
|
Train
|
||||||
|
Bondage
|
||||||
|
Wiener
|
||||||
|
Penis
|
||||||
|
Furry
|
||||||
|
Joystick
|
||||||
|
Apples
|
||||||
|
Condom
|
||||||
|
Bisexual
|
||||||
|
Hole
|
||||||
|
Secretary
|
||||||
|
Roll
|
||||||
|
Strip
|
||||||
|
Freak
|
||||||
|
Tramp
|
||||||
|
Foreskin
|
||||||
|
Wine
|
||||||
|
Pee
|
||||||
|
Experiment
|
||||||
|
Johnson
|
||||||
|
Banana
|
||||||
|
Clam
|
||||||
|
Blow
|
||||||
|
Balloon
|
||||||
|
Semen
|
||||||
|
Regret
|
||||||
|
Stripper
|
||||||
|
Homerun
|
||||||
|
Trim
|
||||||
|
Bar
|
||||||
|
Wood
|
||||||
|
Paddle
|
||||||
|
Cowgirl
|
||||||
|
John
|
||||||
|
Candle
|
||||||
|
Cigarette
|
||||||
|
Cigar
|
||||||
|
Knob
|
||||||
|
Sex
|
||||||
|
Gang
|
||||||
|
Stud
|
||||||
|
Screw
|
||||||
|
Trousers
|
||||||
|
Safe
|
||||||
|
Girl
|
||||||
|
Package
|
||||||
|
Grope
|
||||||
|
Jewels
|
||||||
|
Beach
|
||||||
|
Chubby
|
||||||
|
Beef
|
||||||
|
Bender
|
||||||
|
Shaft
|
||||||
|
Peaches
|
||||||
|
Swallow
|
||||||
|
Flower
|
||||||
|
Trunk
|
||||||
|
Sack
|
||||||
|
Job
|
||||||
|
Onion
|
||||||
|
Bowl
|
||||||
|
Jerk
|
||||||
|
Crap
|
||||||
|
Bush
|
||||||
|
Box
|
||||||
|
Mushroom
|
||||||
|
Shame
|
||||||
|
Couple
|
||||||
|
Sweat
|
||||||
|
Strobe
|
||||||
|
Tubesteak
|
||||||
|
Rug
|
||||||
|
Butt
|
||||||
|
Nylon
|
||||||
|
Lick
|
||||||
|
Hotel
|
||||||
|
Boy
|
||||||
|
Boob
|
||||||
|
Biscuits
|
||||||
|
Fatty
|
||||||
|
Share
|
||||||
|
Slut
|
||||||
|
Swimmers
|
||||||
|
Pound
|
||||||
|
Tuna
|
||||||
|
Roach
|
||||||
|
Brownie
|
||||||
|
Nuts
|
||||||
|
Blonde
|
||||||
|
Horny
|
||||||
|
Catcher
|
||||||
|
Body
|
||||||
|
Dominate
|
||||||
|
Mole
|
||||||
|
Shave
|
||||||
|
Orgasm
|
||||||
|
Taboo
|
||||||
|
Roof
|
||||||
|
Twig
|
||||||
|
Red
|
||||||
|
Lube
|
||||||
|
Nude
|
||||||
|
Eat
|
||||||
|
Hooters
|
||||||
|
Legs
|
||||||
|
Behind
|
||||||
|
Olive
|
||||||
|
Brown
|
||||||
|
Shower
|
||||||
|
Oyster
|
||||||
|
Taco
|
||||||
|
Salad
|
||||||
|
Udders
|
||||||
|
Rave
|
||||||
|
Inch
|
||||||
|
Nipple
|
||||||
|
Gay
|
||||||
|
High
|
||||||
|
Booze
|
||||||
|
Beaver
|
||||||
|
Pussy
|
||||||
|
Ice
|
||||||
|
Skank
|
||||||
|
Melons
|
||||||
|
Tail
|
||||||
|
Rack
|
||||||
|
Uranus
|
||||||
|
Queer
|
||||||
|
Lingerie
|
||||||
|
Needle
|
||||||
|
Escort
|
||||||
|
Herb
|
||||||
|
Bear
|
||||||
|
Beans
|
||||||
|
Log
|
||||||
|
Hamster
|
||||||
|
Skirt
|
||||||
|
Gigolo
|
||||||
|
Tap
|
||||||
|
Pie
|
||||||
|
Vasectomy
|
||||||
|
Queen
|
||||||
|
Group
|
||||||
|
Necklace
|
||||||
|
Commando
|
||||||
|
Headlights
|
||||||
|
Ashes
|
||||||
|
Bacon
|
||||||
|
Goose
|
||||||
|
Pillows
|
||||||
|
Smell
|
||||||
|
Latex
|
||||||
|
Tavern
|
||||||
|
Smegma
|
||||||
|
Vegas
|
||||||
|
Queef
|
||||||
|
Hot
|
||||||
|
Navel
|
||||||
|
Gag
|
||||||
|
Headboard
|
||||||
|
Bed
|
||||||
|
Ass
|
||||||
|
Caboose
|
||||||
|
Carpet
|
||||||
|
Smoke
|
||||||
|
Cuffs
|
||||||
|
Teabag
|
||||||
|
Shot
|
||||||
|
Vein
|
||||||
|
Purple
|
||||||
|
Gash
|
||||||
|
Nail
|
||||||
|
Hand
|
||||||
|
Head
|
||||||
|
Chaps
|
||||||
|
Animal
|
||||||
|
Coozie
|
||||||
|
Fish
|
||||||
|
Snatch
|
||||||
|
Rookie
|
||||||
|
Tease
|
||||||
|
Snort
|
||||||
|
Vibrator
|
||||||
|
Pucker
|
||||||
|
Film
|
||||||
|
Mug
|
||||||
|
Bang
|
||||||
|
Hammer
|
||||||
|
Grandma
|
||||||
|
Grass
|
||||||
|
Sniff
|
||||||
|
Prick
|
||||||
|
Tent
|
||||||
|
Baked
|
||||||
|
Video
|
||||||
|
Pub
|
||||||
|
G-Spot
|
||||||
|
Movie
|
||||||
|
Jazz
|
||||||
|
Friction
|
||||||
|
Eyes
|
||||||
|
Drunk
|
||||||
|
Softballs
|
||||||
|
Kitty
|
||||||
|
Tequila
|
||||||
|
Bottom
|
||||||
|
Vinyl
|
||||||
|
Prostate
|
||||||
|
Chains
|
||||||
|
Motorboat
|
||||||
|
Crabs
|
||||||
|
French
|
||||||
|
Hurl
|
||||||
|
Cheek
|
||||||
|
Solo
|
||||||
|
Lizard
|
||||||
|
Threesome
|
||||||
|
Breast
|
||||||
|
Virgin
|
||||||
|
Prison
|
||||||
|
Donkey
|
||||||
|
Monkey
|
||||||
|
Douche
|
||||||
|
Freckles
|
||||||
|
Bond
|
||||||
|
Keg
|
||||||
|
Spank
|
||||||
|
Boxers
|
||||||
|
Throat
|
||||||
|
Pinch
|
||||||
|
Vodka
|
||||||
|
Pot
|
||||||
|
Lips
|
||||||
|
Mom
|
||||||
|
Finger
|
||||||
|
Fluff
|
||||||
|
Bling
|
||||||
|
Rectum
|
||||||
|
Speed
|
||||||
|
Missionary
|
||||||
|
Tickle
|
||||||
|
Sin
|
||||||
|
Vomit
|
||||||
|
Porn
|
||||||
|
Cuddle
|
||||||
|
Moist
|
||||||
|
Manboobs
|
||||||
|
Flash
|
||||||
|
Dildo
|
||||||
|
Cocktail
|
||||||
|
Sperm
|
||||||
|
Emission
|
||||||
|
tie
|
||||||
|
Diarrhea
|
||||||
|
Wad
|
||||||
|
Pork
|
||||||
|
Bottle
|
||||||
|
Mixer
|
||||||
|
Crack
|
||||||
|
Fist
|
||||||
|
Club
|
||||||
|
Cucumber
|
||||||
|
Spoon
|
||||||
|
Seed
|
||||||
|
Tip
|
||||||
|
Intern
|
||||||
|
Wang
|
||||||
|
Pole
|
||||||
|
Champagne
|
||||||
|
Milk
|
||||||
|
Loose
|
||||||
|
Fire
|
||||||
|
Choke
|
||||||
|
Noodle
|
||||||
|
Spread
|
||||||
|
Doggy
|
||||||
|
Tit
|
||||||
|
Beer
|
||||||
|
Waste
|
||||||
|
Poker
|
||||||
|
Gerbil
|
||||||
|
Member
|
||||||
|
Bartender
|
||||||
|
Fetish
|
||||||
|
Bone
|
||||||
|
Motel
|
||||||
|
Squirt
|
||||||
|
Lotion
|
||||||
|
Tongue
|
||||||
|
Flesh
|
||||||
|
Watch
|
||||||
|
Player
|
||||||
|
Balls
|
||||||
|
Meat
|
||||||
|
Cream
|
||||||
|
Fecal
|
||||||
|
Rubber
|
||||||
|
Kinky
|
||||||
|
Stalker
|
||||||
|
Bust
|
||||||
|
Tool
|
||||||
|
Skid
|
||||||
|
Wax
|
||||||
|
Pitcher
|
||||||
|
Knees
|
||||||
|
Martini
|
||||||
|
Lobster
|
||||||
|
Feather
|
||||||
|
Booty
|
||||||
|
Joint
|
||||||
|
Steamy
|
||||||
|
Mesh
|
||||||
|
Top
|
||||||
|
Facial
|
||||||
|
Weed
|
||||||
|
Pipe
|
||||||
|
Cherry
|
||||||
|
Lust
|
||||||
|
Knockers
|
||||||
|
Fantasy
|
||||||
|
Hump
|
||||||
|
Poop
|
||||||
|
Stiff
|
||||||
|
Nurse
|
||||||
|
Torture
|
||||||
|
Bong
|
||||||
|
Wench
|
||||||
|
Pink
|
||||||
|
Gangbang
|
||||||
|
Love
|
||||||
|
Coyote
|
||||||
|
Drill
|
||||||
|
Acid
|
||||||
|
Line
|
||||||
|
Stiletto
|
||||||
|
Turd
|
||||||
|
Touch
|
||||||
|
Daddy
|
||||||
|
Wet
|
||||||
|
Pimp
|
||||||
|
Hell
|
||||||
|
Liquor
|
||||||
|
Burn
|
||||||
|
Drag
|
||||||
|
Cougar
|
||||||
|
Briefs
|
||||||
|
Stones
|
||||||
|
Naked
|
||||||
|
Orgy
|
||||||
|
Chest
|
||||||
|
Whip
|
||||||
|
Pig
|
||||||
|
Jugs
|
||||||
|
Lighter
|
||||||
|
Cannons
|
||||||
|
Down
|
||||||
|
Clap
|
|
@ -0,0 +1,351 @@
|
||||||
|
// Code generated by "esc -o=esc.go -pkg=static -ignore=^(static|esc)\.go$ -modtime=0 -private ."; DO NOT EDIT.
|
||||||
|
|
||||||
|
package static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type _escLocalFS struct{}
|
||||||
|
|
||||||
|
var _escLocal _escLocalFS
|
||||||
|
|
||||||
|
type _escStaticFS struct{}
|
||||||
|
|
||||||
|
var _escStatic _escStaticFS
|
||||||
|
|
||||||
|
type _escDirectory struct {
|
||||||
|
fs http.FileSystem
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
type _escFile struct {
|
||||||
|
compressed string
|
||||||
|
size int64
|
||||||
|
modtime int64
|
||||||
|
local string
|
||||||
|
isDir bool
|
||||||
|
|
||||||
|
once sync.Once
|
||||||
|
data []byte
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_escLocalFS) Open(name string) (http.File, error) {
|
||||||
|
f, present := _escData[path.Clean(name)]
|
||||||
|
if !present {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return os.Open(f.local)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (_escStaticFS) prepare(name string) (*_escFile, error) {
|
||||||
|
f, present := _escData[path.Clean(name)]
|
||||||
|
if !present {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
f.once.Do(func() {
|
||||||
|
f.name = path.Base(name)
|
||||||
|
if f.size == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var gr *gzip.Reader
|
||||||
|
b64 := base64.NewDecoder(base64.StdEncoding, bytes.NewBufferString(f.compressed))
|
||||||
|
gr, err = gzip.NewReader(b64)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.data, err = ioutil.ReadAll(gr)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fs _escStaticFS) Open(name string) (http.File, error) {
|
||||||
|
f, err := fs.prepare(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.File()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (dir _escDirectory) Open(name string) (http.File, error) {
|
||||||
|
return dir.fs.Open(dir.name + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) File() (http.File, error) {
|
||||||
|
type httpFile struct {
|
||||||
|
*bytes.Reader
|
||||||
|
*_escFile
|
||||||
|
}
|
||||||
|
return &httpFile{
|
||||||
|
Reader: bytes.NewReader(f.data),
|
||||||
|
_escFile: f,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Close() error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||||
|
if !f.isDir {
|
||||||
|
return nil, fmt.Errorf(" escFile.Readdir: '%s' is not directory", f.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fis, ok := _escDirs[f.local]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf(" escFile.Readdir: '%s' is directory, but we have no info about content of this dir, local=%s", f.name, f.local)
|
||||||
|
}
|
||||||
|
limit := count
|
||||||
|
if count <= 0 || limit > len(fis) {
|
||||||
|
limit = len(fis)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(fis) == 0 && count > 0 {
|
||||||
|
return nil, io.EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
return fis[0:limit], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Stat() (os.FileInfo, error) {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Name() string {
|
||||||
|
return f.name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Size() int64 {
|
||||||
|
return f.size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Mode() os.FileMode {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) ModTime() time.Time {
|
||||||
|
return time.Unix(f.modtime, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) IsDir() bool {
|
||||||
|
return f.isDir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *_escFile) Sys() interface{} {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// _escFS returns a http.Filesystem for the embedded assets. If useLocal is true,
|
||||||
|
// the filesystem's contents are instead used.
|
||||||
|
func _escFS(useLocal bool) http.FileSystem {
|
||||||
|
if useLocal {
|
||||||
|
return _escLocal
|
||||||
|
}
|
||||||
|
return _escStatic
|
||||||
|
}
|
||||||
|
|
||||||
|
// _escDir returns a http.Filesystem for the embedded assets on a given prefix dir.
|
||||||
|
// If useLocal is true, the filesystem's contents are instead used.
|
||||||
|
func _escDir(useLocal bool, name string) http.FileSystem {
|
||||||
|
if useLocal {
|
||||||
|
return _escDirectory{fs: _escLocal, name: name}
|
||||||
|
}
|
||||||
|
return _escDirectory{fs: _escStatic, name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _escFSByte returns the named file from the embedded assets. If useLocal is
|
||||||
|
// true, the filesystem's contents are instead used.
|
||||||
|
func _escFSByte(useLocal bool, name string) ([]byte, error) {
|
||||||
|
if useLocal {
|
||||||
|
f, err := _escLocal.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b, err := ioutil.ReadAll(f)
|
||||||
|
_ = f.Close()
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
f, err := _escStatic.prepare(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f.data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// _escFSMustByte is the same as _escFSByte, but panics if name is not present.
|
||||||
|
func _escFSMustByte(useLocal bool, name string) []byte {
|
||||||
|
b, err := _escFSByte(useLocal, name)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// _escFSString is the string version of _escFSByte.
|
||||||
|
func _escFSString(useLocal bool, name string) (string, error) {
|
||||||
|
b, err := _escFSByte(useLocal, name)
|
||||||
|
return string(b), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// _escFSMustString is the string version of _escFSMustByte.
|
||||||
|
func _escFSMustString(useLocal bool, name string) string {
|
||||||
|
return string(_escFSMustByte(useLocal, name))
|
||||||
|
}
|
||||||
|
|
||||||
|
var _escData = map[string]*_escFile{
|
||||||
|
|
||||||
|
"/codenames/README.md": {
|
||||||
|
name: "README.md",
|
||||||
|
local: "codenames/README.md",
|
||||||
|
size: 96,
|
||||||
|
modtime: 0,
|
||||||
|
compressed: `
|
||||||
|
H4sIAAAAAAAC/xTL0QnCQAwG4HenyAJtsIJg53CBePf3WmzMkZxkfXGA77kjQNtxIkgcFPb1gkqbm660
|
||||||
|
j9FjZc7M+WXitYmiAe+5mPJfdWng6+2+PBYuVvERRUxpXqfziHH5BQAA//+Xdl6cYAAAAA==
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
"/codenames/default.txt": {
|
||||||
|
name: "default.txt",
|
||||||
|
local: "codenames/default.txt",
|
||||||
|
size: 2482,
|
||||||
|
modtime: 0,
|
||||||
|
compressed: `
|
||||||
|
H4sIAAAAAAAC/ySW0XbaSg+F7/dbYRNCGyD+MaescynGwlY9HvmXZ0Ldpz9Lzl1YERpJ+9MWR41xfal2
|
||||||
|
uHOMOKhmXPiFf9VGtLNJ6lFrsYxbeTAalZRxo0fkjDbKjD1lxt4kRpx40oTK87TBmBMOJIabWhjQZg88
|
||||||
|
Uw4DfpjHRQ0jDkYpMHZlyUZRCCeZtCySGG02pgnvUb8Yl2IL48SzcRioJDSRVtxU84CdTajYptIR9kKT
|
||||||
|
pg73gSKj1kkCzjRNHve+1dTQsuAsyyKR/cNWvs5oBtYkf3Ame0R/nZ5PHKQv5mE2ouaUqRjagTp94SDL
|
||||||
|
gFpz1oS3fp0zbgNTZkMb/O0DxYibkTepFhh7Sbp4gsqHdfYWb8VGXv3J4LlSNgoZlUnXM676kISTxzXR
|
||||||
|
h1dR6nAQ2/4aUalOqCmjHTSjLZJRDxp0i71qiZx94myh2IqzasKFM070Wr1IyhyjZMYu9RzRztKx4Z1t
|
||||||
|
orR6ySMacbU+NgaMvF5zxdWRSF0JWc1Hk1GVPjLu3+Gcw1BmNMbL4mkT4642YRfnBZWL05BjIr898ZXC
|
||||||
|
yBm1qUczRez+ZnbR5k0fozCUzDhq5ugy24pWYydsuNKKRpe8KRsY7f8LmUO2LKgo405fjJoM7VT6PrLh
|
||||||
|
LfXRp1gbLQNuJNH/3+GollDTLJkiDuxI7jmMqMrzSVFxlmC6BJ0ZPzljX8KIq5d/M5KEg3Ds8K6xw03C
|
||||||
|
iHrwL/+vOG9tNhkZH5R6MlWfyMhog3DKsmQvaaN9oJGXmb2Bu6QOH9LhbXrQsqyOgu+lZsa7aUkd9kbP
|
||||||
|
jCNNuJPhrGXhDVA21MN3CZIIlcaMdtaMRigpmjJLRBM14ySa0GiUwDgydThJ3r6uKbBlnEuPXxRKmbDL
|
||||||
|
kbxY74VeaF+b0O24LsFoZsOJlo378vAN/GLD7mkSyMFdsgTsX2RPnGjGSZ4uZuLVh77wMijjnyTBFWjn
|
||||||
|
1eHIC+6+Pc2WvN0a/hEYN+px5wcOlPBplHrGjSfnpKZEHaENarP39U/q2DKNbDi7yG/FXLtWS8Ru3sjS
|
||||||
|
6Alnn517yPQocbOi/catP1dv/nOlx0Mc8TBi13PKaN24LiX7Bma8D07gp0S0ZWYb2BS1dowPeQmOunxD
|
||||||
|
1VIu5qTECVXZXKNN+poo4chRgs4+/JOGARcv4KQ9GpMU/MNNJjczHXHlL40le4+tT+7sbbSze+67Ofd3
|
||||||
|
WgbfY5oYFVPG0SO2UaMRc284SRqx35o9xNW3HC3b16bgoBq9ihGVlWVA45/bTIafNOHT+m11LTr0FBin
|
||||||
|
bSwT/VXfH19FWlDH8kDrr9zpm0i/A+3gLvqzzPKN2ezi/nR/dHtb5+J8ObqfUb4cdM9fMRneo3d21Rca
|
||||||
|
WfJWYOo04ep1/qKEX5w90k10IOtdXcvYy8LkbVOccPb19QRpG5Gk3iVoVqNJOnzObOQNiC+J+H743qYe
|
||||||
|
F+kHN1ZeMv51q7j5O2xoyjSPknAbhJ+ovLeq9O7rGfUanC6O/O0ZNcUndmKo9I8D5zd0KNMDu5TJQvZV
|
||||||
|
uVlJ48YEGk59Ee9Ps2d2ahhHeo1ua5lt9cs2b3vRxtLjM2T1+d0HmbGbeNu9H19q7o9bVHmgjvL0HXzg
|
||||||
|
jdyu3zmJz9y/s99cwH8GHNkN/aJfHNHIzDjKRJFWl9X05UVt65g6IVyYO+dv0NmlDgNO7iQ34e/fCKgd
|
||||||
|
zFqdrfovhwGV613p9MC1uCEf1Hyylea8gUzfdYzY63ZeKveBSl/Yc2/sd23azmFivAuljIuv996o943I
|
||||||
|
1EmZcIh+MWoyUz8CI/sdH5y6s3YccdNxVbytfh3/SFAc/Sa0L9f7g1fsonDCTV8us8ribJt83yn3+I/k
|
||||||
|
JlYPxc92pU5FHZVGXCT9JnzGdXI1Ko5us87Pnin7byAH9iwxiibyQ76n1bN5SCNOe4dazOG5qjv6sJlc
|
||||||
|
TbOfXkou238BAAD///SFqXuyCQAA
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
"/codenames/duet.txt": {
|
||||||
|
name: "duet.txt",
|
||||||
|
local: "codenames/duet.txt",
|
||||||
|
size: 2633,
|
||||||
|
modtime: 0,
|
||||||
|
compressed: `
|
||||||
|
H4sIAAAAAAAC/ySW0XqrOg6F79fDzDsE0ianbXo4IdN8M3eKUcCDsdiyXTb76eeTe5MLPiJkrbV++ahl
|
||||||
|
QaN+YNxplIh/R//N6vOOs884pAkNpYRDyiqRSsZRQsAn6zejFXITGqYF/SoS0UqJWXd8SmK8+zjioHkq
|
||||||
|
ij7TsqK1n0bJR3wwPXEruViRlPFRoptwm0ocWNFJpiw4cprxd/QS8RJ4nShmHKKbRNHK9pAdr0FkwEUm
|
||||||
|
2mb0FDOh89lNrGhIH+wK26eyPehnypxwKWnmzPYg0oqehiEwThw940I6o5+kBOviJN+sURQXit4VZRxi
|
||||||
|
nnjBmUKQjTnik7csEY09kIhXX4u1Ss+MUyDnWdHSzLhSxo3ijCb4OKBfvfqMlh6B0W82lxcfU2YfcTYx
|
||||||
|
Wt1TpoATk+Ldp4SON3SyWWO3ojkwGnIS0U+sQdyM/lchk0tkxpX+iOKonJKp0Kh3s03+jw+4Wck+1083
|
||||||
|
fvbRo+OIq+naTupTXijhWh6POsUdF6nze7carzLiSoMXO+QDrURGP5cQcJ+Yg5vIK17GEU3JmRV3Vt4k
|
||||||
|
PNFOrLqjo+w5Zhx1Z2uQttqfpIRX/q4fjBQJnQSfJrxHyejIx4w+iy44SRi8HTeh86YC2sn8dJHECT1t
|
||||||
|
aPSn3Sv5gKusjD4rs5UZ0NKa7fW7FT/8ZhNbc0InqxONaCnZZHt2Egeb1IK/KfmEC6eEi+lw+KZA0U2M
|
||||||
|
G/+mhL6Ym+thryVl3O1AfWYOeLFBO7XBNiXOaCkyvjiWhA8aqtGLJmvC+vukVQKbomUkxdEruyyKg2Or
|
||||||
|
QtmyVtzM5htO9ZU0W/x0QFPCAw1biHpHyk5lw19jEMGt/OZBrJk84WpZ9cm6bZRpwJWd6ICjWeekzHGS
|
||||||
|
khhHrcpmm0Djkys+4+pXfEpWxpEWxodf0U+0rCKmLttByM24GAQ8oy+LoKeJlHCnoCXhaAM607JYLIIU
|
||||||
|
C4LSjj7/q6NcTfruQ8ZF4sw7XlVGHDlmn7LpGR+y4T75qtGVfRy4+tICbyIsEmlg9MGvK2uFg+KLglWI
|
||||||
|
jI7r46vQYCMf0MgWWHHxYf6xLy400h97t1Gj3o0JZw4LZ7wGGnGrWr6Zs/rs3VwVXFdTZKIwo5GYyKPf
|
||||||
|
mDJOFMefQdcoPMNePbyjp0ADDrqIol9kZrT0zfinUA0p02xomtkaCzjtSzRC3sXQGCgOOKoxGv1aNOG/
|
||||||
|
sjw8o/Mj2sCyUlbCrUK14xh3gxa+JBSTjJxhI+fAafKr8Y4cmbutzz6YdY8cMpnyK96EIuSJgzq0YlNo
|
||||||
|
KE+WTcGpLPiiZfVqAPDopDrzpzhehRZcZWAxLHlVDnbsjEtZlh1NIDenxecJrfVxEsr4oBIH3dEw40Y5
|
||||||
|
i1igkifcLOC04j9siU+5WtNEO9ue6YPkCe8ljngtZitXQxHx4ccpxx++bAGn4jMZFywkHZMGEzXjy5s1
|
||||||
|
/3KMw2g77fnkugkoTxJxyNk73HkYrFBrc3yUCoxK3s32z60693MPEvEhzkzekfNP79AWN1sGRSMNggsv
|
||||||
|
5oA3G9aOi4+SqSgaPxr4RnQ0Mvp18vE3Wi2JDBJfEhxFwdU/DdniA86G2ca73QXGW1nM1/0ikicLXmBe
|
||||||
|
TfxcN/pJRRZcq2VeSRe8lWFkfBn6R7xQ9eilDOhIVbIp/TCxEuFl4GhGNW7XzdZT3XMXiYQPn8gsyrbT
|
||||||
|
/vwxCv1TSK3aG29syTrT8ig62i4NlH4I7QMa+W059M4sriqKv2wzWphvfvW40MxlxcUbxSwaZ1ZXgv3d
|
||||||
|
jxGdfQ13kYCzLDXAsWTbo0ayXsqKQ6A0Exp67HaZqO6eZLMwBkPEp9CEXmjFrcTIwfzgpnq3odoEPuSb
|
||||||
|
8cYh7E/bFX3mPElytk9uNjK8FuOFpF/FZ8Hd1wFUITniRGqju5PPtoPt45LNMCHgoywP1v9VVPLgna1p
|
||||||
|
o2wonH4o/atY+jsmk+SHaDygX+zfrSxrsfO0stkrsdSVM8hmN5tsTfTGiFNdunULOK7UOKwqEV9eAtst
|
||||||
|
TuycJs2bGMfOFfvtzwDOtJH3OG6kT3TGwwuN3nmKxuSU0RZb5k152CXibhn4fwAAAP//c4lDFUkKAAA=
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
"/codenames/undercover.txt": {
|
||||||
|
name: "undercover.txt",
|
||||||
|
local: "codenames/undercover.txt",
|
||||||
|
size: 2447,
|
||||||
|
modtime: 0,
|
||||||
|
compressed: `
|
||||||
|
H4sIAAAAAAAC/yyW33qqwA7F79e7nIcoWHW3ajnCrtcBIuRjmLDnT5U+/fmC56b2E1mZJL+szFFDZNSU
|
||||||
|
PeGoOnFAnVQdzprTiEZzN/b68Kg9TYzbKHHiFZV0k2McNTJOPLDvUbgcR+ykm1BqN+HNdTqqM+1IA6Pi
|
||||||
|
7qUeSIYxodbAaHRF4aibTDnZj4Joj9LC4iNLxygCYUczoxxNu5DUjag7e7semZdNccGZUgocI5pA4lGo
|
||||||
|
7y3qTdhzQMVeIvY5hBUfusZkUm/L4jiiVN/rjEIiPzM5HNUxau4CJworruqcxZAF+8A0WYB5wV4Dx0k8
|
||||||
|
buLt3Iz358JBZvYJHzr6qB4FefKE0tGMwukDBTmn6lHzzB5XHgKnl/jCAUedOWSPJsiMggJuqj0q6nvH
|
||||||
|
KPUxSHCbNkry23cyUOCU/v8fPr22qPmJA/kBdco96i7wA03QHDlE1HRnHEynom6yCh2CLowPfrCLKJi6
|
||||||
|
EeWY23ZFwXxHwb63to10T6jsMUfUD8vjgb3TBwc0IfsJtbXxQ1t8ebHc9eHwwWFCaf0pjI5CnzjnOAbV
|
||||||
|
2SStq5oXq/eDaauEtowmtxyT1fqaBxQ5JVxWpx4na9tREzsURo5qa33rsqSIPaW0mqqR4XJC/ZB5tqQr
|
||||||
|
zb5HY4xf1RIsgj68MC45RWuM743l4FeUlLqRAwrtV+x0Fk+Jcd6QGOmH8RUGijMaalVxVb2jeciAK/c4
|
||||||
|
5dYke8Y7JZumZMFPPFhdR/E9vpz88Cs66nGr3dcak5WQOkVNjnr87Xt772rR/vhuxEUMVBxoxVEGq6L+
|
||||||
|
srXqx8jOMa740zHqifyEMzv1EQ2Jw9U68jeQzxH/zcwBJ/EDB8uc2QB6j52GhCOH1gSD/fERJx1wpHk7
|
||||||
|
WT1JSDjIoE7R0IJKGN8UuUs6r5uuN4jyggt3k6POmjrP5HvFkal3Nu4Rb9HIKahTj4OacVRiEEXUMzuH
|
||||||
|
EyV+orGkvH01zIRvHuh19Lu1HRf6YYcDDZtwqxR6FNzjLUaU1pHIKCksNlSzTowy3+8RDVNLg1U84ZvF
|
||||||
|
o8rhVdE44mKVOpLvN02UIy0Rb15mcihVf4WxlziaAZrzXFUnYZM03/RWvW9pAyW1XmwWtxc342zk2hQe
|
||||||
|
ySDEIZDvZ7LPGFF7ud9RBeO5MccoaOIe39KzosotDv+pF004648wPuj3F/sgXbLBel85YvcaOb2nlpyL
|
||||||
|
+BSDv+F/WRyh0JR0xrf41aEKGpNRXI4kPuKsSUOrlGww22iuZpQdc3AoR2aTdYqT/Fp5mzEwR50NW6Zo
|
||||||
|
2YbBShjEHG6n3vbB+fWxM99mU7T1EDcXxicPqBdjs9Cnkd2MwaJXYmG/tZ8IlSacZLHDzdhvkGLv8v2O
|
||||||
|
wom38epSnlEvzD3OEqOoN3NuXnuoFo9vnSWh0uBR5s0xzyox4Uy+VbU8HW0LyvW6bahkja8XDjPe55ck
|
||||||
|
kjB2QiGMTLhRb3rTVk7TkycHK1o3GRIJpcstytzlubVBWV7Ozj0aWfDHJyP5ZgxU5h/lSPNCgzchN+G0
|
||||||
|
0bqXYE+M1YuqnbpegnG402Gw/JLZcMCNoq3HbUEfOLTicOYtbEEhvTx6z0k2k7UQm0nW/7IN70k3cBr1
|
||||||
|
Q2bsHccRt43mytG6aRhDZ96YYJqx544crrm1CJ/ipxV1ImfRixwTGrsi1JP0uNETlbxM89MzR5wpJPGC
|
||||||
|
k7abgeyZ0stSNdn2FW9GzzSvONtJGl2wp07I4WbFq2SxkrDt6pMF+/Rqc2UO7xPFFcc8L6hUbffbHF2y
|
||||||
|
XWIaDSkHtvwH3DamK/HTtgtb68JJf8yaVk2MXRDn8NZJb57IJuQ4JUWTDfrt/rGjvl9xY0N1XnDcXEr+
|
||||||
|
ZbUiBI9doMH2l23eIgjfo92dPEdctmn+CsNqicRktxszzgEfeYg4mSMaSuS9mfXO9kHpaMH/AgAA//8f
|
||||||
|
O6UajwkAAA==
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
|
||||||
|
"/": {
|
||||||
|
name: "/",
|
||||||
|
local: `.`,
|
||||||
|
isDir: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
"/codenames": {
|
||||||
|
name: "codenames",
|
||||||
|
local: `codenames`,
|
||||||
|
isDir: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var _escDirs = map[string][]os.FileInfo{
|
||||||
|
|
||||||
|
".": {
|
||||||
|
_escData["/codenames"],
|
||||||
|
},
|
||||||
|
|
||||||
|
"codenames": {
|
||||||
|
_escData["/codenames/README.md"],
|
||||||
|
_escData["/codenames/default.txt"],
|
||||||
|
_escData["/codenames/duet.txt"],
|
||||||
|
_escData["/codenames/undercover.txt"],
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package static
|
||||||
|
|
||||||
|
import "github.com/zikaeroh/codies/internal/words"
|
||||||
|
|
||||||
|
//go:generate go run github.com/mjibson/esc -o=esc.go -pkg=static -ignore=^(static|esc)\.go$ -modtime=0 -private .
|
||||||
|
|
||||||
|
var (
|
||||||
|
Default = words.NewListFromLines(_escFSMustString(false, "/codenames/default.txt"))
|
||||||
|
Duet = words.NewListFromLines(_escFSMustString(false, "/codenames/duet.txt"))
|
||||||
|
Undercover = words.NewListFromLines(_escFSMustString(false, "/codenames/undercover.txt"))
|
||||||
|
)
|
|
@ -0,0 +1,73 @@
|
||||||
|
package words
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type List struct {
|
||||||
|
words [][]string
|
||||||
|
len int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newList(words []string) List {
|
||||||
|
return List{
|
||||||
|
words: [][]string{words},
|
||||||
|
len: len(words),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewList(words []string) List {
|
||||||
|
cleaned := make([]string, 0, len(words))
|
||||||
|
for _, w := range words {
|
||||||
|
w = strings.TrimSpace(w)
|
||||||
|
w = strings.ToUpper(w)
|
||||||
|
if w != "" {
|
||||||
|
cleaned = append(cleaned, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newList(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListFromLines(s string) List {
|
||||||
|
words := make([]string, 0, strings.Count(s, "\n"))
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(s))
|
||||||
|
|
||||||
|
for scanner.Scan() {
|
||||||
|
word := scanner.Text()
|
||||||
|
word = strings.TrimSpace(word)
|
||||||
|
word = strings.ToUpper(word)
|
||||||
|
|
||||||
|
if word != "" {
|
||||||
|
words = append(words, word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newList(words)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *List) Len() int {
|
||||||
|
return l.len
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *List) Get(i int) string {
|
||||||
|
for _, words := range l.words {
|
||||||
|
if i < len(words) {
|
||||||
|
return words[i]
|
||||||
|
}
|
||||||
|
i -= len(words)
|
||||||
|
}
|
||||||
|
panic("out of bounds")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l List) Concat(other List) List {
|
||||||
|
words := make([][]string, 0, len(l.words)+len(other.words))
|
||||||
|
words = append(words, l.words...)
|
||||||
|
words = append(words, other.words...)
|
||||||
|
|
||||||
|
return List{
|
||||||
|
words: words,
|
||||||
|
len: l.len + other.len,
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/go-chi/chi/middleware"
|
||||||
|
"github.com/gofrs/uuid"
|
||||||
|
"github.com/jessevdk/go-flags"
|
||||||
|
"github.com/posener/ctxutil"
|
||||||
|
"github.com/tomwright/queryparam/v4"
|
||||||
|
"github.com/zikaeroh/codies/internal/protocol"
|
||||||
|
"github.com/zikaeroh/codies/internal/server"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"nhooyr.io/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var args = struct {
|
||||||
|
Addr string `long:"addr" env:"CODIES_ADDR" description:"Address to listen at"`
|
||||||
|
Origins []string `long:"origins" env:"CODIES_ORIGINS" env-delim:"," description:"Additional valid origins for WebSocket connections"`
|
||||||
|
Debug bool `long:"debug" env:"CODIES_DEBUG" description:"Enables debug mode"`
|
||||||
|
}{
|
||||||
|
Addr: ":5000",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if _, err := flags.Parse(&args); err != nil {
|
||||||
|
// Default flag parser prints messages, so just exit.
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
wsOpts := &websocket.AcceptOptions{
|
||||||
|
OriginPatterns: args.Origins,
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.Debug {
|
||||||
|
log.Println("starting in debug mode, allowing any WebSocket origin host")
|
||||||
|
wsOpts.OriginPatterns = []string{"*"}
|
||||||
|
}
|
||||||
|
|
||||||
|
rand.Seed(time.Now().Unix())
|
||||||
|
|
||||||
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
|
|
||||||
|
g, ctx := errgroup.WithContext(ctxutil.Interrupt())
|
||||||
|
|
||||||
|
srv := server.NewServer()
|
||||||
|
|
||||||
|
r := chi.NewMux()
|
||||||
|
r.Use(middleware.Heartbeat("/ping"))
|
||||||
|
r.Use(middleware.Recoverer)
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.Compress(5))
|
||||||
|
fs := http.Dir("./frontend/build")
|
||||||
|
r.NotFound(http.FileServer(fs).ServeHTTP)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.NoCache)
|
||||||
|
|
||||||
|
r.Get("/api/time", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(&protocol.TimeResponse{Time: time.Now()})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/api/exists", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := &protocol.ExistsQuery{}
|
||||||
|
if err := queryparam.Parse(r.URL.Query(), query); err != nil {
|
||||||
|
httpErr(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room := srv.FindRoomByID(query.RoomID)
|
||||||
|
if room == nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = w.Write([]byte("."))
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Post("/api/room", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
req := &protocol.RoomRequest{}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(req); err != nil {
|
||||||
|
httpErr(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !req.Valid() {
|
||||||
|
httpErr(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &protocol.RoomResponse{}
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
|
||||||
|
if req.Create {
|
||||||
|
room, err := srv.CreateRoom(req.RoomName, req.RoomPass)
|
||||||
|
if err != nil {
|
||||||
|
switch err {
|
||||||
|
case server.ErrRoomExists:
|
||||||
|
resp.Error = stringPtr("Room already exists.")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
case server.ErrTooManyRooms:
|
||||||
|
resp.Error = stringPtr("Too many rooms.")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
default:
|
||||||
|
resp.Error = stringPtr("An unknown error occurred.")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp.ID = &room.ID
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
room := srv.FindRoom(req.RoomName)
|
||||||
|
if room == nil || room.Password != req.RoomPass {
|
||||||
|
resp.Error = stringPtr("Room not found or password does not match.")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
} else {
|
||||||
|
resp.ID = &room.ID
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/api/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := &protocol.WSQuery{}
|
||||||
|
if err := queryparam.Parse(r.URL.Query(), query); err != nil {
|
||||||
|
httpErr(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !query.Valid() {
|
||||||
|
httpErr(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
room := srv.FindRoomByID(query.RoomID)
|
||||||
|
if room == nil {
|
||||||
|
httpErr(w, http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c, err := websocket.Accept(w, r, wsOpts)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
room.HandleConn(query.PlayerID, query.Nickname, c)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
r.Get("/api/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rooms, clients := srv.Stats()
|
||||||
|
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
_ = enc.Encode(&protocol.StatsResponse{
|
||||||
|
Rooms: rooms,
|
||||||
|
Clients: clients,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
return srv.Run(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
httpSrv := http.Server{Addr: args.Addr, Handler: r}
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
return httpSrv.Shutdown(ctx)
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
return httpSrv.ListenAndServe()
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Fatal(g.Wait())
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpErr(w http.ResponseWriter, code int) {
|
||||||
|
http.Error(w, http.StatusText(code), code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
queryparam.DefaultParser.ValueParsers[reflect.TypeOf(uuid.UUID{})] = func(value string, _ string) (reflect.Value, error) {
|
||||||
|
id, err := uuid.FromString(value)
|
||||||
|
return reflect.ValueOf(id), err
|
||||||
|
}
|
||||||
|
}
|