67 | Every time a player chooses a cell, the other player is sent to the board in
68 | the same position.
69 |
70 |
71 | Example: has made his first move in the{' '}
72 | top-right cell of the center board, so {' '}
73 | must choose a cell from the top-right board to play in.
74 |
75 |
76 |
80 |
81 |
82 |
83 |
Occupied Boards
84 |
Once a board is either won or tied, it can no longer be played in.
85 |
86 | If a player is sent to such a board, they can choose any open board to
87 | play in.
88 |
89 |
Be careful! Much of the game's strategy lies in this simple rule.
90 |
91 |
95 |
96 |
97 |
98 |
Winning
99 |
100 | You win overall when you win three boards in a row.
101 |
102 |
103 | It is much harder to reach a stalemate due to the increased constraints and
104 | permutations of moves (but still possible!).
105 |
106 |
107 | Have fun!{' '}
108 |
109 | Start playing
110 | {' '}
111 | or{' '}
112 |
113 | learn how this app was made
114 |
115 | .
116 |
72 | u3t.app is a web implementation of ultimate tic-tac-toe, a beautifully
73 | complex expansion on regular tic-tac-toe. You can find out more about the game
74 | on its{' '}
75 |
76 | Wikipedia page
77 |
78 |
79 | .
80 |
81 |
82 | The online multiplayer mode supports reconnection and spectators, and includes a
83 | turn log to keep track of the game.
84 |
85 |
86 | This site also also meets the requirements for a progressive web application
87 | (PWA), so you can{' '}
88 | {' '}
97 | on your device and continue playing even while offline.
98 |
99 |
100 |
101 |
Open Source
102 |
103 | This application was developed in{' '}
104 | TypeScript{' '}
105 | using React on the
106 | frontend and Node.js{' '}
107 | and Socket.IO to power
108 | the backend.
109 |
110 |
111 | The complete source code is available on{' '}
112 |
113 | GitHub
114 |
115 |
116 | .
117 |
118 |
119 |
120 |
Thank you
121 |
122 | If you enjoyed yourself or you have any questions or feedback, please let me
123 | know via the contact form.
124 |
125 |
126 | This application is provided freely for entertainment and education and will
127 | remain ad-free forever. You can help to keep me and the server chugging through
128 | coffee donations below.
129 |
130 |
135 |
141 |
142 |
Thanks for playing!
143 |
Chace
144 |
145 |
146 | );
147 | }
148 |
--------------------------------------------------------------------------------
/client/src/registerServiceWorker.ts:
--------------------------------------------------------------------------------
1 | const isLocalhost = Boolean(
2 | window.location.hostname === 'localhost' ||
3 | // [::1] is the IPv6 localhost address.
4 | window.location.hostname === '[::1]' ||
5 | // 127.0.0.0/8 are considered localhost for IPv4.
6 | window.location.hostname.match(
7 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
8 | )
9 | );
10 |
11 | type Config = {
12 | onSuccess?: (registration: ServiceWorkerRegistration) => void;
13 | onUpdate?: (registration: ServiceWorkerRegistration) => void;
14 | onError?: (error: Error) => void;
15 | };
16 |
17 | export default function register(config?: Config) {
18 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
19 | // The URL constructor is available in all browsers that support SW.
20 | const publicUrl = new URL(window.location.href);
21 | if (publicUrl.origin !== window.location.origin) {
22 | return;
23 | }
24 |
25 | window.addEventListener('load', () => {
26 | const swUrl = '/service-worker.js';
27 |
28 | if (isLocalhost) {
29 | // This is running on localhost. Let's check if a service worker still exists or not.
30 | checkValidServiceWorker(swUrl, config);
31 |
32 | // Add some additional logging to localhost, pointing developers to the
33 | // service worker/PWA documentation.
34 | navigator.serviceWorker.ready.then(() => {
35 | console.log('This web app is being served cache-first by a service worker');
36 | });
37 | } else {
38 | // Is not localhost. Just register service worker
39 | registerValidSW(swUrl, config);
40 | }
41 | });
42 | }
43 | }
44 |
45 | function updateServiceWorker(registration: ServiceWorkerRegistration) {
46 | const waitingRegistration = registration.waiting;
47 |
48 | if (waitingRegistration) {
49 | waitingRegistration?.postMessage({ type: 'SKIP_WAITING' });
50 |
51 | waitingRegistration?.addEventListener('statechange', (e) => {
52 | if ((e.target as ServiceWorker).state === 'activated') {
53 | window.location.reload();
54 | }
55 | });
56 | }
57 | }
58 |
59 | function registerValidSW(swUrl: string, config?: Config) {
60 | navigator.serviceWorker
61 | .register(swUrl)
62 | .then((registration) => {
63 | registration.onupdatefound = () => {
64 | const installingWorker = registration.installing;
65 | if (installingWorker == null) {
66 | return;
67 | }
68 | installingWorker.onstatechange = () => {
69 | if (installingWorker.state === 'installed') {
70 | if (navigator.serviceWorker.controller) {
71 | // At this point, the updated precached content has been fetched,
72 | // but the previous service worker will still serve the older
73 | // content until all client tabs are closed.
74 | window.confirm('New version available. Refresh to update?') &&
75 | updateServiceWorker(registration);
76 | // Execute callback
77 | if (config && config.onUpdate) {
78 | config.onUpdate(registration);
79 | }
80 | } else {
81 | // Execute callback
82 | if (config && config.onSuccess) {
83 | config.onSuccess(registration);
84 | }
85 | }
86 | }
87 | };
88 | };
89 | })
90 | .catch((error) => {
91 | console.error('Error during service worker registration:', error);
92 | if (config && config.onError) {
93 | config.onError(error);
94 | }
95 | });
96 | }
97 |
98 | function checkValidServiceWorker(swUrl: string, config?: Config) {
99 | // Check if the service worker can be found. If it can't reload the page.
100 | fetch(swUrl, {
101 | headers: { 'Service-Worker': 'script' },
102 | })
103 | .then((response) => {
104 | // Ensure service worker exists, and that we really are getting a JS file.
105 | const contentType = response.headers.get('content-type');
106 | if (
107 | response.status === 404 ||
108 | (contentType != null && contentType.indexOf('javascript') === -1)
109 | ) {
110 | // No service worker found. Probably a different app. Reload the page.
111 | navigator.serviceWorker.ready.then((registration) => {
112 | registration.unregister().then(() => {
113 | window.location.reload();
114 | });
115 | });
116 | } else {
117 | // Service worker found. Proceed as normal.
118 | registerValidSW(swUrl, config);
119 | }
120 | })
121 | .catch(() => {
122 | console.log('No internet connection found. App is running in offline mode.');
123 | });
124 | }
125 |
126 | export function unregister() {
127 | if ('serviceWorker' in navigator) {
128 | navigator.serviceWorker.ready
129 | .then((registration) => {
130 | registration.unregister();
131 | })
132 | .catch((error) => {
133 | console.error(error.message);
134 | });
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/client/src/Components/GameArea/TurnList/TurnList.tsx:
--------------------------------------------------------------------------------
1 | import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons';
2 | import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
3 | import { IGameState, Player } from '@u3t/common';
4 | import React, { useEffect, useRef, useState } from 'react';
5 | import styled from 'styled-components';
6 |
7 | import { IMultiplayerState } from '../../../hooks/useLobbyReducer';
8 | import { flexColumns } from '../../../styles/mixins';
9 | import { Button } from '../../Button';
10 | import TurnListItem, { TurnListCell, TurnListParagraph } from './TurnListItem';
11 |
12 | const TurnListButtonsContainer = styled.div`
13 | display: flex;
14 | justify-content: space-between;
15 | `;
16 |
17 | const TurnListToggle = styled(Button)`
18 | color: white;
19 | background-color: #594b5c;
20 | z-index: 1;
21 |
22 | :hover,
23 | :focus {
24 | filter: none;
25 | outline: 0;
26 | }
27 |
28 | & > svg {
29 | margin-left: 0.5em;
30 | }
31 | `;
32 |
33 | const TurnListContainer = styled.div<{ expanded: boolean }>`
34 | background-color: #594b5c;
35 | flex-direction: column;
36 | flex: 1;
37 | width: 100%;
38 | overflow: auto;
39 | color: white;
40 | display: none;
41 | ${({ expanded }) => expanded && `display: flex;`}
42 | `;
43 |
44 | const OuterContainer = styled.div<{ $requiresOverlay: boolean }>`
45 | flex: 1;
46 | display: flex;
47 | justify-content: flex-end;
48 | overflow: hidden;
49 |
50 | ${({ $requiresOverlay }) => $requiresOverlay && `overflow: initial;`}
51 | `;
52 |
53 | const StyledTurnList = styled.div<{ expanded: boolean; $requiresOverlay: boolean }>`
54 | ${flexColumns}
55 | flex: 1;
56 | flex-direction: column-reverse;
57 | overflow: hidden;
58 | font-weight: bold;
59 | font-size: 16px;
60 | color: #594b5c;
61 | z-index: 1;
62 |
63 | ${({ expanded, $requiresOverlay }) =>
64 | expanded &&
65 | `height: 100%;
66 | flex-direction: column;
67 | position: relative;
68 | width: 100%;
69 |
70 | ${
71 | $requiresOverlay
72 | ? `opacity: 0.9;
73 | position: absolute;
74 | height: calc(100vh - 172px);
75 | top: 0;`
76 | : ''
77 | }
78 | `}
79 |
80 | ${({ $requiresOverlay }) =>
81 | false &&
82 | !$requiresOverlay &&
83 | `position: relative;
84 | padding-top: 0;
85 | opacity: 1;
86 | bottom: 0;`}
87 | `;
88 |
89 | const OpeningText = ({ lobbyState }: { lobbyState: Partial }) => {
90 | if (!lobbyState.started) return Waiting for opponent to join.;
91 | if (lobbyState.isSpectator) return You are spectating.;
92 | if (lobbyState.playerSeat)
93 | return (
94 |
95 | You are playing as . Have fun!
96 |
97 | );
98 | return New game started.;
99 | };
100 |
101 | const isOverlayRequired = (el: HTMLDivElement | null) => !!(el && el?.offsetHeight < 200);
102 |
103 | const TurnList = ({
104 | state,
105 | lobbyState,
106 | RestartButton,
107 | }: {
108 | state: IGameState;
109 | seat?: Player;
110 | RestartButton: JSX.Element;
111 | lobbyState: Partial;
112 | }) => {
113 | const containerRef = useRef(null);
114 |
115 | const [requiresOverlay, setRequiresOverlay] = useState(false);
116 | const [expanded, setExpanded] = useState(true);
117 |
118 | const handleResize = () => {
119 | setRequiresOverlay(isOverlayRequired(containerRef.current));
120 | };
121 |
122 | useEffect(() => {
123 | // Initial check after mount
124 | if (isOverlayRequired(containerRef.current)) {
125 | setRequiresOverlay(true);
126 | setExpanded(false);
127 | }
128 | // Add listener incase of screen resize
129 | window.addEventListener('resize', handleResize);
130 | }, []);
131 |
132 | return (
133 |
134 |
135 |
136 | setExpanded((ex) => !ex)}>
137 | Turn log
138 |
139 |
140 | {RestartButton}
141 |
142 |
143 |
144 |
145 |
146 | {[...state.turnList].reverse().map((t, i, arr) => (
147 |
148 | ))}
149 | {state.finished && (
150 |
151 | {state.tied ? (
152 | It's a draw!
153 | ) : (
154 |
155 | wins!
156 |
157 | )}
158 |
159 | )}
160 |
161 |
162 |
163 | );
164 | };
165 |
166 | export default TurnList;
167 |
--------------------------------------------------------------------------------
/server/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | // "lib": [], /* Specify library files to be included in the compilation. */
8 | // "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | // "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
11 | // "declaration": true /* Generates corresponding '.d.ts' file. */,
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | // "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | "outDir": "./build" /* Redirect output structure to the directory. */,
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | // "removeComments": true, /* Do not emit comments to output. */
20 | // "noEmit": true, /* Do not emit outputs. */
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
24 |
25 | /* Strict Type-Checking Options */
26 | // "strict": true /* Enable all strict type-checking options. */,
27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
46 | // "typeRoots": [], /* List of folders to include type definitions from. */
47 | // "types": [], /* Type declaration files to be included in compilation. */
48 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
52 |
53 | /* Source Map Options */
54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
58 |
59 | /* Experimental Options */
60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
62 |
63 | /* Advanced Options */
64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "ES6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | // "lib": [], /* Specify library files to be included in the compilation. */
8 | // "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
11 | // "declaration": true /* Generates corresponding '.d.ts' file. */,
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | // "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | "outDir": "./build", /* Redirect output structure to the directory. */
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | // "removeComments": true, /* Do not emit comments to output. */
20 | "noEmit": true, /* Do not emit outputs. */
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
24 |
25 | /* Strict Type-Checking Options */
26 | "strict": true /* Enable all strict type-checking options. */,
27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
46 | // "typeRoots": [], /* List of folders to include type definitions from. */
47 | // "types": [], /* Type declaration files to be included in compilation. */
48 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
52 |
53 | /* Source Map Options */
54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
58 |
59 | /* Experimental Options */
60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
62 |
63 | /* Advanced Options */
64 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
65 | },
66 | "include": ["src/**/*"]
67 | }
68 |
--------------------------------------------------------------------------------
/common/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Basic Options */
4 | // "incremental": true, /* Enable incremental compilation */
5 | "target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
7 | // "lib": [], /* Specify library files to be included in the compilation. */
8 | // "allowJs": true, /* Allow javascript files to be compiled. */
9 | // "checkJs": true, /* Report errors in .js files. */
10 | // "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */,
11 | "declaration": true /* Generates corresponding '.d.ts' file. */,
12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
13 | // "sourceMap": true, /* Generates corresponding '.map' file. */
14 | // "outFile": "./", /* Concatenate and emit output to single file. */
15 | "outDir": "./build" /* Redirect output structure to the directory. */,
16 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
17 | // "composite": true, /* Enable project compilation */
18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
19 | "removeComments": true /* Do not emit comments to output. */,
20 | // "noEmit": true, /* Do not emit outputs. */
21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
24 |
25 | /* Strict Type-Checking Options */
26 | // "strict": true /* Enable all strict type-checking options. */,
27 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
28 | // "strictNullChecks": true, /* Enable strict null checks. */
29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
32 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
34 |
35 | /* Additional Checks */
36 | // "noUnusedLocals": true, /* Report errors on unused locals. */
37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
40 |
41 | /* Module Resolution Options */
42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
46 | // "typeRoots": [], /* List of folders to include type definitions from. */
47 | // "types": [], /* Type declaration files to be included in compilation. */
48 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */,
49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
52 |
53 | /* Source Map Options */
54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
58 |
59 | /* Experimental Options */
60 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
61 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
62 |
63 | /* Advanced Options */
64 |
65 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
66 | },
67 | "include": ["*.ts"],
68 | "exclude": ["*.test.ts"]
69 | }
70 |
--------------------------------------------------------------------------------
/common/game.ts:
--------------------------------------------------------------------------------
1 | import cloneDeep from 'clone-deep';
2 | import * as T from './types';
3 |
4 | const getWinnableSets = (cell: T.Cell) => {
5 | switch (cell) {
6 | case 0:
7 | return [
8 | [0, 1, 2],
9 | [0, 3, 6],
10 | [0, 4, 8],
11 | ];
12 | case 1:
13 | return [
14 | [0, 1, 2],
15 | [1, 4, 7],
16 | ];
17 | case 2:
18 | return [
19 | [0, 1, 2],
20 | [2, 5, 8],
21 | [2, 4, 6],
22 | ];
23 | case 3:
24 | return [
25 | [0, 3, 6],
26 | [3, 4, 5],
27 | ];
28 | case 4:
29 | return [
30 | [0, 4, 8],
31 | [1, 4, 7],
32 | [2, 4, 6],
33 | [3, 4, 5],
34 | ];
35 | case 5:
36 | return [
37 | [2, 5, 8],
38 | [3, 4, 5],
39 | ];
40 | case 6:
41 | return [
42 | [0, 3, 6],
43 | [6, 7, 8],
44 | [6, 4, 2],
45 | ];
46 | case 7:
47 | return [
48 | [1, 4, 7],
49 | [6, 7, 8],
50 | ];
51 | case 8:
52 | return [
53 | [2, 5, 8],
54 | [6, 7, 8],
55 | [8, 4, 0],
56 | ];
57 | default:
58 | return [];
59 | }
60 | };
61 |
62 | // Private
63 | const didWinBoard = (state: T.IGameState, payload: T.ITurnInput) => {
64 | const board = state.boards[payload.board];
65 |
66 | return getWinnableSets(payload.cell).some(([p1, p2, p3]) =>
67 | [board.cells[p1], board.cells[p2], board.cells[p3]].every((e) => e === payload.player)
68 | );
69 | };
70 |
71 | const didWinGame = (state: T.IGameState, payload: T.ITurnInput) => {
72 | const { boards } = state;
73 |
74 | return getWinnableSets(payload.board).find(([p1, p2, p3]) =>
75 | [boards[p1], boards[p2], boards[p3]].every(({ winner }) => winner === payload.player)
76 | );
77 | };
78 |
79 | // Public
80 | export const generateRandomMove = (state: T.IGameState) => {
81 | const { boards, currentPlayer: player, activeBoard } = state;
82 | console.log('state?', state);
83 |
84 | const randomElement = (arr: any) => arr[Math.floor(Math.random() * arr.length)];
85 |
86 | // optimise if reasonable
87 | const filteredBoards = boards.reduce((all: any, current, i) => {
88 | if (
89 | !activeBoard.includes(i as T.Cell) ||
90 | current.cellsOpen === 0 ||
91 | current.winner !== null
92 | )
93 | return all;
94 |
95 | return [...all, i];
96 | }, []);
97 | const board = randomElement(filteredBoards);
98 |
99 | if (!boards[board]) console.error('PROBLEM BOARD:', board);
100 | const filteredCells = boards[board].cells.reduce((all: any, current, i) => {
101 | if (current !== null) return all;
102 |
103 | return [...all, i];
104 | }, []);
105 | const cell = randomElement(filteredCells);
106 |
107 | return { player, board, cell };
108 | };
109 |
110 | export function getInitialState(): T.IGameState {
111 | return {
112 | turn: 1,
113 | currentPlayer: 1,
114 | boards: Array(9).fill({
115 | winner: null,
116 | cells: Array(9).fill(null),
117 | cellsOpen: 9,
118 | open: true,
119 | }),
120 | activeBoard: [0, 1, 2, 3, 4, 5, 6, 7, 8],
121 | winner: null,
122 | totalCellsOpen: 81,
123 | tied: false,
124 | finished: false,
125 | winningSet: [],
126 | turnList: [],
127 | };
128 | }
129 |
130 | const isOpenBoard = (board: T.IBoardState) => !board.winner && board.cellsOpen > 0;
131 |
132 | export function isInvalidTurn(state: T.IGameState, turn: T.ITurnInput) {
133 | const { player, board, cell } = turn;
134 |
135 | if (state.finished) {
136 | return T.Errors.GameIsFinished;
137 | }
138 |
139 | if (state.currentPlayer !== player) {
140 | // Play out of turn
141 | return T.Errors.WrongTurn;
142 | }
143 |
144 | if (!state.activeBoard.includes(board)) {
145 | // Board not playable
146 | return T.Errors.BoardNotPlayable;
147 | }
148 |
149 | if (state.boards[board].cells[cell] !== null) {
150 | // Cell is occupied
151 | return T.Errors.CellOccupied;
152 | }
153 | }
154 |
155 | export function forfeit(state: T.IGameState, player: T.Player) {
156 | const nextState = cloneDeep(state);
157 |
158 | switch (player) {
159 | case 1:
160 | nextState.winner = 2;
161 | break;
162 | case 2:
163 | nextState.winner = 1;
164 | }
165 |
166 | nextState.finished = true;
167 |
168 | return nextState;
169 | }
170 |
171 | export default function (
172 | state: T.IGameState,
173 | payload: T.ITurnInput,
174 | validate?: boolean
175 | ): { error?: T.Errors; state: T.IGameState } {
176 | const { player, board, cell } = payload;
177 |
178 | if (validate) {
179 | const error = isInvalidTurn(state, payload);
180 | if (error) {
181 | return { error, state };
182 | }
183 | }
184 |
185 | // Turn is valid, proceed to clone state
186 | const nextState = cloneDeep(state);
187 |
188 | // Record turn input
189 | nextState.turnList.push(payload);
190 |
191 | // Capture cell
192 | const currentBoard = nextState.boards[board];
193 | currentBoard.cells[cell] = player;
194 | currentBoard.cellsOpen -= 1;
195 | nextState.totalCellsOpen -= 1;
196 |
197 | if (didWinBoard(nextState, payload)) {
198 | currentBoard.winner = player;
199 | nextState.totalCellsOpen -= currentBoard.cellsOpen;
200 |
201 | const winningSet = didWinGame(nextState, payload);
202 |
203 | if (winningSet) {
204 | nextState.winningSet = winningSet;
205 | nextState.winner = player;
206 | nextState.finished = true;
207 |
208 | return { state: nextState };
209 | }
210 | }
211 |
212 | if (nextState.totalCellsOpen === 0) {
213 | nextState.tied = true;
214 | nextState.finished = true;
215 |
216 | return { state: nextState };
217 | }
218 |
219 | nextState.turn += 1;
220 | nextState.currentPlayer = nextState.currentPlayer === 1 ? 2 : 1;
221 | nextState.activeBoard = isOpenBoard(nextState.boards[cell])
222 | ? [cell]
223 | : nextState.boards.reduce((all, b, i) => {
224 | if (isOpenBoard(b)) all.push(i as T.Cell);
225 | return all;
226 | }, [] as T.Cell[]);
227 |
228 | return { state: nextState };
229 | }
230 |
--------------------------------------------------------------------------------
/client/src/Containers/Contact/contact.tsx:
--------------------------------------------------------------------------------
1 | import React, { FormEvent, useState } from 'react';
2 | import styled from 'styled-components';
3 |
4 | import { Button } from '../../Components/Button';
5 | import useDocumentTitle from '../../hooks/useDocumentTitle';
6 | import client from '../../micro-sentry';
7 | import { media } from '../../styles/mixins';
8 | import palette from '../../utils/palette';
9 | import { Article } from '../Rules/index';
10 |
11 | const emailRegex =
12 | // eslint-disable-next-line no-useless-escape
13 | /^([a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+\/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
14 |
15 | const Label = styled.label`
16 | color: white;
17 | font-weight: bold;
18 | `;
19 |
20 | const Input = styled.input<{ invalid: boolean }>`
21 | height: 2em;
22 |
23 | ${({ invalid }) => invalid && `border-color: ${palette.red};`}
24 | `;
25 |
26 | const Field = styled.div<{ gridArea: string }>`
27 | display: flex;
28 | flex-direction: column;
29 | grid-area: ${(p) => p.gridArea};
30 |
31 | label {
32 | margin-bottom: 0.5em;
33 | }
34 | `;
35 |
36 | const Textarea = styled.textarea<{ invalid: boolean }>`
37 | height: 8em;
38 | resize: vertical;
39 |
40 | ${({ invalid }) => invalid && `border-color: ${palette.red};`}
41 | `;
42 |
43 | const B = styled(Button)`
44 | grid-area: 'button';
45 | `;
46 |
47 | const Form = styled(Article)`
48 | padding: 1em;
49 | display: flex;
50 | flex-direction: column;
51 | color: white;
52 |
53 | > * + * {
54 | margin-top: 1em;
55 | }
56 |
57 | button {
58 | margin-top: 1em;
59 |
60 | ${media.aboveMobileS`
61 | align-self: flex-end;
62 | `}
63 | }
64 | `;
65 |
66 | const FormGrid = styled.div`
67 | display: grid;
68 | grid-gap: 1em;
69 | grid-template-areas:
70 | 'name'
71 | 'email'
72 | 'message';
73 |
74 | ${media.aboveMobileL`
75 | grid-template-areas:
76 | 'name email'
77 | 'message message';
78 | grid-template-columns: repeat(2, minmax(0, 1fr));
79 | `};
80 | `;
81 |
82 | const Text = styled.p<{ color: string }>`
83 | color: ${({ color }) => color};
84 | `;
85 |
86 | type SentStates = 'ready' | 'sending' | 'sent' | 'failed';
87 |
88 | interface ContactBody {
89 | name: string;
90 | email: string;
91 | message: string;
92 | }
93 |
94 | export default function Contact() {
95 | const [state, setState] = useState({ name: '', email: '', message: '' });
96 | const [sentStatus, setSentStatus] = useState('ready');
97 | const [invalidFields, setInvalidFields] = useState<(keyof ContactBody)[]>([]);
98 | useDocumentTitle('Contact');
99 |
100 | const set = (key: keyof ContactBody, value: string) =>
101 | setState((s) => ({ ...s, [key]: value }));
102 |
103 | const validate = () => {
104 | const invalidFields = Object.entries(state)
105 | .filter(
106 | ([key, value]) => value.length < 2 || (key === 'email' && !emailRegex.test(value))
107 | )
108 | .map(([key]) => key as keyof ContactBody);
109 |
110 | setInvalidFields(invalidFields);
111 |
112 | return !invalidFields.length;
113 | };
114 |
115 | return (
116 |