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: `
|
||||
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
|
||||
TbOfXkou238BAAD///SFqXuyCQAA
|
||||
`,
|
||||
},
|
||||
|
||||
"/codenames/duet.txt": {
|
||||
name: "duet.txt",
|
||||
local: "codenames/duet.txt",
|
||||
size: 2633,
|
||||
modtime: 0,
|
||||
compressed: `
|
||||
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
|
||||
`,
|
||||
},
|
||||
|
||||
"/codenames/undercover.txt": {
|
||||
name: "undercover.txt",
|
||||
local: "codenames/undercover.txt",
|
||||
size: 2447,
|
||||
modtime: 0,
|
||||
compressed: `
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|