├── server ├── .prod.env ├── .projectile ├── README.md ├── test │ ├── Spec.hs │ ├── Poker │ │ ├── HandSpec.hs │ │ └── UtilsSpec.hs │ └── PokerSpec.hs ├── Setup.hs ├── .dockerignore ├── ChangeLog.md ├── .dev.env ├── server.service ├── Dockerfile ├── .gitignore ├── docs │ ├── socket.md │ ├── lobbyAPI.md │ └── userAPI.md ├── bootstrap.sh ├── deploy-server.sh ├── provision.sh ├── ping.sh ├── src │ ├── Socket │ │ ├── Setup.hs │ │ ├── Utils.hs │ │ ├── Auth.hs │ │ ├── Lobby.hs │ │ ├── Subscriptions.hs │ │ ├── Workers.hs │ │ └── Types.hs │ ├── Types.hs │ ├── Schema.hs │ ├── Env.hs │ ├── Poker │ │ └── Game │ │ │ ├── Hands.hs │ │ │ ├── Privacy.hs │ │ │ └── Blinds.hs │ └── Users.hs ├── UNLICENSE.txt ├── deploy.sh ├── app │ └── Main.hs ├── package.yaml ├── stack.yaml.lock ├── stack.yaml └── shell.nix ├── client ├── .dockerignore ├── config │ ├── jest-mocks │ │ ├── image.js │ │ └── cssModule.js │ ├── test-setup.js │ ├── jest.config.js │ ├── webpack.prod.babel.js │ ├── webpack.dev.babel.js │ └── webpack.base.babel.js ├── .prettierrc ├── jest.config.js ├── app │ ├── styles │ │ ├── components │ │ │ ├── _footer.scss │ │ │ ├── _lobby.scss │ │ │ ├── _forms.scss │ │ │ ├── game │ │ │ │ ├── _boardCards.scss │ │ │ │ ├── _actionPanel.scss │ │ │ │ ├── _slider.scss │ │ │ │ ├── _cards.scss │ │ │ │ ├── _seat.scss │ │ │ │ └── _table.scss │ │ │ ├── _buttons.scss │ │ │ ├── _game.scss │ │ │ └── _navbar.scss │ │ ├── _common.scss │ │ ├── main.scss │ │ ├── common │ │ │ ├── _colours.scss │ │ │ ├── _mixins.scss │ │ │ └── _variables.scss │ │ └── layout │ │ │ └── _app.scss │ ├── containers │ │ ├── HomeContainer.js │ │ ├── NavBarContainer.js │ │ ├── ProfileContainer.js │ │ ├── LobbyContainer.js │ │ ├── SignInFormContainer.js │ │ ├── SignUpFormContainer.js │ │ ├── AppContainer.js │ │ └── GameContainer.js │ ├── components │ │ ├── Home.js │ │ ├── Signout.js │ │ ├── Profile.js │ │ ├── Footer.js │ │ ├── NotFoundPage.js │ │ ├── Board.js │ │ ├── Lobby.js │ │ ├── Card.js │ │ ├── SignInForm.js │ │ ├── App.js │ │ ├── Seat.js │ │ ├── NavBar.js │ │ ├── SignUpForm.js │ │ └── ActionPanel.js │ ├── selectors │ │ ├── lobby.js │ │ ├── profile.js │ │ ├── socket.js │ │ ├── route.js │ │ ├── tests │ │ │ └── games.test.js │ │ ├── auth.js │ │ └── games.js │ ├── reducers │ │ ├── lobby.js │ │ ├── games.js │ │ ├── rootReducer.js │ │ ├── profile.js │ │ ├── auth.js │ │ ├── socket.js │ │ └── tests │ │ │ └── auth.test.js │ ├── actions │ │ ├── socket.js │ │ ├── lobby.js │ │ ├── types.js │ │ ├── profile.js │ │ ├── games.js │ │ ├── auth.js │ │ └── tests │ │ │ └── auth.test.js │ ├── utils │ │ └── request.js │ ├── index.html │ ├── reducers.js │ ├── app.js │ ├── configureStore.js │ └── middleware │ │ └── socket.js ├── server │ ├── util │ │ ├── argv.js │ │ ├── port.js │ │ └── logger.js │ ├── middlewares │ │ ├── frontendMiddleware.js │ │ ├── addProdMiddlewares.js │ │ └── addDevMiddlewares.js │ └── index.js ├── static │ ├── fonts │ │ ├── MonoP-Bold.woff │ │ ├── MonoP-Medium.woff │ │ ├── aktivgrotesk.ttf │ │ ├── sentinel-book.ttf │ │ ├── sentinel-light.ttf │ │ ├── aktivgrotesk-black.ttf │ │ ├── aktivgrotesk-bold.ttf │ │ ├── GothamPro │ │ │ ├── GothamPro-Black.ttf │ │ │ ├── GothamPro-Bold.ttf │ │ │ ├── GothamPro-Book.ttf │ │ │ ├── GothamPro-Light.ttf │ │ │ ├── GothamNarrow-Bold.ttf │ │ │ ├── GothamNarrow-Book.ttf │ │ │ ├── GothamPro-Black.woff2 │ │ │ ├── GothamPro-Bold.woff2 │ │ │ ├── GothamPro-Book.woff2 │ │ │ ├── GothamPro-Light.woff2 │ │ │ ├── GothamPro-Medium.ttf │ │ │ ├── GothamPro-Medium.woff2 │ │ │ └── GothamNarrow-Medium.ttf │ │ ├── cabin │ │ │ ├── cabin-v14-latin-500.ttf │ │ │ ├── cabin-v14-latin-500.woff │ │ │ └── cabin-v14-latin-500.woff2 │ │ └── raleway │ │ │ ├── raleway-v14-latin-200.woff │ │ │ ├── raleway-v14-latin-300.woff │ │ │ ├── raleway-v14-latin-500.woff │ │ │ ├── raleway-v14-latin-700.woff │ │ │ ├── raleway-v14-latin-800.woff │ │ │ ├── raleway-v14-latin-200.woff2 │ │ │ ├── raleway-v14-latin-300.woff2 │ │ │ ├── raleway-v14-latin-500.woff2 │ │ │ ├── raleway-v14-latin-700.woff2 │ │ │ ├── raleway-v14-latin-800.woff2 │ │ │ ├── raleway-v14-latin-regular.woff │ │ │ └── raleway-v14-latin-regular.woff2 │ ├── Diamonds.svg │ ├── Hearts.svg │ ├── Clubs.svg │ └── Spades.svg ├── .gitignore ├── .editorconfig ├── .eslintignore ├── .travis.yml ├── Dockerfile ├── shell.nix ├── netlify.toml ├── .babelrc ├── images │ ├── diamond.svg │ ├── heart.svg │ ├── club.svg │ └── spade.svg ├── LICENSE.md ├── .eslintrc ├── .gitattributes ├── package.json └── README.md └── docker-compose.yml /server/.prod.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.projectile: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Server 2 | -------------------------------------------------------------------------------- /client/config/jest-mocks/image.js: -------------------------------------------------------------------------------- 1 | module.exports = 'IMAGE_MOCK' 2 | -------------------------------------------------------------------------------- /server/test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -F -pgmF hspec-discover #-} 2 | -------------------------------------------------------------------------------- /client/config/jest-mocks/cssModule.js: -------------------------------------------------------------------------------- 1 | module.exports = 'CSS_MODULE' 2 | -------------------------------------------------------------------------------- /server/Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false 4 | } 5 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./config/jest.config') 2 | -------------------------------------------------------------------------------- /client/app/styles/components/_footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | background: green; 3 | } -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | .dockerignore 2 | .gitignore 3 | .stack-work 4 | Dockerfile -------------------------------------------------------------------------------- /server/ChangeLog.md: -------------------------------------------------------------------------------- 1 | # Changelog for poker-server 2 | 3 | ## Unreleased changes 4 | -------------------------------------------------------------------------------- /client/server/util/argv.js: -------------------------------------------------------------------------------- 1 | module.exports = require('minimist')(process.argv.slice(2)) 2 | -------------------------------------------------------------------------------- /client/app/containers/HomeContainer.js: -------------------------------------------------------------------------------- 1 | import Home from '../components/Home' 2 | 3 | export default Home -------------------------------------------------------------------------------- /client/static/fonts/MonoP-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/MonoP-Bold.woff -------------------------------------------------------------------------------- /client/static/fonts/MonoP-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/MonoP-Medium.woff -------------------------------------------------------------------------------- /client/static/fonts/aktivgrotesk.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/aktivgrotesk.ttf -------------------------------------------------------------------------------- /client/static/fonts/sentinel-book.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/sentinel-book.ttf -------------------------------------------------------------------------------- /client/server/util/port.js: -------------------------------------------------------------------------------- 1 | const argv = require('./argv') 2 | 3 | module.exports = parseInt(argv.port || process.env.PORT || '3000', 10) 4 | -------------------------------------------------------------------------------- /client/static/fonts/sentinel-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/sentinel-light.ttf -------------------------------------------------------------------------------- /client/static/fonts/aktivgrotesk-black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/aktivgrotesk-black.ttf -------------------------------------------------------------------------------- /client/static/fonts/aktivgrotesk-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/aktivgrotesk-bold.ttf -------------------------------------------------------------------------------- /server/.dev.env: -------------------------------------------------------------------------------- 1 | dbConnStr='port=5432 user=postgres dbname=postgres password=postgres' 2 | port=8000 3 | secret="wwaaifidsa9109f0dasfda-=2-13" 4 | 5 | -------------------------------------------------------------------------------- /client/app/components/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Home = () => ( 4 |
Home 5 |
6 | ); 7 | 8 | export default Home; 9 | -------------------------------------------------------------------------------- /client/app/components/Signout.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Signout = () => ( 4 |
Signout
5 | ); 6 | 7 | export default Signout; 8 | -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Black.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Bold.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Book.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Book.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Light.ttf -------------------------------------------------------------------------------- /client/static/fonts/cabin/cabin-v14-latin-500.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/cabin/cabin-v14-latin-500.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamNarrow-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamNarrow-Bold.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamNarrow-Book.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamNarrow-Book.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Black.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Black.woff2 -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Bold.woff2 -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Book.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Book.woff2 -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Light.woff2 -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Medium.ttf -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamPro-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamPro-Medium.woff2 -------------------------------------------------------------------------------- /client/static/fonts/cabin/cabin-v14-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/cabin/cabin-v14-latin-500.woff -------------------------------------------------------------------------------- /client/static/fonts/cabin/cabin-v14-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/cabin/cabin-v14-latin-500.woff2 -------------------------------------------------------------------------------- /client/app/components/Profile.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Profile = username =>
profile
4 | 5 | export default Profile 6 | -------------------------------------------------------------------------------- /client/static/fonts/GothamPro/GothamNarrow-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/GothamPro/GothamNarrow-Medium.ttf -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-200.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-200.woff -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-300.woff -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-500.woff -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-700.woff -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-800.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-800.woff -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-200.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-200.woff2 -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-300.woff2 -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-500.woff2 -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-700.woff2 -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-800.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-800.woff2 -------------------------------------------------------------------------------- /client/app/components/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | const Footer = () => ( 5 |
Footer
6 | ); 7 | 8 | export default Footer; 9 | -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-regular.woff -------------------------------------------------------------------------------- /client/static/fonts/raleway/raleway-v14-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therewillbecode/poker-maison/HEAD/client/static/fonts/raleway/raleway-v14-latin-regular.woff2 -------------------------------------------------------------------------------- /client/app/components/NotFoundPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const NotFoundPage = () => ( 4 |
NotFoundPage 5 |
6 | ); 7 | 8 | export default NotFoundPage; 9 | -------------------------------------------------------------------------------- /client/app/styles/_common.scss: -------------------------------------------------------------------------------- 1 | /* Re-export all common scss files */ 2 | @import 'common/mixins'; 3 | @import 'common/variables'; 4 | @import 'common/typography'; 5 | @import 'common/colours'; -------------------------------------------------------------------------------- /client/app/selectors/lobby.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { createSelector } from 'reselect' 3 | 4 | export const getLobbyState = state => state.get('global').get('lobby') 5 | -------------------------------------------------------------------------------- /client/app/styles/components/_lobby.scss: -------------------------------------------------------------------------------- 1 | .game-table-list { 2 | margin: auto; 3 | text-align: center; 4 | margin-top: 10%; 5 | width: 60%; 6 | } 7 | 8 | .game-table-list tr { 9 | padding-top: 1em; 10 | } 11 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Don't check auto-generated stuff into git 2 | coverage 3 | build 4 | node_modules 5 | stats.json 6 | 7 | # Cruft 8 | .DS_Store 9 | npm-debug.log 10 | .idea 11 | 12 | # Logs 13 | yarn-error.log 14 | 15 | -------------------------------------------------------------------------------- /client/app/selectors/profile.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import { createSelector } from 'reselect' 3 | 4 | export const profile = state => ({}) 5 | 6 | export const getProfileSelector = createSelector(profile, profile => ({})) 7 | -------------------------------------------------------------------------------- /client/app/styles/components/_forms.scss: -------------------------------------------------------------------------------- 1 | .form-container { 2 | width: 35vw; 3 | min-width: 200px; 4 | margin: auto; 5 | text-align: center; 6 | } 7 | .form { 8 | text-align: centre; 9 | } 10 | 11 | .form-container { 12 | margin: auto; 13 | } 14 | -------------------------------------------------------------------------------- /server/server.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=server 3 | 4 | [Service] 5 | Type=simple 6 | ExecStart=/opt/server/poker-server-exe 7 | Restart=always 8 | User=ubuntu 9 | EnvironmentFile=/etc/systemd/system/prod.env 10 | 11 | [Install] 12 | WantedBy=default.target -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | /build/** 2 | /coverage/** 3 | /docs/** 4 | /jsdoc/** 5 | /templates/** 6 | /tests/bench/** 7 | /tests/fixtures/** 8 | /tests/performance/** 9 | /tmp/** 10 | /lib/util/unicode/is-combining-character.js 11 | /sass/ 12 | /node/modules 13 | test.js 14 | !.eslintrc.js 15 | .gitignore -------------------------------------------------------------------------------- /client/.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | os: osx 4 | 5 | node_js: 6 | - 8 7 | - 6 8 | 9 | script: 10 | - npm run test 11 | - npm run build 12 | 13 | notifications: 14 | email: 15 | on_failure: change 16 | 17 | cache: 18 | yarn: true 19 | directories: 20 | - node_modules 21 | -------------------------------------------------------------------------------- /client/app/styles/components/game/_boardCards.scss: -------------------------------------------------------------------------------- 1 | .board-cards { 2 | justify-self: center; 3 | align-self: center; 4 | grid-area: 3/1/5/6; 5 | } 6 | 7 | .board-cards-container { 8 | display: flex; 9 | height: 100%; 10 | width: 100%; 11 | justify-self: center; 12 | align-self: center; 13 | } 14 | -------------------------------------------------------------------------------- /client/app/selectors/socket.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import { createSelector } from 'reselect' 3 | 4 | export const socket = state => state.get('global').get('socket') 5 | 6 | export const isSocketAuthenticated = createSelector( 7 | socket, 8 | state => state.get('isSocketAuth') 9 | ) 10 | -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM fpco/stack-build-small 2 | 3 | RUN mkdir -p /app 4 | 5 | 6 | COPY . /app 7 | 8 | WORKDIR /app 9 | 10 | RUN apt-get update && \ 11 | apt-get install libpq-dev lzma-dev libpq-dev -yy 12 | 13 | RUN stack build --only-dependencies 14 | 15 | RUN stack build 16 | 17 | CMD stack run 18 | -------------------------------------------------------------------------------- /client/config/test-setup.js: -------------------------------------------------------------------------------- 1 | // needed for regenerator-runtime 2 | // (ES7 generator support is required by redux-saga) 3 | import 'babel-polyfill' 4 | 5 | // Enzyme adapter for React 16 6 | import Enzyme from 'enzyme' 7 | import Adapter from 'enzyme-adapter-react-16' 8 | 9 | Enzyme.configure({ adapter: new Adapter() }) 10 | -------------------------------------------------------------------------------- /client/app/selectors/route.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import { createSelector } from 'reselect' 3 | 4 | export const getLocation = state => state.get('route').get('location') 5 | 6 | export const getPathname = createSelector( 7 | getLocation, 8 | location => (location ? location.get('pathname') : null) 9 | ) 10 | -------------------------------------------------------------------------------- /client/app/styles/components/game/_actionPanel.scss: -------------------------------------------------------------------------------- 1 | .action-panel { 2 | grid-column-start: 2; 3 | grid-row-start: 2; 4 | width: 70%; 5 | margin: auto; 6 | grid-row-end: 4; 7 | display: flex; 8 | 9 | @include md { 10 | border-radius: 3em 3em 0 0; 11 | } 12 | } 13 | 14 | .user-actions-container { 15 | margin: auto; 16 | } 17 | -------------------------------------------------------------------------------- /client/app/selectors/tests/games.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import Immutable from 'immutable' 3 | 4 | describe('Games Selectors', () => { 5 | describe('getGames', () => { 6 | it('should return correct action an authSuccess action for received asset', () => { 7 | expect(authRequested()).toEqual({ type: types.AUTH_REQUESTED }) 8 | }) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /client/app/selectors/auth.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | import { createSelector } from 'reselect' 3 | 4 | export const auth = state => state.get('global').get('auth') 5 | 6 | export const isAuthenticated = createSelector(auth, state => 7 | state.get('authenticated') 8 | ) 9 | 10 | export const getUsername = createSelector(auth, state => state.get('username')) 11 | -------------------------------------------------------------------------------- /client/app/reducers/lobby.js: -------------------------------------------------------------------------------- 1 | import Immutable, { fromJS } from 'immutable'; 2 | 3 | import * as types from '../actions/types'; 4 | 5 | const initialState = Immutable.fromJS([]); 6 | 7 | export default function (state = initialState, action) { 8 | switch (action.type) { 9 | case types.NEW_LOBBY: 10 | return fromJS(action.lobby) 11 | default: 12 | return state 13 | } 14 | } -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | poker-server.cabal 2 | *~ 3 | .env 4 | build 5 | dist 6 | dist-* 7 | cabal-dev 8 | *.o 9 | *.hi 10 | *.chi 11 | *.chs.h 12 | *.dyn_o 13 | *.dyn_hi 14 | .hpc 15 | .hsenv 16 | .cabal-sandbox/ 17 | cabal.sandbox.config 18 | *.prof 19 | *.aux 20 | *.hp 21 | *.eventlog 22 | .stack-work/ 23 | cabal.project.local 24 | cabal.project.local~ 25 | .HTF/ 26 | .ghc.environment.* 27 | 28 | .prod.env -------------------------------------------------------------------------------- /client/app/reducers/games.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as types from '../actions/types'; 4 | 5 | const initialState = Immutable.Map({}); 6 | 7 | export default function (state = initialState, action) { 8 | switch (action.type) { 9 | case types.NEW_GAME_STATE: 10 | return state.set(action.tableName, action.gameState); 11 | default: 12 | return state 13 | } 14 | } -------------------------------------------------------------------------------- /client/app/reducers/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux-immutable' 2 | 3 | import auth from './auth' 4 | import socket from './socket' 5 | import lobby from './lobby' 6 | import games from './games' 7 | import profile from './profile' 8 | 9 | const rootReducer = combineReducers({ 10 | auth, 11 | socket, 12 | lobby, 13 | profile, 14 | games 15 | }) 16 | 17 | export default rootReducer 18 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # base image 2 | FROM node:10.16.3-alpine 3 | 4 | RUN apk add --no-cache \ 5 | autoconf \ 6 | automake \ 7 | bash \ 8 | g++ \ 9 | libc6-compat \ 10 | libjpeg-turbo-dev \ 11 | libpng-dev \ 12 | make \ 13 | nasm 14 | 15 | WORKDIR /app 16 | 17 | RUN npm install yarn@1.17.3 -g 18 | 19 | # install and cache app dependencies 20 | COPY package.json . 21 | 22 | RUN yarn 23 | 24 | COPY . . 25 | 26 | CMD yarn run start:docker 27 | -------------------------------------------------------------------------------- /client/app/styles/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | @import "../common"; 2 | 3 | .button { 4 | border-radius: 1.5em; 5 | width: 8em; 6 | height: 4rem; 7 | margin: 0.5em; 8 | padding: 0.8em; 9 | border: 0; 10 | background-color: $primary-colour-700; 11 | color: $neutral-colour-100; 12 | border: none; 13 | } 14 | 15 | .button:hover, 16 | .button:focus { 17 | background-color: $primary-colour-600; 18 | transition: background-color ease-in-out 0.25s; 19 | outline: 0; 20 | } 21 | -------------------------------------------------------------------------------- /client/shell.nix: -------------------------------------------------------------------------------- 1 | # so we can access the `pkgs` and `stdenv` variables 2 | with import {}; 3 | 4 | # Make a new "derivation" that represents our shell 5 | stdenv.mkDerivation { 6 | name = "my-environment"; 7 | 8 | # The packages in the `buildInputs` list will be added to the PATH in our shell 9 | buildInputs = [ 10 | pkgs.nodejs-10_x 11 | pkgs.yarn 12 | pkgs.ocaml 13 | pkgs.pngquant 14 | pkgs.libpng12 15 | pkgs.python 16 | pkgs.autoreconfHook 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /client/app/styles/main.scss: -------------------------------------------------------------------------------- 1 | /* Main App Grid */ 2 | @import "layout/app"; 3 | 4 | /* Improt common modules */ 5 | @import "common"; 6 | 7 | /* Components */ 8 | @import "components/game"; 9 | @import "components/footer"; 10 | @import "components/navbar"; 11 | @import "components/buttons"; 12 | @import "components/lobby"; 13 | 14 | @import "components/forms"; 15 | 16 | // Main Layout Colours 17 | html { 18 | color: $neutral-colour-100; 19 | background: $gradient; // #10212d; 20 | height: 100%; 21 | } 22 | -------------------------------------------------------------------------------- /server/docs/socket.md: -------------------------------------------------------------------------------- 1 | {"tag":"subscribeToTable","contents":"Black"} 2 | 3 | {"tag":"TakeSeat","contents":["Black",3000]} 4 | 5 | {"tag":"GameMove","contents":["Black",{"tag":"PostBlind","contents":"SmallBlind"}]} 6 | {"tag":"GameMove","contents":["Black",{"tag":"PostBlind","contents":"BigBlind"}]} 7 | {"tag":"GameMove","contents":["Black",{"tag":"Call"}]} 8 | {"tag":"GameMove","contents":["Black",{"tag":"Check"}]} 9 | {"tag":"GameMove","contents":["Black",{"tag":"Bet","contents":100}]} 10 | {"tag":"LeaveSeat","contents":"Black"} -------------------------------------------------------------------------------- /client/app/components/Board.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Card from './Card'; 4 | 5 | const Board = ({ cards }) => ( 6 |
7 |
8 | {cards.map(card => { 9 | const rank = card.get('rank') 10 | const suit = card.get('suit') 11 | 12 | return () 17 | })} 18 |
19 |
); 20 | 21 | export default Board; -------------------------------------------------------------------------------- /client/netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "build" 3 | command = "yarn run build:prod" 4 | 5 | [build.environment] 6 | NODE_VERSION = "10.16.3" 7 | YARN_VERION = "1.17.3" 8 | 9 | # The following redirect is intended for use with most SPAs that handle 10 | # routing internally. 11 | [[redirects]] 12 | from = "/*" 13 | to = "/index.html" 14 | status = 200 15 | 16 | [[headers]] 17 | # Define which paths this specific [[headers]] block will cover. 18 | for = "/*" 19 | [headers.values] 20 | Access-Control-Allow-Origin = "*" -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "env", 5 | { 6 | "modules": false 7 | } 8 | ], 9 | "react", 10 | "stage-0" 11 | ], 12 | "env": { 13 | "production": { 14 | "only": ["app"], 15 | "plugins": [ 16 | "transform-react-remove-prop-types", 17 | "transform-react-constant-elements", 18 | "transform-react-inline-elements" 19 | ] 20 | }, 21 | "test": { 22 | "plugins": ["transform-es2015-modules-commonjs", "dynamic-import-node"] 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/app/styles/components/_game.scss: -------------------------------------------------------------------------------- 1 | @import "game/table"; 2 | @import "game/actionPanel"; 3 | @import "game/boardCards"; 4 | @import "game/cards"; 5 | @import "game/seat"; 6 | @import "game/slider"; 7 | 8 | .game-container { 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | grid-column-start: 2; 13 | grid-row-start: 1; 14 | margin-top: 11vh; 15 | } 16 | 17 | .game-grid { 18 | grid-area: 2/2/5/5; 19 | display: grid; 20 | grid-template-columns: repeat(5, 1fr); 21 | grid-template-rows: repeat(6, 1fr); 22 | } 23 | -------------------------------------------------------------------------------- /client/app/actions/socket.js: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | 3 | export const socketConnErr = err => ({ type: types.SOCKET_CONN_ERR, err }) 4 | 5 | export const socketConnected = socket => ({ type: types.SOCKET_CONNECTED, socket }) 6 | 7 | export const socketAuthSuccess = () => ({ type: types.SOCKET_AUTH_SUCCESS }) 8 | 9 | export const socketAuthErr = err => ({ type: types.SOCKET_AUTH_ERR, err }) 10 | 11 | export const socketReconnecting = () => ({ type: "SOCKET_RECONNECTING" }) 12 | 13 | export const socketReconnectFail = () => ({ type: "SOCKET_RECONNECT_FAIL" }) -------------------------------------------------------------------------------- /client/server/middlewares/frontendMiddleware.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | 3 | /** 4 | * Front-end middleware 5 | */ 6 | module.exports = (app, options) => { 7 | const isProd = process.env.NODE_ENV === 'production' 8 | 9 | if (isProd) { 10 | const addProdMiddlewares = require('./addProdMiddlewares') 11 | addProdMiddlewares(app, options) 12 | } else { 13 | const webpackConfig = require('../../config/webpack.dev.babel') 14 | const addDevMiddlewares = require('./addDevMiddlewares') 15 | addDevMiddlewares(app, webpackConfig) 16 | } 17 | 18 | return app 19 | } 20 | -------------------------------------------------------------------------------- /client/app/styles/common/_colours.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | .card { 4 | background-color: $neutral-colour-100; 5 | color: $neutral-colour-800; 6 | } 7 | 8 | .navbar { 9 | //background-color: $neutral-colour-700; 10 | background-color: $neutral-accent-700; // #e8e9f3; // #a18276; // #39a9db; // #39a9db; 11 | // background-color: $neutral-accent-600; 12 | color: $neutral-colour-100; 13 | } 14 | .action-panel { 15 | background-color: transparent; 16 | } 17 | 18 | .hidden-pocket-cards .card { 19 | background-color: $neutral-colour-100; // $neutral-colour-100; 20 | } 21 | 22 | %seat { 23 | background-color: $primary-colour-700; 24 | } 25 | -------------------------------------------------------------------------------- /server/docs/lobbyAPI.md: -------------------------------------------------------------------------------- 1 | ## POST /gooby 2 | 3 | ### Request: 4 | 5 | - Supported content types are: 6 | 7 | - `application/json;charset=utf-8` 8 | - `application/json` 9 | 10 | - Sample User (`application/json;charset=utf-8`, `application/json`): 11 | 12 | ```javascript 13 | {"email":"gooby@g.com","username":"Tom","chips":2000,"password":"n84!@R5G"} 14 | ``` 15 | 16 | ### Response: 17 | 18 | - Status code 200 19 | - Headers: [] 20 | 21 | - Supported content types are: 22 | 23 | - `application/json;charset=utf-8` 24 | - `application/json` 25 | 26 | - Sample User (`application/json;charset=utf-8`, `application/json`): 27 | 28 | ```javascript 29 | 1 30 | ``` 31 | 32 | -------------------------------------------------------------------------------- /client/app/reducers/profile.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | import * as types from '../actions/types' 4 | 5 | const initialState = Immutable.Map({ 6 | profile: null, 7 | isLoading: false, 8 | error: null 9 | }) 10 | 11 | export default function(state = initialState, action) { 12 | switch (action.type) { 13 | case types.GET_PROFILE_REQUEST: 14 | return state.set('isLoading', true) 15 | case types.GET_PROFILE_SUCCESS: 16 | return state 17 | .set('profile', action.profile) 18 | .set('isLoading', false) 19 | .set('error', null) 20 | case types.GET_PROFILE_ERR: 21 | return state.set('error', action.error).set('isLoading', false) 22 | default: 23 | return state 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/app/containers/NavBarContainer.js: -------------------------------------------------------------------------------- 1 | import { withRouter } from 'react-router-dom' 2 | import { connect } from 'react-redux' 3 | 4 | import { logoutUser } from '../actions/auth' 5 | import { isAuthenticated, getUsername } from '../selectors/auth' 6 | import { getPathname } from '../selectors/route' 7 | 8 | import NavBar from '../components/NavBar' 9 | 10 | const mapStateToProps = state => ({ 11 | isAuthenticated: isAuthenticated(state), 12 | username: getUsername(state), 13 | currRoute: getPathname(state) 14 | }) 15 | 16 | const mapDispatchToProps = (dispatch, { history }) => ({ 17 | logoutUser: () => dispatch(logoutUser(history)) 18 | }) 19 | 20 | export default withRouter( 21 | connect( 22 | mapStateToProps, 23 | mapDispatchToProps 24 | )(NavBar) 25 | ) 26 | -------------------------------------------------------------------------------- /client/app/actions/lobby.js: -------------------------------------------------------------------------------- 1 | /* 2 | The data value of the action forms the websocket msg payload. 3 | */ 4 | import * as types from './types' 5 | 6 | export const getLobby = () => ({ 7 | type: types.GET_LOBBY, 8 | data: { tag: 'GetTables' } 9 | }) 10 | 11 | export const newLobby = lobby => ({ type: types.NEW_LOBBY, lobby }) 12 | 13 | // should be moved as this is game action 14 | export const takeSeat = (tableName, chips) => ({ 15 | type: types.TAKE_SEAT, 16 | data: { 17 | tag: 'GameMsgIn', 18 | contents: { 19 | tag: 'TakeSeat', 20 | contents: [tableName, Number(chips)] 21 | } 22 | } 23 | }) 24 | 25 | export const subscribeToTable = tableName => ({ 26 | type: types.SUBSCRIBE_TO_TABLE, 27 | data: { tag: 'SubscribeToTable', contents: tableName } 28 | }) 29 | -------------------------------------------------------------------------------- /client/server/middlewares/addProdMiddlewares.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const express = require('express') 3 | const compression = require('compression') 4 | 5 | module.exports = function addProdMiddlewares(app, options) { 6 | const publicPath = options.publicPath || '/' 7 | const outputPath = options.outputPath || path.resolve(process.cwd(), 'build') 8 | 9 | // compression middleware compresses your server responses which makes them 10 | // smaller (applies also to assets). You can read more about that technique 11 | // and other good practices on official Express.js docs http://mxs.is/googmy 12 | app.use(compression()) 13 | app.use(publicPath, express.static(outputPath)) 14 | 15 | app.get('*', (req, res) => 16 | res.sendFile(path.resolve(outputPath, 'index.html')) 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /client/app/styles/layout/_app.scss: -------------------------------------------------------------------------------- 1 | @import "../common/mixins"; 2 | 3 | %full-screen { 4 | position: absolute; 5 | height: 100%; 6 | width: 100%; 7 | } 8 | 9 | .app { 10 | @extend %full-screen; 11 | } 12 | 13 | .app-wrapper { 14 | @extend %full-screen; 15 | display: grid; 16 | grid-template-columns: auto; 17 | grid-template-rows: 0.8fr 15fr; 18 | grid-template-areas: 19 | "navbar" 20 | "main"; 21 | } 22 | 23 | .game-view-grid { 24 | display: grid; 25 | grid-template-columns: max-content; 26 | grid-template-rows: 75vh 10vh; 27 | 28 | @include md { 29 | margin-left: 14vw; 30 | margin-right: 14vw; 31 | } 32 | 33 | /* Prevents table Aspect Ratio from being too wide on wider screens. */ 34 | @include xl { 35 | margin-left: 23vw; 36 | margin-right: 23vw; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/bootstrap.sh: -------------------------------------------------------------------------------- 1 | # install virtual env and python 2 | 3 | # activate python virtual env 4 | virtualenv venv 5 | ~ source venv/bin/activate 6 | 7 | # install ansible 8 | sudo dnf install ansible 9 | 10 | pip install docker-py boto 11 | 12 | # install aws-cli 13 | 14 | # Give executable permissions to the 15 | # script which dynamically retrieves 16 | # AWS EC2 instance inventory 17 | chmod +x ansible/inventory/ec2.py 18 | 19 | # Environment variables to the AWS EC2 inventory management script 20 | # 21 | # 22 | # Our EC2 dynamic inventory script has the file name ec2.py 23 | # 24 | # This variable tells Ansible to use the dynamic 25 | # EC2 script instead of a static /etc/ansible/hosts file. 26 | export EC2_INI_PATH=./ansible/inventory/ec2.ini 27 | # This variable tells ec2.py where the ec2.ini config file is located. 28 | export ANSIBLE_INVENTORY=./ansible/inventory/ec2.py -------------------------------------------------------------------------------- /client/app/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | 3 | import * as types from '../actions/types'; 4 | 5 | const initialState = Immutable.Map({ authenticated: false, username: null, error: null, isLoading: false }); 6 | 7 | export default function (state = initialState, action) { 8 | switch (action.type) { 9 | case types.AUTH_REQUESTED: 10 | return state.set('isLoading', true); 11 | case types.AUTHENTICATED: 12 | return state.set('authenticated', true).set('username', action.username).set('isLoading', false).set('error', null); 13 | case types.UNAUTHENTICATED: 14 | return state.set('authenticated', false).set('username', null).set('isLoading', false).set('error', null); 15 | case types.AUTHENTICATION_ERROR: 16 | return state.set('error', action.error).set('isLoading', false) 17 | default: 18 | return state 19 | } 20 | } -------------------------------------------------------------------------------- /client/app/containers/ProfileContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import { getUsername } from '../selectors/auth' 5 | import { getProfileSelector } from '../selectors/profile' 6 | 7 | import { getProfile } from '../actions/profile' 8 | import Profile from '../components/Profile' 9 | 10 | class ProfileContainer extends React.Component { 11 | componentDidMount() { 12 | this.props.getProfile() 13 | } 14 | 15 | render() { 16 | return 17 | } 18 | } 19 | 20 | const mapStateToProps = state => ({ 21 | username: getUsername(state), 22 | profile: getProfileSelector(state) 23 | }) 24 | 25 | const mapDispatchToProps = (dispatch, { username }) => ({ 26 | getProfile: () => dispatch(getProfile(username)) 27 | }) 28 | 29 | export default connect( 30 | mapStateToProps, 31 | mapDispatchToProps 32 | )(ProfileContainer) 33 | -------------------------------------------------------------------------------- /server/deploy-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ## First arg to the bashfile must be the path to the key file to SSH into the EC2 host(s) 3 | ## 4 | ## Build and push new docker image to AWS ECR then pull and run new image in EC2 instance. 5 | ## Ensure AWS credentials are set in environment or the ansible playbook will fail to login to AWS 6 | 7 | if [ $# -eq 0 ] 8 | then 9 | echo "You forgot to speciffy the path to the key file to authenticate the SSH connection" 10 | fi 11 | 12 | sudo -H pip install pip==18.0.0 \ 13 | && sudo -H pip uninstall --yes setuptools \ 14 | && sudo -H pip install 'setuptools<20.2' --ignore-installed \ 15 | && sudo -H pip install 'requests[security]' --ignore-installed \ 16 | && sudo -H pip install boto awscli ansible docker-py --ignore-installed \ 17 | && ANSIBLE_CONFIG=ansible/ansible.cfg ansible-playbook ansible/push-new-image.yml ansible/deploy-image.yml --key-file=$1 -vvvv -------------------------------------------------------------------------------- /server/provision.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | REMOTE="ubuntu@34.244.29.59" 8 | INSTALL_SYS_DEPENDENCIES="sudo apt-get update && sudo apt-get install -yy build-essential lzma-dev libpq-dev" 9 | 10 | 11 | ssh -i ~/Downloads/tenprod.pem $REMOTE sudo $INSTALL_SYS_DEPENDENCIES 12 | 13 | # create release dir and give ubuntu user permissions 14 | ssh -i ~/.ssh/id_rsa $REMOTE sudo "sudo mkdir -pv /opt/server && sudo chown ubuntu /opt/server" -v 15 | 16 | # give ubuntu user ownership of systemd service file dir 17 | ssh -i ~/.ssh/id_rsa $REMOTE sudo "sudo chown ubuntu /etc/systemd/system" -v 18 | 19 | # copy systemd service conf file to remote 20 | scp -i ~/.ssh/id_rsa "server.service" $REMOTE:/etc/systemd/system 21 | 22 | ssh -i ~/.ssh/id_rsa $REMOTE "sudo systemctl enable server.service" 23 | 24 | scp -i ~/.ssh/id_rsa ".prod.env" $REMOTE:/etc/systemd/system/prod.env -------------------------------------------------------------------------------- /client/app/utils/request.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses the JSON returned by a network request 3 | * 4 | * @param {object} response A response from a network request 5 | * 6 | * @return {object} The parsed JSON from the request 7 | */ 8 | export function parseJSON(response) { 9 | if (response.status === 204 || response.status === 205) { 10 | return null 11 | } 12 | return response.json() 13 | } 14 | 15 | /** 16 | * Checks if a network request came back fine, and throws an error if not 17 | * 18 | * @param {object} response A response from a network request 19 | * 20 | * @return {object|undefined} Returns either the response, or throws an error 21 | */ 22 | export function checkStatus(response) { 23 | if (response.status >= 200 && response.status < 300) { 24 | return response 25 | } 26 | 27 | const error = new Error(response.statusText) 28 | error.response = response 29 | throw error 30 | } 31 | -------------------------------------------------------------------------------- /client/images/diamond.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/static/Diamonds.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/app/containers/LobbyContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from "react-redux"; 3 | import { withRouter } from 'react-router-dom' 4 | 5 | import { getLobby, subscribeToTable } from '../actions/lobby' 6 | import { getLobbyState } from '../selectors/lobby' 7 | import Lobby from '../components/Lobby' 8 | 9 | class LobbyContainer extends React.Component { 10 | componentDidMount() { 11 | this.props.getLobby() 12 | } 13 | 14 | render() { 15 | const { lobby } = this.props 16 | return () 17 | } 18 | } 19 | 20 | const mapStateToProps = state => ({ 21 | lobby: getLobbyState(state) 22 | }); 23 | 24 | const mapDispatchToProps = (dispatch) => ({ 25 | getLobby: () => dispatch(getLobby()), 26 | subscribeToATable: tableName => dispatch(subscribeToTable(tableName)) 27 | }); 28 | 29 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(LobbyContainer)); 30 | -------------------------------------------------------------------------------- /client/config/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverageFrom: [ 3 | 'app/**/*.{js,jsx}', 4 | '!app/**/*.test.{js,jsx}', 5 | '!app/*/RbGenerated*/*.{js,jsx}', 6 | '!app/app.js', 7 | '!app/*/*/Loadable.{js,jsx}' 8 | ], 9 | coverageThreshold: { 10 | global: { 11 | statements: 90, 12 | branches: 80, 13 | functions: 92, 14 | lines: 90 15 | } 16 | }, 17 | coverageReporters: ['json', 'lcov', 'text-summary'], 18 | moduleDirectories: ['node_modules', 'app'], 19 | moduleNameMapper: { 20 | '.*\\.(css|less|styl|scss|sass)$': 21 | '/config/jest-mocks/cssModule.js', 22 | '.*\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 23 | '/config/jest-mocks/image.js' 24 | }, 25 | setupTestFrameworkScriptFile: '/config/test-setup.js', 26 | testEnvironment: 'node', 27 | testRegex: 'tests/.*\\.test\\.js$' 28 | } 29 | -------------------------------------------------------------------------------- /client/server/util/logger.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | const chalk = require('chalk') 4 | const ip = require('ip') 5 | 6 | const divider = chalk.gray('\n-----------------------------------') 7 | 8 | /** 9 | * Logger middleware, you can customize it to make messages more personal 10 | */ 11 | const logger = { 12 | // Called whenever there's an error on the server we want to print 13 | error: err => { 14 | console.error(chalk.red(err)) 15 | }, 16 | 17 | // Called when express.js app starts on given port w/o errors 18 | appStarted: (port, host) => { 19 | console.log(`Server started ! ${chalk.green('✓')}`) 20 | 21 | console.log(` 22 | ${chalk.bold('Access URLs:')}${divider} 23 | Localhost: ${chalk.magenta(`http://${host}:${port}`)} 24 | LAN: ${chalk.magenta(`http://${ip.address()}:${port}`)}${divider} 25 | ${chalk.blue(`Press ${chalk.italic('CTRL-C')} to stop`)} 26 | `) 27 | } 28 | } 29 | 30 | module.exports = logger 31 | -------------------------------------------------------------------------------- /server/ping.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/env bash 3 | set -o errexit 4 | set -o pipefail 5 | set -o nounset 6 | 7 | BINARY_PATH=".stack-work/dist/x86_64-linux/Cabal-2.4.0.1/build/poker-server-exe/poker-server-exe" 8 | HOST="34.244.29.59" 9 | REMOTE="ubuntu@"$HOST 10 | 11 | RUN="sudo /opt/server/server-exe" 12 | 13 | 14 | serverHealthCheck(){ 15 | local url="https://tenpoker.co.uk/lobby" 16 | local statusCode=`echo $(curl -s -o /dev/null -w "%{http_code}" $url)` 17 | 18 | if [[ "$statusCode" != 2* ]] && [[ "$statusCode" != 0* ]]; then 19 | echo "Error: Server responded with http status: $statusCode" # if the content of statusCode isn't a "2xx" print the error. 20 | fi 21 | 22 | if [[ "$statusCode" = 0* ]]; then 23 | echo "Error: Server unreachable: $statusCode" # connection refused 24 | fi 25 | 26 | if [[ "$statusCode" = 2* ]]; then 27 | echo "Success: Server responded with http status: $statusCode" 28 | fi 29 | } 30 | 31 | serverHealthCheck -------------------------------------------------------------------------------- /client/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Poker 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /client/app/reducers/socket.js: -------------------------------------------------------------------------------- 1 | 2 | import Immutable, { fromJS } from 'immutable'; 3 | 4 | import { 5 | SOCKET_AUTH_SUCCESS, 6 | SOCKET_AUTH_ERR, 7 | SOCKET_CONN_ERR, 8 | SOCKET_CONNECTED, 9 | DISCONNECT_SOCKET 10 | } from '../actions/types' 11 | 12 | const initialState = fromJS({ 13 | socketAuth: false, 14 | socketAuthErr: null, 15 | socketConnErr: null, 16 | socketConnected: false 17 | }) 18 | 19 | export default function socket(state = initialState, action) { 20 | switch (action.type) { 21 | case SOCKET_CONNECTED: 22 | return state.set('socketConnected', true).set('socketConnErr', null).set('socketAuthErr', null) 23 | case DISCONNECT_SOCKET: 24 | return state.set('socketConnected', false).set('socketConnErr', null).set('socketAuthErr', null) 25 | case SOCKET_AUTH_SUCCESS: 26 | return state.set('socketAuth', true) 27 | case SOCKET_AUTH_ERR: 28 | return state.set('socketAuthError', action.err) 29 | case SOCKET_CONN_ERR: 30 | return state.set('socketConnError', action.err) 31 | default: 32 | return state 33 | } 34 | } -------------------------------------------------------------------------------- /client/app/styles/components/_navbar.scss: -------------------------------------------------------------------------------- 1 | .navbar { 2 | display: flex; 3 | text-align: center; 4 | min-height: 4em; 5 | } 6 | 7 | .navbar-brand { 8 | width: 16em; 9 | margin: auto; 10 | text-align: left; 11 | padding-left: 2.5em; 12 | } 13 | 14 | .navbar-menu { 15 | display: flex; 16 | justify-content: space-between; 17 | width: 100%; 18 | } 19 | 20 | .navbar-start { 21 | display: flex; 22 | justify-content: space-between; 23 | align-items: center; 24 | } 25 | 26 | .navbar-end { 27 | display: flex; 28 | justify-content: space-between; 29 | align-items: center; 30 | padding-right: 2.5em; 31 | } 32 | 33 | .navbar-item { 34 | width: 9em; 35 | margin: 7%; 36 | } 37 | 38 | .navbar-item-active { 39 | color: $neutral-colour-500; 40 | } 41 | 42 | .navbar-item h4 { 43 | font-weight: 700; 44 | } 45 | 46 | .navbar-item:hover { 47 | opacity: 0.82; 48 | transition: all 0.08s linear; 49 | } 50 | 51 | .navbar-item-active { 52 | width: 7em; 53 | margin: 7%; 54 | color: $eerie-black; 55 | } 56 | 57 | .brand-title { 58 | letter-spacing: 0.1em; 59 | } 60 | -------------------------------------------------------------------------------- /client/server/index.js: -------------------------------------------------------------------------------- 1 | /* eslint consistent-return:0 */ 2 | 3 | const express = require('express') 4 | const { resolve } = require('path') 5 | const logger = require('./util//logger') 6 | 7 | const argv = require('./util/argv') 8 | const port = require('./util//port') 9 | const setup = require('./middlewares/frontendMiddleware') 10 | 11 | const app = express() 12 | 13 | // If you need a backend, e.g. an API, add your custom backend-specific middleware here 14 | // app.use('/api', myApi); 15 | 16 | // In production we need to pass these values in instead of relying on webpack 17 | setup(app, { 18 | outputPath: resolve(process.cwd(), 'build'), 19 | publicPath: '/' 20 | }) 21 | 22 | // get the intended host and port number, use localhost and port 3000 if not provided 23 | const customHost = argv.host || process.env.HOST 24 | const host = customHost || null // Let http.Server use its default IPv6/4 host 25 | const prettyHost = customHost || 'localhost' 26 | 27 | // Start your app. 28 | app.listen(port, host, err => { 29 | if (err) { 30 | return logger.error(err.message) 31 | } 32 | logger.appStarted(port, prettyHost) 33 | }) 34 | -------------------------------------------------------------------------------- /client/LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Dinesh Pandiyan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/images/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jd Med Copy 3 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/static/Hearts.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Jd Med Copy 3 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /client/server/middlewares/addDevMiddlewares.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const webpackDevMiddleware = require('webpack-dev-middleware') 4 | const webpackHotMiddleware = require('webpack-hot-middleware') 5 | 6 | function createWebpackMiddleware(compiler, publicPath) { 7 | return webpackDevMiddleware(compiler, { 8 | noInfo: true, 9 | publicPath, 10 | silent: true, 11 | stats: 'errors-only' 12 | }) 13 | } 14 | 15 | module.exports = function addDevMiddlewares(app, webpackConfig) { 16 | const compiler = webpack(webpackConfig) 17 | const middleware = createWebpackMiddleware( 18 | compiler, 19 | webpackConfig.output.publicPath 20 | ) 21 | 22 | app.use(middleware) 23 | app.use(webpackHotMiddleware(compiler)) 24 | 25 | // Since webpackDevMiddleware uses memory-fs internally to store build 26 | // artifacts, we use it instead 27 | const fs = middleware.fileSystem 28 | 29 | app.get('*', (req, res) => { 30 | fs.readFile(path.join(compiler.outputPath, 'index.html'), (err, file) => { 31 | if (err) { 32 | res.sendStatus(404) 33 | } else { 34 | res.send(file.toString()) 35 | } 36 | }) 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /server/test/Poker/HandSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | {-# LANGUAGE ScopedTypeVariables #-} 4 | 5 | module Poker.HandSpec where 6 | 7 | import qualified Data.ByteString.Lazy.Char8 as C 8 | import Data.Maybe (isJust) 9 | import Data.Text (Text) 10 | import qualified Data.Text as T 11 | import Hedgehog (Property, forAll, property, (===)) 12 | import qualified Hedgehog.Gen as Gen 13 | import qualified Hedgehog.Range as Range 14 | import Poker.Game.Hands (maybeFlush, value) 15 | import Poker.Generators (genSameSuitCards, genShuffledCards) 16 | import Test.Hspec (describe, it) 17 | import Test.Hspec.Hedgehog 18 | ( PropertyT, 19 | diff, 20 | forAll, 21 | hedgehog, 22 | modifyMaxDiscardRatio, 23 | (/==), 24 | (===), 25 | ) 26 | 27 | spec = do 28 | describe "value" $ do 29 | it "Number of cards before and after valuation is involutive" $ 30 | hedgehog $ do 31 | sevenCards <- forAll (genShuffledCards 7) 32 | let (_, cs) = value sevenCards 33 | length cs === 5 34 | 35 | it "7 suited cards always a flush" $ 36 | hedgehog $ do 37 | cs <- forAll $ genSameSuitCards 7 38 | isJust (maybeFlush cs) === True 39 | -------------------------------------------------------------------------------- /server/src/Socket/Setup.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Socket.Setup where 4 | 5 | import Control.Concurrent.Async (Async) 6 | import Control.Monad (void) 7 | import Control.Monad.Except (void) 8 | import Control.Monad.Reader (runReaderT) 9 | import Data.ByteString.Char8 10 | ( pack, 11 | unpack, 12 | ) 13 | import Data.Int (Int64) 14 | import Data.List (unfoldr) 15 | import Data.Map.Lazy (Map) 16 | import qualified Data.Map.Lazy as M 17 | import Data.Text (Text) 18 | import Database (runRedisAction) 19 | import Database.Persist.Postgresql (ConnectionString) 20 | import Database.Redis 21 | ( Redis, 22 | connect, 23 | runRedis, 24 | setex, 25 | ) 26 | import qualified Database.Redis as Redis 27 | import Socket.Lobby (initialLobby) 28 | import Types (RedisConfig) 29 | 30 | -- lobby including all game state is stored in redis 31 | setInitialLobby :: RedisConfig -> IO () 32 | setInitialLobby redisConfig = do 33 | lobby <- initialLobby 34 | runRedisAction redisConfig $ 35 | void $ 36 | Redis.hsetnx 37 | "gamesState" 38 | "lobby" 39 | (pack $ show lobby) 40 | 41 | intialiseGameStateInRedis :: RedisConfig -> IO () 42 | intialiseGameStateInRedis = setInitialLobby 43 | -------------------------------------------------------------------------------- /client/app/selectors/games.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | 3 | export const getGames = state => state.get('global').get('games') 4 | 5 | export const getGame = tableName => 6 | createSelector(getGames, games => games.get(tableName)) 7 | 8 | export const getCurrentPlayerToAct = tableName => ( 9 | getGame(tableName), 10 | game => { 11 | if (!game) { 12 | return null 13 | } 14 | 15 | const currentPosToAct = game.get('_currentPosToAct') 16 | 17 | if (Number.isInteger(currentPosToAct)) { 18 | const player = game.get('_players').get(currentPosToAct) 19 | 20 | if (player.get("_hasActed") == true) { 21 | return null 22 | } else { 23 | return player.get('_playerName') 24 | } 25 | } 26 | 27 | return null 28 | } 29 | ) 30 | 31 | export const isTurnToAct = (username, tableName) => createSelector( 32 | getCurrentPlayerToAct(tableName), 33 | playerName => playerName === username 34 | ) 35 | 36 | export const getPlayerPosition = (tableName, username) => createSelector( 37 | getGame(tableName), game => { 38 | if (!game) return null 39 | return game.get('_players') 40 | .findIndex( 41 | plyr => plyr.get('_playerName') === username 42 | ) 43 | } 44 | ) 45 | -------------------------------------------------------------------------------- /client/app/reducers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Combine all reducers in this file and export the combined reducers. 3 | */ 4 | 5 | import { fromJS } from 'immutable' 6 | import { combineReducers } from 'redux-immutable' 7 | import { LOCATION_CHANGE } from 'react-router-redux' 8 | 9 | import rootReducer from 'reducers/rootReducer' 10 | 11 | /* 12 | * routeReducer 13 | * 14 | * The reducer merges route location changes into our immutable state. 15 | * The change is necessitated by moving to react-router-redux@5 16 | * 17 | */ 18 | 19 | // Initial routing state 20 | const routeInitialState = fromJS({ 21 | location: null 22 | }) 23 | 24 | /** 25 | * Merge route into the global application state 26 | */ 27 | function routeReducer(state = routeInitialState, action) { 28 | switch (action.type) { 29 | /* istanbul ignore next */ 30 | case LOCATION_CHANGE: 31 | return state.merge({ 32 | location: action.payload 33 | }) 34 | default: 35 | return state 36 | } 37 | } 38 | 39 | /** 40 | * Creates the main reducer with the dynamically injected ones 41 | */ 42 | export default function createReducer(injectedReducers) { 43 | return combineReducers({ 44 | route: routeReducer, 45 | global: rootReducer, 46 | ...injectedReducers 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | services: 4 | db: 5 | image: postgres:9.4 6 | environment: 7 | - DB_USER=postgres 8 | - DB_PASS=postgres 9 | - DB_NAME=poker 10 | - POSTGRES_PASSWORD=postgres 11 | # volumes: 12 | # - db-data:/var/lib/postgresql/data 13 | restart: on-failure 14 | networks: 15 | - backend 16 | 17 | redis: 18 | image: redis:5.0-rc4-alpine 19 | networks: 20 | - backend 21 | restart: on-failure 22 | volumes: 23 | - redis-data:/var/lib/redis 24 | 25 | server: 26 | build: ./server 27 | environment: 28 | - dbConnStr=host=db port=5432 user=postgres dbname=postgres password=postgres 29 | - secret=aw4-4z0ds21c970dasdak4dm=9jhkbn8da268tkj7=rsfdaf92x88 30 | - redisHost=redis 31 | depends_on: 32 | - db 33 | - redis 34 | ports: 35 | - "8000:8000" 36 | - "5000:5000" 37 | restart: on-failure 38 | networks: 39 | - backend 40 | 41 | client: 42 | build: ./client 43 | restart: on-failure 44 | environment: 45 | - HOST=0.0.0.0 46 | ports: 47 | - target: 3000 48 | published: 3000 49 | protocol: tcp 50 | mode: host 51 | 52 | networks: 53 | backend: 54 | 55 | volumes: 56 | db-data: 57 | redis-data: -------------------------------------------------------------------------------- /server/UNLICENSE.txt: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to -------------------------------------------------------------------------------- /server/src/Socket/Utils.hs: -------------------------------------------------------------------------------- 1 | module Socket.Utils where 2 | 3 | import Data.Aeson (decode, encode) 4 | import qualified Data.ByteString as BS 5 | import qualified Data.ByteString.Lazy.Char8 as C 6 | import Data.Map.Lazy (Map) 7 | import qualified Data.Map.Lazy as M 8 | import Data.Text (Text) 9 | import qualified Data.Text as T 10 | import qualified Data.Text.Lazy as X 11 | import qualified Data.Text.Lazy.Encoding as D 12 | import Data.Time.Calendar (Day (ModifiedJulianDay)) 13 | import Data.Time.Clock (UTCTime (UTCTime), secondsToDiffTime) 14 | import Socket.Types (Lobby (..), MsgIn, MsgOut, Table, TableName) 15 | import Text.Pretty.Simple (pPrint) 16 | import Prelude 17 | 18 | encodeMsgToJSON :: MsgOut -> Text 19 | encodeMsgToJSON a = T.pack $ show $ X.toStrict $ D.decodeUtf8 $ encode a 20 | 21 | encodeMsgX :: MsgIn -> Text 22 | encodeMsgX a = T.pack $ show $ X.toStrict $ D.decodeUtf8 $ encode a 23 | 24 | parseMsgFromJSON :: Text -> Maybe MsgIn 25 | parseMsgFromJSON jsonTxt = decode $ C.pack $ T.unpack jsonTxt 26 | 27 | parseMsgFromJSON' :: BS.ByteString -> Maybe MsgIn 28 | parseMsgFromJSON' jsonTxt = decode $ C.fromStrict jsonTxt 29 | 30 | getTimestamp :: UTCTime 31 | getTimestamp = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0) 32 | 33 | unLobby :: Lobby -> Map TableName Table 34 | unLobby (Lobby lobby) = lobby 35 | -------------------------------------------------------------------------------- /client/app/containers/SignInFormContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { withRouter } from 'react-router' 4 | 5 | import { login } from '../actions/auth' 6 | import SignInForm from '../components/SignInForm' 7 | 8 | class SignInFormContainer extends React.Component { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.state = { 13 | username: '', 14 | password: '' 15 | } 16 | } 17 | 18 | handleChange = event => 19 | this.setState({ 20 | [event.target.name]: event.target.value 21 | }) 22 | 23 | handleSubmit = event => { 24 | const { username, password } = this.state 25 | this.props.login( 26 | username, 27 | password 28 | ) 29 | event.preventDefault() 30 | } 31 | 32 | validateForm = () => { 33 | return this.state.username.length > 0 && this.state.password.length > 0 34 | } 35 | 36 | render() { 37 | return ( 38 | 42 | ) 43 | } 44 | } 45 | 46 | const mapDispatchToProps = (dispatch, { history }) => ({ 47 | login: (username, password) => dispatch(login(username, password, history)) 48 | }) 49 | 50 | export default connect( 51 | undefined, 52 | mapDispatchToProps 53 | )(withRouter(SignInFormContainer)) 54 | -------------------------------------------------------------------------------- /client/config/webpack.prod.babel.js: -------------------------------------------------------------------------------- 1 | // Important modules this config uses 2 | const path = require('path') 3 | // const webpack = require('webpack'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin') 5 | 6 | module.exports = require('./webpack.base.babel')({ 7 | mode: 'production', 8 | // In production, we skip all hot-reloading stuff 9 | entry: [path.join(process.cwd(), 'app/app.js')], 10 | 11 | // Utilize long-term caching by adding content hashes (not compilation hashes) to compiled assets 12 | output: { 13 | filename: '[name].[chunkhash].js', 14 | chunkFilename: '[name].[chunkhash].chunk.js', 15 | publicPath: '/' 16 | }, 17 | 18 | plugins: [ 19 | // Minify and optimize the index.html 20 | new HtmlWebpackPlugin({ 21 | template: 'app/index.html', 22 | minify: { 23 | removeComments: true, 24 | collapseWhitespace: true, 25 | removeRedundantAttributes: true, 26 | useShortDoctype: true, 27 | removeEmptyAttributes: true, 28 | removeStyleLinkTypeAttributes: true, 29 | keepClosingSlash: true, 30 | minifyJS: true, 31 | minifyCSS: true, 32 | minifyURLs: true 33 | }, 34 | inject: true 35 | }) 36 | ], 37 | 38 | performance: { 39 | assetFilter: assetFilename => 40 | !/(\.map$)|(^(main\.|favicon\.))/.test(assetFilename) 41 | } 42 | }) 43 | -------------------------------------------------------------------------------- /server/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -o errexit 3 | set -o pipefail 4 | set -o nounset 5 | 6 | # Location of executable 7 | BUILD_DIR="./build" 8 | BINARY_PATH=$BUILD_DIR"/poker-server-exe" 9 | 10 | # Where we deploy 11 | HOST="34.244.29.59" 12 | REMOTE="ubuntu@"$HOST 13 | 14 | RUN="sudo /opt/server/server-exe" 15 | 16 | 17 | serverHealthCheck(){ 18 | local url="https://tenpoker.co.uk/lobby" 19 | local statusCode=`echo $(curl -s -o /dev/null -w "%{http_code}" $url)` 20 | 21 | if [[ "$statusCode" != 2* ]] && [[ "$statusCode" != 0* ]]; then 22 | echo "Error: Server responded with http status: $statusCode" # if the content of statusCode isn't a "2xx" print the error. 23 | fi 24 | 25 | if [[ "$statusCode" = 0* ]]; then 26 | echo "Error: Server unreachable: $statusCode" # connection refused 27 | fi 28 | 29 | if [[ "$statusCode" = 2* ]]; then 30 | echo "Success: Server responded with http status: $statusCode" 31 | fi 32 | } 33 | 34 | # compile binrary 35 | stack build --copy-bins --local-bin-path $BUILD_DIR 36 | 37 | 38 | ssh -i ~/.ssh/id_rsa $REMOTE sudo "systemctl stop server.service" 39 | 40 | 41 | # copy server binary to remote 42 | scp -i ~/.ssh/id_rsa $BINARY_PATH $REMOTE:/opt/server 43 | 44 | # restart server using systemd 45 | ssh -i ~/.ssh/id_rsa $REMOTE sudo "systemctl start server.service" 46 | 47 | serverHealthCheck -------------------------------------------------------------------------------- /client/app/components/Lobby.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Lobby = ({ lobby, history, subscribeToATable }) => 4 | < table className="table game-table-list" > 5 | 6 | 7 |

Name

8 |

Players

9 |

Waitlist

10 |

Min Buy In

11 |

Max Buy In

12 |

Big Blind

13 | 14 | 15 | 16 | 17 | {lobby.map((table) => { 18 | const tableName = table.get('_tableName') 19 | 20 | return 23 | {tableName} 24 | {`${table.get('_playerCount')} / ${table.get('_maxPlayers')}`} 25 | {table.get('_waitlistCount')} 26 | {table.get('_minBuyInChips')} 27 | {table.get('_maxBuyInChips')} 28 | {table.get('_bigBlind')} 29 | 33 | 34 | })} 35 | 36 | 37 | 38 | export default Lobby; 39 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "airbnb", 5 | "plugin:react/recommended", 6 | "prettier/react", 7 | "prettier" 8 | ], 9 | "env": { 10 | "browser": true, 11 | "node": true, 12 | "jest": true, 13 | "es6": true 14 | }, 15 | "plugins": [ 16 | "react", 17 | "jsx-a11y", 18 | "prettier" 19 | ], 20 | "parserOptions": { 21 | "ecmaVersion": 6, 22 | "sourceType": "module", 23 | "ecmaFeatures": { 24 | "jsx": true 25 | } 26 | }, 27 | "rules": { 28 | "no-param-reassign": "off", 29 | "arrow-parens": "off", 30 | "function-paren-newline": "off", 31 | "comma-dangle": [ 32 | "error", 33 | "only-multiline" 34 | ], 35 | "import/no-extraneous-dependencies": 0, 36 | "import/prefer-default-export": 0, 37 | "indent": [ 38 | 2, 39 | 2, 40 | { 41 | "SwitchCase": 1 42 | } 43 | ], 44 | "max-len": 0, 45 | "no-console": 1, 46 | "react/forbid-prop-types": 0, 47 | "react/jsx-curly-brace-presence": "off", 48 | "react/jsx-first-prop-new-line": [ 49 | 2, 50 | "multiline" 51 | ], 52 | "react/jsx-filename-extension": 0, 53 | "react/self-closing-comp": 0, 54 | "jsx-a11y/anchor-is-valid": 0 55 | }, 56 | "settings": { 57 | "import/resolver": { 58 | "webpack": { 59 | "config": "./config/webpack.prod.babel.js" 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /client/app/containers/SignUpFormContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from 'react-redux' 3 | import { withRouter } from 'react-router' 4 | 5 | import { register } from '../actions/auth' 6 | import SignUpForm from '../components/SignUpForm' 7 | 8 | class SignUpFormContainer extends React.Component { 9 | constructor(props) { 10 | super(props) 11 | 12 | this.state = { 13 | email: '', 14 | username: '', 15 | password: '', 16 | repeatPassword: '' 17 | } 18 | } 19 | 20 | handleChange = event => 21 | this.setState({ 22 | [event.target.name]: event.target.value 23 | }) 24 | 25 | handleSubmit = event => { 26 | const { username, email, password } = this.state 27 | this.props.register( 28 | username, 29 | email, 30 | password 31 | ) 32 | event.preventDefault() 33 | } 34 | 35 | validateForm = () => { 36 | return this.state.email.length > 0 && this.state.password.length > 0 37 | } 38 | 39 | render() { 40 | return ( 41 | 45 | ) 46 | } 47 | } 48 | 49 | const mapDispatchToProps = (dispatch, { history }) => ({ 50 | register: (username, email, password) => dispatch(register(username, email, password, history)) 51 | }) 52 | 53 | export default connect( 54 | undefined, 55 | mapDispatchToProps 56 | )(withRouter(SignUpFormContainer)) 57 | -------------------------------------------------------------------------------- /client/app/components/Card.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import clubs from '../../static/Clubs.svg' 4 | import hearts from '../../static/Hearts.svg' 5 | import spades from '../../static/Spades.svg' 6 | import diamonds from '../../static/Diamonds.svg' 7 | 8 | const showRank = rank => { 9 | switch (rank) { 10 | case 'Ace': 11 | return 'A' 12 | case 'King': 13 | return 'K' 14 | case 'Queen': 15 | return 'Q' 16 | case 'Jack': 17 | return 'J' 18 | case 'Ten': 19 | return '10' 20 | case 'Nine': 21 | return '9' 22 | case 'Eight': 23 | return '8' 24 | case 'Seven': 25 | return '7' 26 | case 'Six': 27 | return '6' 28 | case 'Five': 29 | return '5' 30 | case 'Four': 31 | return '4' 32 | case 'Three': 33 | return '3' 34 | case 'Two': 35 | return '2' 36 | } 37 | } 38 | 39 | const suitSVG = suit => { 40 | switch (suit) { 41 | case 'Spades': 42 | return spades 43 | case 'Diamonds': 44 | return diamonds 45 | case 'Hearts': 46 | return hearts 47 | case 'Clubs': 48 | return clubs 49 | } 50 | } 51 | 52 | const Card = ({ rank, suit }) => ( 53 |
54 |
55 | {showRank(rank)} 56 |
57 |
58 | {suit} 59 |
60 |
); 61 | 62 | export default Card; 63 | -------------------------------------------------------------------------------- /client/app/containers/AppContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { withRouter } from 'react-router' 4 | 5 | import { isAuthenticated, getUsername } from '../selectors/auth' 6 | import { isSocketAuthenticated } from '../selectors/socket' 7 | 8 | import { connectSocket } from '../actions/auth' 9 | 10 | import App from '../components/App' 11 | 12 | class AppContainer extends Component { 13 | componentDidMount() { 14 | /* If we have a token and socket not connected then try and connect */ 15 | const { connectSocket, isSocketAuthenticated } = this.props 16 | const token = localStorage.getItem('token') 17 | if (token && !isSocketAuthenticated) { 18 | try { 19 | const { access_token } = JSON.parse(token) 20 | connectSocket(access_token) 21 | } catch (e) { 22 | console.log(e) 23 | } 24 | } 25 | } 26 | 27 | render() { 28 | const { username } = this.props 29 | 30 | return 31 | } 32 | } 33 | 34 | const mapStateToProps = state => ({ 35 | isAuthenticated: isAuthenticated(state), 36 | isSocketAuthenticated: isSocketAuthenticated(state), 37 | username: getUsername(state) 38 | }) 39 | 40 | const mapDispatchToProps = dispatch => ({ 41 | connectSocket: (url, token) => dispatch(connectSocket(url, token)) 42 | }) 43 | 44 | export default withRouter( 45 | connect( 46 | mapStateToProps, 47 | mapDispatchToProps 48 | )(AppContainer) 49 | ) 50 | -------------------------------------------------------------------------------- /client/app/styles/components/game/_slider.scss: -------------------------------------------------------------------------------- 1 | .slidecontainer { 2 | width: 100%; /* Width of the outside container */ 3 | margin: auto; 4 | text-align: center; 5 | padding-bottom: 1.5rem; 6 | } 7 | 8 | /* The slider itself */ 9 | .slider { 10 | -webkit-appearance: none; /* Override default CSS styles */ 11 | appearance: none; 12 | width: 50%; /* Full-width */ 13 | 14 | height: 0.3em; /* Specified height */ 15 | background: $neutral-colour-700; /* Grey background */ 16 | outline: none; /* Remove outline */ 17 | opacity: 0.5; /* Set transparency (for mouse-over effects on hover) */ 18 | -webkit-transition: 0.2s; /* 0.2 seconds transition on hover */ 19 | transition: opacity 0.2s; 20 | } 21 | 22 | /* Mouse-over effects */ 23 | .slider:hover { 24 | opacity: 1; /* Fully shown on mouse-over */ 25 | } 26 | 27 | /* The slider handle (use -webkit- (Chrome, Opera, Safari, Edge) and -moz- (Firefox) to override default look) */ 28 | .slider::-webkit-slider-thumb { 29 | -webkit-appearance: none; /* Override default look */ 30 | appearance: none; 31 | width: 1.1em; /* Set a specific slider handle width */ 32 | height: 1.1em; /* Slider handle height */ 33 | background: $neutral-accent-200; 34 | border-radius: 100%; 35 | cursor: pointer; /* Cursor on hover */ 36 | } 37 | 38 | .slider::-moz-range-thumb { 39 | width: 2em; /* Set a specific slider handle width */ 40 | height: 2em; /* Slider handle height */ 41 | background: #4caf50; /* Green background */ 42 | cursor: pointer; /* Cursor on hover */ 43 | } 44 | -------------------------------------------------------------------------------- /client/images/club.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/static/Clubs.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/config/webpack.dev.babel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * DEVELOPMENT WEBPACK CONFIGURATION 3 | */ 4 | 5 | const path = require('path') 6 | const webpack = require('webpack') 7 | const HtmlWebpackPlugin = require('html-webpack-plugin') 8 | const CircularDependencyPlugin = require('circular-dependency-plugin') 9 | 10 | module.exports = require('./webpack.base.babel')({ 11 | mode: 'development', 12 | // Add hot reloading in development 13 | entry: [ 14 | 'eventsource-polyfill', // Necessary for hot reloading with IE 15 | 'webpack-hot-middleware/client?reload=true', 16 | path.join(process.cwd(), 'app/app.js') // Start with js/app.js 17 | ], 18 | 19 | // Don't use hashes in dev mode for better performance 20 | output: { 21 | filename: '[name].js', 22 | chunkFilename: '[name].chunk.js' 23 | }, 24 | 25 | // Add development plugins 26 | plugins: [ 27 | new webpack.HotModuleReplacementPlugin(), // Tell webpack we want hot reloading 28 | new HtmlWebpackPlugin({ 29 | inject: true, // Inject all files that are generated by webpack, e.g. bundle.js 30 | template: 'app/index.html' 31 | }), 32 | new CircularDependencyPlugin({ 33 | exclude: /a\.js|node_modules/, // exclude node_modules 34 | failOnError: false // show a warning when there is a circular dependency 35 | }) 36 | ], 37 | 38 | // Emit a source map for easier debugging 39 | // See https://webpack.js.org/configuration/devtool/#devtool 40 | devtool: 'eval-source-map', 41 | 42 | performance: { 43 | hints: false 44 | } 45 | }) 46 | -------------------------------------------------------------------------------- /client/images/spade.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape Copy 5 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/static/Spades.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape Copy 5 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /client/app/components/SignInForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const SignInForm = ({ handleChange, handleSubmit }) => ( 4 |
5 |
6 |
7 | 8 |

Log In

9 | 10 |
11 |
12 |

13 | handleChange(e)} /> 14 | 15 | 16 | 17 | 18 | 19 | 20 |

21 |
22 |
23 |

24 | handleChange(e)} /> 25 | 26 | 27 | 28 |

29 |
30 |
31 |

32 | 35 |

36 |
37 |
38 |
39 |
40 |
41 | ); 42 | 43 | export default SignInForm; 44 | -------------------------------------------------------------------------------- /server/src/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveAnyClass #-} 2 | {-# LANGUAGE DeriveGeneric #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | 5 | module Types where 6 | 7 | import Data.Aeson (FromJSON, ToJSON) 8 | import Data.Text (Text) 9 | import qualified Data.Text as T 10 | import Data.Time.Clock (UTCTime) 11 | import Database.Redis (ConnectInfo) 12 | import GHC.Generics (Generic) 13 | import Servant () 14 | import Servant.API 15 | ( Capture, 16 | Get, 17 | JSON, 18 | (:>), 19 | ) 20 | import Servant.Auth.Server (FromJWT, ToJWT) 21 | 22 | type RedisConfig = ConnectInfo 23 | 24 | type Password = Text 25 | 26 | data Login = Login 27 | { loginUsername :: Text, 28 | loginPassword :: Text 29 | } 30 | deriving (Eq, Show, Generic, ToJSON, FromJSON) 31 | 32 | data Register = Register 33 | { newUserEmail :: Text, 34 | newUsername :: Username, 35 | newUserPassword :: Text 36 | } 37 | deriving (Eq, Show, Generic, FromJSON, ToJSON) 38 | 39 | newtype Username 40 | = Username Text 41 | deriving (Generic, Show, Read, Eq, Ord, ToJWT, FromJWT) 42 | 43 | unUsername :: Username -> Text 44 | unUsername (Username username) = username 45 | 46 | instance ToJSON Username 47 | 48 | instance FromJSON Username 49 | 50 | type UserID = Text 51 | 52 | data UserProfile = UserProfile 53 | { proUsername :: Username, 54 | proEmail :: Text, 55 | proAvailableChips :: Int, 56 | proChipsInPlay :: Int, 57 | proUserCreatedAt :: UTCTime 58 | } 59 | deriving (Eq, Show, Generic, ToJSON, FromJSON) 60 | 61 | data ReturnToken = ReturnToken 62 | { access_token :: Text, 63 | refresh_token :: Text, 64 | expiration :: Int --seconds to expire 65 | } 66 | deriving (Generic, ToJSON, FromJSON) 67 | -------------------------------------------------------------------------------- /client/app/actions/types.js: -------------------------------------------------------------------------------- 1 | /* Actions prefixed with /server denote actions which trigger the sending of a websocket msg to server*/ 2 | 3 | /* User API Types */ 4 | export const AUTH_REQUESTED = 'AUTH_REQUESTED' 5 | export const AUTHENTICATED = 'AUTHENTICATED' 6 | export const UNAUTHENTICATED = 'UNAUTHENTICATED' 7 | export const AUTHENTICATION_ERROR = 'AUTHENTICATION_ERROR' 8 | 9 | /* Retrieve User Profile */ 10 | export const GET_PROFILE_REQUEST = 'GET_PROFILE_REQUEST' 11 | export const GET_PROFILE_SUCCESS = 'GET_PROFILE_SUCCESS' 12 | export const GET_PROFILE_ERR = 'GET_PROFILE_ERR' 13 | 14 | /* Websocket Action Types */ 15 | export const CONNECT_SOCKET = 'CONNECT_SOCKET' 16 | export const SOCKET_CONNECTED = 'SOCKET_CONNECTED' 17 | export const DISCONNECT_SOCKET = 'DISCONNECT_SOCKET' 18 | export const SOCKET_AUTH_SUCCESS = 'SOCKET_AUTH_SUCCESS' 19 | export const SOCKET_AUTH_ERR = 'SOCKET_AUTH_ERR' 20 | export const SOCKET_CONN_ERR = 'SOCKET_CONN_ERR' 21 | 22 | /* Lobby Action Types */ 23 | export const GET_LOBBY = 'server/GET_LOBBY' 24 | export const NEW_LOBBY = 'NEW_LOBBY' 25 | export const TAKE_SEAT = 'server/TAKE_SEAT' 26 | export const SUBSCRIBE_TO_TABLE = 'server/SUBSCRIBE_TO_TABLE' 27 | 28 | /* Game Action Types */ 29 | export const NEW_GAME_STATE = 'NEW_GAME_STATE' 30 | export const SUCCESSFULLY_SAT_DOWN = 'SUCCESSFULLY_SAT_DOWN' 31 | export const POST_BIG_BLIND = 'server/POST_BIG_BLIND' 32 | export const POST_SMALL_BLIND = 'server/POST_SMALL_BLIND' 33 | export const BET = 'server/BET' 34 | export const RAISE = 'server/RAISE' 35 | export const CHECK = 'server/CHECK' 36 | export const FOLD = 'server/FOLD' 37 | export const CALL = 'server/CALL' 38 | export const SIT_IN = 'server/SIT_IN' 39 | export const LEAVE_SEAT = 'server/LEAVE_SEAT' 40 | -------------------------------------------------------------------------------- /client/app/reducers/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { Map } from "immutable"; 3 | 4 | import reducer from "../auth"; 5 | import * as types from "../../actions/types"; 6 | 7 | const initialState = Map({ 8 | authenticated: false, 9 | username: null, 10 | error: null, 11 | isLoading: false 12 | }); 13 | 14 | describe("auth reducer", () => { 15 | it("should return the initial state", () => { 16 | expect(reducer(undefined, {})).toEqual(initialState); 17 | }); 18 | 19 | it("should handle authentication success", () => { 20 | const username = 'Argo' 21 | const expectedState = Map({ 22 | authenticated: true, 23 | username, 24 | error: null, 25 | isLoading: false 26 | }); 27 | 28 | expect( 29 | reducer(initialState, 30 | { type: types.AUTHENTICATED, username } 31 | )) 32 | .toEqual(expectedState) 33 | }); 34 | 35 | 36 | it("should handle authentication errors", () => { 37 | const error = '404' 38 | const expectedState = Map({ 39 | authenticated: false, 40 | username: null, 41 | error, 42 | isLoading: false 43 | }); 44 | 45 | expect(reducer( 46 | initialState, { type: types.AUTHENTICATION_ERROR, error } 47 | )).toEqual(expectedState) 48 | }); 49 | 50 | it("should handle logout", () => { 51 | const initialState = Map({ 52 | authenticated: true, 53 | username: 'Argo', 54 | error: null, 55 | isLoading: false 56 | }); 57 | const expectedState = Map({ 58 | authenticated: false, 59 | username: null, 60 | error: null, 61 | isLoading: false 62 | }); 63 | 64 | expect(reducer(initialState, { type: types.UNAUTHENTICATED })).toEqual(expectedState) 65 | }); 66 | }); -------------------------------------------------------------------------------- /server/src/Socket/Auth.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | module Socket.Auth where 5 | 6 | import Control.Monad.Except (runExceptT) 7 | import Crypto.JOSE as Jose (decodeCompact) 8 | import Crypto.JWT 9 | ( ClaimsSet, 10 | JWTError, 11 | decodeCompact, 12 | defaultJWTValidationSettings, 13 | verifyClaims, 14 | ) 15 | import Crypto.JWT as Jose 16 | ( ClaimsSet, 17 | JWTError, 18 | JWTValidationSettings, 19 | decodeCompact, 20 | defaultJWTValidationSettings, 21 | verifyClaims, 22 | ) 23 | import qualified Data.ByteString as BS 24 | import qualified Data.ByteString.Lazy as BL 25 | import Data.Either (Either) 26 | import qualified Data.Set as Set 27 | import Data.Text (Text) 28 | import qualified Data.Text as T 29 | import Database.Persist.Postgresql 30 | ( ConnectionString, 31 | entityVal, 32 | ) 33 | import qualified Network.WebSockets as WS 34 | import Servant.Auth.Server 35 | ( IsMatch (DoesNotMatch, Matches), 36 | JWTSettings (audienceMatches), 37 | defaultJWTSettings, 38 | fromSecret, 39 | ) 40 | import Text.Pretty.Simple (pPrint) 41 | import Prelude 42 | 43 | verifyJWT :: BS.ByteString -> BL.ByteString -> IO (Either JWTError ClaimsSet) 44 | verifyJWT key jwt = runExceptT $ do 45 | jwt' <- decodeCompact jwt 46 | -- decode JWT 47 | verifyClaims jwtCfg jwk jwt' 48 | where 49 | jwk = fromSecret key 50 | jwtCfg = jwtSettingsToJwtValidationSettings $ defaultJWTSettings jwk 51 | 52 | jwtSettingsToJwtValidationSettings :: JWTSettings -> Jose.JWTValidationSettings 53 | jwtSettingsToJwtValidationSettings s = 54 | defaultJWTValidationSettings 55 | (toBool <$> audienceMatches s) 56 | where 57 | toBool Matches = True 58 | toBool DoesNotMatch = False 59 | -------------------------------------------------------------------------------- /server/app/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Main where 4 | 5 | import Control.Concurrent.Async 6 | import qualified Data.ByteString.Lazy as BL 7 | import Data.Text.Encoding as TSE 8 | import Database.Redis ( defaultConnectInfo ) 9 | import Network.Wai.Handler.Warp 10 | import Prelude 11 | import qualified System.Remote.Monitoring as EKG 12 | 13 | import qualified Data.ByteString.Lazy.Char8 as C 14 | 15 | import API 16 | import Database 17 | import Env 18 | import Socket 19 | 20 | import Crypto.JWT 21 | import Data.Proxy 22 | import Types 23 | 24 | main :: IO ((), ()) 25 | main = do 26 | dbConnString <- getDBConnStrFromEnv 27 | userAPIPort <- getAuthAPIPort defaultUserAPIPort 28 | socketAPIPort <- getSocketAPIPort defaultSocketAPIPort 29 | redisConfig <- getRedisHostFromEnv defaultRedisHost 30 | print "REDIS config: " 31 | print redisConfig 32 | secretKey <- getSecretKey 33 | let runSocketAPI = 34 | runSocketServer secretKey socketAPIPort dbConnString redisConfig 35 | app' = app secretKey dbConnString redisConfig 36 | settings = setPort userAPIPort (setHost "0.0.0.0" defaultSettings) 37 | 38 | migrateDB dbConnString 39 | ekg <- runMonitoringServer 40 | concurrently (runSettings settings app') runSocketAPI 41 | where 42 | defaultUserAPIPort = 8000 43 | defaultSocketAPIPort = 5000 44 | defaultRedisHost = "localhost" 45 | defaultMonitoringServerAddress = "localhost" 46 | defaultMonitoringServerPort = 9999 47 | runMonitoringServer = 48 | EKG.forkServer defaultMonitoringServerAddress defaultMonitoringServerPort 49 | 50 | -------------------------------------------------------------------------------- /client/app/containers/GameContainer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { connect } from "react-redux"; 3 | 4 | import Game from '../components/Game' 5 | import { getGame, getPlayerPosition, isTurnToAct } from '../selectors/games' 6 | import { call, bet, fold, raise, check, postBigBlind, postSmallBlind, sitIn, leaveSeat } from '../actions/games' 7 | import { takeSeat } from '../actions/lobby' 8 | 9 | class GameContainer extends React.Component { 10 | constructor(props) { 11 | super(props) 12 | 13 | this.state = { 14 | betValue: 0 15 | } 16 | } 17 | handleChange = event => { 18 | this.setState({ betValue: event.target.value }); 19 | } 20 | 21 | render() { 22 | return () 23 | } 24 | } 25 | 26 | const mapStateToProps = (state, { username, match: { params: { tableName } } }) => ({ 27 | game: getGame(tableName)(state), 28 | isTurnToAct: isTurnToAct(username, tableName)(state), 29 | playerPosition: getPlayerPosition(tableName)(state) 30 | }); 31 | 32 | const mapDispatchToProps = (dispatch, { match: { params: { tableName } } }) => ({ 33 | bet: amount => dispatch(bet(tableName, amount)), 34 | raise: amount => dispatch(raise(tableName, amount)), 35 | call: () => dispatch(call(tableName)), 36 | fold: () => dispatch(fold(tableName)), 37 | check: () => dispatch(check(tableName)), 38 | postSmallBlind: () => dispatch(postSmallBlind(tableName)), 39 | postBigBlind: () => dispatch(postBigBlind(tableName)), 40 | sitDown: chips => dispatch(takeSeat(tableName, chips)), 41 | sitIn: () => dispatch(sitIn(tableName)), 42 | leaveGameSeat: () => dispatch(leaveSeat(tableName)) 43 | }); 44 | 45 | 46 | export default connect(mapStateToProps, mapDispatchToProps)(GameContainer) 47 | -------------------------------------------------------------------------------- /client/app/actions/profile.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import * as types from './types' 4 | 5 | /* Action Creators for User API authentication */ 6 | const AUTH_API_URL = 'https://tenpoker.co.uk' 7 | //process.env.NODE_ENV === 'production' ? 'https://tenpoker.co.uk' : 'http://localhost:8000' 8 | 9 | export const getProfileRequest = () => ({ type: types.GET_PROFILE_REQUEST }) 10 | 11 | export const getProfileSuccess = profile => ({ 12 | type: types.GET_PROFILE_SUCCESS, 13 | profile 14 | }) 15 | 16 | export const getProfileErr = error => ({ type: types.GET_PROFILE_ERR, error }) 17 | 18 | export const getProfile = username => { 19 | return dispatch => { 20 | const token = localStorage.getItem('token') 21 | if (token) { 22 | try { 23 | const { access_token } = JSON.parse(token) 24 | dispatch(getProfileRequest()) 25 | console.log('access token', access_token) 26 | axios 27 | .get(`${AUTH_API_URL}/profile`, { 28 | headers: { 29 | Authorization: access_token, 30 | 'Content-Type': 'application/json' 31 | } 32 | }) 33 | .then(({ data }) => { 34 | const profile = { 35 | chipsInPlay: data.proChipsInPlay, 36 | availableChips: data.proAvailableChips, 37 | userCreatedAt: data.proUserCreatedAt, 38 | username: data.proUsername, 39 | email: data.proEmail 40 | } 41 | 42 | dispatch(getProfileSuccess(profile)) 43 | }) 44 | .catch(err => dispatch(getProfileErr(err))) 45 | } catch (e) { 46 | console.log(e) 47 | dispatch(getProfileErr(e)) 48 | } 49 | } else { 50 | dispatch(getProfileErr('No JWT token for profile request')) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/app/components/App.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * App 4 | * 5 | * This component is the skeleton around the actual pages, and should only 6 | * contain code that should be seen on all pages. (e.g. navigation bar) 7 | */ 8 | 9 | import React from 'react' 10 | import { Helmet } from 'react-helmet' 11 | import { Switch, Route } from 'react-router-dom' 12 | 13 | import HomeContainer from '../containers/HomeContainer' 14 | import NavBarContainer from '../containers/NavBarContainer' 15 | import SignUpFormContainer from '../containers/SignUpFormContainer' 16 | import SignInFormContainer from '../containers/SignInFormContainer' 17 | import LobbyContainer from '../containers/LobbyContainer' 18 | import GameContainer from '../containers/GameContainer' 19 | import ProfileContainer from '../containers/ProfileContainer' 20 | 21 | import Footer from './Footer' 22 | import NotFoundPage from './NotFoundPage' 23 | import Signout from './Signout' 24 | 25 | const App = ({ username }) => ( 26 |
27 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | } 44 | /> 45 | 46 | 47 |
48 | ) 49 | 50 | export default App 51 | -------------------------------------------------------------------------------- /server/src/Schema.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DataKinds #-} 2 | {-# LANGUAGE DerivingStrategies #-} 3 | {-# LANGUAGE FlexibleInstances #-} 4 | {-# LANGUAGE GADTs #-} 5 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 6 | {-# LANGUAGE MultiParamTypeClasses #-} 7 | {-# LANGUAGE OverloadedStrings #-} 8 | {-# LANGUAGE QuasiQuotes #-} 9 | {-# LANGUAGE RecordWildCards #-} 10 | {-# LANGUAGE StandaloneDeriving #-} 11 | {-# LANGUAGE TemplateHaskell #-} 12 | {-# LANGUAGE TypeFamilies #-} 13 | {-# LANGUAGE UndecidableInstances #-} 14 | 15 | module Schema where 16 | 17 | import Control.Monad () 18 | import Data.Aeson () 19 | import Data.Aeson.Types () 20 | import Data.Text (Text) 21 | import Data.Time.Clock (UTCTime) 22 | import Database.Persist.TH 23 | ( mkMigrate, 24 | mkPersist, 25 | persistLowerCase, 26 | share, 27 | sqlSettings, 28 | ) 29 | import Poker.Types 30 | ( Bet, 31 | Card, 32 | Player, 33 | PlayerName, 34 | Street, 35 | Winners, 36 | ) 37 | 38 | share 39 | [mkPersist sqlSettings, mkMigrate "migrateAll"] 40 | [persistLowerCase| 41 | UserEntity json sql=users 42 | username Text 43 | email Text 44 | password Text 45 | availableChips Int 46 | chipsInPlay Int 47 | createdAt UTCTime default=now() 48 | UniqueEmail email 49 | UniqueUsername username 50 | deriving Show Read 51 | TableEntity json sql=tables 52 | name Text 53 | UniqueName name 54 | deriving Show Read 55 | GameEntity json sql=games 56 | tableID TableEntityId 57 | createdAt UTCTime default=now() 58 | players [Player] 59 | minBuyInChips Int 60 | maxBuyInChips Int 61 | maxPlayers Int 62 | board [Card] 63 | winners Winners 64 | waitlist [PlayerName] 65 | deck [Card] 66 | smallBlind Int 67 | bigBlind Int 68 | street Street 69 | pot Int 70 | maxBet Bet 71 | dealer Int 72 | currentPosToAct Int Maybe 73 | deriving Show Read 74 | |] 75 | -------------------------------------------------------------------------------- /client/app/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * app.js 3 | * 4 | * This is the entry file for the application, only setup and boilerplate 5 | * code. 6 | */ 7 | 8 | // Needed for redux-saga es6 generator support 9 | import 'babel-polyfill' 10 | 11 | // Import all the third party stuff 12 | import React from 'react' 13 | import ReactDOM from 'react-dom' 14 | import { Provider } from 'react-redux' 15 | import { ConnectedRouter } from 'react-router-redux' 16 | import createHistory from 'history/createBrowserHistory' 17 | 18 | import 'sanitize.css/sanitize.css' 19 | 20 | import AppContainer from 'containers/AppContainer' 21 | 22 | import { authSuccess } from './actions/auth' 23 | 24 | import 'styles/main.scss' 25 | 26 | import configureStore from './configureStore' 27 | 28 | // Create redux store with history 29 | const initialState = {} 30 | const history = createHistory() 31 | const store = configureStore(initialState, history) 32 | const MOUNT_NODE = document.getElementById('app') 33 | 34 | const render = () => { 35 | ReactDOM.render( 36 | 37 | 38 | 39 | 40 | , 41 | MOUNT_NODE 42 | ) 43 | } 44 | 45 | if (module.hot) { 46 | // Hot reloadable React components and translation json files 47 | // modules.hot.accept does not accept dynamic dependencies, 48 | // have to be constants at compile-time 49 | module.hot.accept(['containers/AppContainer'], () => { 50 | ReactDOM.unmountComponentAtNode(MOUNT_NODE) 51 | render() 52 | }) 53 | } 54 | 55 | // If we have a JWT token in localStorage then treat user as authenticated 56 | const token = localStorage.getItem('token') 57 | 58 | if (token) { 59 | try { 60 | const { username } = JSON.parse(localStorage.getItem('token')) 61 | store.dispatch(authSuccess(username)) 62 | } catch (e) { 63 | console.log(e) 64 | } 65 | } 66 | 67 | render() 68 | -------------------------------------------------------------------------------- /client/app/components/Seat.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | let isPlayerInactive = playerState => 4 | playerState === 'Folded' || 5 | playerState === 'SatOut' 6 | 7 | 8 | const Seat = ({ activePlayerCount, gameStage, isEveryoneAllIn, playerName, chips, isTurnToAct, hasPocketCards, position, playerState }) => { 9 | 10 | console.log('player state', playerState) 11 | console.log('has pockets', hasPocketCards) 12 | console.log('is everyone all in ', isEveryoneAllIn) 13 | console.log(' hasPocketCards && playerState !== "Folded" && !isEveryoneAllIn', hasPocketCards && playerState !== "Folded" && !isEveryoneAllIn) 14 | 15 | return ( 16 |
17 | {hasPocketCards && playerState == "In" && !isEveryoneAllIn && (gameStage !== 'Showdown') && (activePlayerCount > 1) ? 18 |
19 |
20 |
21 |
22 |
23 |
: ''} 24 |
30 |

{playerName || ''}

31 |

32 | {playerState == 'In' && chips == 0 ? 'All In' : ''} 33 | {playerState == 'Folded' ? 'Folded' : ''} 34 | {playerState == 'SatOut' ? 'Sat Out' : ''}

35 | {playerName ? 36 |

37 | 38 | {playerState == 'In' && chips == 0 ? '' : `$${chips}`}

: ''} 39 |
40 |
) 41 | 42 | } 43 | 44 | export default Seat; -------------------------------------------------------------------------------- /client/app/styles/components/game/_cards.scss: -------------------------------------------------------------------------------- 1 | @import "../../common"; 2 | 3 | .rank { 4 | font-family: "MonoP-Bold", sans-serif; 5 | font-weight: 800; 6 | height: 1.32em; 7 | } 8 | 9 | .suit > img { 10 | height: 0.75em; 11 | width: 0.75em; 12 | margin-bottom: 1.3em; 13 | } 14 | 15 | .suit-icon { 16 | width: 100%; 17 | } 18 | 19 | .card { 20 | @extend %card; 21 | } 22 | 23 | /* cards behind player oval representing opponent pockets outwith showdown */ 24 | .hidden-pocket-cards .card { 25 | position: absolute; 26 | z-index: -1; 27 | margin: 0; 28 | height: 3em; 29 | top: -1em; 30 | } 31 | 32 | .hidden-pocket-cards-container { 33 | width: 70%; 34 | margin: auto; 35 | } 36 | 37 | .pocket-one { 38 | right: 65%; 39 | margin-left: 0; 40 | } 41 | 42 | /* Pocket cards held by opponents that are face down */ 43 | .hidden-pocket-cards { 44 | position: relative; 45 | margin: auto; 46 | width: 0; 47 | } 48 | 49 | /* Cards displayed at showdown */ 50 | .showdown-pocket-cards-container { 51 | width: 100%; 52 | 53 | display: flex; 54 | } 55 | 56 | .showdown-pocket-cards-container:first-child { 57 | margin-right: 0.15em; 58 | } 59 | 60 | .showdown-pocket-cards-container:last-child { 61 | margin-right: 0.15em; 62 | } 63 | 64 | .showdown-pocket-cards-0 { 65 | @extend %showdown-pocket-cards; 66 | grid-area: 4/3; 67 | } 68 | 69 | .showdown-pocket-cards-1 { 70 | @extend %showdown-pocket-cards; 71 | grid-area: 3/1; 72 | } 73 | 74 | .showdown-pocket-cards-2 { 75 | @extend %showdown-pocket-cards; 76 | grid-area: 1/1; 77 | } 78 | 79 | .showdown-pocket-cards-3 { 80 | @extend %showdown-pocket-cards; 81 | grid-area: 1/3; 82 | } 83 | 84 | .showdown-pocket-cards-4 { 85 | @extend %showdown-pocket-cards; 86 | grid-area: 1/5; 87 | } 88 | 89 | .showdown-pocket-cards-5 { 90 | @extend %showdown-pocket-cards; 91 | grid-area: 3/5; 92 | } 93 | 94 | .showdown-pocket-cards-6 { 95 | @extend %showdown-pocket-cards; 96 | grid-area: 1/3; 97 | } 98 | -------------------------------------------------------------------------------- /client/app/styles/common/_mixins.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | 3 | /* 4 | --------------------------------- 5 | Media queries 6 | --------------------------------- 7 | */ 8 | 9 | // Small devices 10 | @mixin xs { 11 | @media (min-width: #{$screen-xs-min}) { 12 | @content; 13 | } 14 | } 15 | 16 | // Small devices 17 | @mixin sm { 18 | @media (min-width: #{$screen-sm-min}) { 19 | @content; 20 | } 21 | } 22 | 23 | // Medium devices 24 | @mixin md { 25 | @media (min-width: #{$screen-md-min}) { 26 | @content; 27 | } 28 | } 29 | 30 | // Large devices 31 | @mixin lg { 32 | @media (min-width: #{$screen-lg-min}) { 33 | @content; 34 | } 35 | } 36 | 37 | // Extra large devices 38 | @mixin xl { 39 | @media (min-width: #{$screen-xl-min}) { 40 | @content; 41 | } 42 | } 43 | 44 | // Extra large devices 45 | @mixin xxl { 46 | @media (min-width: #{$screen-xxl-min}) { 47 | @content; 48 | } 49 | } 50 | 51 | // Game 52 | %seat { 53 | margin: auto; 54 | justify-content: center; 55 | align-content: center; 56 | display: grid; 57 | width: 100%; 58 | height: 70%; 59 | border-radius: 7em; 60 | min-height: 5.3rem; 61 | } 62 | 63 | %showdown-pocket-cards { 64 | margin: 0; 65 | position: relative; 66 | top: 0.42em; 67 | justify-self: center; 68 | align-self: end; 69 | z-index: -1; 70 | } 71 | 72 | /* A poker cards dimensions are 63.5mm X 88.9mm */ 73 | %card { 74 | height: 2.5em; 75 | width: 1.7em; 76 | margin-left: 0.2em; 77 | text-align: center; 78 | font-weight: 800; 79 | background-color: $neutral-colour-100; 80 | border-radius: 0.33em; 81 | color: neutral-colour-900; 82 | } 83 | 84 | %dealer-btn { 85 | height: 1.9em; 86 | width: 1.9em; 87 | font-family: "Raleway", sans-serif; 88 | font-weight: 600; 89 | font-size: 1em; 90 | line-height: 2em; 91 | text-align: center; 92 | letter-spacing: -0.1em; 93 | border-radius: 1.7em; 94 | background-color: $neutral-accent-600; 95 | //box-shadow: 1px 1px 1px 0px rgba(18, 27, 33, 0.55); 96 | } 97 | -------------------------------------------------------------------------------- /client/app/configureStore.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Create the store with dynamic reducers 3 | */ 4 | 5 | import { createStore, applyMiddleware, compose } from 'redux' 6 | import { fromJS } from 'immutable' 7 | import { routerMiddleware } from 'react-router-redux' 8 | import reduxThunk from 'redux-thunk' 9 | 10 | import createReducer from './reducers' 11 | import socketMiddleware from "./middleware/socket"; 12 | 13 | export default function configureStore(initialState = {}, history) { 14 | // Create the store with two middlewares 15 | // 1. sagaMiddleware: Makes redux-sagas work 16 | // 2. routerMiddleware: Syncs the location/URL path to the state 17 | const middlewares = [reduxThunk, socketMiddleware, routerMiddleware(history)] 18 | 19 | const enhancers = [applyMiddleware(...middlewares)] 20 | 21 | // If Redux DevTools Extension is installed use it, otherwise use Redux compose 22 | /* eslint-disable no-underscore-dangle */ 23 | const composeEnhancers = 24 | process.env.NODE_ENV !== 'production' && 25 | typeof window === 'object' && 26 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ 27 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ 28 | // TODO Try to remove when `react-router-redux` is out of beta, LOCATION_CHANGE should not be fired more than once after hot reloading 29 | // Prevent recomputing reducers for `replaceReducer` 30 | shouldHotReload: false 31 | }) 32 | : compose 33 | /* eslint-enable */ 34 | 35 | const store = createStore( 36 | createReducer(), 37 | fromJS(initialState), 38 | composeEnhancers(...enhancers) 39 | ) 40 | 41 | // Extensions 42 | store.injectedReducers = {} // Reducer registry 43 | 44 | // Make reducers hot reloadable, see http://mxs.is/googmo 45 | /* istanbul ignore next */ 46 | if (module.hot) { 47 | module.hot.accept('./reducers', () => { 48 | store.replaceReducer(createReducer(store.injectedReducers)) 49 | store.dispatch({ type: '@@REDUCER_INJECTED' }) 50 | }) 51 | } 52 | 53 | return store 54 | } 55 | -------------------------------------------------------------------------------- /client/.gitattributes: -------------------------------------------------------------------------------- 1 | # From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text eol=lf 23 | *.coffee text 24 | *.json text 25 | *.htm text 26 | *.html text 27 | *.xml text 28 | *.svg text 29 | *.txt text 30 | *.ini text 31 | *.inc text 32 | *.pl text 33 | *.rb text 34 | *.py text 35 | *.scm text 36 | *.sql text 37 | *.sh text 38 | *.bat text 39 | 40 | # templates 41 | *.ejs text 42 | *.hbt text 43 | *.jade text 44 | *.haml text 45 | *.hbs text 46 | *.dot text 47 | *.tmpl text 48 | *.phtml text 49 | 50 | # server config 51 | .htaccess text 52 | .nginx.conf text 53 | 54 | # git config 55 | .gitattributes text 56 | .gitignore text 57 | .gitconfig text 58 | 59 | # code analysis config 60 | .jshintrc text 61 | .jscsrc text 62 | .jshintignore text 63 | .csslintrc text 64 | 65 | # misc config 66 | *.yaml text 67 | *.yml text 68 | .editorconfig text 69 | 70 | # build config 71 | *.npmignore text 72 | *.bowerrc text 73 | 74 | # Heroku 75 | Procfile text 76 | .slugignore text 77 | 78 | # Documentation 79 | *.md text 80 | LICENSE text 81 | AUTHORS text 82 | 83 | 84 | # 85 | ## These files are binary and should be left untouched 86 | # 87 | 88 | # (binary is a macro for -text -diff) 89 | *.png binary 90 | *.jpg binary 91 | *.jpeg binary 92 | *.gif binary 93 | *.ico binary 94 | *.mov binary 95 | *.mp4 binary 96 | *.mp3 binary 97 | *.flv binary 98 | *.fla binary 99 | *.swf binary 100 | *.gz binary 101 | *.zip binary 102 | *.7z binary 103 | *.ttf binary 104 | *.eot binary 105 | *.woff binary 106 | *.pyc binary 107 | *.pdf binary 108 | -------------------------------------------------------------------------------- /client/app/actions/games.js: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | 3 | export const newGameState = (tableName, gameState) => ({ 4 | type: types.NEW_GAME_STATE, 5 | tableName, 6 | gameState 7 | }) 8 | 9 | export const postBigBlind = tableName => ({ 10 | type: types.POST_BIG_BLIND, 11 | data: { 12 | tag: 'GameMsgIn', 13 | contents: { 14 | tag: 'GameMove', 15 | contents: [tableName, { tag: 'PostBlind', contents: 'Big' }] 16 | } 17 | } 18 | }) 19 | 20 | export const postSmallBlind = tableName => ({ 21 | type: types.POST_SMALL_BLIND, 22 | data: { 23 | tag: 'GameMsgIn', 24 | contents: { 25 | tag: 'GameMove', 26 | contents: [tableName, { tag: 'PostBlind', contents: 'Small' }] 27 | } 28 | } 29 | }) 30 | 31 | export const bet = (tableName, amount) => ({ 32 | type: types.BET, 33 | data: { 34 | tag: 'GameMsgIn', 35 | contents: { 36 | tag: 'GameMove', 37 | contents: [tableName, { tag: 'Bet', contents: Number(amount) }] 38 | } 39 | } 40 | }) 41 | 42 | export const raise = (tableName, amount) => ({ 43 | type: types.RAISE, 44 | data: { 45 | tag: 'GameMsgIn', 46 | contents: { 47 | tag: 'GameMove', 48 | contents: [tableName, { tag: 'Raise', contents: Number(amount) }] 49 | } 50 | } 51 | }) 52 | 53 | export const call = tableName => ({ 54 | type: types.CALL, 55 | data: { 56 | tag: 'GameMsgIn', 57 | contents: { tag: 'GameMove', contents: [tableName, { tag: 'Call' }] } 58 | } 59 | }) 60 | 61 | export const check = tableName => ({ 62 | type: types.CHECK, 63 | data: { 64 | tag: 'GameMsgIn', 65 | contents: { tag: 'GameMove', contents: [tableName, { tag: 'Check' }] } 66 | } 67 | }) 68 | 69 | export const fold = tableName => ({ 70 | type: types.FOLD, 71 | data: { 72 | tag: 'GameMsgIn', 73 | contents: { tag: 'GameMove', contents: [tableName, { tag: 'Fold' }] } 74 | } 75 | }) 76 | 77 | export const leaveSeat = tableName => ({ 78 | type: types.LEAVE_SEAT, 79 | data: { 80 | tag: 'GameMsgIn', 81 | contents: { 82 | tag: 'LeaveSeat', 83 | contents: tableName 84 | } 85 | } 86 | }) 87 | 88 | export const sitIn = tableName => ({ 89 | type: types.SIT_IN, 90 | data: { tag: 'GameMove', contents: [tableName, { tag: 'SitIn' }] } 91 | }) 92 | -------------------------------------------------------------------------------- /server/src/Env.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Env where 4 | 5 | import qualified Data.ByteString.Char8 as C 6 | import Data.ByteString.UTF8 as BSU (fromString) 7 | import Data.Maybe (Maybe (Just, Nothing), maybe) 8 | import Data.Text (pack) 9 | import Database.Redis 10 | ( ConnectInfo, 11 | Redis, 12 | connect, 13 | connectHost, 14 | connectPort, 15 | defaultConnectInfo, 16 | parseConnectInfo, 17 | runRedis, 18 | setex, 19 | ) 20 | import System.Environment (lookupEnv) 21 | import Text.Read (readMaybe) 22 | import Web.JWT (secret) 23 | import Prelude 24 | 25 | getRedisHostFromEnv :: String -> IO ConnectInfo 26 | getRedisHostFromEnv defaultHostName = do 27 | maybeConnInfo <- lookupEnv "redisHost" 28 | case maybeConnInfo of 29 | Nothing -> do 30 | print "couldn't parse redishost from env default used" 31 | return defaultRedisConn 32 | Just hostname -> do 33 | print "Redis host name from env is: " 34 | print hostname 35 | return $ defaultConnectInfo {connectHost = hostname} 36 | where 37 | defaultRedisConn = defaultConnectInfo {connectHost = defaultHostName} 38 | 39 | -- get the postgres connection string from dbConnStr env variable 40 | getDBConnStrFromEnv :: IO C.ByteString 41 | getDBConnStrFromEnv = do 42 | dbConnStr <- lookupEnv "dbConnStr" 43 | case dbConnStr of 44 | Nothing -> error "Missing dbConnStr in env" 45 | Just conn -> return $ C.pack conn 46 | 47 | -- get the port from the userAPIPort env variable 48 | getAuthAPIPort :: Int -> IO Int 49 | getAuthAPIPort defaultPort = do 50 | maybeEnvPort <- lookupEnv "authAPIPort" 51 | case maybeEnvPort of 52 | Nothing -> return defaultPort 53 | Just port -> maybe (return defaultPort) return (readMaybe port) 54 | 55 | -- get the port from the socketAPIPort env variable 56 | getSocketAPIPort :: Int -> IO Int 57 | getSocketAPIPort defaultPort = do 58 | maybeEnvPort <- lookupEnv "socketPort" 59 | case maybeEnvPort of 60 | Nothing -> return defaultPort 61 | Just port -> maybe (return defaultPort) return (readMaybe port) 62 | 63 | -- get the secret key for signing JWT authentication tokens 64 | getSecretKey :: IO C.ByteString 65 | getSecretKey = do 66 | maybeSecretKey <- lookupEnv "secret" 67 | case maybeSecretKey of 68 | Nothing -> error "Missing secret key in env" 69 | Just s -> return $ BSU.fromString s 70 | -------------------------------------------------------------------------------- /server/package.yaml: -------------------------------------------------------------------------------- 1 | name: poker-server 2 | version: 0.1.0.0 3 | github: "githubuser/poker-server" 4 | license: Unlicense 5 | author: "therewillbecode" 6 | maintainer: "tomw08@gmail.com" 7 | copyright: "2019 Tom Chambrier" 8 | 9 | extra-source-files: 10 | - README.md 11 | - ChangeLog.md 12 | 13 | description: A Poker Server Built With Haskell 14 | 15 | dependencies: 16 | - base >= 4.12 && < 5 17 | - adjunctions 18 | - distributive 19 | - async 20 | - aeson 21 | - bytestring 22 | - comonad 23 | - free 24 | - hedis 25 | - ekg 26 | - containers 27 | - cryptohash 28 | - hashable 29 | - jose 30 | - persistent 31 | - persistent-postgresql 32 | - persistent-template 33 | - time 34 | - servant 35 | - servant-server 36 | - servant-auth 37 | - servant-auth-server 38 | - servant-auth-client 39 | - servant-foreign 40 | - servant-options 41 | - servant-websockets 42 | - pipes 43 | - pipes-aeson 44 | - pipes-concurrency 45 | - pipes-parse 46 | - transformers 47 | - random 48 | - text 49 | - wai 50 | - wai-extra 51 | - wai-logger 52 | - wai-cors 53 | - websockets 54 | - pretty-simple 55 | - utf8-string 56 | - split 57 | - stm 58 | - MonadRandom 59 | - monad-logger 60 | - mtl 61 | - jwt 62 | - listsafe 63 | - warp 64 | - lens 65 | - vector 66 | 67 | library: 68 | source-dirs: src 69 | exposed-modules: 70 | - API 71 | - Bots 72 | - Database 73 | - Schema 74 | - Env 75 | - Types 76 | - Poker.Poker 77 | - Poker.Game.Actions 78 | - Poker.ActionValidation 79 | - Poker.Types 80 | - Poker.Game.Blinds 81 | - Poker.Game.Game 82 | - Poker.Game.Hands 83 | - Poker.Game.Utils 84 | - Poker.Game.Privacy 85 | - Socket 86 | - Socket.Table 87 | 88 | executables: 89 | poker-server-exe: 90 | main: Main.hs 91 | source-dirs: app 92 | ghc-options: 93 | - -threaded 94 | - -rtsopts 95 | - -with-rtsopts=-N 96 | dependencies: 97 | - poker-server 98 | 99 | tests: 100 | spec: 101 | main: Spec.hs 102 | source-dirs: test 103 | ghc-options: 104 | - -threaded 105 | - -rtsopts 106 | - -with-rtsopts=-N 107 | dependencies: 108 | - poker-server 109 | - hspec 110 | - hedgehog 111 | - hspec-hedgehog -------------------------------------------------------------------------------- /server/docs/userAPI.md: -------------------------------------------------------------------------------- 1 | ## POST /register 2 | 3 | ### Request: 4 | 5 | - Supported content types are: 6 | 7 | - `application/json;charset=utf-8` 8 | - `application/json` 9 | 10 | - Sample Register (`application/json;charset=utf-8`, `application/json`): 11 | 12 | ```javascript 13 | 14 | { 15 | newUsername: "Argo", 16 | newEmail: "gooby@goo.com", 17 | newPassword: "password123" 18 | } 19 | ``` 20 | 21 | ### Response: 22 | 23 | - Supported content types are: 24 | 25 | - `application/json;charset=utf-8` 26 | - `application/json` 27 | 28 | - Sample ReturnToken (`application/json;charset=utf-8`, `application/json`): 29 | 30 | ```javascript 31 | 32 | { 33 | "access_token": "eyJhbGciOiJIUzI1NiIs", 34 | "expiration": 3600, 35 | "refresh_token": "EwMIjImdgoeswazNQx" 36 | } 37 | 38 | ``` 39 | 40 | ## POST /login 41 | 42 | ### Request: 43 | 44 | - Supported content types are: 45 | 46 | - `application/json;charset=utf-8` 47 | - `application/json` 48 | 49 | - Sample Login (`application/json;charset=utf-8`, `application/json`): 50 | 51 | ```javascript 52 | 53 | { 54 | loginUsername: "gooby", 55 | loginPassword: "password123" 56 | } 57 | ``` 58 | 59 | ### Response: 60 | 61 | - Supported content types are: 62 | 63 | - `application/json;charset=utf-8` 64 | - `application/json` 65 | 66 | - Sample ReturnToken (`application/json;charset=utf-8`, `application/json`): 67 | 68 | ```javascript 69 | 70 | { 71 | "access_token": "eyJhbGciOiJIUzI1NiIs", 72 | "expiration": 3600, 73 | "refresh_token": "EwMIjImdgoeswazNQx" 74 | } 75 | 76 | ``` 77 | 78 | ## GET /profile 79 | 80 | ### Request: 81 | 82 | - Supported content types are: 83 | 84 | - `application/json;charset=utf-8` 85 | - `application/json` 86 | 87 | - Headers: 88 | - Authorization: eyJhbGciOiJIUzI1NiIs 89 | 90 | Ensure access token is in Authorization header 91 | 92 | ### Response: 93 | 94 | - Supported content types are: 95 | 96 | - `application/json;charset=utf-8` 97 | - `application/json` 98 | 99 | - Sample ReturnToken (`application/json;charset=utf-8`, `application/json`): 100 | 101 | ```javascript 102 | 103 | { 104 | "proChips": 3000, 105 | "proUsername": "Argo", 106 | "proEmail": "gooby@goo.com" 107 | } 108 | 109 | ``` 110 | -------------------------------------------------------------------------------- /client/app/styles/components/game/_seat.scss: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | The real power of mixins comes when you pass them arguments. 4 | Arguments are declared as a parenthesized, comma-separated list of variables. 5 | 6 | Each of those variables is assigned a value each time the mixin is used. 7 | 8 | @mixin default-box($color, $boxModel, $padding) { 9 | $borderColor: $color; 10 | border: 1px solid $borderColor; 11 | clear: both; 12 | display: $boxModel; 13 | margin: 5px 0; 14 | padding: 5px $padding; 15 | } 16 | 17 | header{ @include default-box(#666, block, 10px); } 18 | footer{ @include default-box(#999, inline-block, 5px); 19 | */ 20 | /* Mixins */ 21 | @import "../../common"; 22 | 23 | .player-chip-count > .monospaced-font-bold { 24 | font-size: 1.2em; 25 | @include lg { 26 | font-size: 1.2em; 27 | } 28 | @include xxl { 29 | font-size: 1.33em; 30 | } 31 | } 32 | 33 | // current seated player's turn to act 34 | .active-player { 35 | border: 0.3em; 36 | border-style: solid; 37 | box-sizing: border-box; 38 | -moz-box-sizing: border-box; 39 | -webkit-box-sizing: border-box; 40 | border-color: $neutral-accent-100; 41 | } 42 | 43 | .disabled { 44 | opacity: 0.9; 45 | } 46 | 47 | div[class^="seat-"] > h4 { 48 | margin: 0; 49 | } 50 | div[class^="seat-"] > h5 { 51 | margin: 0; 52 | } 53 | div[class^="seat-"] > h3 { 54 | margin: 0; 55 | } 56 | 57 | .seat-0 { 58 | @extend %seat; 59 | } 60 | 61 | .seat-0-container { 62 | grid-area: 5/3; 63 | } 64 | 65 | .seat-1 { 66 | @extend %seat; 67 | } 68 | 69 | .seat-1-container { 70 | grid-area: 4/1; 71 | } 72 | 73 | .seat-2 { 74 | @extend %seat; 75 | grid-area: 2/1; 76 | } 77 | 78 | .seat-2-container { 79 | grid-area: 2/1; 80 | } 81 | 82 | .seat-3 { 83 | @extend %seat; 84 | grid-area: 1/3; 85 | } 86 | 87 | .seat-3-container { 88 | grid-area: 1/3; 89 | } 90 | 91 | .seat-4 { 92 | @extend %seat; 93 | grid-area: 2/5; 94 | } 95 | 96 | .seat-4-container { 97 | grid-area: 2/5; 98 | } 99 | 100 | .seat-5 { 101 | @extend %seat; 102 | } 103 | 104 | .seat-5-container { 105 | grid-area: 4/5; 106 | } 107 | 108 | .empty-seat { 109 | opacity: 0.15; 110 | color: transparent; 111 | } 112 | 113 | .empty-seat:hover { 114 | opacity: 0.2; 115 | transition: ease-in-out 0.1s; 116 | color: $primary-colour-700; 117 | } 118 | -------------------------------------------------------------------------------- /client/app/components/NavBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const NavBar = ({ 4 | isAuthenticated, 5 | currRoute, 6 | username, 7 | history, 8 | logoutUser 9 | }) => ( 10 | 85 | ) 86 | 87 | export default NavBar 88 | -------------------------------------------------------------------------------- /client/app/components/SignUpForm.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const SignUpForm = ({ handleChange, handleSubmit }) => ( 4 |
5 |
6 |

Sign Up

7 | 8 |
9 |
10 |

11 | handleChange(e)} 17 | /> 18 | 19 | 20 | 21 | 22 | 23 | 24 |

25 |
26 |
27 |

28 | handleChange(e)} 34 | /> 35 | 36 | 37 | 38 |

39 |
40 |
41 |

42 | handleChange(e)} 48 | /> 49 | 50 | 51 | 52 |

53 |
54 |
55 |

56 | handleChange(e)} 62 | /> 63 | 64 | 65 | 66 |

67 |
68 |
69 |

70 | 71 |

72 |
73 | 74 |
75 |
76 | ) 77 | 78 | export default SignUpForm 79 | -------------------------------------------------------------------------------- /server/src/Poker/Game/Hands.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE TupleSections #-} 3 | 4 | module Poker.Game.Hands where 5 | 6 | import Data.Function (on) 7 | import Data.List (foldl', groupBy, nubBy, sort, sortBy) 8 | import Data.Ord (comparing) 9 | import Poker.Types 10 | ( Card (rank, suit), 11 | HandRank (..), 12 | Rank (Ace, Five, Four, Three, Two), 13 | ) 14 | 15 | type RankGroup = Int 16 | 17 | value :: [Card] -> (HandRank, [Card]) 18 | value hand = maybe (ifNotFlush hand) ifFlush (maybeFlush hand) 19 | 20 | ifNotFlush :: [Card] -> (HandRank, [Card]) 21 | ifNotFlush hand = maybe (checkGroups hand) (Straight,) (maybeStraight hand) 22 | 23 | ifFlush :: [Card] -> (HandRank, [Card]) 24 | ifFlush hand = 25 | maybe (Flush, take 5 hand) (StraightFlush,) (maybeStraight hand) 26 | 27 | lastNelems :: Int -> [a] -> [a] 28 | lastNelems n xs = foldl' (const . drop 1) xs (drop n xs) 29 | 30 | maybeFlush :: [Card] -> Maybe [Card] 31 | maybeFlush cs 32 | | length cs' >= 5 = Just cs' 33 | | otherwise = Nothing 34 | where 35 | sortBySuit = sortBy (comparing suit <> flip compare) 36 | groupBySuit = groupBy ((==) `on` suit) 37 | cs' = head $ sortByLength $ groupBySuit $ sortBySuit cs 38 | 39 | maybeStraight :: [Card] -> Maybe [Card] 40 | maybeStraight cards 41 | | length cs'' >= 5 = Just (lastNelems 5 cs'') 42 | | otherwise = maybeWheel cardsUniqRanks 43 | where 44 | cardsUniqRanks = nubBy ((==) `on` rank) cards 45 | cs'' = head $ sortByLength $ groupBySuccCards $ sort cardsUniqRanks 46 | 47 | maybeWheel :: [Card] -> Maybe [Card] 48 | maybeWheel cards 49 | | length filteredCards == 5 = Just filteredCards 50 | | otherwise = Nothing 51 | where 52 | filteredCards = 53 | (flip elem [Ace, Two, Three, Four, Five] . rank) `filter` cards 54 | 55 | checkGroups :: [Card] -> (HandRank, [Card]) 56 | checkGroups hand = (hRank, cards) 57 | where 58 | groups = sortByLength $ groupBy ((==) `on` rank) $ sort hand 59 | cards = take 5 $ concat groups 60 | groupedRankLengths = length <$> groups 61 | hRank = evalGroupedRanks groupedRankLengths 62 | 63 | evalGroupedRanks :: [RankGroup] -> HandRank 64 | evalGroupedRanks = \case 65 | (4 : _) -> Quads 66 | (3 : 2 : _) -> FullHouse 67 | (3 : _) -> Trips 68 | (2 : 2 : _) -> TwoPair 69 | (2 : _) -> Pair 70 | _ -> HighCard 71 | 72 | groupBySuccCards :: [Card] -> [[Card]] 73 | groupBySuccCards = foldr f [] 74 | where 75 | f :: Card -> [[Card]] -> [[Card]] 76 | f a [] = [[a]] 77 | f a xs@(x : xs') 78 | | succ (rank a) == rank (head x) = (a : x) : xs' 79 | | otherwise = [a] : xs 80 | 81 | sortByLength :: Ord a => [[a]] -> [[a]] 82 | sortByLength = sortBy (flip (comparing length) <> flip compare) 83 | -------------------------------------------------------------------------------- /client/app/actions/auth.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | 3 | import * as types from './types' 4 | import { checkStatus } from '../utils/request' 5 | 6 | /* Action Creators for Socket API authentication */ 7 | 8 | // Redux Socket Middleware intercepts this action and handles connection logic 9 | export const connectSocket = token => ({ 10 | type: types.CONNECT_SOCKET, 11 | token 12 | }) 13 | 14 | export const disconnectSocket = () => ({ type: types.DISCONNECT_SOCKET }) 15 | 16 | export const logoutUser = history => dispatch => { 17 | localStorage.removeItem('token') 18 | dispatch(logout()) 19 | dispatch(disconnectSocket()) 20 | history.push('/') 21 | } 22 | 23 | console.log('env var', process.env) 24 | 25 | const AUTH_API_URL = 26 | process.env.NODE_ENV === 'docker' 27 | ? 'http://192.168.99.100:8000' 28 | : process.env.NODE_ENV === 'production' 29 | ? 'https://tenpoker.co.uk' 30 | : 'http://localhost:8000' 31 | 32 | export const authRequested = () => ({ type: types.AUTH_REQUESTED }) 33 | 34 | export const authSuccess = username => ({ type: types.AUTHENTICATED, username }) 35 | 36 | export const authError = error => ({ type: types.AUTHENTICATION_ERROR, error }) 37 | 38 | export const logout = () => ({ type: types.UNAUTHENTICATED }) 39 | 40 | export function login(username, password, history) { 41 | return async dispatch => { 42 | dispatch(authRequested()) 43 | axios 44 | .post( 45 | `${AUTH_API_URL}/login`, 46 | { 47 | loginUsername: username, 48 | loginPassword: password 49 | }, 50 | { 51 | headers: { 52 | 'Access-Control-Allow-Origin': '*' 53 | } 54 | } 55 | ) 56 | .then(({ data }) => { 57 | const { access_token } = data 58 | dispatch(authSuccess(username)) 59 | dispatch(connectSocket(access_token)) 60 | localStorage.setItem('token', JSON.stringify({ ...data, username })) 61 | history.push('/profile') 62 | }) 63 | .catch(err => dispatch(authError(err))) 64 | } 65 | } 66 | 67 | export function register(username, email, password, history) { 68 | return async dispatch => { 69 | dispatch(authRequested()) 70 | axios 71 | .post(`${AUTH_API_URL}/register`, { 72 | newUsername: username, 73 | newUserEmail: email, 74 | newUserPassword: password 75 | }) 76 | .then(({ data }) => { 77 | const { access_token } = data 78 | dispatch(authSuccess(username)) 79 | dispatch(connectSocket(access_token)) 80 | localStorage.setItem('token', JSON.stringify({ ...data, username })) 81 | history.push('/profile') 82 | }) 83 | .catch(err => dispatch(authError(err))) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /server/src/Poker/Game/Privacy.hs: -------------------------------------------------------------------------------- 1 | {- 2 | Logic for excluding sensitive game data from game state. 3 | -} 4 | {-# LANGUAGE RecordWildCards #-} 5 | 6 | module Poker.Game.Privacy where 7 | 8 | import Control.Lens ((%~), (&), (.~)) 9 | import Data.Text (Text) 10 | import Poker.Game.Game (canPubliciseActivesCards) 11 | import Poker.Types 12 | 13 | -- For players that are sat in game 14 | excludeOtherPlayerCards :: PlayerName -> Game -> Game 15 | excludeOtherPlayerCards playerName = excludePrivateCards $ Just playerName 16 | 17 | -- -- For spectators who aren't in game 18 | excludeAllPlayerCards :: Game -> Game 19 | excludeAllPlayerCards = excludePrivateCards Nothing 20 | 21 | -- Exclude player cards and Deck so spectators can't see private cards. 22 | -- 23 | -- Takes an optional playerName. If no playerName is given then all private 24 | -- cards are excluded. However if playerName is given then their cards 25 | -- will not be excluded. 26 | -- 27 | -- So if a game update is going to be sent to a user then we pass in his playerName 28 | -- so that information that is private to him is not excluded from the 29 | -- Game state (his pocket cards) 30 | -- 31 | -- If everyone in the game is AllIn then their pocket cards should all be visible. 32 | -- 33 | ---- We show all active players cards in the case of every active player being all in 34 | -- or during the final showdown stage of the game 35 | -- 36 | -- If they are 37 | -- then all the pocket cards held by players whose _playerStatus 38 | -- is set to In (active and) are public and therefore not removed. 39 | excludePrivateCards :: Maybe PlayerName -> Game -> Game 40 | excludePrivateCards maybePlayerName game = 41 | game & (players %~ (<$>) pocketCardsPrivacyModifier) . (deck .~ Deck []) 42 | where 43 | showAllActivesCards = canPubliciseActivesCards game 44 | pocketCardsPrivacyModifier = 45 | maybe 46 | (updatePocketCardsForSpectator showAllActivesCards) 47 | (updatePocketCardsForPlayer showAllActivesCards) 48 | maybePlayerName 49 | 50 | updatePocketCardsForSpectator :: Bool -> (Player -> Player) 51 | updatePocketCardsForSpectator showAllActivesCards 52 | | showAllActivesCards = \player@Player {..} -> 53 | if _playerStatus /= InHand Folded then player else Player {_pockets = Nothing, ..} 54 | | otherwise = \Player {..} -> Player {_pockets = Nothing, ..} 55 | 56 | updatePocketCardsForPlayer :: Bool -> PlayerName -> (Player -> Player) 57 | updatePocketCardsForPlayer showAllActivesCards playerName 58 | | showAllActivesCards = \player@Player {..} -> 59 | if (_playerStatus /= InHand Folded) || (_playerName == playerName) 60 | then player 61 | else Player {_pockets = Nothing, ..} 62 | | otherwise = \player@Player {..} -> 63 | if _playerName == playerName 64 | then player 65 | else Player {_pockets = Nothing, ..} 66 | -------------------------------------------------------------------------------- /server/test/PokerSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | {-# LANGUAGE ScopedTypeVariables #-} 4 | 5 | module PokerSpec where 6 | 7 | import Data.Text (Text) 8 | import qualified Data.Text as T 9 | import Hedgehog (Property, forAll, property, withDiscards, (===)) 10 | import qualified Hedgehog.Gen as Gen 11 | import qualified Hedgehog.Range as Range 12 | import Poker.Game.Game (haveAllPlayersActed) 13 | import Poker.Game.Utils (getActivePlayers) 14 | import Poker.Generators (allPStates, genGame) 15 | import Poker.Poker (canProgressGame) 16 | import Poker.Types 17 | import Test.Hspec (SpecWith, describe, it) 18 | import Test.Hspec.Hedgehog (forAll, hedgehog, (===)) 19 | 20 | player1 :: Player 21 | player1 = 22 | Player 23 | { _pockets = 24 | Just $ 25 | PocketCards 26 | Card {rank = Three, suit = Diamonds} 27 | Card {rank = Four, suit = Spades}, 28 | _chips = 2000, 29 | _bet = 50, 30 | _playerStatus = SatIn NotFolded, 31 | _playerName = "player1", 32 | _committed = 50, 33 | _actedThisTurn = True, 34 | _possibleActions = [] 35 | } 36 | 37 | player2 :: Player 38 | player2 = 39 | Player 40 | { _pockets = 41 | Just $ 42 | PocketCards 43 | Card {rank = Three, suit = Clubs} 44 | Card {rank = Four, suit = Hearts}, 45 | _chips = 0, 46 | _bet = 0, 47 | _playerStatus = SatIn NotFolded, 48 | _playerName = "player2", 49 | _committed = 50, 50 | _actedThisTurn = False, 51 | _possibleActions = [] 52 | } 53 | 54 | player3 :: Player 55 | player3 = 56 | Player 57 | { _pockets = Nothing, 58 | _chips = 2000, 59 | _bet = 0, 60 | _playerStatus = SatIn NotFolded, 61 | _playerName = "player3", 62 | _committed = 50, 63 | _actedThisTurn = False, 64 | _possibleActions = [] 65 | } 66 | 67 | player4 :: Player 68 | player4 = 69 | Player 70 | { _pockets = Nothing, 71 | _chips = 2000, 72 | _bet = 0, 73 | _playerStatus = SatOut, 74 | _playerName = "player4", 75 | _committed = 0, 76 | _actedThisTurn = False, 77 | _possibleActions = [] 78 | } 79 | 80 | player5 :: Player 81 | player5 = 82 | Player 83 | { _pockets = 84 | Just $ 85 | PocketCards 86 | Card {rank = King, suit = Diamonds} 87 | Card {rank = Four, suit = Spades}, 88 | _chips = 2000, 89 | _bet = 50, 90 | _playerStatus = SatIn NotFolded, 91 | _playerName = "player1", 92 | _committed = 50, 93 | _actedThisTurn = True, 94 | _possibleActions = [] 95 | } 96 | 97 | initPlayers :: [Player] 98 | initPlayers = [player1, player2, player3] 99 | 100 | spec :: SpecWith () 101 | spec = describe "Poker" $ do 102 | return () -------------------------------------------------------------------------------- /server/src/Socket/Lobby.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | module Socket.Lobby where 5 | 6 | import Control.Concurrent 7 | ( MVar, 8 | modifyMVar, 9 | modifyMVar_, 10 | readMVar, 11 | ) 12 | import Control.Concurrent.STM (atomically, newBroadcastTChan) 13 | import Control.Concurrent.STM.TChan (newBroadcastTChan) 14 | import Control.Lens (At (at), (.~), (?~)) 15 | import Control.Lens.At (At (at)) 16 | import Control.Monad (void) 17 | import Control.Monad.STM (atomically) 18 | import Data.ByteString.Char8 19 | ( pack, 20 | unpack, 21 | ) 22 | import Data.Int (Int64) 23 | import Data.List (unfoldr) 24 | import Data.Map.Lazy (Map) 25 | import qualified Data.Map.Lazy as M 26 | import Data.Text (Text) 27 | import Pipes.Concurrent (atomically, newest, spawn) 28 | import Poker.Game.Utils (shuffledDeck) 29 | import Poker.Poker (initPlayer, initialGameState) 30 | import Poker.Types (Game (..), unChips) 31 | import Socket.Types 32 | ( Lobby (..), 33 | Table (..), 34 | TableName, 35 | TableSummary (..), 36 | headsUpBotsConfig, 37 | ) 38 | import Socket.Utils (unLobby) 39 | import System.Random (getStdGen) 40 | import Types (Username (..)) 41 | 42 | initialLobby :: IO Lobby 43 | initialLobby = do 44 | chan <- atomically newBroadcastTChan 45 | randGen <- getStdGen 46 | let shuffledDeck' = shuffledDeck randGen 47 | (output, input) <- spawn $ newest 1 48 | let tableName = "Black" 49 | let table' = 50 | Table 51 | { subscribers = [], 52 | gameInMailbox = output, 53 | gameOutMailbox = input, 54 | waitlist = [], 55 | game = initialGameState shuffledDeck', 56 | channel = chan, 57 | config = headsUpBotsConfig 58 | } 59 | return $ Lobby $ M.fromList [("Black", table')] 60 | 61 | joinGame :: Username -> Int -> Game -> Game 62 | joinGame (Username username) chips Game {..} = 63 | Game {_players = _players <> pure player, ..} 64 | where 65 | player = initPlayer username chips 66 | 67 | joinTableWaitlist :: Username -> Table -> Table 68 | joinTableWaitlist username Table {..} = 69 | Table {waitlist = waitlist <> [username], ..} 70 | 71 | insertTable :: TableName -> Table -> Lobby -> Lobby 72 | insertTable tableName newTable = Lobby . (at tableName ?~ newTable) . unLobby 73 | 74 | canJoinGame :: Game -> Bool 75 | canJoinGame Game {..} = length _players < _maxPlayers 76 | 77 | summariseGame :: TableName -> Table -> TableSummary 78 | summariseGame tableName Table {game = Game {..}, ..} = 79 | TableSummary 80 | { _tableName = tableName, 81 | _playerCount = length _players, 82 | _waitlistCount = length _waitlist, 83 | _minBuyInChips = unChips _minBuyInChips, 84 | _maxBuyInChips = unChips _maxBuyInChips, 85 | .. 86 | } 87 | 88 | summariseTables :: Lobby -> [TableSummary] 89 | summariseTables (Lobby lobby) = uncurry summariseGame <$> M.toList lobby 90 | -------------------------------------------------------------------------------- /server/stack.yaml.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by Stack. 2 | # You should not edit this file by hand. 3 | # For more information, please see the documentation at: 4 | # https://docs.haskellstack.org/en/stable/lock_files 5 | 6 | packages: 7 | - completed: 8 | hackage: servant-options-0.1.0.0@sha256:39f50166a68006250e79370372f553ca476f14d06d93fa0c401050253aeba803,914 9 | pantry-tree: 10 | size: 293 11 | sha256: 7812baf1b27c62068ba634207fbd7077b886424d666b36739a78c6d4008a1fd4 12 | original: 13 | hackage: servant-options-0.1.0.0 14 | - completed: 15 | hackage: jwt-0.7.2@sha256:b5858c05476741b4dc7f9f075bb8c8aca128ed25a9f325d937d370aa3d4910e1,4073 16 | pantry-tree: 17 | size: 978 18 | sha256: 8e5b90fe8051580276d073ec028eeb2073f993c42ed9db040d73261978687a0b 19 | original: 20 | hackage: jwt-0.7.2 21 | - completed: 22 | hackage: servant-websockets-2.0.0@sha256:6e9e3600bced90fd52ed3d1bf632205cb21479075b20d6637153cc4567000234,2253 23 | pantry-tree: 24 | size: 523 25 | sha256: 085c6620bff7671bef1d969652a349271c3703fbf10dd753cb63ee1cd700bca5 26 | original: 27 | hackage: servant-websockets-2.0.0@sha256:6e9e3600bced90fd52ed3d1bf632205cb21479075b20d6637153cc4567000234,2253 28 | - completed: 29 | hackage: servant-auth-client-0.4.1.0@sha256:96d8153907a00ef05e8918ca03a972d95ecde485da0df12f6532d248f057eb60,3437 30 | pantry-tree: 31 | size: 480 32 | sha256: 1d87e9ef405be1b7b728dfd9058ef79220398633b36996c7ff363278ed7ab459 33 | original: 34 | hackage: servant-auth-client-0.4.1.0@sha256:96d8153907a00ef05e8918ca03a972d95ecde485da0df12f6532d248f057eb60,3437 35 | - completed: 36 | hackage: ekg-0.4.0.15@sha256:d6e48859a89fbbe23496f871581e44a41f97dac627c2b9db81f49b92fa066516,2031 37 | pantry-tree: 38 | size: 1495 39 | sha256: e56e9c0eadc309a27400af106a2d3133d70ad0a3bbbb9be2c54e837ccdb4a77d 40 | original: 41 | hackage: ekg-0.4.0.15@sha256:d6e48859a89fbbe23496f871581e44a41f97dac627c2b9db81f49b92fa066516,2031 42 | - completed: 43 | hackage: ekg-core-0.1.1.7@sha256:c4356aefea0e1e2f80a236d3b3f81b83b445c1e53519302e96477da0adee2e9f,2039 44 | pantry-tree: 45 | size: 1073 46 | sha256: f403baeb7787c69c5e28f5aa1e94f78d4363beb19362d66f18de4a1aef0723fe 47 | original: 48 | hackage: ekg-core-0.1.1.7@sha256:c4356aefea0e1e2f80a236d3b3f81b83b445c1e53519302e96477da0adee2e9f,2039 49 | - completed: 50 | hackage: ekg-json-0.1.0.6@sha256:e16efc1b09ae7635db3f0535335ee3e8aa666fe4bf3749783f4022020f6ca3b8,1050 51 | pantry-tree: 52 | size: 265 53 | sha256: 09239a4e3ab0d58be0ac360d41c491df017f4a18ba558fd67909e24f14caece7 54 | original: 55 | hackage: ekg-json-0.1.0.6@sha256:e16efc1b09ae7635db3f0535335ee3e8aa666fe4bf3749783f4022020f6ca3b8,1050 56 | snapshots: 57 | - completed: 58 | size: 586268 59 | url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/18/13.yaml 60 | sha256: d9e658a22cfe8d87a64fdf219885f942fef5fe2bcb156a9800174911c5da2443 61 | original: lts-18.13 62 | -------------------------------------------------------------------------------- /server/stack.yaml: -------------------------------------------------------------------------------- 1 | # Resolver to choose a 'specific' stackage snapshot or a compiler version. 2 | # A snapshot resolver dictates the compiler version and the set of packages 3 | # to be used for project dependencies. For example: 4 | # 5 | # resolver: lts-3.5 6 | # resolver: nightly-2015-09-21 7 | # resolver: ghc-7.10.2 8 | # resolver: ghcjs-0.1.0_ghc-7.10.2 9 | # 10 | # The location of a snapshot can be provided as a file or url. Stack assumes 11 | # a snapshot provided as a file might change, whereas a url resource does not. 12 | # 13 | # resolver: ./custom-snapshot.yaml 14 | # resolver: https://example.com/snapshots/2018-01-01.yaml 15 | resolver: lts-18.13 16 | 17 | # User packages to be built. 18 | # Various formats can be used as shown in the example below. 19 | # 20 | # packages: 21 | # - some-directory 22 | # - https://example.com/foo/bar/baz-0.0.2.tar.gz 23 | # - location: 24 | # git: https://github.com/commercialhaskell/stack.git 25 | # commit: e7b331f14bcffb8367cd58fbfc8b40ec7642100a 26 | # - location: https://github.com/commercialhaskell/stack/commit/e7b331f14bcffb8367cd58fbfc8b40ec7642100a 27 | # subdirs: 28 | # - auto-update 29 | # - wai 30 | packages: 31 | - . 32 | allow-newer: true 33 | extra-deps: 34 | - servant-options-0.1.0.0 35 | - jwt-0.7.2 36 | - servant-websockets-2.0.0@sha256:6e9e3600bced90fd52ed3d1bf632205cb21479075b20d6637153cc4567000234,2253 37 | - servant-auth-client-0.4.1.0@sha256:96d8153907a00ef05e8918ca03a972d95ecde485da0df12f6532d248f057eb60,3437 38 | - ekg-0.4.0.15@sha256:d6e48859a89fbbe23496f871581e44a41f97dac627c2b9db81f49b92fa066516,2031 39 | - ekg-core-0.1.1.7@sha256:c4356aefea0e1e2f80a236d3b3f81b83b445c1e53519302e96477da0adee2e9f,2039 40 | - ekg-json-0.1.0.6@sha256:e16efc1b09ae7635db3f0535335ee3e8aa666fe4bf3749783f4022020f6ca3b8,1050 41 | 42 | 43 | nix: 44 | enable: false 45 | pure: false 46 | packages: [ postgresql zlib ] 47 | 48 | # This file was automatically generated by 'stack init' 49 | # 50 | # Some commonly used options have been documented as comments in this file. 51 | # For advanced use and comprehensive documentation of the format, please see: 52 | # https://docs.haskellstack.org/en/stable/yaml_configuration/ 53 | 54 | local-bin-path: build 55 | # Dependency packages to be pulled from upstream that are not in the resolver 56 | # using the same syntax as the packages field. 57 | # (e.g., acme-missiles-0.3) 58 | # extra-deps: [] 59 | 60 | # Override default flag values for local packages and extra-deps 61 | # flags: {} 62 | 63 | # Extra package databases containing global packages 64 | # extra-package-dbs: [] 65 | 66 | # Control whether we use the GHC we find on the path 67 | # system-ghc: true 68 | # 69 | # Require a specific version of stack, using version ranges 70 | # require-stack-version: -any # Default 71 | # require-stack-version: ">=1.7" 72 | # 73 | # Override the architecture used by stack, especially useful on Windows 74 | # arch: i386 75 | # arch: x86_64 76 | # 77 | ghc-options: 78 | "$locals": -j # Parallel Builds 79 | "$locals": -O0 # No GHC optimisations 80 | 81 | # Extra directories used by stack for building 82 | # extra-include-dirs: [/path/to/dir] 83 | # extra-lib-dirs: [/path/to/dir] 84 | # 85 | # Allow a newer minor version of GHC than the snapshot specifies 86 | # compiler-check: newer-minor 87 | -------------------------------------------------------------------------------- /client/config/webpack.base.babel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * COMMON WEBPACK CONFIGURATION 3 | */ 4 | 5 | const path = require('path') 6 | const webpack = require('webpack') 7 | 8 | process.noDeprecation = true 9 | 10 | module.exports = options => ({ 11 | mode: options.mode, 12 | entry: options.entry, 13 | output: Object.assign( 14 | { 15 | // Compile into js/build.js 16 | path: path.resolve(process.cwd(), 'build'), 17 | publicPath: '/' 18 | }, 19 | options.output 20 | ), // Merge with env dependent settings 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.js$/, // Transform all .js files required somewhere with Babel 25 | exclude: /node_modules/, 26 | use: { 27 | loader: 'babel-loader', 28 | options: options.babelQuery 29 | } 30 | }, 31 | { 32 | // Preprocess our own .scss files 33 | test: /\.scss$/, 34 | exclude: /node_modules/, 35 | use: ['style-loader', 'css-loader', 'sass-loader'] 36 | }, 37 | { 38 | // Preprocess 3rd party .css files located in node_modules 39 | test: /\.css$/, 40 | include: /node_modules/, 41 | use: ['style-loader', 'css-loader'] 42 | }, 43 | { 44 | test: /\.(eot|svg|otf|ttf|woff|woff2)$/, 45 | use: 'file-loader' 46 | }, 47 | { 48 | test: /\.(jpg|png|gif)$/, 49 | use: [ 50 | 'file-loader', 51 | { 52 | loader: 'image-webpack-loader', 53 | options: { 54 | query: { 55 | gifsicle: { 56 | interlaced: true 57 | }, 58 | mozjpeg: { 59 | progressive: true 60 | }, 61 | optipng: { 62 | optimizationLevel: 7 63 | }, 64 | pngquant: { 65 | quality: '65-90', 66 | speed: 4 67 | } 68 | } 69 | } 70 | } 71 | ] 72 | }, 73 | { 74 | test: /\.html$/, 75 | use: 'html-loader' 76 | }, 77 | { 78 | test: /\.json$/, 79 | use: 'json-loader' 80 | }, 81 | { 82 | test: /\.(mp4|webm)$/, 83 | use: { 84 | loader: 'url-loader', 85 | options: { 86 | limit: 10000 87 | } 88 | } 89 | } 90 | ] 91 | }, 92 | plugins: options.plugins.concat([ 93 | new webpack.ProvidePlugin({ 94 | // make fetch available 95 | fetch: 'exports-loader?self.fetch!whatwg-fetch' 96 | }), 97 | 98 | // Always expose NODE_ENV to webpack, in order to use `process.env.NODE_ENV` 99 | // inside your code for any environment checks; UglifyJS will automatically 100 | // drop any unreachable code. 101 | new webpack.DefinePlugin({ 102 | 'process.env': { 103 | NODE_ENV: JSON.stringify(process.env.NODE_ENV) 104 | } 105 | }) 106 | ]), 107 | resolve: { 108 | modules: ['app', 'node_modules'], 109 | extensions: ['.js', '.jsx', '.scss', '.react.js'], 110 | mainFields: ['browser', 'jsnext:main', 'main'] 111 | }, 112 | devtool: options.devtool, 113 | target: 'web', // Make web variables accessible to webpack, e.g. window 114 | performance: options.performance || {}, 115 | optimization: { 116 | namedModules: true, 117 | splitChunks: { 118 | name: 'vendor', 119 | minChunks: 2 120 | } 121 | } 122 | }) 123 | -------------------------------------------------------------------------------- /server/test/Poker/UtilsSpec.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | module Poker.UtilsSpec where 4 | 5 | import Control.Lens ((.~)) 6 | import Data.List () 7 | import Data.List.Lens () 8 | import Data.Text (Text) 9 | import qualified Data.Text as T 10 | import Hedgehog (forAll, (===)) 11 | import qualified Hedgehog.Gen as Gen 12 | import qualified Hedgehog.Range as Range 13 | import Poker.ActionValidation () 14 | import Poker.Game.Utils (initialDeck, modInc) 15 | import Poker.Poker (initialGameState) 16 | import Poker.Types 17 | ( Game, 18 | Player (..), 19 | PlayerState (..), 20 | SatInState (..), 21 | Street (PreFlop), 22 | players, 23 | street, 24 | ) 25 | import Test.Hspec (describe, it, shouldBe) 26 | import Test.Hspec.Hedgehog 27 | ( PropertyT, 28 | diff, 29 | forAll, 30 | hedgehog, 31 | modifyMaxDiscardRatio, 32 | (/==), 33 | (===), 34 | ) 35 | 36 | initialGameState' :: Game 37 | initialGameState' = initialGameState initialDeck 38 | 39 | player1 :: Player 40 | player1 = 41 | Player 42 | { _pockets = Nothing, 43 | _chips = 2000, 44 | _bet = 0, 45 | _playerStatus = SatIn NotFolded, 46 | _playerName = "player1", 47 | _committed = 100, 48 | _actedThisTurn = True, 49 | _possibleActions = [] 50 | } 51 | 52 | player2 :: Player 53 | player2 = 54 | Player 55 | { _pockets = Nothing, 56 | _chips = 2000, 57 | _bet = 0, 58 | _playerStatus = SatIn Folded, 59 | _playerName = "player2", 60 | _committed = 50, 61 | _actedThisTurn = False, 62 | _possibleActions = [] 63 | } 64 | 65 | player3 :: Player 66 | player3 = 67 | Player 68 | { _pockets = Nothing, 69 | _chips = 2000, 70 | _bet = 0, 71 | _playerStatus = SatIn NotFolded, 72 | _playerName = "player3", 73 | _committed = 50, 74 | _actedThisTurn = False, 75 | _possibleActions = [] 76 | } 77 | 78 | player4 :: Player 79 | player4 = 80 | Player 81 | { _pockets = Nothing, 82 | _chips = 2000, 83 | _bet = 0, 84 | _playerStatus = SatIn NotFolded, 85 | _playerName = "player3", 86 | _committed = 0, 87 | _actedThisTurn = False, 88 | _possibleActions = [] 89 | } 90 | 91 | player5 :: Player 92 | player5 = 93 | Player 94 | { _pockets = Nothing, 95 | _chips = 4000, 96 | _bet = 4000, 97 | _playerStatus = SatIn NotFolded, 98 | _playerName = "player5", 99 | _committed = 4000, 100 | _actedThisTurn = True, 101 | _possibleActions = [] 102 | } 103 | 104 | player6 :: Player 105 | player6 = 106 | Player 107 | { _pockets = Nothing, 108 | _chips = 2000, 109 | _bet = 200, 110 | _playerStatus = SatIn NotFolded, 111 | _playerName = "player6", 112 | _committed = 250, 113 | _actedThisTurn = True, 114 | _possibleActions = [] 115 | } 116 | 117 | bettingFinishedGame :: Game 118 | bettingFinishedGame = 119 | ((players .~ [player1, player2]) . (street .~ PreFlop)) initialGameState' 120 | 121 | bettingNotFinishedGame :: Game 122 | bettingNotFinishedGame = 123 | ((players .~ [player1, player2, player3, player4]) . (street .~ PreFlop)) 124 | initialGameState' 125 | 126 | spec = do 127 | describe "ModInc" $ do 128 | it "should increment in modulo fashion" $ do 129 | modInc 1 0 2 `shouldBe` 1 130 | modInc 1 1 1 `shouldBe` 0 131 | modInc 1 6 7 `shouldBe` 7 132 | 133 | it "result should always be greater than zero" $ do 134 | hedgehog $ do 135 | i <- forAll $ Gen.int $ Range.linear 0 9 136 | (modInc 1 i 9 >= 0) === True 137 | -------------------------------------------------------------------------------- /server/shell.nix: -------------------------------------------------------------------------------- 1 | { nixpkgs ? import {}, compiler ? "default", doBenchmark ? false }: 2 | 3 | let 4 | 5 | inherit (nixpkgs) pkgs; 6 | 7 | f = { mkDerivation, adjunctions, aeson, async, base, bytestring 8 | , comonad, containers, cryptohash, distributive, ekg, free 9 | , hashable, hedgehog, hedis, hpack, hspec, hspec-hedgehog, jose 10 | , jwt, lens, lib, listsafe, monad-logger, MonadRandom, mtl 11 | , persistent, persistent-postgresql, persistent-template, pipes 12 | , pipes-aeson, pipes-concurrency, pipes-parse, pretty-simple 13 | , random, servant, servant-auth, servant-auth-client 14 | , servant-auth-server, servant-foreign, servant-options 15 | , servant-server, servant-websockets, split, stm, text, time 16 | , transformers, utf8-string, vector, wai, wai-cors, wai-extra 17 | , wai-logger, warp, websockets 18 | }: 19 | mkDerivation { 20 | pname = "poker-server"; 21 | version = "0.1.0.0"; 22 | src = ./.; 23 | isLibrary = true; 24 | isExecutable = true; 25 | libraryHaskellDepends = [ 26 | adjunctions aeson async base bytestring comonad containers 27 | cryptohash distributive ekg free hashable hedis jose jwt lens 28 | listsafe monad-logger MonadRandom mtl persistent 29 | persistent-postgresql persistent-template pipes pipes-aeson 30 | pipes-concurrency pipes-parse pretty-simple random servant 31 | servant-auth servant-auth-client servant-auth-server 32 | servant-foreign servant-options servant-server servant-websockets 33 | split stm text time transformers utf8-string vector wai wai-cors 34 | wai-extra wai-logger warp websockets 35 | ]; 36 | libraryToolDepends = [ hpack ]; 37 | executableHaskellDepends = [ 38 | adjunctions aeson async base bytestring comonad containers 39 | cryptohash distributive ekg free hashable hedis jose jwt lens 40 | listsafe monad-logger MonadRandom mtl persistent 41 | persistent-postgresql persistent-template pipes pipes-aeson 42 | pipes-concurrency pipes-parse pretty-simple random servant 43 | servant-auth servant-auth-client servant-auth-server 44 | servant-foreign servant-options servant-server servant-websockets 45 | split stm text time transformers utf8-string vector wai wai-cors 46 | wai-extra wai-logger warp websockets 47 | ]; 48 | testHaskellDepends = [ 49 | adjunctions aeson async base bytestring comonad containers 50 | cryptohash distributive ekg free hashable hedgehog hedis hspec 51 | hspec-hedgehog jose jwt lens listsafe monad-logger MonadRandom mtl 52 | persistent persistent-postgresql persistent-template pipes 53 | pipes-aeson pipes-concurrency pipes-parse pretty-simple random 54 | servant servant-auth servant-auth-client servant-auth-server 55 | servant-foreign servant-options servant-server servant-websockets 56 | split stm text time transformers utf8-string vector wai wai-cors 57 | wai-extra wai-logger warp websockets 58 | ]; 59 | prePatch = "hpack"; 60 | homepage = "https://github.com/githubuser/poker-server#readme"; 61 | license = lib.licenses.unlicense; 62 | }; 63 | 64 | haskellPackages = if compiler == "default" 65 | then pkgs.haskellPackages 66 | else pkgs.haskell.packages.${compiler}; 67 | 68 | variant = if doBenchmark then pkgs.haskell.lib.doBenchmark else pkgs.lib.id; 69 | 70 | drv = variant (haskellPackages.callPackage f {}); 71 | 72 | in 73 | 74 | if pkgs.lib.inNixShell then drv.env else drv 75 | -------------------------------------------------------------------------------- /server/src/Socket/Subscriptions.hs: -------------------------------------------------------------------------------- 1 | {- 2 | Logic for updating players about table changes 3 | -} 4 | {-# LANGUAGE OverloadedStrings #-} 5 | {-# LANGUAGE RecordWildCards #-} 6 | 7 | module Socket.Subscriptions where 8 | 9 | import Control.Concurrent.STM 10 | ( STM, 11 | atomically, 12 | readTVar, 13 | readTVarIO, 14 | swapTVar, 15 | throwSTM, 16 | ) 17 | import Control.Monad (Monad (return)) 18 | import Control.Monad.Except (Monad (return), MonadIO (liftIO)) 19 | import Control.Monad.Reader 20 | ( Monad (return), 21 | MonadIO (liftIO), 22 | MonadReader (ask), 23 | ReaderT, 24 | ) 25 | import Control.Monad.STM (STM, atomically, throwSTM) 26 | import Control.Monad.State.Lazy (Monad (return), MonadIO (liftIO)) 27 | import Data.Either (Either (..)) 28 | import Data.Map.Lazy (Map) 29 | import qualified Data.Map.Lazy as M 30 | import Data.Maybe (Maybe (Just, Nothing)) 31 | import Data.Monoid ((<>)) 32 | import Data.Text (Text) 33 | import qualified Data.Text as T 34 | import qualified Network.WebSockets as WS 35 | import Poker.Game.Privacy (excludePrivateCards) 36 | import Socket.Clients (sendMsg) 37 | import Socket.Lobby (insertTable) 38 | import Socket.Types 39 | ( CannotAddAlreadySubscribed (CannotAddAlreadySubscribed), 40 | Err (TableDoesNotExist), 41 | Lobby (..), 42 | MsgHandlerConfig (..), 43 | MsgIn (SubscribeToTable), 44 | MsgOut (SuccessfullySubscribedToTable), 45 | ServerState (ServerState, clients, lobby), 46 | Table (..), 47 | TableDoesNotExistInLobby (TableDoesNotExistInLobby), 48 | TableName, 49 | ) 50 | import Socket.Utils (unLobby) 51 | import Text.Pretty.Simple (pPrint) 52 | import Types (Username, unUsername) 53 | import Prelude 54 | 55 | getTableSubscribers :: TableName -> Lobby -> [Username] 56 | getTableSubscribers tableName (Lobby lobby) = case M.lookup tableName lobby of 57 | Nothing -> [] 58 | Just Table {..} -> subscribers 59 | 60 | -- First we check the table exists and if the user is not already subscribed then we add them to the list of subscribers 61 | -- Game and any other table updates will be propagated to those on the subscriber list 62 | subscribeToTableHandler :: 63 | MsgIn -> ReaderT MsgHandlerConfig IO (Either Err MsgOut) 64 | subscribeToTableHandler (SubscribeToTable tableName) = do 65 | msgHandlerConfig@MsgHandlerConfig {..} <- ask 66 | ServerState {..} <- liftIO $ readTVarIO serverStateTVar 67 | case M.lookup tableName $ unLobby lobby of 68 | Nothing -> return $ Left $ TableDoesNotExist tableName 69 | Just Table {..} -> do 70 | let privatisedGame = excludePrivateCards (Just (unUsername username)) game 71 | msg' = SuccessfullySubscribedToTable tableName privatisedGame 72 | if username `notElem` subscribers 73 | then do 74 | liftIO $ atomically $ subscribeToTable tableName msgHandlerConfig 75 | liftIO $ sendMsg clientConn msg' 76 | return $ Right msg' 77 | else do 78 | return $ Right msg' 79 | 80 | subscribeToTable :: TableName -> MsgHandlerConfig -> STM () 81 | subscribeToTable tableName MsgHandlerConfig {..} = do 82 | ServerState {..} <- readTVar serverStateTVar 83 | let maybeTable = M.lookup tableName $ unLobby lobby 84 | case maybeTable of 85 | Nothing -> throwSTM $ TableDoesNotExistInLobby tableName 86 | Just table@Table {..} -> 87 | if username `notElem` subscribers 88 | then do 89 | let updatedTable = 90 | Table {subscribers = subscribers <> [username], ..} 91 | let updatedLobby = insertTable tableName updatedTable lobby 92 | let newServerState = ServerState {lobby = updatedLobby, ..} 93 | swapTVar serverStateTVar newServerState 94 | return () 95 | else throwSTM $ CannotAddAlreadySubscribed tableName 96 | -------------------------------------------------------------------------------- /client/app/styles/components/game/_table.scss: -------------------------------------------------------------------------------- 1 | /* Six Player Table */ 2 | .table-container { 3 | display: grid; 4 | grid-template-columns: 1.5fr 1fr 1.5fr 1fr 1.5fr; 5 | grid-template-rows: repeat(5, 5fr); 6 | width: 100%; 7 | height: 100%; 8 | margin-left: 14vw; 9 | margin-right: 14vw; 10 | 11 | @include md { 12 | margin: auto; 13 | } 14 | } 15 | 16 | .table-container h2 { 17 | justify-self: center; 18 | margin: 0; 19 | } 20 | 21 | /* 22 | Selects the chip count inside the player oval 23 | 24 | Adds whitespace between player name and chip count 25 | */ 26 | .table-container h2 + h2 { 27 | padding: 0.7em 0 0 0; 28 | } 29 | 30 | .game-table { 31 | position: relative; 32 | grid-area: 2/2/5/5; 33 | width: 120%; 34 | height: 120%; 35 | left: -10.25%; 36 | top: -13.4%; 37 | border-radius: 100%; 38 | z-index: -10; 39 | background: radial-gradient($primary-colour-600, $primary-colour-900); 40 | } 41 | 42 | .pot-label { 43 | grid-area: 2/2/2/5; 44 | top: -0.6em; 45 | position: relative; 46 | color: $neutral-colour-100; 47 | justify-self: center; 48 | } 49 | 50 | .winners-label { 51 | color: $neutral-colour-100; 52 | font-family: $cabin; 53 | text-transform: capitalize; 54 | grid-area: 3/1/3/6; 55 | justify-self: center; 56 | font-weight: 500; 57 | top: -0.9em; 58 | position: relative; 59 | } 60 | 61 | .dealer-btn-pos-0 { 62 | @extend %dealer-btn; 63 | grid-area: 6/2; 64 | justify-self: center; 65 | align-self: center; 66 | } 67 | 68 | .dealer-btn-pos-1 { 69 | @extend %dealer-btn; 70 | grid-area: 4/1; 71 | align-self: center; 72 | } 73 | 74 | .dealer-btn-pos-2 { 75 | @extend %dealer-btn; 76 | grid-area: 1/2; 77 | 78 | justify-self: left; 79 | align-self: center; 80 | } 81 | 82 | .dealer-btn-pos-3 { 83 | @extend %dealer-btn; 84 | grid-area: 1/4; 85 | } 86 | 87 | .dealer-btn-pos-4 { 88 | @extend %dealer-btn; 89 | grid-area: 2/5; 90 | justify-self: center; 91 | } 92 | 93 | .dealer-btn-pos-5 { 94 | @extend %dealer-btn; 95 | grid-area: 4/5; 96 | justify-self: center; 97 | } 98 | 99 | .player-bet-label { 100 | margin-right: 0.25em; 101 | margin-left: 0.32em; 102 | height: 1.25em; 103 | width: 100%; 104 | } 105 | 106 | .player-bet-chip { 107 | background-color: $neutral-accent-200; 108 | height: 1.25em; 109 | width: 1.25em; 110 | border-radius: 1.25em; 111 | //box-shadow: 1px 1px 1px 0px rgba(0, 0, 0, 0.55); 112 | } 113 | 114 | .player-bet-container-pos-0 { 115 | grid-area: 5/3/5/5; 116 | } 117 | 118 | .player-bet-pos-0 { 119 | display: flex; 120 | justify-content: center; 121 | } 122 | 123 | .player-bet-container-pos-1 { 124 | grid-area: 5/1/5/3; 125 | align-self: start; 126 | } 127 | 128 | .player-bet-pos-1 { 129 | width: 100%; 130 | text-align: left; 131 | display: flex; 132 | } 133 | 134 | .player-bet-container-pos-2 { 135 | grid-area: 2/1 / span 1 / span 2; 136 | position: relative; 137 | } 138 | 139 | .player-bet-pos-2 { 140 | text-align: left; 141 | display: flex; 142 | } 143 | 144 | .player-bet-container-pos-3 { 145 | margin-top: 0.25em; 146 | grid-area: 1/4 / span 1 / span 2; 147 | text-align: left; 148 | } 149 | 150 | .player-bet-pos-3 { 151 | width: 100%; 152 | display: flex; 153 | justify-content: right; 154 | } 155 | 156 | .player-bet-container-pos-4 { 157 | grid-area: 2/5; 158 | position: relative; 159 | text-align: right; 160 | } 161 | 162 | .player-bet-pos-4 { 163 | text-align: right; 164 | position: absolute; 165 | bottom: 0; 166 | display: flex; 167 | flex-direction: row-reverse; 168 | } 169 | 170 | .player-bet-container-pos-5 { 171 | grid-area: 5/5; 172 | text-align: right; 173 | position: relative; 174 | } 175 | 176 | .player-bet-pos-5 { 177 | position: absolute; 178 | display: flex; 179 | flex-direction: row-reverse; 180 | } 181 | -------------------------------------------------------------------------------- /server/src/Socket/Workers.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE RecordWildCards #-} 2 | 3 | module Socket.Workers where 4 | 5 | import Control.Concurrent (threadDelay) 6 | import Control.Concurrent.Async (Async, async) 7 | import Control.Concurrent.STM 8 | ( TChan, 9 | TVar, 10 | atomically, 11 | dupTChan, 12 | readTChan, 13 | ) 14 | import Control.Concurrent.STM.TChan (TChan, dupTChan, readTChan) 15 | import Control.Monad (forever) 16 | import Control.Monad.STM (atomically) 17 | import Data.Map.Lazy (Map) 18 | import qualified Data.Map.Lazy as M 19 | import Data.Text (Text) 20 | import Database 21 | ( dbGetTableEntity, 22 | dbInsertTableEntity, 23 | dbRefillAvailableChips, 24 | ) 25 | import Database.Persist (Entity (Entity), PersistEntity (Key)) 26 | import Database.Persist.Postgresql 27 | ( ConnectionString, 28 | SqlPersistT, 29 | runMigration, 30 | withPostgresqlConn, 31 | ) 32 | import Schema (Key, TableEntity) 33 | import Socket.Types 34 | ( Lobby (..), 35 | MsgOut (NewGameState), 36 | ServerState, 37 | Table 38 | ( Table, 39 | channel, 40 | game, 41 | gameInMailbox, 42 | gameOutMailbox, 43 | subscribers, 44 | waitlist 45 | ), 46 | TableName, 47 | ) 48 | 49 | forkBackgroundJobs :: 50 | ConnectionString -> TVar ServerState -> Lobby -> IO [Async ()] 51 | forkBackgroundJobs connString serverStateTVar lobby = do 52 | forkChipRefillDBWriter connString chipRefillInterval chipRefillThreshold -- Periodically refill player chip balances when too low. 53 | forkGameDBWriters connString lobby -- At the end of game write new game and player data to the DB. 54 | where 55 | chipRefillInterval = 50000000 -- 1 mins 56 | chipRefillThreshold = 200000 -- any lower chip count will be topped up on refill to this amount 57 | 58 | -- Fork a new thread for each table that writes game updates received from the table channel to the DB 59 | forkGameDBWriters :: ConnectionString -> Lobby -> IO [Async ()] 60 | forkGameDBWriters connString (Lobby lobby) = 61 | sequence $ 62 | ( \(tableName, Table {..}) -> forkGameDBWriter connString channel tableName 63 | ) 64 | <$> M.toList lobby 65 | 66 | -- Looks up the tableName in the DB to get the key and if no corresponsing table is found in the db then 67 | -- we insert a new table to the db. This step is necessary as we use the TableID as a foreign key in the 68 | -- For Game Entities in the DB. 69 | -- After we have the TableID we fork a new process which listens to the channel which emits new game states 70 | -- for a given table. For each new game state msg received we write the new game state into the DB. 71 | forkGameDBWriter :: 72 | ConnectionString -> TChan MsgOut -> TableName -> IO (Async ()) 73 | forkGameDBWriter connString chan tableName = do 74 | maybeTableEntity <- dbGetTableEntity connString tableName 75 | case maybeTableEntity of 76 | Nothing -> do 77 | tableKey <- dbInsertTableEntity connString tableName 78 | forkGameWriter tableKey 79 | Just (Entity tableKey _) -> forkGameWriter tableKey 80 | where 81 | forkGameWriter tableKey = 82 | async (writeNewGameStatesToDB connString chan tableKey) 83 | 84 | writeNewGameStatesToDB :: 85 | ConnectionString -> TChan MsgOut -> Key TableEntity -> IO () 86 | writeNewGameStatesToDB connString chan tableKey = do 87 | dupChan <- atomically $ dupTChan chan 88 | forever $ do 89 | chanMsg <- atomically $ readTChan dupChan 90 | case chanMsg of 91 | (NewGameState tableName game) -> return () 92 | _ -> return () 93 | 94 | -- Fork a thread which refills low player chips balances in DB at a given interval 95 | forkChipRefillDBWriter :: ConnectionString -> Int -> Int -> IO (Async ()) 96 | forkChipRefillDBWriter connString interval chipsThreshold = 97 | async $ 98 | forever $ do 99 | dbRefillAvailableChips connString chipsThreshold 100 | threadDelay interval 101 | -------------------------------------------------------------------------------- /client/app/styles/common/_variables.scss: -------------------------------------------------------------------------------- 1 | /*** Variables ***/ 2 | 3 | /* 4 | --------------------------------- 5 | Colours 6 | --------------------------------- 7 | */ 8 | 9 | $primary-colour-100: #edf6fc; 10 | $primary-colour-200: #a0d7ff; 11 | $primary-colour-300: #4bb3fd; 12 | $primary-colour-400: #14a1ff; 13 | $primary-colour-500: #027cce; 14 | $primary-colour-600: #015c99; 15 | $primary-colour-700: #014877; 16 | $primary-colour-800: #39a9db; 17 | $primary-colour-900: #003856; 18 | 19 | $neutral-colour-100: #e5f1f9; 20 | $neutral-colour-200: #dee2e2; 21 | $neutral-colour-300: #cee2e2; 22 | $neutral-colour-400: #5d8989; 23 | $neutral-colour-500: #4f535b; 24 | $neutral-colour-600: #244242; 25 | $neutral-colour-700: #243942; 26 | $neutral-colour-800: #131414; 27 | 28 | $neutral-accent-100: #cfe8f9; 29 | $neutral-accent-200: #a0c9e5; 30 | $neutral-accent-300: #6698ba; 31 | $neutral-accent-400: #56819e; 32 | $neutral-accent-500: #495c68; 33 | $neutral-accent-600: #213b4f; 34 | $neutral-accent-700: #172630; 35 | $neutral-accent-900: #121b21; 36 | 37 | //2c3e50 38 | //$gradient: radial-gradient($neutral-accent-600, $neutral-accent-900); 39 | $gradient: radial-gradient($neutral-accent-600, $neutral-accent-900); 40 | 41 | //$success-accent-100: 42 | //$success-accent-200: 43 | //$success-accent-300: 44 | //$success-accent-400: 45 | //$success-accent-500: 46 | //$success-accent-600: 47 | //$success-accent-700: 48 | // 49 | //$warning-accent-100: 50 | //$warning-accent-200: 51 | //$warning-accent-300: 52 | //$warning-accent-400: 53 | //$warning-accent-500: 54 | //$warning-accent-600: 55 | //$warning-accent-700: 56 | // 57 | //$danger-accent-100: 58 | //$danger-accent-200: 59 | //$danger-accent-300: 60 | //$danger-accent-400: 61 | //$danger-accent-500: 62 | //$danger-accent-600: 63 | //$danger-accent-700: 64 | 65 | // table gradient should comprise of mixed palette colours 66 | $dark-imperial-blue: #003e60; 67 | 68 | $anti-flash-white: white; // #F0EFF4; 69 | 70 | $prussian-blue: #003049; 71 | $dark-cerulean: #064789; 72 | $eerie-black: #161925; 73 | $deep-space-purple: #415a77; 74 | 75 | $outer-space-grey: #424b54; 76 | 77 | $color-grey-400: $outer-space-grey; 78 | 79 | // or 80 | $orioles-orange: #f34213; // perhaps busies the palette too much 81 | 82 | // App colours 83 | $color-primary: $prussian-blue; 84 | $color-secondary: $dark-imperial-blue; 85 | $color-accent: $orioles-orange; 86 | $color-light: $deep-space-purple; 87 | $color-darkest: $eerie-black; 88 | 89 | $color-brand: $prussian-blue; 90 | 91 | // gradients 92 | $gotham: radial-gradient(#240b36, $dark-imperial-blue); 93 | 94 | $witching-hour: radial-gradient(#c31432, #240b36); 95 | $night-hawk: radial-gradient(#2980b9, #2c3e50); 96 | $red-mist: radial-gradient(#e74c3c, #000000); 97 | $namn: linear-gradient(#a73737, #7a2828); 98 | 99 | // Brand colours 100 | $color-facebook: #3b5998; 101 | $color-feedly: #2bb24c; 102 | $color-github: #333; 103 | $color-google: #dc4e41; 104 | $color-instagram: #3f729b; 105 | $color-linkedin: #0077b5; 106 | $color-medium: #00ab6b; 107 | $color-messenger: #0084ff; 108 | $color-rss: #f26522; 109 | $color-spotify: #2ebd59; 110 | $color-twitter: #55acee; 111 | 112 | // Borders 113 | $border-light: solid 1px rgba(0, 0, 0, 0.05); 114 | 115 | /* 116 | --------------------------------- 117 | Media queries 118 | --------------------------------- 119 | */ 120 | 121 | // Very small devices such as small phones 122 | $screen-xs-min: 376px; 123 | 124 | // Small tablets and large smartphones (landscape view) 125 | $screen-sm-min: 576px; 126 | 127 | // Small tablets (portrait view) 128 | $screen-md-min: 798px; 129 | 130 | // Tablets and small desktops 131 | $screen-lg-min: 1222px; 132 | 133 | // Large tablets and desktops 134 | $screen-xl-min: 1250px; 135 | 136 | // Large tablets and desktops 137 | $screen-xxl-min: 1600px; 138 | 139 | /* 140 | --------------------------------- 141 | Animation 142 | --------------------------------- 143 | */ 144 | 145 | // Animation 146 | $anime-in: 0.4s; 147 | $anime-out: 0.5s; 148 | -------------------------------------------------------------------------------- /server/src/Poker/Game/Blinds.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts #-} 2 | {-# LANGUAGE MultiParamTypeClasses #-} 3 | {-# LANGUAGE RecordWildCards #-} 4 | 5 | module Poker.Game.Blinds where 6 | 7 | import Control.Lens ((%~), (&)) 8 | import Control.Monad.State () 9 | import Data.Char (toLower) 10 | import Data.List (all, find, length, splitAt, tail, zip, zipWith) 11 | import qualified Data.List.Safe as Safe 12 | import Data.Maybe 13 | import Data.Monoid ((<>)) 14 | import Data.Text (Text) 15 | import Poker.Game.Utils 16 | ( getGamePlayer, 17 | getPlayerNames, 18 | getPlayerPosition, 19 | modInc, 20 | ) 21 | import Poker.Types 22 | import Text.Read (readMaybe) 23 | import Prelude 24 | 25 | -- Gets the player position where the next required blind is 26 | -- This function always us timeout players in the blinds stage if they don't post 27 | -- the required blinds in order 28 | getPosNextBlind :: Int -> Game -> Int 29 | getPosNextBlind currIx game@Game {..} = nextIx 30 | where 31 | iplayers = zip [0 ..] _players 32 | iplayers' = let (a, b) = splitAt currIx iplayers in b <> a 33 | (nextIx, nextPlayer) = 34 | fromJust $ 35 | find 36 | ( \(_, p@Player {..}) -> 37 | isJust $ blindRequiredByPlayer game _playerName 38 | ) 39 | (tail iplayers') 40 | 41 | haveRequiredBlindsBeenPosted :: Game -> Bool 42 | haveRequiredBlindsBeenPosted game@Game {..} = 43 | all (== True) $ 44 | zipWith 45 | ( \requiredBlind Player {..} -> case requiredBlind of 46 | Nothing -> True 47 | Just BigBlind -> fromCommittedChips _committed == _bigBlind 48 | Just SmallBlind -> fromCommittedChips _committed == _smallBlind 49 | ) 50 | requiredBlinds 51 | _players 52 | where 53 | requiredBlinds = getRequiredBlinds game 54 | 55 | getRequiredBlinds :: Game -> [Maybe Blind] 56 | getRequiredBlinds game@Game {..} 57 | | _street /= PreDeal = [] 58 | | otherwise = blindRequiredByPlayer game <$> getPlayerNames _players 59 | 60 | -- We use the list of required blinds to calculate if a player has posted 61 | -- chips sufficient to be "In" for this hand. 62 | activatePlayersWhenNoBlindNeeded :: [Maybe Blind] -> [Player] -> [Player] 63 | activatePlayersWhenNoBlindNeeded = zipWith updatePlayer 64 | where 65 | updatePlayer blindReq Player {..} = 66 | Player 67 | { _playerStatus = 68 | if isNothing blindReq 69 | then InHand (CanAct Nothing) 70 | else _playerStatus, 71 | .. 72 | } 73 | 74 | -- Sets player state to in if they don't need to post blind 75 | updatePlayersInHand :: Game -> Game 76 | updatePlayersInHand game = 77 | game & (players %~ activatePlayersWhenNoBlindNeeded (getRequiredBlinds game)) 78 | 79 | getSmallBlindPosition :: [Text] -> Int -> Int 80 | getSmallBlindPosition playersSatIn dealerPos = 81 | if length playersSatIn == 2 82 | then dealerPos 83 | else modInc incAmount dealerPos (length playersSatIn - 1) 84 | where 85 | incAmount = 1 86 | 87 | -- if a player does not post their blind at the appropriate time then their state will be changed to 88 | -- SatOut signifying that they have a seat but are now sat out 89 | -- blind is required either if player is sitting in bigBlind or smallBlind position relative to dealer 90 | -- or if their current playerStatus is set to Out 91 | -- If no blind is required for the player to remain In for the next hand then we will return Nothing 92 | blindRequiredByPlayer :: Game -> PlayerName -> Maybe Blind 93 | blindRequiredByPlayer game playerName 94 | | playerPosition == smallBlindPos = 95 | Just SmallBlind 96 | | playerPosition == bigBlindPos = Just BigBlind 97 | | otherwise = Nothing 98 | where 99 | player = fromJust $ getGamePlayer game playerName 100 | playerNames = getPlayerNames (_players game) 101 | playerPosition = fromJust $ getPlayerPosition playerNames playerName 102 | smallBlindPos = getSmallBlindPosition playerNames (_dealer game) 103 | incAmount = 1 104 | bigBlindPos = modInc incAmount smallBlindPos (length playerNames - 1) 105 | -------------------------------------------------------------------------------- /client/app/components/ActionPanel.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | // TODO move to own component called pocket cards 4 | import Card from './Card' 5 | 6 | const getPocketCards = cards => 7 | cards !== undefined && cards !== null ? cards.map(card => { 8 | const rank = card.get('rank') 9 | const suit = card.get('suit') 10 | 11 | return () 16 | }) : '' 17 | 18 | 19 | 20 | 21 | const ActionPanel = ({ 22 | updateBetValue, 23 | betValue, 24 | bet, 25 | raise, 26 | call, 27 | fold, 28 | check, 29 | postSmallBlind, 30 | postBigBlind, 31 | sitDown, 32 | leaveGameSeat, 33 | userPocketCards, 34 | gameStage, 35 | sitIn, 36 | bigBlind, 37 | maxCurrBet, 38 | isTurnToAct, 39 | availableActions, 40 | userPlayer 41 | }) => { 42 | console.log('available actions', availableActions) 43 | console.log(gameStage) 44 | const preDealActions = 45 | gameStage === "PreDeal" ? 46 | 47 | {availableActions.includes("PostBigBlind") ? 48 | : ''} 53 | 54 | {availableActions.includes("PostSmallBlind") ? 55 | : ' '} 59 | 60 | {userPlayer ? '' : } 67 | 68 | {userPlayer && (userPlayer.get("_playerState") === "SatOut") ? : ''} 74 | 75 | {userPlayer ? : ' '} 81 | 82 | : ''; 83 | 84 | 85 | let minBet = maxCurrBet >= bigBlind ? 2 * maxCurrBet : bigBlind 86 | 87 | return ( 88 |
89 | 90 | 91 |
92 | {(availableActions.includes("Bet") || !userPlayer || availableActions.includes("Raise")) ? 93 |
94 | 104 |
: ''} 105 | {preDealActions} 106 | {true ? // gameStage !== 'Showdown' && gameStage !== 'PreDeal' && isTurnToAct ? 107 | 108 | 109 | {availableActions.includes("Check") ? 110 | : ''} 113 | 114 | {availableActions.includes("Call") ? 115 | : ''} 117 | 118 | {availableActions.includes("Bet") ? : ''} 122 | {availableActions.includes("Raise") ? 123 | : ''} 128 | {availableActions.includes("Fold") ? : ''} 134 | 135 | : ''} 136 | 137 |
138 |
) 139 | } 140 | 141 | 142 | export default ActionPanel 143 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poker-client", 3 | "version": "0.1.0", 4 | "description": "Poker Client", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/therewillbecode/haskell-poker" 8 | }, 9 | "engines": { 10 | "npm": ">=3", 11 | "node": ">=5" 12 | }, 13 | "author": "therewillbecode", 14 | "license": "Unlicense", 15 | "scripts": { 16 | "prebuild": "npm run build:clean", 17 | "build:prod": "NODE_ENV=production node_modules/.bin/webpack --config config/webpack.prod.babel.js --color -p --progress --hide-modules --display-optimization-bailout", 18 | "build:clean": "rimraf ./build", 19 | "start": "cross-env NODE_ENV=development node server/index.js", 20 | "start:docker": "cross-env NODE_ENV=docker node server/index.js", 21 | "start:production": "npm run test && npm run build && npm run start:prod", 22 | "start:prod": "cross-env NODE_ENV=production node server/index.js", 23 | "deploy": "npm run build:clean && yarn run copy-static && yarn run build && aws s3 sync build/ s3://poker-client --delete", 24 | "clean": "npm run test:clean && npm run build:clean", 25 | "lint": "npm run lint:eslint", 26 | "lint:eslint": "eslint .", 27 | "eslint:fix": "eslint --fix .", 28 | "prettier:fix": "node ./node_modules/prettier/bin-prettier.js --write app /**/*.js", 29 | "test:clean": "rimraf ./coverage", 30 | "test": "cross-env NODE_ENV=test jest --coverage", 31 | "test:watch": "cross-env NODE_ENV=test jest --watchAll" 32 | }, 33 | "dependencies": { 34 | "axios": "^0.19.0", 35 | "babel-polyfill": "6.26.0", 36 | "chalk": "^2.3.2", 37 | "fontfaceobserver": "2.0.13", 38 | "history": "4.7.2", 39 | "hoist-non-react-statics": "3.0.1", 40 | "immutable": "3.8.2", 41 | "invariant": "2.2.4", 42 | "ip": "1.1.5", 43 | "modularscale-sass": "^3.0.5", 44 | "prop-types": "15.6.2", 45 | "react": "^16.11.0", 46 | "react-dom": "^16.11.0", 47 | "react-helmet": "5.2.0", 48 | "react-loadable": "^5.4.0", 49 | "react-redux": "5.0.7", 50 | "react-router-dom": "^4.3.1", 51 | "react-router-redux": "5.0.0-alpha.6", 52 | "reconnecting-websocket": "^4.2.0", 53 | "redux": "4.0.0", 54 | "redux-immutable": "4.0.0", 55 | "redux-logger": "^3.0.6", 56 | "redux-thunk": "^2.3.0", 57 | "reselect": "3.0.1", 58 | "sanitize.css": "11.0.0", 59 | "warning": "^4.0.1", 60 | "whatwg-fetch": "2.0.4" 61 | }, 62 | "devDependencies": { 63 | "add-asset-html-webpack-plugin": "2.1.3", 64 | "babel-cli": "6.26.0", 65 | "babel-core": "^6.26.3", 66 | "babel-eslint": "8.2.6", 67 | "babel-loader": "7.1.5", 68 | "babel-plugin-dynamic-import-node": "2.0.0", 69 | "babel-plugin-react-transform": "3.0.0", 70 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 71 | "babel-plugin-transform-react-constant-elements": "6.23.0", 72 | "babel-plugin-transform-react-inline-elements": "6.22.0", 73 | "babel-plugin-transform-react-remove-prop-types": "0.4.14", 74 | "babel-preset-env": "^1.6.1", 75 | "babel-preset-react": "6.24.1", 76 | "babel-preset-stage-0": "6.24.1", 77 | "circular-dependency-plugin": "5.0.2", 78 | "compression": "1.7.4", 79 | "cross-env": "6.0.3", 80 | "css-loader": "1.0.0", 81 | "enzyme": "^3.3.0", 82 | "enzyme-adapter-react-16": "^1.1.1", 83 | "eslint": "5.3.0", 84 | "eslint-config-airbnb": "17.0.0", 85 | "eslint-config-airbnb-base": "13.0.0", 86 | "eslint-config-prettier": "^2.9.0", 87 | "eslint-import-resolver-webpack": "^0.10.0", 88 | "eslint-plugin-import": "^2.12.0", 89 | "eslint-plugin-jsx-a11y": "6.1.1", 90 | "eslint-plugin-prettier": "^2.6.2", 91 | "eslint-plugin-react": "^7.9.1", 92 | "eventsource-polyfill": "0.9.6", 93 | "exports-loader": "0.7.0", 94 | "express": "4.17.1", 95 | "file-loader": "1.1.11", 96 | "html-loader": "0.5.5", 97 | "html-webpack-plugin": "3.2.0", 98 | "image-webpack-loader": "^4.3.1", 99 | "imports-loader": "0.8.0", 100 | "jest-cli": "^23.1.0", 101 | "lint-staged": "^7.1.3", 102 | "moxios": "^0.4.0", 103 | "node-plop": "^0.16.0", 104 | "node-sass": "^4.13.0", 105 | "null-loader": "0.1.1", 106 | "plop": "2.1.0", 107 | "prettier": "^1.14.2", 108 | "react-test-renderer": "^16.4.0", 109 | "redux-mock-store": "^1.5.3", 110 | "rimraf": "2.6.2", 111 | "sass-loader": "^7.0.1", 112 | "shelljs": "^0.8.1", 113 | "style-loader": "^0.22.1", 114 | "url-loader": "1.0.1", 115 | "webpack": "^4.41.2", 116 | "webpack-cli": "^3.0.3", 117 | "webpack-dev-middleware": "^3.3.3", 118 | "webpack-dev-server": "^3.9.0", 119 | "webpack-hot-middleware": "^2.3.2" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /client/app/actions/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import configureMockStore from "redux-mock-store"; 3 | import thunk from "redux-thunk"; 4 | import axios from "axios"; 5 | 6 | import { authRequested, authError, authSuccess, login, logout, register } from "../auth"; 7 | import * as types from "../types"; 8 | 9 | const localStorageMock = { 10 | getItem: jest.fn(), 11 | setItem: jest.fn(), 12 | clear: jest.fn() 13 | }; 14 | 15 | global.localStorage = localStorageMock; 16 | 17 | const jestMock = response => jest 18 | .fn() 19 | .mockImplementation( 20 | () => 21 | new Promise( 22 | (resolve, reject) => 23 | response.status !== 200 ? reject(response) : resolve(response) 24 | ) 25 | ); 26 | 27 | const stubAxios = response => { 28 | axios.get = jestMock(response) 29 | axios.post = jestMock(response) 30 | }; 31 | 32 | describe("auth actions", () => { 33 | describe("action creators", () => { 34 | describe("authRequested", () => { 35 | it("should return correct action an authSuccess action for received asset", () => { 36 | expect(authRequested()).toEqual({ type: types.AUTH_REQUESTED }) 37 | }); 38 | }) 39 | 40 | describe("authSuccess", () => { 41 | it("should return correct action an authSuccess action for received asset", () => { 42 | const username = 'Argo' 43 | expect(authSuccess(username)).toEqual({ type: types.AUTHENTICATED, username }) 44 | }); 45 | }) 46 | 47 | describe("authError", () => { 48 | it("should return correct action an authSuccess action for received asset", () => { 49 | const error = '404' 50 | expect(authError(error)).toEqual({ type: types.AUTHENTICATION_ERROR, error }) 51 | }); 52 | }) 53 | 54 | describe("logout", () => { 55 | it("should return correct action an authSuccess action for received asset", () => { 56 | expect(logout()).toEqual({ type: types.UNAUTHENTICATED }) 57 | }); 58 | }) 59 | }) 60 | 61 | describe("thunk actions", () => { 62 | let mockStore; 63 | let historyMock = { push: jest.fn() } // mocks react router history 64 | 65 | describe("signIn", () => { 66 | beforeEach(() => { 67 | const middlewares = [thunk]; 68 | mockStore = configureMockStore(middlewares); 69 | }); 70 | 71 | afterEach(() => { 72 | axios.get.mockReset(); 73 | historyMock.push.mockReset() 74 | localStorage.clear() 75 | }); 76 | 77 | afterAll(() => { 78 | axios.get.mockRestore(); 79 | }); 80 | 81 | const username = 'Argo' 82 | const email = 'email@email.com' 83 | const password = 'password' 84 | 85 | it("should dispatch correct actions when authentication succeeds", () => { 86 | const store = mockStore({}); 87 | const expectedActions = [ 88 | { type: types.AUTH_REQUESTED }, 89 | { type: types.AUTHENTICATED } 90 | ]; 91 | 92 | stubAxios({ status: 200, data: { token: 'JWT' } }); 93 | return store.dispatch(login({ email, password }, historyMock)).then(() => { 94 | expect(store.getActions()).toEqual(expectedActions); 95 | }); 96 | }); 97 | 98 | 99 | it("should dispatch correction actions when error occurs while fetching user profile", () => { 100 | const store = mockStore({}); 101 | const error = { 102 | "response": { "data": "Unauthorized" }, "status": 401 103 | } 104 | const expectedActions = [ 105 | { type: types.AUTH_REQUESTED }, 106 | { type: types.AUTHENTICATION_ERROR, error } 107 | ]; 108 | 109 | stubAxios({ status: 401, response: { data: "Unauthorized" } }); 110 | return store.dispatch(login({ email, password }, historyMock)).then(() => { 111 | expect(store.getActions()).toEqual(expectedActions); 112 | }); 113 | }); 114 | 115 | it("should redirect to correct route on auth success", () => { 116 | const store = mockStore({}); 117 | const expectedRoute = '/lobby' 118 | stubAxios({ status: 200, data: { token: 'JWT' } }); 119 | 120 | return store.dispatch(login({ email, password }, historyMock)).then(() => { 121 | expect(historyMock.push).toBeCalledWith(expectedRoute) 122 | }); 123 | }); 124 | 125 | it("should store JWT token in localStorage on auth success", () => { 126 | const store = mockStore({}); 127 | const token = 'JWT' 128 | stubAxios({ status: 200, data: { token } }); 129 | 130 | return store.dispatch(login({ email, password }, historyMock)).then(() => { 131 | expect(localStorage.setItem).toBeCalledWith('token', token) 132 | }); 133 | }); 134 | }); 135 | }); 136 | }); -------------------------------------------------------------------------------- /server/src/Users.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | module Users where 5 | 6 | import Control.Monad.Except 7 | ( MonadError (throwError), 8 | MonadIO (liftIO), 9 | runExceptT, 10 | ) 11 | import qualified Crypto.Hash.SHA256 as H 12 | import qualified Crypto.JOSE as Jose 13 | import Crypto.JWT (JWK) 14 | import qualified Crypto.JWT as Jose 15 | import qualified Data.ByteString.Char8 as C 16 | import qualified Data.ByteString.Lazy as BSL 17 | import qualified Data.ByteString.Lazy.Char8 as BS 18 | import qualified Data.ByteString.Lazy.Char8 as CL 19 | import Data.ByteString.Lazy.UTF8 as BLU (toString) 20 | import Data.Text (Text) 21 | import qualified Data.Text as T 22 | import Data.Text.Encoding (decodeUtf8, encodeUtf8) 23 | import Data.Time.Clock (getCurrentTime) 24 | import Database 25 | ( dbGetUserByLogin, 26 | dbGetUserByUsername, 27 | dbRegisterUser, 28 | ) 29 | import Database.Persist.Postgresql (ConnectionString) 30 | import Schema 31 | ( UserEntity 32 | ( UserEntity, 33 | userEntityAvailableChips, 34 | userEntityChipsInPlay, 35 | userEntityCreatedAt, 36 | userEntityEmail, 37 | userEntityPassword, 38 | userEntityUsername 39 | ), 40 | ) 41 | import Servant 42 | ( Handler, 43 | NoContent (..), 44 | ServerError (errBody), 45 | err401, 46 | err404, 47 | ) 48 | import Servant.Auth.Server (JWTSettings, makeJWT) 49 | import Types 50 | ( Login (..), 51 | RedisConfig, 52 | Register (..), 53 | ReturnToken (..), 54 | UserProfile (..), 55 | Username (..), 56 | ) 57 | 58 | fetchUserProfileHandler :: ConnectionString -> Username -> Handler UserProfile 59 | fetchUserProfileHandler connString username' = do 60 | maybeUser <- liftIO $ dbGetUserByUsername connString username' 61 | case maybeUser of 62 | Nothing -> throwError err404 63 | Just UserEntity {..} -> 64 | return $ 65 | UserProfile 66 | { proEmail = userEntityEmail, 67 | proAvailableChips = userEntityAvailableChips, 68 | proChipsInPlay = userEntityChipsInPlay, 69 | proUsername = Username userEntityUsername, 70 | proUserCreatedAt = userEntityCreatedAt 71 | } 72 | 73 | hashPassword :: Text -> Text 74 | hashPassword password = T.pack $ C.unpack $ H.hash $ encodeUtf8 password 75 | 76 | signToken :: JWTSettings -> Username -> Handler ReturnToken 77 | signToken jwtSettings username' = do 78 | eToken <- liftIO $ makeJWT username' jwtSettings expiryTime 79 | case eToken of 80 | Left e -> throwError $ unAuthErr $ BS.pack $ show eToken 81 | Right token -> 82 | return $ 83 | ReturnToken 84 | { access_token = T.pack (BLU.toString token), 85 | refresh_token = "", 86 | expiration = 9999999, 87 | .. 88 | } 89 | where 90 | expiryTime = Nothing 91 | unAuthErr e = err401 {errBody = e} 92 | 93 | loginHandler :: JWTSettings -> ConnectionString -> Login -> Handler ReturnToken 94 | loginHandler jwtSettings connString l@Login {..} = do 95 | liftIO (print l) 96 | maybeUser <- liftIO $ dbGetUserByLogin connString loginWithHashedPswd 97 | case maybeUser of 98 | Nothing -> throwError unAuthErr 99 | Just u@UserEntity {..} -> 100 | signToken jwtSettings (Username userEntityUsername) 101 | where 102 | unAuthErr = err401 {errBody = "Incorrect email or password"} 103 | loginWithHashedPswd = 104 | Login {loginPassword = hashPassword loginPassword, ..} 105 | 106 | -- when we register new user we check to see if email and username are already taken 107 | -- if they are then the exception will be propagated to the client 108 | registerUserHandler :: 109 | JWTSettings -> 110 | ConnectionString -> 111 | RedisConfig -> 112 | Register -> 113 | Handler ReturnToken 114 | registerUserHandler jwtSettings connString redisConfig Register {..} = do 115 | currTime <- liftIO getCurrentTime 116 | let hashedPassword = hashPassword newUserPassword 117 | (Username username) = newUsername 118 | newUser = 119 | UserEntity 120 | { userEntityUsername = username, 121 | userEntityEmail = newUserEmail, 122 | userEntityPassword = hashedPassword, 123 | userEntityAvailableChips = 3000, 124 | userEntityChipsInPlay = 0, 125 | userEntityCreatedAt = currTime 126 | } 127 | registrationResult <- 128 | liftIO $ 129 | runExceptT $ 130 | dbRegisterUser connString redisConfig newUser 131 | case registrationResult of 132 | Left err -> throwError $ err401 {errBody = CL.pack $ T.unpack err} 133 | _ -> signToken jwtSettings newUsername 134 | 135 | getLobbyHandler :: JWTSettings -> ConnectionString -> RedisConfig -> Handler NoContent 136 | getLobbyHandler _ _ _ = do 137 | return NoContent 138 | -------------------------------------------------------------------------------- /client/app/middleware/socket.js: -------------------------------------------------------------------------------- 1 | import { fromJS } from 'immutable' 2 | 3 | import { disconnectSocket } from '../actions/auth' 4 | import { 5 | socketConnErr, 6 | socketConnected, 7 | socketAuthErr, 8 | socketAuthSuccess 9 | } from '../actions/socket' 10 | import { newLobby, getLobby } from '../actions/lobby' 11 | import { newGameState } from '../actions/games' 12 | 13 | import * as types from '../actions/types' 14 | 15 | //const SOCKET_API_URL = 'ws://localhost:5000' 16 | // 'wss://tengame.co.uk' 17 | 18 | const SOCKET_API_URL = 19 | process.env.NODE_ENV === 'docker' 20 | ? 'ws://192.168.99.100:5000' 21 | : process.env.NODE_ENV === 'production' 22 | ? 'wss://tengame.co.uk' 23 | : 'ws://localhost:5000' 24 | 25 | import ReconnectingWebSocket from 'reconnecting-websocket' 26 | 27 | //process.env.NODE_ENV === 'production' ? 'wss://tengame.co.uk' : 'ws://localhost:5000' 28 | 29 | function addHandlers(socket, authToken, dispatch) { 30 | socket.onopen = event => { 31 | // connected to server but not authenticated 32 | dispatch(socketConnected(socket)) // pass ref to socket so dispatcher - socket middleware has access to new connected socket instance 33 | socket.send(authToken) 34 | } 35 | 36 | socket.onclose = event => { 37 | dispatch(disconnectSocket()) 38 | // try and reconnect nearly instantly which is 39 | // useful when the client has refreshed their web browser 40 | setTimeout(() => { 41 | if (socket.connect) { 42 | socket.connect() 43 | } else { 44 | } 45 | }, 750) 46 | } 47 | 48 | socket.onmessage = msg => { 49 | console.log(msg) 50 | 51 | const parsedMsg = JSON.parse(JSON.parse(msg.data)) 52 | if (parsedMsg.tag === 'AuthSuccess') { 53 | dispatch(socketAuthSuccess()) 54 | } 55 | if (parsedMsg.tag === 'TableList') { 56 | const tableList = parsedMsg.contents 57 | dispatch(newLobby(fromJS(tableList))) 58 | } 59 | if ( 60 | parsedMsg.tag === 'SuccessfullySatDown' || 61 | parsedMsg.tag === 'NewGameState' || 62 | parsedMsg.tag === 'SuccessfullySubscribedToTable' 63 | ) { 64 | const tableName = parsedMsg.contents[0] 65 | const gameState = parsedMsg.contents[1] 66 | dispatch(newGameState(tableName, fromJS(gameState))) 67 | } 68 | } 69 | 70 | socket.onerror = err => { 71 | console.log(err) 72 | dispatch(socketAuthErr(err)) 73 | console.log(err) 74 | } 75 | } 76 | 77 | let connectedSocket = null 78 | 79 | function connHandler(dispatch, action) { 80 | if (action.type === types.CONNECT_SOCKET) { 81 | const { token } = action 82 | connectedSocket = new ReconnectingWebSocket(SOCKET_API_URL) 83 | // new WebSocket(SOCKET_API_URL) 84 | 85 | addHandlers(connectedSocket, token, dispatch) 86 | } 87 | 88 | if (action.type === types.DISCONNECT_SOCKET && connectedSocket) { 89 | if (connectedSocket.readyState === 1) { 90 | connectedSocket.close() 91 | } 92 | } 93 | 94 | if (action.data && connectedSocket) { 95 | //if (connectedSocket.readyState === 1) 96 | // connectedSocket.send(action.payload) 97 | } 98 | } 99 | 100 | /** 101 | * Allows you to register actions that when dispatched, send the action to the 102 | * server via a socket. 103 | * `criteria` may be a function (type, action) that returns true if you wish to send the 104 | * action to the server, array of action types, or a string prefix. 105 | * the third parameter is an options object with the following properties: 106 | * { 107 | * eventName,// a string name to use to send and receive actions from the server. 108 | * execute, // a function (action, emit, next, dispatch) that is responsible for 109 | * // sending the message to the server. 110 | * } 111 | */ 112 | const reduxSocketMiddleware = ({ dispatch, getState }) => next => action => { 113 | connHandler(dispatch, action) 114 | console.log(action) 115 | 116 | const criteria = 'server/' 117 | 118 | if (connectedSocket) { 119 | if (evaluate(action, criteria)) { 120 | return defaultExecute(dispatch, next, action) 121 | } 122 | } 123 | return next(action) 124 | } 125 | 126 | function evaluate(action, option) { 127 | if (!action || !action.type) { 128 | return false 129 | } 130 | 131 | const { type } = action 132 | let matched = false 133 | if (typeof option === 'function') { 134 | // Test function 135 | matched = option(type, action) 136 | } else if (typeof option === 'string') { 137 | // String prefix 138 | matched = type.indexOf(option) === 0 139 | } else if (Array.isArray(option)) { 140 | // Array of types 141 | matched = option.some(item => type.indexOf(item) === 0) 142 | } 143 | return matched 144 | } 145 | 146 | function defaultExecute(dispatch, next, action) { 147 | if (connectedSocket) { 148 | if (connectedSocket.readyState === 1) 149 | connectedSocket.send(JSON.stringify(action.data)) 150 | } 151 | } 152 | 153 | export default reduxSocketMiddleware 154 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | react redux boilerplate banner 2 | 3 |
4 | 5 |
A minimal, beginner friendly React-Redux boilerplate with all the industry best practices
6 | 7 |
8 | 9 |
10 | 11 | 12 | Dependency Status 13 | 14 | 15 | 16 | devDependency Status 17 | 18 | 19 | 20 | Build Status 21 | 22 | 23 | 24 | Gitter Chat 25 | 26 |
27 | 28 |
29 | 30 |
31 | Created by Dinesh Pandiyan 32 |
33 | 34 | 35 | ## Why? [![start with why](https://img.shields.io/badge/start%20with-why%3F-brightgreen.svg?style=flat)](http://www.ted.com/talks/simon_sinek_how_great_leaders_inspire_action) 36 | 37 | The whole React community knows and will unanimously agree that [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate) is the ultimate starter template for kickstarting a React project. It's setup with all the industry best practices and standards. But it also has a lot more than what you just need to start a react-redux app. It took me quite some time to get my head around what was happening in the codebase and it's clearly not for starters. They quote this right in their readme, 38 | 39 | > Please note that this boilerplate is **production-ready and not meant for beginners**! If you're just starting out with react or redux, please refer to https://github.com/petehunt/react-howto instead. If you want a solid, battle-tested base to build your next product upon and have some experience with react, this is the perfect start for you. 40 | 41 | So it involves a lot of additional learning curve to get started with [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate). That's why I forked it, stripped it down and made this _leaner, **beginner friendly**_ boilerplate without all the additional complexity. 42 | 43 | 44 | ## Features 45 | 46 | This boilerplate features all the latest tools and practices in the industry. 47 | 48 | - _React.js_ - **React 16**✨, React Router 5 49 | - _Redux.js_ - Redux saga, Redux immutable and Reselect 50 | - _Babel_ - ES6, ESNext, Airbnb and React/Recommended config 51 | - _Webpack_ - **Webpack 4**✨, Hot Reloading, Code Splitting, Optimized Prod Build and more 52 | - _Test_ - Jest with Enzyme 53 | - _Lint_ - ESlint 54 | - _Styles_ - SCSS Styling 55 | 56 | Here are a few highlights to look out for in this boilerplate 57 | 58 |
59 |
Instant feedback
60 |
Enjoy the best DX (Developer eXperience) and code your app at the speed of thought! Your saved changes to the CSS and JS are reflected instantaneously without refreshing the page. Preserve application state even when you update something in the underlying code!
61 | 62 |
Next generation JavaScript
63 |
Use template strings, object destructuring, arrow functions, JSX syntax and more, today.
64 | 65 |
Component Specific Styles
66 |
Separate styles for each component. Style in the good old scss way but still keep it abstracted for each component.
67 | 68 |
Industry-standard routing
69 |
It's natural to want to add pages (e.g. `/about`) to your application, and routing makes this possible.
70 | 71 |
Predictable state management
72 |
Unidirectional data flow allows for change logging and time travel debugging.
73 | 74 |
SEO
75 |
We support SEO (document head tags management) for search engines that support indexing of JavaScript content. (eg. Google)
76 |
77 | 78 | But wait... there's more! 79 | 80 | - *The best test setup:* Automatically guarantee code quality and non-breaking 81 | changes. (Seen a react app with 99% test coverage before?) 82 | - *The fastest fonts:* Say goodbye to vacant text. 83 | - *Stay fast*: Profile your app's performance from the comfort of your command 84 | line! 85 | - *Catch problems:* TravisCI setup included by default, so your 86 | tests get run automatically on each code push. 87 | 88 | 89 | ## Quick start 90 | 91 | 1. Clone this repo using `git clone https://github.com/flexdinesh/react-redux-boilerplate.git` 92 | 2. Move to the appropriate directory: `cd react-redux-boilerplate`.
93 | 3. Run `yarn` or `npm install` to install dependencies.
94 | 4. Run `npm start` to see the example app at `http://localhost:3000`. 95 | 96 | Now you're ready build your beautiful React Application! 97 | 98 | 99 | ## Info 100 | 101 | These are the things I stripped out from [react-boilerplate](https://github.com/react-boilerplate/react-boilerplate) - _github project rules, ngrok tunneling, shjs, service worker, webpack dll plugin, i18n, styled-components, code generators and a few more._ 102 | 103 | 104 | ## License 105 | 106 | MIT license, Copyright (c) 2018 Dinesh Pandiyan. 107 | -------------------------------------------------------------------------------- /server/src/Socket/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveAnyClass #-} 2 | {-# LANGUAGE DeriveGeneric #-} 3 | {-# LANGUAGE OverloadedStrings #-} 4 | {-# LANGUAGE RecordWildCards #-} 5 | 6 | module Socket.Types where 7 | 8 | import Control.Concurrent (MVar) 9 | import Control.Concurrent.STM (TChan, TVar) 10 | import Control.Concurrent.STM.TChan (TChan) 11 | import Control.Exception (Exception) 12 | import Data.Aeson (FromJSON, ToJSON) 13 | import Data.Aeson.Types (FromJSON, ToJSON) 14 | import Data.Map.Lazy (Map) 15 | import qualified Data.Map.Lazy as M 16 | import Data.Text (Text) 17 | import Database.Persist.Postgresql (ConnectionString) 18 | import GHC.Generics (Generic) 19 | import qualified Network.WebSockets as WS 20 | import Pipes.Concurrent (Input, Output) 21 | import Pipes 22 | ( Consumer, 23 | Effect, 24 | Pipe, 25 | await, 26 | runEffect, 27 | yield, 28 | (>->), 29 | ) 30 | import Poker.Types 31 | ( Action, 32 | Game, 33 | GameErr, 34 | ) 35 | import Types 36 | ( RedisConfig, 37 | Username, 38 | ) 39 | 40 | data MsgHandlerConfig = MsgHandlerConfig 41 | { dbConn :: ConnectionString, 42 | serverStateTVar :: TVar ServerState, 43 | username :: Username, 44 | clientConn :: WS.Connection, 45 | redisConfig :: RedisConfig 46 | } 47 | 48 | type TableName = Text 49 | 50 | newtype Lobby 51 | = Lobby (Map TableName Table) 52 | deriving (Ord, Eq, Show) 53 | 54 | instance Show ServerState where 55 | show _ = "" 56 | 57 | -- exception when adding subscriber to table if subscriber already exists inside STM transaction 58 | newtype CannotAddAlreadySubscribed 59 | = CannotAddAlreadySubscribed Text 60 | deriving (Show) 61 | 62 | instance Exception CannotAddAlreadySubscribed 63 | 64 | -- exception for cannot find a table with given TableName in Lobby inside STM transaction 65 | newtype TableDoesNotExistInLobby 66 | = TableDoesNotExistInLobby Text 67 | deriving (Show) 68 | 69 | instance Exception TableDoesNotExistInLobby 70 | 71 | data TableConfig = TableConfig 72 | { botCount :: Int, -- number of bots to maintain at table 73 | minHumans :: Int -- number of humans to wait for before starting a game 74 | } deriving (Show, Eq, Generic, FromJSON, ToJSON) 75 | 76 | headsUpBotsConfig :: TableConfig 77 | headsUpBotsConfig = 78 | TableConfig 79 | { botCount = 2, 80 | minHumans = 0 81 | } 82 | 83 | data Table = Table 84 | { subscribers :: [Username], -- observing public game state includes players sat down 85 | gameOutMailbox :: Input Game, -- outgoing MsgOuts broadcasts -> write source for msgs to propagate new game states to clients 86 | gameInMailbox :: Output Game, --incoming gamestates -> read (consume) source for new game states 87 | waitlist :: [Username], -- waiting to join a full table 88 | bots :: Maybe (Consumer Game IO ()), 89 | game :: Game, 90 | channel :: TChan MsgOut, 91 | config :: TableConfig 92 | } 93 | 94 | instance Show Table where 95 | show Table {..} = 96 | show subscribers <> "\n" <> show waitlist <> "\n" <> show game 97 | 98 | instance Eq Table where 99 | Table {game = game1} == Table {game = game2} = game1 == game2 100 | 101 | instance Ord Table where 102 | Table {game = game1} `compare` Table {game = game2} = 103 | game1 `compare` game2 104 | 105 | data Client = Client 106 | { clientUsername :: Text, 107 | conn :: WS.Connection, 108 | outgoingMailbox :: Output MsgOut 109 | } 110 | 111 | instance Show Client where 112 | show Client {..} = show clientUsername 113 | 114 | data ServerState = ServerState 115 | { clients :: Map Username Client, 116 | lobby :: Lobby 117 | } 118 | 119 | instance Eq Client where 120 | Client {clientUsername = clientUsername1} == Client {clientUsername = clientUsername2} = 121 | clientUsername1 == clientUsername2 122 | 123 | -- incoming messages from a ws client 124 | data MsgIn 125 | = GetTables 126 | | SubscribeToTable TableName 127 | | LeaveTable 128 | | GameMsgIn GameMsgIn 129 | deriving (Show, Eq, Generic, FromJSON, ToJSON) 130 | 131 | data GameMsgIn 132 | = TakeSeat 133 | TableName 134 | Int 135 | | LeaveSeat TableName 136 | | GameMove TableName Action 137 | deriving (Show, Eq, Generic, FromJSON, ToJSON) 138 | 139 | -- For the lobby view so client can make an informed decision about which game to join 140 | data TableSummary = TableSummary 141 | { _tableName :: Text, 142 | _playerCount :: Int, 143 | _minBuyInChips :: Int, 144 | _maxBuyInChips :: Int, 145 | _maxPlayers :: Int, 146 | _waitlistCount :: Int, 147 | _smallBlind :: Int, 148 | _bigBlind :: Int 149 | } 150 | deriving (Show, Eq, Generic, FromJSON, ToJSON) 151 | 152 | -- outgoing messages for clients 153 | data MsgOut 154 | = TableList [TableSummary] 155 | | SuccessfullySatDown 156 | TableName 157 | Game 158 | | SuccessfullyLeftSeat TableName 159 | | SuccessfullySubscribedToTable 160 | TableName 161 | Game 162 | | GameMsgOut GameMsgOut 163 | | NewGameState TableName Game 164 | | ErrMsg Err 165 | | AuthSuccess 166 | | Noop 167 | deriving (Show, Eq, Generic, FromJSON, ToJSON) 168 | 169 | data GameMsgOut 170 | = GameMoveErr Err 171 | | PlayerLeft 172 | | PlayerJoined TableName Text 173 | deriving (Show, Eq, Generic, FromJSON, ToJSON) 174 | 175 | data Err 176 | = TableFull TableName 177 | | TableDoesNotExist TableName 178 | | NotSatAtTable TableName 179 | | AlreadySatInGame TableName 180 | | NotSatInGame TableName 181 | | AlreadySatAtTable TableName 182 | | AlreadySubscribedToTable TableName 183 | | NotEnoughChipsToSit 184 | | GameErr GameErr 185 | | InvalidGameAction 186 | | ChipAmountNotWithinBuyInRange TableName 187 | | UserDoesNotExistInDB Text 188 | | AuthFailed Text 189 | deriving (Show, Eq, Generic, FromJSON, ToJSON) 190 | 191 | newtype Token = Token Text -- JWT 192 | --------------------------------------------------------------------------------