Initial commit

This commit is contained in:
zikaeroh 2020-05-23 14:51:11 -07:00
commit 64bc116ebc
67 changed files with 19415 additions and 0 deletions

32
.dockerignore Normal file
View File

@ -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/

22
.gitignore vendored Normal file
View File

@ -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

2
.prettierignore Normal file
View File

@ -0,0 +1,2 @@
**/build/**
**/node_modules/**

15
.prettierrc Normal file
View File

@ -0,0 +1,15 @@
{
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"printWidth": 120,
"overrides": [
{
"files": ["*.yml", "*.yaml"],
"options": {
"tabWidth": 2,
"singleQuote": false
}
}
]
}

23
Dockerfile Normal file
View File

@ -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

21
LICENSE Normal file
View File

@ -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.

17
README.md Normal file
View File

@ -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')

43
codies.code-workspace Normal file
View File

@ -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"
}
}

BIN
docs/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

2
frontend/.eslintignore Normal file
View File

@ -0,0 +1,2 @@
**/build/**
**/node_modules/**

37
frontend/.eslintrc.json Normal file
View File

@ -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"
}
}
}

23
frontend/.gitignore vendored Normal file
View File

@ -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*

66
frontend/package.json Normal file
View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -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>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -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

View File

@ -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"
}

10
frontend/src/app.test.tsx Normal file
View File

@ -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();
});

32
frontend/src/app.tsx Normal file
View File

@ -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) })}
/>
);
};

View File

@ -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;

View File

@ -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, &quot;animal 3&quot;).
Their teammates then try to guess the words while avoiding the opposing team&apos;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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
}

View File

@ -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);
}

72
frontend/src/index.tsx Normal file
View File

@ -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'));

180
frontend/src/pages/game.tsx Normal file
View File

@ -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}
/>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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,
}),
]);

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -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';

View File

@ -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 },
];

19
frontend/tsconfig.json Normal file
View File

@ -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"]
}

11364
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

18
go.mod Normal file
View File

@ -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
)

112
go.sum Normal file
View File

@ -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=

115
internal/game/board.go Normal file
View File

@ -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
}

14
internal/game/layouts.go Normal file
View File

@ -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}},
}

View File

@ -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]
}))
}
}

20
internal/game/rand.go Normal file
View File

@ -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)
}

363
internal/game/room.go Normal file
View File

@ -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++
}

View File

@ -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"`
}

File diff suppressed because it is too large Load Diff

606
internal/server/server.go Normal file
View File

@ -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, &note); err != nil {
return err
}
r.lastSeen.Store(time.Now())
if err := r.handleNote(playerID, &note); 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, &params); 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, &params); err != nil {
return err
}
resetTimer = true
r.room.NewGame()
case protocol.EndTurnMethod:
var params protocol.EndTurnParams
if err := json.Unmarshal(note.Params, &params); err != nil {
return err
}
resetTimer = true
r.room.EndTurn(playerID)
case protocol.RandomizeTeamsMethod:
var params protocol.RandomizeTeamsParams
if err := json.Unmarshal(note.Params, &params); err != nil {
return err
}
r.room.RandomizeTeams()
case protocol.ChangeTeamMethod:
var params protocol.ChangeTeamParams
if err := json.Unmarshal(note.Params, &params); err != nil {
return err
}
r.room.ChangeTeam(playerID, params.Team)
case protocol.ChangeNicknameMethod:
var params protocol.ChangeNicknameParams
if err := json.Unmarshal(note.Params, &params); 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, &params); err != nil {
return err
}
r.room.ChangeRole(playerID, params.Spymaster)
case protocol.ChangePackMethod:
var params protocol.ChangePackParams
if err := json.Unmarshal(note.Params, &params); err != nil {
return err
}
r.room.ChangePack(params.Num, params.Enable)
case protocol.ChangeTurnModeMethod:
var params protocol.ChangeTurnModeParams
if err := json.Unmarshal(note.Params, &params); err != nil {
return err
}
r.changeTurnMode(params.Timed)
case protocol.ChangeTurnTimeMethod:
var params protocol.ChangeTurnTimeParams
if err := json.Unmarshal(note.Params, &params); err != nil {
return err
}
r.changeTurnTime(params.Seconds)
case protocol.AddPacksMethod:
var params protocol.AddPacksParams
if err := json.Unmarshal(note.Params, &params); 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, &params); 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)
}

View File

@ -0,0 +1 @@
These files are sourced from: https://www.boardgamegeek.com/filepage/136292/codenames-word-list

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,351 @@
// Code generated by "esc -o=esc.go -pkg=static -ignore=^(static|esc)\.go$ -modtime=0 -private ."; DO NOT EDIT.
package static
import (
"bytes"
"compress/gzip"
"encoding/base64"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path"
"sync"
"time"
)
type _escLocalFS struct{}
var _escLocal _escLocalFS
type _escStaticFS struct{}
var _escStatic _escStaticFS
type _escDirectory struct {
fs http.FileSystem
name string
}
type _escFile struct {
compressed string
size int64
modtime int64
local string
isDir bool
once sync.Once
data []byte
name string
}
func (_escLocalFS) Open(name string) (http.File, error) {
f, present := _escData[path.Clean(name)]
if !present {
return nil, os.ErrNotExist
}
return os.Open(f.local)
}
func (_escStaticFS) prepare(name string) (*_escFile, error) {
f, present := _escData[path.Clean(name)]
if !present {
return nil, os.ErrNotExist
}
var err error
f.once.Do(func() {
f.name = path.Base(name)
if f.size == 0 {
return
}
var gr *gzip.Reader
b64 := base64.NewDecoder(base64.StdEncoding, bytes.NewBufferString(f.compressed))
gr, err = gzip.NewReader(b64)
if err != nil {
return
}
f.data, err = ioutil.ReadAll(gr)
})
if err != nil {
return nil, err
}
return f, nil
}
func (fs _escStaticFS) Open(name string) (http.File, error) {
f, err := fs.prepare(name)
if err != nil {
return nil, err
}
return f.File()
}
func (dir _escDirectory) Open(name string) (http.File, error) {
return dir.fs.Open(dir.name + name)
}
func (f *_escFile) File() (http.File, error) {
type httpFile struct {
*bytes.Reader
*_escFile
}
return &httpFile{
Reader: bytes.NewReader(f.data),
_escFile: f,
}, nil
}
func (f *_escFile) Close() error {
return nil
}
func (f *_escFile) Readdir(count int) ([]os.FileInfo, error) {
if !f.isDir {
return nil, fmt.Errorf(" escFile.Readdir: '%s' is not directory", f.name)
}
fis, ok := _escDirs[f.local]
if !ok {
return nil, fmt.Errorf(" escFile.Readdir: '%s' is directory, but we have no info about content of this dir, local=%s", f.name, f.local)
}
limit := count
if count <= 0 || limit > len(fis) {
limit = len(fis)
}
if len(fis) == 0 && count > 0 {
return nil, io.EOF
}
return fis[0:limit], nil
}
func (f *_escFile) Stat() (os.FileInfo, error) {
return f, nil
}
func (f *_escFile) Name() string {
return f.name
}
func (f *_escFile) Size() int64 {
return f.size
}
func (f *_escFile) Mode() os.FileMode {
return 0
}
func (f *_escFile) ModTime() time.Time {
return time.Unix(f.modtime, 0)
}
func (f *_escFile) IsDir() bool {
return f.isDir
}
func (f *_escFile) Sys() interface{} {
return f
}
// _escFS returns a http.Filesystem for the embedded assets. If useLocal is true,
// the filesystem's contents are instead used.
func _escFS(useLocal bool) http.FileSystem {
if useLocal {
return _escLocal
}
return _escStatic
}
// _escDir returns a http.Filesystem for the embedded assets on a given prefix dir.
// If useLocal is true, the filesystem's contents are instead used.
func _escDir(useLocal bool, name string) http.FileSystem {
if useLocal {
return _escDirectory{fs: _escLocal, name: name}
}
return _escDirectory{fs: _escStatic, name: name}
}
// _escFSByte returns the named file from the embedded assets. If useLocal is
// true, the filesystem's contents are instead used.
func _escFSByte(useLocal bool, name string) ([]byte, error) {
if useLocal {
f, err := _escLocal.Open(name)
if err != nil {
return nil, err
}
b, err := ioutil.ReadAll(f)
_ = f.Close()
return b, err
}
f, err := _escStatic.prepare(name)
if err != nil {
return nil, err
}
return f.data, nil
}
// _escFSMustByte is the same as _escFSByte, but panics if name is not present.
func _escFSMustByte(useLocal bool, name string) []byte {
b, err := _escFSByte(useLocal, name)
if err != nil {
panic(err)
}
return b
}
// _escFSString is the string version of _escFSByte.
func _escFSString(useLocal bool, name string) (string, error) {
b, err := _escFSByte(useLocal, name)
return string(b), err
}
// _escFSMustString is the string version of _escFSMustByte.
func _escFSMustString(useLocal bool, name string) string {
return string(_escFSMustByte(useLocal, name))
}
var _escData = map[string]*_escFile{
"/codenames/README.md": {
name: "README.md",
local: "codenames/README.md",
size: 96,
modtime: 0,
compressed: `
H4sIAAAAAAAC/xTL0QnCQAwG4HenyAJtsIJg53CBePf3WmzMkZxkfXGA77kjQNtxIkgcFPb1gkqbm660
j9FjZc7M+WXitYmiAe+5mPJfdWng6+2+PBYuVvERRUxpXqfziHH5BQAA//+Xdl6cYAAAAA==
`,
},
"/codenames/default.txt": {
name: "default.txt",
local: "codenames/default.txt",
size: 2482,
modtime: 0,
compressed: `
H4sIAAAAAAAC/ySW0XbaSg+F7/dbYRNCGyD+MaescynGwlY9HvmXZ0Ldpz9Lzl1YERpJ+9MWR41xfal2
uHOMOKhmXPiFf9VGtLNJ6lFrsYxbeTAalZRxo0fkjDbKjD1lxt4kRpx40oTK87TBmBMOJIabWhjQZg88
Uw4DfpjHRQ0jDkYpMHZlyUZRCCeZtCySGG02pgnvUb8Yl2IL48SzcRioJDSRVtxU84CdTajYptIR9kKT
pg73gSKj1kkCzjRNHve+1dTQsuAsyyKR/cNWvs5oBtYkf3Ame0R/nZ5PHKQv5mE2ouaUqRjagTp94SDL
gFpz1oS3fp0zbgNTZkMb/O0DxYibkTepFhh7Sbp4gsqHdfYWb8VGXv3J4LlSNgoZlUnXM676kISTxzXR
h1dR6nAQ2/4aUalOqCmjHTSjLZJRDxp0i71qiZx94myh2IqzasKFM070Wr1IyhyjZMYu9RzRztKx4Z1t
orR6ySMacbU+NgaMvF5zxdWRSF0JWc1Hk1GVPjLu3+Gcw1BmNMbL4mkT4642YRfnBZWL05BjIr898ZXC
yBm1qUczRez+ZnbR5k0fozCUzDhq5ugy24pWYydsuNKKRpe8KRsY7f8LmUO2LKgo405fjJoM7VT6PrLh
LfXRp1gbLQNuJNH/3+GollDTLJkiDuxI7jmMqMrzSVFxlmC6BJ0ZPzljX8KIq5d/M5KEg3Ds8K6xw03C
iHrwL/+vOG9tNhkZH5R6MlWfyMhog3DKsmQvaaN9oJGXmb2Bu6QOH9LhbXrQsqyOgu+lZsa7aUkd9kbP
jCNNuJPhrGXhDVA21MN3CZIIlcaMdtaMRigpmjJLRBM14ySa0GiUwDgydThJ3r6uKbBlnEuPXxRKmbDL
kbxY74VeaF+b0O24LsFoZsOJlo378vAN/GLD7mkSyMFdsgTsX2RPnGjGSZ4uZuLVh77wMijjnyTBFWjn
1eHIC+6+Pc2WvN0a/hEYN+px5wcOlPBplHrGjSfnpKZEHaENarP39U/q2DKNbDi7yG/FXLtWS8Ru3sjS
6Alnn517yPQocbOi/catP1dv/nOlx0Mc8TBi13PKaN24LiX7Bma8D07gp0S0ZWYb2BS1dowPeQmOunxD
1VIu5qTECVXZXKNN+poo4chRgs4+/JOGARcv4KQ9GpMU/MNNJjczHXHlL40le4+tT+7sbbSze+67Ofd3
WgbfY5oYFVPG0SO2UaMRc284SRqx35o9xNW3HC3b16bgoBq9ihGVlWVA45/bTIafNOHT+m11LTr0FBin
bSwT/VXfH19FWlDH8kDrr9zpm0i/A+3gLvqzzPKN2ezi/nR/dHtb5+J8ObqfUb4cdM9fMRneo3d21Rca
WfJWYOo04ep1/qKEX5w90k10IOtdXcvYy8LkbVOccPb19QRpG5Gk3iVoVqNJOnzObOQNiC+J+H743qYe
F+kHN1ZeMv51q7j5O2xoyjSPknAbhJ+ovLeq9O7rGfUanC6O/O0ZNcUndmKo9I8D5zd0KNMDu5TJQvZV
uVlJ48YEGk59Ee9Ps2d2ahhHeo1ua5lt9cs2b3vRxtLjM2T1+d0HmbGbeNu9H19q7o9bVHmgjvL0HXzg
jdyu3zmJz9y/s99cwH8GHNkN/aJfHNHIzDjKRJFWl9X05UVt65g6IVyYO+dv0NmlDgNO7iQ34e/fCKgd
zFqdrfovhwGV613p9MC1uCEf1Hyylea8gUzfdYzY63ZeKveBSl/Yc2/sd23azmFivAuljIuv996o943I
1EmZcIh+MWoyUz8CI/sdH5y6s3YccdNxVbytfh3/SFAc/Sa0L9f7g1fsonDCTV8us8ribJt83yn3+I/k
JlYPxc92pU5FHZVGXCT9JnzGdXI1Ko5us87Pnin7byAH9iwxiibyQ76n1bN5SCNOe4dazOG5qjv6sJlc
TbOfXkou238BAAD///SFqXuyCQAA
`,
},
"/codenames/duet.txt": {
name: "duet.txt",
local: "codenames/duet.txt",
size: 2633,
modtime: 0,
compressed: `
H4sIAAAAAAAC/ySW0XqrOg6F79fDzDsE0ianbXo4IdN8M3eKUcCDsdiyXTb76eeTe5MLPiJkrbV++ahl
QaN+YNxplIh/R//N6vOOs884pAkNpYRDyiqRSsZRQsAn6zejFXITGqYF/SoS0UqJWXd8SmK8+zjioHkq
ij7TsqK1n0bJR3wwPXEruViRlPFRoptwm0ocWNFJpiw4cprxd/QS8RJ4nShmHKKbRNHK9pAdr0FkwEUm
2mb0FDOh89lNrGhIH+wK26eyPehnypxwKWnmzPYg0oqehiEwThw940I6o5+kBOviJN+sURQXit4VZRxi
nnjBmUKQjTnik7csEY09kIhXX4u1Ss+MUyDnWdHSzLhSxo3ijCb4OKBfvfqMlh6B0W82lxcfU2YfcTYx
Wt1TpoATk+Ldp4SON3SyWWO3ojkwGnIS0U+sQdyM/lchk0tkxpX+iOKonJKp0Kh3s03+jw+4Wck+1083
fvbRo+OIq+naTupTXijhWh6POsUdF6nze7carzLiSoMXO+QDrURGP5cQcJ+Yg5vIK17GEU3JmRV3Vt4k
PNFOrLqjo+w5Zhx1Z2uQttqfpIRX/q4fjBQJnQSfJrxHyejIx4w+iy44SRi8HTeh86YC2sn8dJHECT1t
aPSn3Sv5gKusjD4rs5UZ0NKa7fW7FT/8ZhNbc0InqxONaCnZZHt2Egeb1IK/KfmEC6eEi+lw+KZA0U2M
G/+mhL6Ym+thryVl3O1AfWYOeLFBO7XBNiXOaCkyvjiWhA8aqtGLJmvC+vukVQKbomUkxdEruyyKg2Or
QtmyVtzM5htO9ZU0W/x0QFPCAw1biHpHyk5lw19jEMGt/OZBrJk84WpZ9cm6bZRpwJWd6ICjWeekzHGS
khhHrcpmm0Djkys+4+pXfEpWxpEWxodf0U+0rCKmLttByM24GAQ8oy+LoKeJlHCnoCXhaAM607JYLIIU
C4LSjj7/q6NcTfruQ8ZF4sw7XlVGHDlmn7LpGR+y4T75qtGVfRy4+tICbyIsEmlg9MGvK2uFg+KLglWI
jI7r46vQYCMf0MgWWHHxYf6xLy400h97t1Gj3o0JZw4LZ7wGGnGrWr6Zs/rs3VwVXFdTZKIwo5GYyKPf
mDJOFMefQdcoPMNePbyjp0ADDrqIol9kZrT0zfinUA0p02xomtkaCzjtSzRC3sXQGCgOOKoxGv1aNOG/
sjw8o/Mj2sCyUlbCrUK14xh3gxa+JBSTjJxhI+fAafKr8Y4cmbutzz6YdY8cMpnyK96EIuSJgzq0YlNo
KE+WTcGpLPiiZfVqAPDopDrzpzhehRZcZWAxLHlVDnbsjEtZlh1NIDenxecJrfVxEsr4oBIH3dEw40Y5
i1igkifcLOC04j9siU+5WtNEO9ue6YPkCe8ljngtZitXQxHx4ccpxx++bAGn4jMZFywkHZMGEzXjy5s1
/3KMw2g77fnkugkoTxJxyNk73HkYrFBrc3yUCoxK3s32z60693MPEvEhzkzekfNP79AWN1sGRSMNggsv
5oA3G9aOi4+SqSgaPxr4RnQ0Mvp18vE3Wi2JDBJfEhxFwdU/DdniA86G2ca73QXGW1nM1/0ikicLXmBe
TfxcN/pJRRZcq2VeSRe8lWFkfBn6R7xQ9eilDOhIVbIp/TCxEuFl4GhGNW7XzdZT3XMXiYQPn8gsyrbT
/vwxCv1TSK3aG29syTrT8ig62i4NlH4I7QMa+W059M4sriqKv2wzWphvfvW40MxlxcUbxSwaZ1ZXgv3d
jxGdfQ13kYCzLDXAsWTbo0ayXsqKQ6A0Exp67HaZqO6eZLMwBkPEp9CEXmjFrcTIwfzgpnq3odoEPuSb
8cYh7E/bFX3mPElytk9uNjK8FuOFpF/FZ8Hd1wFUITniRGqju5PPtoPt45LNMCHgoywP1v9VVPLgna1p
o2wonH4o/atY+jsmk+SHaDygX+zfrSxrsfO0stkrsdSVM8hmN5tsTfTGiFNdunULOK7UOKwqEV9eAtst
TuycJs2bGMfOFfvtzwDOtJH3OG6kT3TGwwuN3nmKxuSU0RZb5k152CXibhn4fwAAAP//c4lDFUkKAAA=
`,
},
"/codenames/undercover.txt": {
name: "undercover.txt",
local: "codenames/undercover.txt",
size: 2447,
modtime: 0,
compressed: `
H4sIAAAAAAAC/yyW33qqwA7F79e7nIcoWHW3ajnCrtcBIuRjmLDnT5U+/fmC56b2E1mZJL+szFFDZNSU
PeGoOnFAnVQdzprTiEZzN/b68Kg9TYzbKHHiFZV0k2McNTJOPLDvUbgcR+ykm1BqN+HNdTqqM+1IA6Pi
7qUeSIYxodbAaHRF4aibTDnZj4Joj9LC4iNLxygCYUczoxxNu5DUjag7e7semZdNccGZUgocI5pA4lGo
7y3qTdhzQMVeIvY5hBUfusZkUm/L4jiiVN/rjEIiPzM5HNUxau4CJworruqcxZAF+8A0WYB5wV4Dx0k8
buLt3Iz358JBZvYJHzr6qB4FefKE0tGMwukDBTmn6lHzzB5XHgKnl/jCAUedOWSPJsiMggJuqj0q6nvH
KPUxSHCbNkry23cyUOCU/v8fPr22qPmJA/kBdco96i7wA03QHDlE1HRnHEynom6yCh2CLowPfrCLKJi6
EeWY23ZFwXxHwb63to10T6jsMUfUD8vjgb3TBwc0IfsJtbXxQ1t8ebHc9eHwwWFCaf0pjI5CnzjnOAbV
2SStq5oXq/eDaauEtowmtxyT1fqaBxQ5JVxWpx4na9tREzsURo5qa33rsqSIPaW0mqqR4XJC/ZB5tqQr
zb5HY4xf1RIsgj68MC45RWuM743l4FeUlLqRAwrtV+x0Fk+Jcd6QGOmH8RUGijMaalVxVb2jeciAK/c4
5dYke8Y7JZumZMFPPFhdR/E9vpz88Cs66nGr3dcak5WQOkVNjnr87Xt772rR/vhuxEUMVBxoxVEGq6L+
srXqx8jOMa740zHqifyEMzv1EQ2Jw9U68jeQzxH/zcwBJ/EDB8uc2QB6j52GhCOH1gSD/fERJx1wpHk7
WT1JSDjIoE7R0IJKGN8UuUs6r5uuN4jyggt3k6POmjrP5HvFkal3Nu4Rb9HIKahTj4OacVRiEEXUMzuH
EyV+orGkvH01zIRvHuh19Lu1HRf6YYcDDZtwqxR6FNzjLUaU1pHIKCksNlSzTowy3+8RDVNLg1U84ZvF
o8rhVdE44mKVOpLvN02UIy0Rb15mcihVf4WxlziaAZrzXFUnYZM03/RWvW9pAyW1XmwWtxc342zk2hQe
ySDEIZDvZ7LPGFF7ud9RBeO5MccoaOIe39KzosotDv+pF004648wPuj3F/sgXbLBel85YvcaOb2nlpyL
+BSDv+F/WRyh0JR0xrf41aEKGpNRXI4kPuKsSUOrlGww22iuZpQdc3AoR2aTdYqT/Fp5mzEwR50NW6Zo
2YbBShjEHG6n3vbB+fWxM99mU7T1EDcXxicPqBdjs9Cnkd2MwaJXYmG/tZ8IlSacZLHDzdhvkGLv8v2O
wom38epSnlEvzD3OEqOoN3NuXnuoFo9vnSWh0uBR5s0xzyox4Uy+VbU8HW0LyvW6bahkja8XDjPe55ck
kjB2QiGMTLhRb3rTVk7TkycHK1o3GRIJpcstytzlubVBWV7Ozj0aWfDHJyP5ZgxU5h/lSPNCgzchN+G0
0bqXYE+M1YuqnbpegnG402Gw/JLZcMCNoq3HbUEfOLTicOYtbEEhvTx6z0k2k7UQm0nW/7IN70k3cBr1
Q2bsHccRt43mytG6aRhDZ96YYJqx544crrm1CJ/ipxV1ImfRixwTGrsi1JP0uNETlbxM89MzR5wpJPGC
k7abgeyZ0stSNdn2FW9GzzSvONtJGl2wp07I4WbFq2SxkrDt6pMF+/Rqc2UO7xPFFcc8L6hUbffbHF2y
XWIaDSkHtvwH3DamK/HTtgtb68JJf8yaVk2MXRDn8NZJb57IJuQ4JUWTDfrt/rGjvl9xY0N1XnDcXEr+
ZbUiBI9doMH2l23eIgjfo92dPEdctmn+CsNqicRktxszzgEfeYg4mSMaSuS9mfXO9kHpaMH/AgAA//8f
O6UajwkAAA==
`,
},
"/": {
name: "/",
local: `.`,
isDir: true,
},
"/codenames": {
name: "codenames",
local: `codenames`,
isDir: true,
},
}
var _escDirs = map[string][]os.FileInfo{
".": {
_escData["/codenames"],
},
"codenames": {
_escData["/codenames/README.md"],
_escData["/codenames/default.txt"],
_escData["/codenames/duet.txt"],
_escData["/codenames/undercover.txt"],
},
}

View File

@ -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"))
)

73
internal/words/words.go Normal file
View File

@ -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,
}
}

218
main.go Normal file
View File

@ -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
}
}

8
tools.go Normal file
View File

@ -0,0 +1,8 @@
// +build tools
package tools
import (
_ "github.com/mailru/easyjson/easyjson"
_ "github.com/mjibson/esc"
)