├── public ├── styles │ └── index.css └── favicon.ico ├── .gitignore ├── .travis.yml ├── prettier.config.js ├── README.md ├── src ├── renderers │ ├── dom.js │ └── server.js ├── server │ ├── config.js │ ├── config.test.js │ └── server.js ├── components │ ├── App.js │ ├── StarsDisplay.js │ ├── App.test.js │ ├── PlayAgain.js │ ├── PlayNumber.js │ ├── __snapshots__ │ │ └── App.test.js.snap │ └── Game.js ├── styles │ └── index.css └── math-utils.js ├── babel-node.config.js ├── babel.config.js ├── views └── index.ejs ├── .eslintrc.js ├── webpack.config.js └── package.json /public/styles/index.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | public/bundles/ 3 | build/ 4 | coverage/ 5 | .env 6 | .vscode/ 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jscomplete/rgs-star-match/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | before_install: 5 | - npm i -g npm 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'es5', 4 | arrowParens: 'always', 5 | }; 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Star Match Game 2 | 3 | ``` 4 | npm i 5 | ``` 6 | 7 | ``` 8 | npm start 9 | ``` 10 | 11 | Server will run on port 4242 by default. 12 | -------------------------------------------------------------------------------- /src/renderers/dom.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from 'components/App'; 5 | 6 | import '../styles/index.css'; 7 | 8 | ReactDOM.hydrate(, document.getElementById('root')); 9 | -------------------------------------------------------------------------------- /src/server/config.js: -------------------------------------------------------------------------------- 1 | const env = process.env; 2 | 3 | module.exports = { 4 | port: env.PORT || 4242, 5 | host: env.HOST || 'localhost', 6 | isDev: env.NODE_ENV !== 'production', 7 | isBrowser: typeof window !== 'undefined', 8 | }; 9 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import Game from './Game'; 4 | 5 | const StarMatch = () => { 6 | const [gameId, setGameId] = useState(1); 7 | return setGameId(gameId + 1)} />; 8 | }; 9 | 10 | export default StarMatch; 11 | -------------------------------------------------------------------------------- /src/renderers/server.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOMServer from 'react-dom/server'; 3 | 4 | import App from 'components/App'; 5 | 6 | export default async function serverRenderer() { 7 | return Promise.resolve({ 8 | initialMarkup: ReactDOMServer.renderToString(), 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /babel-node.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/react", 4 | [ 5 | "@babel/env", 6 | { 7 | targets: { 8 | node: "current", 9 | }, 10 | }, 11 | ], 12 | ], 13 | plugins: [["@babel/plugin-proposal-class-properties", { loose: true }]], 14 | }; 15 | -------------------------------------------------------------------------------- /src/server/config.test.js: -------------------------------------------------------------------------------- 1 | import config from './config'; 2 | 3 | describe('config', () => { 4 | it('has defaults', () => { 5 | expect(config.host).toBe('localhost'); 6 | expect(config.port).toBe(4242); 7 | expect(config.isBrowser).toBe(true); 8 | expect(config.isDev).toBe(true); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/StarsDisplay.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import utils from '../math-utils'; 3 | 4 | const StarsDisplay = (props) => ( 5 | <> 6 | {utils.range(1, props.count).map((starId) => ( 7 |
8 | ))} 9 | 10 | ); 11 | 12 | export default StarsDisplay; 13 | -------------------------------------------------------------------------------- /src/components/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import App from './App'; 3 | 4 | import renderer from 'react-test-renderer'; 5 | 6 | describe('App', () => { 7 | it('renders correctly', () => { 8 | const tree = renderer.create().toJSON(); 9 | 10 | expect(tree).toMatchSnapshot(); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/components/PlayAgain.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const PlayAgain = (props) => ( 4 |
5 |
9 | {props.gameStatus === 'lost' ? 'Game Over' : 'Nice'} 10 |
11 | 12 |
13 | ); 14 | 15 | export default PlayAgain; 16 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | "@babel/react", 4 | [ 5 | "@babel/env", 6 | { 7 | targets: [ 8 | "> 1%", 9 | "last 3 versions", 10 | "ie >= 9", 11 | "ios >= 8", 12 | "android >= 4.2", 13 | ], 14 | }, 15 | ], 16 | ], 17 | plugins: [ 18 | "@babel/plugin-transform-runtime", 19 | ["@babel/plugin-proposal-class-properties", { loose: true }], 20 | ], 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/PlayNumber.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const PlayNumber = (props) => ( 4 | 11 | ); 12 | 13 | // Color Theme 14 | const colors = { 15 | available: 'lightgray', 16 | used: 'lightgreen', 17 | wrong: 'lightcoral', 18 | candidate: 'deepskyblue', 19 | }; 20 | 21 | export default PlayNumber; 22 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Star Match 8 | 9 | 10 | 11 | 12 |
<%- initialMarkup -%>
13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/server/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import config from 'server/config'; 3 | import serverRenderer from 'renderers/server'; 4 | import bodyParser from 'body-parser'; 5 | import morgan from 'morgan'; 6 | import serialize from 'serialize-javascript'; 7 | 8 | const app = express(); 9 | app.enable('trust proxy'); 10 | app.use(morgan('common')); 11 | 12 | app.use(express.static('public')); 13 | 14 | app.set('view engine', 'ejs'); 15 | app.use(bodyParser.urlencoded({ extended: false })); 16 | app.use(bodyParser.json()); 17 | 18 | app.locals.serialize = serialize; 19 | 20 | app.get('/', async (req, res) => { 21 | try { 22 | const vars = await serverRenderer(); 23 | res.render('index', vars); 24 | } catch (err) { 25 | console.error(err); 26 | res.status(500).send('Server error'); 27 | } 28 | }); 29 | 30 | app.listen(config.port, config.host, () => { 31 | console.info(`Running on ${config.host}:${config.port}...`); 32 | }); 33 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | .game { 2 | max-width: 500px; 3 | margin: 0 auto; 4 | } 5 | 6 | .body { 7 | display: flex; 8 | } 9 | 10 | .help { 11 | color: #666; 12 | margin: 10px; 13 | text-align: center; 14 | } 15 | 16 | .left { 17 | text-align: center; 18 | width: 50%; 19 | border: thin solid #ddd; 20 | } 21 | 22 | .right { 23 | text-align: center; 24 | padding: 10px; 25 | width: 50%; 26 | border: thin solid #ddd; 27 | } 28 | 29 | .star { 30 | display: inline-block; 31 | margin: 0 15px; 32 | } 33 | 34 | .star:after { 35 | content: '\2605'; 36 | font-size: 45px; 37 | } 38 | 39 | .number { 40 | background-color: #eee; 41 | border: thin solid #ddd; 42 | width: 45px; 43 | height: 45px; 44 | margin: 10px; 45 | font-size: 25px; 46 | } 47 | 48 | .number:focus, 49 | .number:active { 50 | outline: none; 51 | border: thin solid #ddd; 52 | } 53 | 54 | .timer { 55 | color: #666; 56 | margin-top: 3px; 57 | margin-left: 3px; 58 | } 59 | 60 | .game-done .message { 61 | font-size: 250%; 62 | font-weight: bold; 63 | margin: 15px; 64 | } 65 | -------------------------------------------------------------------------------- /src/math-utils.js: -------------------------------------------------------------------------------- 1 | const utils = { 2 | // Sum an array 3 | sum: (arr) => arr.reduce((acc, curr) => acc + curr, 0), 4 | 5 | // create an array of numbers between min and max (edges included) 6 | range: (min, max) => Array.from({ length: max - min + 1 }, (_, i) => min + i), 7 | 8 | // pick a random number between min and max (edges included) 9 | random: (min, max) => min + Math.floor(max * Math.random()), 10 | 11 | // Given an array of numbers and a max... 12 | // Pick a random sum (< max) from the set of all available sums in arr 13 | randomSumIn: (arr, max) => { 14 | const sets = [[]]; 15 | const sums = []; 16 | for (let i = 0; i < arr.length; i++) { 17 | for (let j = 0, len = sets.length; j < len; j++) { 18 | const candidateSet = sets[j].concat(arr[i]); 19 | const candidateSum = utils.sum(candidateSet); 20 | if (candidateSum <= max) { 21 | sets.push(candidateSet); 22 | sums.push(candidateSum); 23 | } 24 | } 25 | } 26 | return sums[utils.random(0, sums.length)]; 27 | }, 28 | }; 29 | 30 | export default utils; 31 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'babel-eslint', 3 | env: { 4 | browser: true, 5 | commonjs: true, 6 | es6: true, 7 | node: true, 8 | jest: true, 9 | }, 10 | extends: [ 11 | 'eslint:recommended', 12 | 'plugin:react/recommended', 13 | 'plugin:react-hooks/recommended', 14 | ], 15 | globals: { 16 | Atomics: 'readonly', 17 | SharedArrayBuffer: 'readonly', 18 | }, 19 | parserOptions: { 20 | ecmaFeatures: { 21 | jsx: true, 22 | }, 23 | ecmaVersion: 2018, 24 | sourceType: 'module', 25 | }, 26 | plugins: ['react', 'react-hooks'], 27 | rules: { 28 | 'react/prop-types': ['off'], 29 | 'no-console': ['warn', { allow: ['info', 'error', 'dir'] }], 30 | 'no-else-return': 'error', 31 | 'no-unneeded-ternary': 'error', 32 | 'no-useless-return': 'error', 33 | 'no-var': 'error', 34 | 'one-var': ['error', 'never'], 35 | 'prefer-arrow-callback': 'error', 36 | 'prefer-const': 'error', 37 | strict: 'error', 38 | 'symbol-description': 'error', 39 | yoda: ['error', 'never', { exceptRange: true }], 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const webpack = require('webpack'); 4 | const WebpackChunkHash = require('webpack-chunk-hash'); 5 | const isDev = process.env.NODE_ENV !== 'production'; 6 | 7 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 8 | 9 | const config = { 10 | resolve: { 11 | modules: [ 12 | path.resolve('./src'), 13 | path.resolve('./node_modules'), 14 | ], 15 | }, 16 | entry: { 17 | main: ['./src/renderers/dom.js'], 18 | }, 19 | output: { 20 | path: path.resolve('public', 'bundles'), 21 | filename: isDev ? '[name].js' : '[name].[chunkhash].js', 22 | }, 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.js$/, 27 | exclude: /node_modules/, 28 | use: { 29 | loader: 'babel-loader', 30 | }, 31 | }, 32 | { 33 | test: /\.css$/, 34 | exclude: /node_modules/, 35 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 36 | }, 37 | ], 38 | }, 39 | optimization: { 40 | splitChunks: { 41 | cacheGroups: { 42 | commons: { 43 | test: /[\\/]node_modules[\\/]/, 44 | name: 'vendor', 45 | chunks: 'all', 46 | }, 47 | }, 48 | }, 49 | }, 50 | plugins: [ 51 | new MiniCssExtractPlugin({ 52 | filename: isDev ? '[name].css' : '[name].[hash].css', 53 | chunkFilename: isDev ? '[id].css' : '[id].[hash].css', 54 | }), 55 | new webpack.HashedModuleIdsPlugin(), 56 | new WebpackChunkHash(), 57 | ], 58 | }; 59 | 60 | module.exports = config; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "star-match", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "concurrently \"npm run dev-server\" \"npm run dev-bundle\"", 7 | "test": "jest", 8 | "dev-server": "cross-env NODE_PATH=./src nodemon --exec \"babel-node src/server/server.js\" --ignore public/", 9 | "dev-bundle": "webpack -wd" 10 | }, 11 | "jest": { 12 | "modulePaths": [ 13 | "./src" 14 | ], 15 | "testPathIgnorePatterns": [ 16 | "/node_modules/" 17 | ] 18 | }, 19 | "dependencies": { 20 | "@babel/cli": "^7.8.4", 21 | "@babel/core": "^7.9.0", 22 | "@babel/plugin-proposal-class-properties": "^7.8.3", 23 | "@babel/plugin-transform-runtime": "^7.9.0", 24 | "@babel/preset-env": "^7.9.5", 25 | "@babel/preset-react": "^7.9.4", 26 | "@babel/runtime": "^7.9.2", 27 | "babel-loader": "^8.1.0", 28 | "body-parser": "^1.19.0", 29 | "cross-env": "^7.0.2", 30 | "css-loader": "^3.5.2", 31 | "ejs": "^3.0.2", 32 | "express": "^4.17.1", 33 | "mini-css-extract-plugin": "^0.9.0", 34 | "morgan": "^1.10.0", 35 | "react": "^16.13.1", 36 | "react-dom": "^16.13.1", 37 | "regenerator-runtime": "^0.13.5", 38 | "serialize-javascript": "^3.0.0", 39 | "style-loader": "^1.1.3", 40 | "webpack": "^4.42.1", 41 | "webpack-chunk-hash": "^0.6.0", 42 | "webpack-cli": "^3.3.11" 43 | }, 44 | "devDependencies": { 45 | "@babel/node": "^7.8.7", 46 | "babel-core": "^7.0.0-bridge.0", 47 | "babel-eslint": "^10.1.0", 48 | "babel-jest": "^25.3.0", 49 | "concurrently": "^5.1.0", 50 | "eslint": "^6.8.0", 51 | "eslint-plugin-react": "^7.19.0", 52 | "eslint-plugin-react-hooks": "^3.0.0", 53 | "jest": "^25.3.0", 54 | "nodemon": "^2.0.3", 55 | "prettier": "^2.0.4", 56 | "react-test-renderer": "^16.13.1" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/__snapshots__/App.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`App renders correctly 1`] = ` 4 |
7 |
10 | Pick 1 or more numbers that sum to the number of stars 11 |
12 |
15 |
18 |
21 |
24 |
27 |
30 |
31 |
34 | 45 | 56 | 67 | 78 | 89 | 100 | 111 | 122 | 133 |
134 |
135 |
138 | Time Remaining: 139 | 10 140 |
141 |
142 | `; 143 | -------------------------------------------------------------------------------- /src/components/Game.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import utils from '../math-utils'; 3 | 4 | import StarsDisplay from './StarsDisplay'; 5 | import PlayNumber from './PlayNumber'; 6 | import PlayAgain from './PlayAgain'; 7 | 8 | const useGameState = () => { 9 | const [stars, setStars] = useState(utils.random(1, 9)); 10 | const [availableNums, setAvailableNums] = useState(utils.range(1, 9)); 11 | const [candidateNums, setCandidateNums] = useState([]); 12 | const [secondsLeft, setSecondsLeft] = useState(10); 13 | 14 | useEffect(() => { 15 | if (secondsLeft > 0 && availableNums.length > 0) { 16 | const timerId = setTimeout( 17 | () => setSecondsLeft((prevSecondsLeft) => prevSecondsLeft - 1), 18 | 1000 19 | ); 20 | return () => clearTimeout(timerId); 21 | } 22 | }, [secondsLeft, availableNums]); 23 | 24 | const setGameState = (newCandidateNums) => { 25 | if (utils.sum(newCandidateNums) !== stars) { 26 | setCandidateNums(newCandidateNums); 27 | } else { 28 | const newAvailableNums = availableNums.filter( 29 | (n) => !newCandidateNums.includes(n) 30 | ); 31 | setStars(utils.randomSumIn(newAvailableNums, 9)); 32 | setAvailableNums(newAvailableNums); 33 | setCandidateNums([]); 34 | } 35 | }; 36 | 37 | return { stars, availableNums, candidateNums, secondsLeft, setGameState }; 38 | }; 39 | 40 | const Game = (props) => { 41 | const { 42 | stars, 43 | availableNums, 44 | candidateNums, 45 | secondsLeft, 46 | setGameState, 47 | } = useGameState(); 48 | 49 | const candidatesAreWrong = utils.sum(candidateNums) > stars; 50 | const gameStatus = 51 | availableNums.length === 0 ? 'won' : secondsLeft === 0 ? 'lost' : 'active'; 52 | 53 | const numberStatus = (number) => { 54 | if (!availableNums.includes(number)) { 55 | return 'used'; 56 | } 57 | 58 | if (candidateNums.includes(number)) { 59 | return candidatesAreWrong ? 'wrong' : 'candidate'; 60 | } 61 | 62 | return 'available'; 63 | }; 64 | 65 | const onNumberClick = (number, currentStatus) => { 66 | if (currentStatus === 'used' || secondsLeft === 0) { 67 | return; 68 | } 69 | 70 | const newCandidateNums = 71 | currentStatus === 'available' 72 | ? candidateNums.concat(number) 73 | : candidateNums.filter((cn) => cn !== number); 74 | 75 | setGameState(newCandidateNums); 76 | }; 77 | 78 | return ( 79 |
80 |
81 | Pick 1 or more numbers that sum to the number of stars 82 |
83 |
84 |
85 | {gameStatus !== 'active' ? ( 86 | 87 | ) : ( 88 | 89 | )} 90 |
91 |
92 | {utils.range(1, 9).map((number) => ( 93 | 99 | ))} 100 |
101 |
102 |
Time Remaining: {secondsLeft}
103 |
104 | ); 105 | }; 106 | 107 | export default Game; 108 | --------------------------------------------------------------------------------