From bd714aa1e8175c38624037acc1b6d2a335a13ee5 Mon Sep 17 00:00:00 2001 From: Mari Date: Thu, 8 Jul 2021 21:57:12 -0400 Subject: [PATCH] Draw the rest of the client --- client/package-lock.json | 130 +++++++++++ client/package.json | 2 + client/src/App.css | 100 ++++++-- client/src/App.tsx | 71 ++++-- client/src/actions/AppAction.ts | 6 + client/src/actions/BaseAction.ts | 3 + client/src/actions/CellAction.ts | 34 +++ client/src/actions/MapAction.ts | 8 + client/src/actions/NetworkAction.ts | 175 ++++++++++++++ client/src/actions/TileAction.ts | 28 +++ client/src/actions/UserAction.ts | 18 ++ client/src/logo.svg | 1 - client/src/reducers/AppStateReducer.ts | 49 ++++ client/src/reducers/ClientReducer.ts | 38 ++++ client/src/reducers/HexMapReducer.ts | 45 ++++ client/src/reducers/NetworkReducer.ts | 20 ++ client/src/reducers/ServerReducer.ts | 265 ++++++++++++++++++++++ client/src/reducers/SyncedStateReducer.ts | 50 ++++ client/src/reducers/TileReducer.ts | 31 +++ client/src/reducers/UserReducer.ts | 15 ++ client/src/state/AppState.ts | 8 + client/src/state/Coordinates.ts | 155 +++++++++++++ client/src/state/HexMap.ts | 106 +++++++++ client/src/state/NetworkState.ts | 65 ++++++ client/src/state/SyncedState.ts | 7 + client/src/state/UserState.ts | 3 + client/src/ui/HexColorPicker.tsx | 79 +++++++ client/src/ui/HexMapRenderer.tsx | 22 ++ client/src/ui/HexTileRenderer.tsx | 45 ++++ client/src/ui/context/DispatchContext.ts | 4 + client/src/ui/debug/ConsoleConnection.tsx | 176 ++++++++++++++ client/src/util/ArrayUtils.ts | 7 + client/src/util/TypeUtils.ts | 4 + 33 files changed, 1731 insertions(+), 39 deletions(-) create mode 100644 client/src/actions/AppAction.ts create mode 100644 client/src/actions/BaseAction.ts create mode 100644 client/src/actions/CellAction.ts create mode 100644 client/src/actions/MapAction.ts create mode 100644 client/src/actions/NetworkAction.ts create mode 100644 client/src/actions/TileAction.ts create mode 100644 client/src/actions/UserAction.ts delete mode 100644 client/src/logo.svg create mode 100644 client/src/reducers/AppStateReducer.ts create mode 100644 client/src/reducers/ClientReducer.ts create mode 100644 client/src/reducers/HexMapReducer.ts create mode 100644 client/src/reducers/NetworkReducer.ts create mode 100644 client/src/reducers/ServerReducer.ts create mode 100644 client/src/reducers/SyncedStateReducer.ts create mode 100644 client/src/reducers/TileReducer.ts create mode 100644 client/src/reducers/UserReducer.ts create mode 100644 client/src/state/AppState.ts create mode 100644 client/src/state/Coordinates.ts create mode 100644 client/src/state/HexMap.ts create mode 100644 client/src/state/NetworkState.ts create mode 100644 client/src/state/SyncedState.ts create mode 100644 client/src/state/UserState.ts create mode 100644 client/src/ui/HexColorPicker.tsx create mode 100644 client/src/ui/HexMapRenderer.tsx create mode 100644 client/src/ui/HexTileRenderer.tsx create mode 100644 client/src/ui/context/DispatchContext.ts create mode 100644 client/src/ui/debug/ConsoleConnection.tsx create mode 100644 client/src/util/ArrayUtils.ts create mode 100644 client/src/util/TypeUtils.ts diff --git a/client/package-lock.json b/client/package-lock.json index 6573e13..16c6e36 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,8 +13,10 @@ "@types/jest": "^26.0.23", "@types/node": "^12.20.15", "@types/react": "^17.0.13", + "@types/react-color": "^3.0.5", "@types/react-dom": "^17.0.8", "react": "^17.0.2", + "react-color": "^2.19.3", "react-dom": "^17.0.2", "react-scripts": "4.0.3", "typescript": "^4.3.5", @@ -1896,6 +1898,14 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==" }, + "node_modules/@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3570,6 +3580,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-color": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.5.tgz", + "integrity": "sha512-0VZy8Uq5x04cW5QFz24Qw8MMMlsMi8Bb+XG5h59ATqPnWVq6OheHtrwv5LeakdTRDaECQnExJNSFOsSe4Eo/zQ==", + "dependencies": { + "@types/react": "*", + "@types/reactcss": "*" + } + }, "node_modules/@types/react-dom": { "version": "17.0.8", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.8.tgz", @@ -3578,6 +3597,14 @@ "@types/react": "*" } }, + "node_modules/@types/reactcss": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.4.tgz", + "integrity": "sha512-1rhVqteMSD6KQjO+dPBObE1OkKadw00HVJkG5WCYsyvMwGgdTZ530wF7Bkrg/4TWxB2AtINIzFotjW51eViw+w==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -13035,6 +13062,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash._reinterpolate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", @@ -13192,6 +13224,11 @@ "node": ">=0.10.0" } }, + "node_modules/material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "node_modules/md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -16189,6 +16226,23 @@ "node": ">=10" } }, + "node_modules/react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "dependencies": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -16492,6 +16546,14 @@ "semver": "bin/semver" } }, + "node_modules/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "dependencies": { + "lodash": "^4.0.1" + } + }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -19126,6 +19188,14 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "node_modules/tinycolor2": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz", + "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==", + "engines": { + "node": "*" + } + }, "node_modules/tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", @@ -22880,6 +22950,12 @@ "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==" }, + "@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", + "requires": {} + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -24115,6 +24191,15 @@ "csstype": "^3.0.2" } }, + "@types/react-color": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.5.tgz", + "integrity": "sha512-0VZy8Uq5x04cW5QFz24Qw8MMMlsMi8Bb+XG5h59ATqPnWVq6OheHtrwv5LeakdTRDaECQnExJNSFOsSe4Eo/zQ==", + "requires": { + "@types/react": "*", + "@types/reactcss": "*" + } + }, "@types/react-dom": { "version": "17.0.8", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.8.tgz", @@ -24123,6 +24208,14 @@ "@types/react": "*" } }, + "@types/reactcss": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.4.tgz", + "integrity": "sha512-1rhVqteMSD6KQjO+dPBObE1OkKadw00HVJkG5WCYsyvMwGgdTZ530wF7Bkrg/4TWxB2AtINIzFotjW51eViw+w==", + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz", @@ -31345,6 +31438,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash._reinterpolate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", @@ -31476,6 +31574,11 @@ "object-visit": "^1.0.0" } }, + "material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==" + }, "md5.js": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", @@ -33858,6 +33961,20 @@ "whatwg-fetch": "^3.4.1" } }, + "react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "requires": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + } + }, "react-dev-utils": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-11.0.4.tgz", @@ -34096,6 +34213,14 @@ } } }, + "reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "requires": { + "lodash": "^4.0.1" + } + }, "read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -36186,6 +36311,11 @@ "resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz", "integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=" }, + "tinycolor2": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.4.2.tgz", + "integrity": "sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==" + }, "tmpl": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", diff --git a/client/package.json b/client/package.json index f2b6779..45838e8 100644 --- a/client/package.json +++ b/client/package.json @@ -9,8 +9,10 @@ "@types/jest": "^26.0.23", "@types/node": "^12.20.15", "@types/react": "^17.0.13", + "@types/react-color": "^3.0.5", "@types/react-dom": "^17.0.8", "react": "^17.0.2", + "react-color": "^2.19.3", "react-dom": "^17.0.2", "react-scripts": "4.0.3", "typescript": "^4.3.5", diff --git a/client/src/App.css b/client/src/App.css index 74b5e05..00ac1a7 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -1,38 +1,94 @@ -.App { +body { text-align: center; + background-color: #282c34; } -.App-logo { - height: 40vmin; - pointer-events: none; +.App, .scrollbox { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; } -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } +.scrollbox { + overflow-x: scroll; + overflow-y: scroll; } - -.App-header { - background-color: #282c34; - min-height: 100vh; +.centerbox { display: flex; - flex-direction: column; align-items: center; justify-content: center; - font-size: calc(10px + 2vmin); - color: white; + align-content: center; + flex-direction: column; + min-width: 100%; + min-height: 100%; + width: max-content; + height: max-content; + box-sizing: border-box; +} +.map { + flex: 1 0 auto; + box-sizing: border-box; } -.App-link { - color: #61dafb; +.mapTile { + stroke: black; + stroke-width: 2; + stroke-linejoin: round; } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); +.swatch { + stroke: black; + stroke-width: 2; + stroke-linejoin: round; +} + +.swatch.active { + stroke-width: 4; +} +.swatch.selected { + stroke: white; + stroke-width: 5; + z-index: -1; +} +.hexColorPicker { + position: absolute; + bottom: 1vh; + left: 1vw; + height: auto; +} +.colorPickerBackground { + fill: lightslategray; +} +.consoleConnector { + position: absolute; + text-shadow: white 0 0 5px; + color: black; + top: 0; + right: 0; +} +@media screen and (min-width: 1000px) { + .hexColorPicker { + width: 30vw; } - to { - transform: rotate(360deg); + .centerbox { + padding-bottom: calc(2vh + (30vw * 126 / 855)); } } +@media screen and (min-width: 306px) and (max-width: 1000px) { + .hexColorPicker { + width: 300px; + } + .centerbox { + padding-bottom: calc(2vh + (300px * 126 / 855)); + } +} +@media screen and (max-width: 306px) { + .hexColorPicker { + width: 98vw; + } + .centerbox { + padding-bottom: calc(2vh + (98vw * 126 / 855)); + } +} \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index a53698a..f72a256 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,24 +1,63 @@ -import React from 'react'; -import logo from './logo.svg'; +import React, {useMemo, useReducer} from 'react'; import './App.css'; +import {DispatchContext} from './ui/context/DispatchContext'; +import {appStateReducer, AppStateReducer} from "./reducers/AppStateReducer"; +import {USER_ACTIVE_COLOR} from "./actions/UserAction"; +import {ServerConnectionState} from "./state/NetworkState"; +import HexMapRenderer from "./ui/HexMapRenderer"; +import HexColorPicker from "./ui/HexColorPicker"; +import {sizeFromLinesAndCells} from "./state/Coordinates"; +import {ConsoleConnector} from "./ui/debug/ConsoleConnection"; function App() { + const [state, dispatch] = useReducer( + appStateReducer, + null, + () => ({ + localState: null, + network: { + serverState: null, + connectionState: ServerConnectionState.OFFLINE, + specialMessage: null, + nextID: 0, + sentActions: [], + pendingActions: [], + goodbyeCode: 1000, + goodbyeReason: "", + autoReconnectAt: null, + reconnectAttempts: null + } + })); + + const {user, map} = state.localState || {user: null, map: null} + const offsets = useMemo(() => (!!map ? { + top: 10, + left: 10, + size: 50, + displayMode: map.displayMode + } : null), [map]) + const mapElement = !!offsets && !!map ? : null; + const {width, height} = useMemo(() => !!offsets && !!map ? sizeFromLinesAndCells({ + bottomMargin: 10, cells: map.cells_per_line, lines: map.lines, offsets: offsets, rightMargin: 10 + }) : {width: 1, height: 1}, [map, offsets]); + const colorPickerElement = !!user ? dispatch({ + type: USER_ACTIVE_COLOR, + color: colorResult.hex + })} /> : null + return (
-
- logo -

- Edit src/App.tsx and save to reload. -

- - Learn React - -
+ +
+
+ e.preventDefault()}> + {mapElement} + +
+
+ {colorPickerElement} + +
); } diff --git a/client/src/actions/AppAction.ts b/client/src/actions/AppAction.ts new file mode 100644 index 0000000..0510ab8 --- /dev/null +++ b/client/src/actions/AppAction.ts @@ -0,0 +1,6 @@ +import {MapAction} from "./MapAction"; +import {TileAction} from "./TileAction"; +import {UserAction} from "./UserAction"; +import {NetworkAction} from "./NetworkAction"; + +export type AppAction = MapAction | TileAction | UserAction | NetworkAction; \ No newline at end of file diff --git a/client/src/actions/BaseAction.ts b/client/src/actions/BaseAction.ts new file mode 100644 index 0000000..a5cd884 --- /dev/null +++ b/client/src/actions/BaseAction.ts @@ -0,0 +1,3 @@ +export interface BaseAction { + readonly type: string +} \ No newline at end of file diff --git a/client/src/actions/CellAction.ts b/client/src/actions/CellAction.ts new file mode 100644 index 0000000..a0819e5 --- /dev/null +++ b/client/src/actions/CellAction.ts @@ -0,0 +1,34 @@ +import {BaseAction} from "./BaseAction"; +import {StorageCoordinates} from "../state/Coordinates"; +import {AppAction} from "./AppAction"; + +export const CELL_COLOR = "CELL_COLOR" +/** Sets the given tile to the given color, or erases it entirely. */ +export interface CellColorAction extends BaseAction { + readonly type: typeof CELL_COLOR + /** The staggered (storage) coordinates of the tile to have its color changed */ + readonly at: StorageCoordinates + /** A color in #RRGGBBAA format. */ + readonly color: string +} + +export function isCellColorAction(action: AppAction): action is CellColorAction { + return action.type === CELL_COLOR +} + +export const CELL_REMOVE = "CELL_REMOVE" +export interface CellRemoveAction extends BaseAction { + readonly type: typeof CELL_REMOVE + /** The staggered (storage) coordinates of the tile to be removed */ + readonly at: StorageCoordinates +} + +export function isRemoveCellAction(action: AppAction): action is CellRemoveAction { + return action.type === CELL_REMOVE +} + +export function isCellAction(action: AppAction): action is CellAction { + return isRemoveCellAction(action) || isCellColorAction(action); +} + +export type CellAction = CellColorAction | CellRemoveAction \ No newline at end of file diff --git a/client/src/actions/MapAction.ts b/client/src/actions/MapAction.ts new file mode 100644 index 0000000..1e8b9e4 --- /dev/null +++ b/client/src/actions/MapAction.ts @@ -0,0 +1,8 @@ +import {CellAction, isCellAction} from "./CellAction"; +import {AppAction} from "./AppAction"; + +export function isMapAction(action: AppAction): action is MapAction { + return isCellAction(action); +} + +export type MapAction = CellAction \ No newline at end of file diff --git a/client/src/actions/NetworkAction.ts b/client/src/actions/NetworkAction.ts new file mode 100644 index 0000000..84d8b97 --- /dev/null +++ b/client/src/actions/NetworkAction.ts @@ -0,0 +1,175 @@ +import {BaseAction} from "./BaseAction"; +import {AppAction} from "./AppAction"; +import {CellColorAction, isCellColorAction} from "./CellAction"; +import {SyncedState} from "../state/SyncedState"; +import {isUserActiveColorAction, UserActiveColorAction} from "./UserAction"; + +// TODO: Split into ServerAction and ClientAction files + +export const SERVER_HELLO = "SERVER_HELLO" +/** Sent in response to the client's ClientHelloAction when the client has been accepted. */ +export interface ServerHelloAction extends BaseAction { + readonly type: typeof SERVER_HELLO + /** The protocol version the server is running on. */ + readonly version: number + /** The current state of the server as of when the client connected. */ + readonly state: SyncedState +} +export function isServerHelloAction(action: AppAction): action is ServerHelloAction { + return action.type === SERVER_HELLO +} + +export const SERVER_GOODBYE = "SERVER_GOODBYE" +/** Synthesized when the server closes the connection. */ +export interface ServerGoodbyeAction extends BaseAction { + readonly type: typeof SERVER_GOODBYE + /** The error code sent with the close message by the server, or -1 if our side crashed.*/ + readonly code: number + /** The text of the server's goodbye message, or the exception message if our side crashed. */ + readonly reason: string + /** The current time when this close message was received. */ + readonly currentTime: Date +} +export function isServerGoodbyeAction(action: AppAction): action is ServerGoodbyeAction { + return action.type === SERVER_GOODBYE +} + +export const SERVER_REFRESH = "SERVER_REFRESH" +/** Sent in response to the client's ClientRefreshAction. */ +export interface ServerRefreshAction extends BaseAction { + readonly type: typeof SERVER_REFRESH + /** The current state of the server as of when the client's refresh request was processed. */ + readonly state: SyncedState +} +export function isServerRefreshAction(action: AppAction): action is ServerRefreshAction { + return action.type === SERVER_REFRESH +} + +export const SERVER_OK = "SERVER_OK" +/** Sent in response to the client's ClientNestedAction, if it succeeds and is applied to the server map. */ +export interface ServerOKAction extends BaseAction { + readonly type: typeof SERVER_OK + /** + * The IDs of the successful actions. + * These will always be sent in sequential order - the order in which the server received and applied them. + * This allows the client to apply that set of actions to its server-state copy and then refresh its local copy. + */ + readonly ids: readonly number[] +} +export function isServerOKAction(action: AppAction): action is ServerOKAction { + return action.type === SERVER_OK +} + +export interface ActionFailure { + readonly id: number + readonly error: string +} + +export const SERVER_FAILED = "SERVER_FAILED" +/** Sent in response to the client's ClientNestedAction, if it fails and has not been applied to the server map. */ +export interface ServerFailedAction extends BaseAction { + readonly type: typeof SERVER_FAILED + readonly failures: readonly ActionFailure[] +} +export function isServerFailedAction(action: AppAction): action is ServerFailedAction { + return action.type === SERVER_FAILED +} + +export enum SocketState { + CONNECTING = "CONNECTING", + OPEN = "OPEN", +} +export const SERVER_SOCKET_STARTUP = "SERVER_SOCKET_STARTUP" +/** Synthesized when the websocket begins connecting, i.e., enters the Connecting or Open states.. */ +export interface ServerSocketStartupAction extends BaseAction { + readonly type: typeof SERVER_SOCKET_STARTUP + readonly state: SocketState +} +export function isServerSocketStartupAction(action: AppAction): action is ServerSocketStartupAction { + return action.type === SERVER_SOCKET_STARTUP +} + +export const SERVER_SENT = "SERVER_SENT" +/** Sent by the server when another client has performed an action. Never sent for the client's own actions. */ +export interface ServerSentAction extends BaseAction { + readonly type: typeof SERVER_SENT + readonly actions: readonly SyncableAction[] +} +export function isServerSentAction(action: AppAction): action is ServerSentAction { + return action.type === SERVER_SENT +} + +export type SyncableAction = SendableAction +export function isSyncableAction(action: AppAction): action is SyncableAction { + return isSendableAction(action) +} + +export type ServerAction = + ServerHelloAction | ServerGoodbyeAction | ServerRefreshAction | + ServerOKAction | ServerFailedAction | + ServerSocketStartupAction | ServerSentAction +export function isServerAction(action: AppAction) { + return isServerHelloAction(action) || isServerGoodbyeAction(action) || isServerRefreshAction(action) + || isServerOKAction(action) || isServerFailedAction(action) + || isServerSocketStartupAction(action) || isServerSentAction(action) +} + +export const CLIENT_HELLO = "CLIENT_HELLO" +/** Sent when the client connects. */ +export interface ClientHelloAction extends BaseAction { + readonly type: typeof CLIENT_HELLO + /** The protocol version the client is running on */ + readonly version: number +} +export function isClientHelloAction(action: AppAction): action is ClientHelloAction { + return action.type === CLIENT_HELLO +} + +export const CLIENT_REFRESH = "CLIENT_REFRESH" +/** Sent when the user requests a refresh, or if a malformed action comes through. */ +export interface ClientRefreshAction extends BaseAction { + readonly type: typeof CLIENT_REFRESH +} +export function isClientRefreshAction(action: AppAction): action is ClientRefreshAction { + return action.type === CLIENT_REFRESH +} + +export const CLIENT_PENDING = "CLIENT_PENDING" +/** Synthesized when a user action is ready to be sent to the server. */ +export interface ClientPendingAction extends BaseAction { + readonly type: typeof CLIENT_PENDING + readonly pending: SendableAction +} +export function isClientPendingAction(action: AppAction): action is ClientPendingAction { + return action.type === CLIENT_PENDING +} + +export interface SentAction { + readonly id: number + readonly action: SendableAction +} + +export const CLIENT_SENT = "CLIENT_SENT" +/** Sent to the server when the user performs an action. */ +export interface ClientSentAction extends BaseAction { + readonly type: typeof CLIENT_SENT + readonly nested: readonly SentAction[] +} +export function isClientSentAction(action: AppAction): action is ClientSentAction { + return action.type === CLIENT_SENT +} + +export type SendableAction = CellColorAction | UserActiveColorAction +export function isSendableAction(action: AppAction): action is SendableAction { + return isCellColorAction(action) || isUserActiveColorAction(action) +} + +export type ClientAction = ClientHelloAction | ClientRefreshAction | ClientPendingAction | ClientSentAction +export function isClientAction(action: AppAction): action is ClientAction { + return isClientHelloAction(action) || isClientRefreshAction(action) || isClientPendingAction(action) || isClientSentAction(action) +} + +export type NetworkAction = ServerAction | ClientAction; +export function isNetworkAction(action: AppAction): action is NetworkAction { + return isServerAction(action) || isClientAction(action) +} \ No newline at end of file diff --git a/client/src/actions/TileAction.ts b/client/src/actions/TileAction.ts new file mode 100644 index 0000000..f51d0c2 --- /dev/null +++ b/client/src/actions/TileAction.ts @@ -0,0 +1,28 @@ +import {BaseAction} from "./BaseAction"; +import {StorageCoordinates} from "../state/Coordinates"; +import {AppAction} from "./AppAction"; + +export const TILE_PAINT = "TILE_PAINT" +export interface TilePaintAction extends BaseAction { + readonly type: typeof TILE_PAINT + readonly at: StorageCoordinates +} + +export function isTilePaintAction(action: AppAction): action is TilePaintAction { + return action.type === TILE_PAINT +} + +export const TILE_REMOVE = "TILE_REMOVE" +export interface TileRemoveAction extends BaseAction { + readonly type: typeof TILE_REMOVE + readonly at: StorageCoordinates +} +export function isTileRemoveAction(action: AppAction): action is TileRemoveAction { + return action.type === TILE_REMOVE +} + +export function isTileAction(action: AppAction): action is TileAction { + return isTilePaintAction(action) || isTileRemoveAction(action) +} + +export type TileAction = TilePaintAction | TileRemoveAction \ No newline at end of file diff --git a/client/src/actions/UserAction.ts b/client/src/actions/UserAction.ts new file mode 100644 index 0000000..4e591fe --- /dev/null +++ b/client/src/actions/UserAction.ts @@ -0,0 +1,18 @@ +import {BaseAction} from "./BaseAction"; +import {AppAction} from "./AppAction"; + +export const USER_ACTIVE_COLOR = "USER_ACTIVE_COLOR" +export interface UserActiveColorAction extends BaseAction { + readonly type: typeof USER_ACTIVE_COLOR + /** The hex color that the user will begin painting with. */ + readonly color: string +} +export function isUserActiveColorAction(action: AppAction): action is UserActiveColorAction { + return action.type === USER_ACTIVE_COLOR +} + +export function isUserAction(action: AppAction): action is UserAction { + return isUserActiveColorAction(action) +} + +export type UserAction = UserActiveColorAction; diff --git a/client/src/logo.svg b/client/src/logo.svg deleted file mode 100644 index 9dfc1c0..0000000 --- a/client/src/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/reducers/AppStateReducer.ts b/client/src/reducers/AppStateReducer.ts new file mode 100644 index 0000000..9196c73 --- /dev/null +++ b/client/src/reducers/AppStateReducer.ts @@ -0,0 +1,49 @@ +import {AppAction} from "../actions/AppAction"; +import {AppState} from "../state/AppState"; +import {isTileAction} from "../actions/TileAction"; +import {tileReducer} from "./TileReducer"; +import {networkReducer} from "./NetworkReducer"; +import {CLIENT_PENDING, isNetworkAction, isSendableAction} from "../actions/NetworkAction"; +import {syncedStateReducer} from "./SyncedStateReducer"; +import {exhaustivenessCheck} from "../util/TypeUtils"; +import {isMapAction, MapAction} from "../actions/MapAction"; +import {isUserAction, UserAction} from "../actions/UserAction"; +import {clientReducer} from "./ClientReducer"; + +function appSyncedStateReducer(oldState: AppState, action: MapAction|UserAction): AppState { + if (oldState.localState === null) { + return oldState + } + const localState = syncedStateReducer(oldState.localState, action) + if (localState === oldState.localState) { + return oldState + } + if (isSendableAction(action)) { + return { + ...oldState, + localState, + network: clientReducer(oldState.network, { + type: CLIENT_PENDING, + pending: action + }) + } + } else { + return { + ...oldState, + localState + } + } +} + +export function appStateReducer(oldState: AppState, action: AppAction): AppState { + if (isMapAction(action) || isUserAction(action)) { + return appSyncedStateReducer(oldState, action) + } else if (isTileAction(action)) { + return tileReducer(oldState, action) + } else if (isNetworkAction(action)) { + return networkReducer(oldState, action) + } + exhaustivenessCheck(action) +} + +export type AppStateReducer = typeof appStateReducer; \ No newline at end of file diff --git a/client/src/reducers/ClientReducer.ts b/client/src/reducers/ClientReducer.ts new file mode 100644 index 0000000..23edda8 --- /dev/null +++ b/client/src/reducers/ClientReducer.ts @@ -0,0 +1,38 @@ +import {CLIENT_HELLO, CLIENT_PENDING, CLIENT_REFRESH, CLIENT_SENT, ClientAction} from "../actions/NetworkAction"; +import {NetworkState, ServerConnectionState} from "../state/NetworkState"; + +// TODO: Verify that only one special message exists at a time. +export function clientReducer(oldState: NetworkState, action: ClientAction): NetworkState { + switch (action.type) { + case CLIENT_HELLO: + return { + ...oldState, + specialMessage: action, + connectionState: ServerConnectionState.AWAITING_HELLO, + } + case CLIENT_REFRESH: + return { + ...oldState, + specialMessage: action, + connectionState: ServerConnectionState.AWAITING_REFRESH, + } + case CLIENT_PENDING: + // This happens when an action is successfully applied locally, so we prepare to send it to the server; + // we don't have to actually do anything because it was already done by the time we got here. + return { + ...oldState, + pendingActions: [...oldState.pendingActions, action.pending], + } + case CLIENT_SENT: + if (!action.nested.every((innerAction, index) => + innerAction.id === oldState.nextID + index && innerAction.action === oldState.pendingActions[index])) { + throw Error("Only the next actions can be sent, and only with the next IDs.") + } + return { + ...oldState, + nextID: oldState.nextID + action.nested.length, + sentActions: [...oldState.sentActions, ...action.nested], + pendingActions: oldState.pendingActions.slice(action.nested.length), + } + } +} \ No newline at end of file diff --git a/client/src/reducers/HexMapReducer.ts b/client/src/reducers/HexMapReducer.ts new file mode 100644 index 0000000..32ad5d2 --- /dev/null +++ b/client/src/reducers/HexMapReducer.ts @@ -0,0 +1,45 @@ +import {StorageCoordinates} from "../state/Coordinates"; +import {areCellsEquivalent, EMPTY_CELL, getCell, HexCell, HexMap} from "../state/HexMap"; +import {MapAction} from "../actions/MapAction"; +import {CELL_COLOR, CELL_REMOVE} from "../actions/CellAction"; + +export function updateCell(map: HexMap, coords: StorageCoordinates, newData: Partial): HexMap { + const oldCell = getCell(map, coords) || EMPTY_CELL; + return replaceCell(map, coords, { + ...oldCell, + ...newData, + }) +} + +export function replaceCell(oldMap: HexMap, {line, cell}: StorageCoordinates, newCell: HexCell|null): HexMap { + if (areCellsEquivalent(getCell(oldMap, {line, cell}), newCell)) { + return oldMap + } + const oldLines = oldMap.lineCells + const oldLine = oldLines[line] + const newLine = [ + ...oldLine.slice(0, cell), + newCell, + ...oldLine.slice(cell + 1) + ] + const newLines = [ + ...oldLines.slice(0, line), + newLine, + ...oldLines.slice(line + 1) + ] + return { + ...oldMap, + lineCells: newLines + } +} + +export function hexMapReducer(oldState: HexMap, action: MapAction): HexMap { + switch (action.type) { + case CELL_COLOR: + return updateCell(oldState, action.at, { color: action.color }) + case CELL_REMOVE: + return replaceCell(oldState, action.at, null) + } +} + +export type HexMapReducer = typeof hexMapReducer; \ No newline at end of file diff --git a/client/src/reducers/NetworkReducer.ts b/client/src/reducers/NetworkReducer.ts new file mode 100644 index 0000000..b471911 --- /dev/null +++ b/client/src/reducers/NetworkReducer.ts @@ -0,0 +1,20 @@ +import {AppState} from "../state/AppState"; +import {isClientAction, NetworkAction} from "../actions/NetworkAction"; +import {clientReducer} from "./ClientReducer"; +import {serverReducer} from "./ServerReducer"; + +export function networkReducer(oldState: AppState, action: NetworkAction): AppState { + if (isClientAction(action)) { + const newState = clientReducer(oldState.network, action) + if (newState === oldState.network) + { + return oldState + } + return { + ...oldState, + network: newState + } + } else /* if (isServerAction(action)) */ { + return serverReducer(oldState, action) + } +} \ No newline at end of file diff --git a/client/src/reducers/ServerReducer.ts b/client/src/reducers/ServerReducer.ts new file mode 100644 index 0000000..9cd3d62 --- /dev/null +++ b/client/src/reducers/ServerReducer.ts @@ -0,0 +1,265 @@ +import {AppState} from "../state/AppState"; +import { + CLIENT_HELLO, + SendableAction, + SERVER_FAILED, + SERVER_GOODBYE, + SERVER_HELLO, + SERVER_OK, + SERVER_REFRESH, + SERVER_SENT, + SERVER_SOCKET_STARTUP, + ServerAction, + ServerFailedAction, + ServerGoodbyeAction, + ServerHelloAction, + ServerOKAction, + ServerRefreshAction, + ServerSentAction, + ServerSocketStartupAction, + SocketState, + SyncableAction, +} from "../actions/NetworkAction"; +import {NetworkState, ServerConnectionState} from "../state/NetworkState"; +import {SyncedState} from "../state/SyncedState"; +import {applySyncableActions} from "./SyncedStateReducer"; +import {clientReducer} from "./ClientReducer"; + +interface StateRecalculationInputs { + /** The original server state before the actions changed. The base on which the actions are all applied. */ + readonly serverState: SyncedState + /** The permanent actions which will be applied to form the new server state. */ + readonly permanentActions: readonly SyncableAction[] + /** + * Actions which have already been sent to the server and therefore can't be deleted, + * but have not been approved and can't be applied to the server state. + * Applied to the local state first. + */ + readonly sentActions: readonly SendableAction[] + /** + * Actions which have not yet been sent to the server and therefore can be deleted. + * Applied to the local state second, and failed or no-effect actions are removed from the list returned. + */ + readonly unsentActions: readonly SendableAction[] +} + +interface StateRecalculationOutputs { + /** The new server state after applying permanentActions to oldServerState. */ + readonly newServerState: SyncedState, + /** The new local state after applying permanentActions, sentActions, and unsentActions (in that order) to oldServerState. */ + readonly newLocalState: SyncedState, + /** The unsentActions list from the input, minus any actions which failed or had no effect. */ + readonly appliedUnsentActions: readonly SendableAction[] +} + +function recalculateStates(input: StateRecalculationInputs): StateRecalculationOutputs { + const newServerState = applySyncableActions(input.serverState, input.permanentActions).state + const {state: sentLocalState} = applySyncableActions(newServerState, input.sentActions) + const {state: newLocalState, appliedActions: appliedUnsentActions} = applySyncableActions(sentLocalState, input.unsentActions) + return { newServerState, newLocalState, appliedUnsentActions } +} + +function serverHelloReducer(oldState: AppState, action: ServerHelloAction): AppState { + const { + newServerState, + newLocalState, + appliedUnsentActions, + } = recalculateStates({ + serverState: action.state, + permanentActions: [], + sentActions: [], + unsentActions: oldState.network.pendingActions + }) + // TODO: The connection state should be AWAITING_HELLO and the special message should be our Hello + // TODO: Destroy all pending actions if the server's map has a different GUID from ours. + return { + ...oldState, + localState: newLocalState, + network: { + ...oldState.network, + connectionState: ServerConnectionState.CONNECTED, + specialMessage: null, + serverState: newServerState, + pendingActions: appliedUnsentActions, + reconnectAttempts: 0, + } + } +} + +function serverRefreshReducer(oldState: AppState, action: ServerRefreshAction): AppState { + const { + newServerState, + newLocalState, + appliedUnsentActions, + } = recalculateStates({ + serverState: action.state, + permanentActions: [], + sentActions: oldState.network.sentActions.map((sent) => sent.action), + unsentActions: oldState.network.pendingActions + }) + // TODO: The connection state should be AWAITING_REFRESH and the special message should be our Refresh + // TODO: Destroy all pending actions if the server's map has a different GUID from ours. + return { + ...oldState, + localState: newLocalState, + network: { + ...oldState.network, + connectionState: ServerConnectionState.CONNECTED, + specialMessage: null, + serverState: newServerState, + pendingActions: appliedUnsentActions, + } + }; +} + +function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeAction): NetworkState { + // TODO: Sort out the correct state and autoReconnectAt based on the time in the action. + return { + ...oldState, + connectionState: ServerConnectionState.OFFLINE, + specialMessage: null, + goodbyeCode: action.code, + goodbyeReason: action.reason, + } +} + +function serverOkReducer(oldState: AppState, action: ServerOKAction): AppState { + if (oldState.network.serverState === null) { + return oldState + } + const okIndexes: {[id: number]: boolean|undefined} = {} + for (let index = 0; index < action.ids.length; index += 1) { + okIndexes[action.ids[index]] = true + } + const receivedActions = oldState.network.sentActions.filter((sent) => !!okIndexes[sent.id]).map((sent) => sent.action) + const stillWaitingActions = oldState.network.sentActions.filter((sent) => !okIndexes[sent.id]) + const {newServerState, newLocalState, appliedUnsentActions} = recalculateStates({ + serverState: oldState.network.serverState, + permanentActions: receivedActions, + sentActions: stillWaitingActions.map((sent) => sent.action), + unsentActions: oldState.network.pendingActions, + }) + return { + ...oldState, + localState: newLocalState, + network: { + ...oldState.network, + serverState: newServerState, + sentActions: stillWaitingActions, + pendingActions: appliedUnsentActions, + } + } +} + +function serverFailedReducer(oldState: AppState, action: ServerFailedAction): AppState { + if (oldState.network.serverState === null) { + return oldState + } + const failedIndexes: {[id: number]: boolean|undefined} = {} + for (let index = 0; index < action.failures.length; index += 1) { + failedIndexes[action.failures[index].id] = true + } + // TODO: Figure out somewhere to put the failures for logging purposes, so the messages aren't wasted. + /* const failedActions = */ + oldState.network.sentActions.filter((sent) => !!failedIndexes[sent.id]).map((sent) => sent.action) + const stillWaitingActions = oldState.network.sentActions.filter((sent) => !failedIndexes[sent.id]) + const {newServerState, newLocalState, appliedUnsentActions} = recalculateStates({ + serverState: oldState.network.serverState, + permanentActions: [], + sentActions: stillWaitingActions.map((sent) => sent.action), + unsentActions: oldState.network.pendingActions, + }) + return { + ...oldState, + localState: newLocalState, + network: { + ...oldState.network, + serverState: newServerState, + sentActions: stillWaitingActions, + pendingActions: appliedUnsentActions, + } + } +} + +function serverSocketStartupReducer(oldState: NetworkState, action: ServerSocketStartupAction): NetworkState { + switch (action.state) { + case SocketState.OPEN: + return clientReducer(oldState, { + type: CLIENT_HELLO, + version: 1, + }) + case SocketState.CONNECTING: + return { + ...oldState, + connectionState: ServerConnectionState.CONNECTING, + serverState: null, + specialMessage: null, + goodbyeCode: null, + goodbyeReason: null, + autoReconnectAt: null, + nextID: 0, + sentActions: [], + pendingActions: [ + ...oldState.sentActions.map((wrapper) => (wrapper.action)), + ...oldState.pendingActions, + ], + // Don't clear reconnectAttempts until SERVER_HELLO. Even if we fail _after_ establishing + // a connection, we still want to keep using the longer exponential backoff state, or even give up. + } + } +} + +function serverSentReducer(oldState: AppState, action: ServerSentAction): AppState { + if (oldState.network.serverState === null) { + return oldState + } + const { + newServerState, + newLocalState, + appliedUnsentActions, + } = recalculateStates({ + serverState: oldState.network.serverState, + permanentActions: action.actions, + sentActions: oldState.network.sentActions.map((sent) => sent.action), + unsentActions: oldState.network.pendingActions + }) + return { + ...oldState, + localState: newLocalState, + network: { + ...oldState.network, + connectionState: ServerConnectionState.CONNECTED, + serverState: newServerState, + pendingActions: appliedUnsentActions, + } + }; +} + +export function serverReducer(oldState: AppState, action: ServerAction): AppState { + // TODO: Verify that these messages are only received at the proper times and in the proper states. + // e.g., BeginConnecting should only happen when the state is somewhere in the disconnected region. + // Goodbye, OK, Failed, and Sent, on the other hand, _can't_ happen then. + // Hello and Refresh should only happen when we are explicitly waiting for them. + switch (action.type) { + case SERVER_HELLO: + return serverHelloReducer(oldState, action); + case SERVER_REFRESH: + return serverRefreshReducer(oldState, action); + case SERVER_GOODBYE: + return { + ...oldState, + network: serverGoodbyeReducer(oldState.network, action) + }; + case SERVER_OK: + return serverOkReducer(oldState, action); + case SERVER_FAILED: + return serverFailedReducer(oldState, action); + case SERVER_SOCKET_STARTUP: + return { + ...oldState, + network: serverSocketStartupReducer(oldState.network, action) + } + case SERVER_SENT: + return serverSentReducer(oldState, action) + } +} \ No newline at end of file diff --git a/client/src/reducers/SyncedStateReducer.ts b/client/src/reducers/SyncedStateReducer.ts new file mode 100644 index 0000000..9076a12 --- /dev/null +++ b/client/src/reducers/SyncedStateReducer.ts @@ -0,0 +1,50 @@ +import {SyncedState} from "../state/SyncedState"; +import {SyncableAction} from "../actions/NetworkAction"; +import {isMapAction, MapAction} from "../actions/MapAction"; +import {hexMapReducer} from "./HexMapReducer"; +import {isUserAction, UserAction} from "../actions/UserAction"; +import {userReducer} from "./UserReducer"; +import {exhaustivenessCheck} from "../util/TypeUtils"; + +export function syncedStateReducer(state: SyncedState, action: (MapAction|UserAction)): SyncedState { + if (isMapAction(action)) { + const newMap = hexMapReducer(state.map, action) + return newMap === state.map ? state : { + ...state, + map: newMap + } + } else if (isUserAction(action)) { + const newUser = userReducer(state.user, action) + return newUser === state.user ? state : { + ...state, + user: newUser + } + } + exhaustivenessCheck(action) +} + +interface SyncableActionApplicationResults { + state: SyncedState + appliedActions: readonly T[] +} +/** Applies a sequence of actions, skipping failed actions, and returns an array of the successful ones. */ +export function applySyncableActions(state: SyncedState, actions: readonly T[]): SyncableActionApplicationResults { + return actions.reduce(({state, appliedActions}: SyncableActionApplicationResults, action) => { + // Save our breath by reusing the actions array if this is the last action in the list. + const resultActions = (appliedActions.length === actions.length - 1) ? actions : [...appliedActions, action] + try { + const newState = syncedStateReducer(state, action) + if (newState === state) { + // Act as if it wasn't there - it did nothing. + return { state, appliedActions } + } + return { + state: newState, + appliedActions: resultActions + } + } catch (e) { + // Just skip it - continue as if it wasn't there, and don't let the exception escape. + return { state, appliedActions } + } + }, {state: state, appliedActions: []}) +} \ No newline at end of file diff --git a/client/src/reducers/TileReducer.ts b/client/src/reducers/TileReducer.ts new file mode 100644 index 0000000..1fc25ec --- /dev/null +++ b/client/src/reducers/TileReducer.ts @@ -0,0 +1,31 @@ +import {AppState} from "../state/AppState"; +import {TILE_PAINT, TILE_REMOVE, TileAction} from "../actions/TileAction"; +import {getCell} from "../state/HexMap"; +import {CELL_COLOR, CELL_REMOVE} from "../actions/CellAction"; +import {appStateReducer} from "./AppStateReducer"; + +export function tileReducer(oldState: AppState, action: TileAction): AppState { + if (oldState.localState === null) { + return oldState + } + const {map, user} = oldState.localState + switch (action.type) { + case TILE_PAINT: + if (getCell(map, action.at)?.color === user.activeColor) { + return oldState + } + return appStateReducer(oldState, { + type: CELL_COLOR, + at: action.at, + color: user.activeColor + }) + case TILE_REMOVE: + if (getCell(map, action.at) === null) { + return oldState + } + return appStateReducer(oldState, { + type: CELL_REMOVE, + at: action.at + }) + } +} \ No newline at end of file diff --git a/client/src/reducers/UserReducer.ts b/client/src/reducers/UserReducer.ts new file mode 100644 index 0000000..b6e6ff2 --- /dev/null +++ b/client/src/reducers/UserReducer.ts @@ -0,0 +1,15 @@ +import {USER_ACTIVE_COLOR, UserAction} from "../actions/UserAction"; +import {UserState} from "../state/UserState"; + +export function userReducer(oldState: UserState, action: UserAction): UserState { + switch (action.type) { + case USER_ACTIVE_COLOR: + if (oldState.activeColor === action.color) { + return oldState + } + return { + ...oldState, + activeColor: action.color + } + } +} \ No newline at end of file diff --git a/client/src/state/AppState.ts b/client/src/state/AppState.ts new file mode 100644 index 0000000..927c5ad --- /dev/null +++ b/client/src/state/AppState.ts @@ -0,0 +1,8 @@ +import {NetworkState} from "./NetworkState"; +import {SyncedState} from "./SyncedState"; + +export interface AppState { + /** The current state of the app with pending actions applied; the local version. */ + readonly localState: SyncedState | null + readonly network: NetworkState +} \ No newline at end of file diff --git a/client/src/state/Coordinates.ts b/client/src/state/Coordinates.ts new file mode 100644 index 0000000..433098b --- /dev/null +++ b/client/src/state/Coordinates.ts @@ -0,0 +1,155 @@ +/** Cubic (3-dimensional) coordinates for algorithms. */ +import {HexagonOrientation, HexMapRepresentation, LineParity} from "./HexMap"; + +export interface CubicCoordinates { + /** The cubic x-coordinate. */ + readonly x: number + /** The cubic y-coordinate. */ + readonly y: number + /** The cubic z-coordinate. */ + readonly z: number +} + +/** Axial (2-dimensional cube variant) coordinates for display. */ +export interface AxialCoordinates { + /** The axial x-coordinate. */ + readonly q: number + /** The axial y-coordinate. */ + readonly r: number +} + +/** Staggered (storage) coordinates for accessing cell storage. */ +export interface StorageCoordinates { + /** The index of the line within the map. */ + readonly line: number + /** The index of the cell within the line. */ + readonly cell: number +} + +/** Translates storage coordinates to a key unique among all cells in the map. */ +export function storageCoordinatesToKey({line, cell}: StorageCoordinates): string { + return `${line},${cell}` +} + +/** + * The full set of coordinates on screen for a hexagon. + * The axis changes depending on the orientation. + * For POINTY_TOP mode, the flat axis is the X axis, and the pointy axis is the Y axis. + * For FLAT_TOP mode, the flat axis is the Y axis, and the pointy axis is the X axis. + * The vertices of a hexagon will always be at these pairs, in this order, with the axes set as above: + * pointyStart, flatCenter + * pointyFirstThird, flatStart + * pointyLastThird, flatStart + * pointyEnd, flatCenter + * pointyLastThird, flatEnd + * pointyFirstThird, flatEnd + */ +export interface RenderCoordinates { + /** The orientation of the hexagons, for determining which axes to use. */ + readonly orientation: HexagonOrientation, + /** The beginning of the pointy axis - where the first pointy vertex is, on the axis that runs through the pointy vertices. */ + readonly pointyStart: number, + /** The end of the pointy axis - where the last pointy vertex is, on the axis that runs through the pointy vertices. */ + readonly pointyEnd: number, + /** The first "third" (actually a quarter) of the pointy axis - where the first vertex of each flat side is, on the axis that runs through the pointy vertices. */ + readonly pointyFirstThird: number, + /** The last "third" (actually a quarter) of the pointy axis - where the last vertex of each flat side is, on the axis that runs through the pointy vertices. */ + readonly pointyLastThird: number, + /** The start of the flat axis - where the first flat side is, on the axis that runs through the flat sides. */ + readonly flatStart: number, + /** The center of the flat axis - where the two pointy vertices are, on the axis that runs through the flat sides. */ + readonly flatCenter: number, + /** The end of the flat axis - where the second flat side is, on the axis that runs through the flat sides. */ + readonly flatEnd: number, +} + +/** The offsets for rendering a hexagonal map. */ +export interface RenderOffsets { + /** The distance from the left the first x coordinate should be. */ + readonly left: number + /** The distance from the top the first y coordinate should be. */ + readonly top: number + /** How big each hexagon should be. The "radius" from the center to any vertex. */ + readonly size: number + /** The way the hex map should be displayed. Usually the same as the origin map. */ + readonly displayMode: HexMapRepresentation +} + +export interface RenderSize { + readonly width: number + readonly height: number +} + +// Heavily based on https://www.redblobgames.com/grids/hexagons/ +const POINTY_AXIS_FACTOR = 2; +const FLAT_AXIS_FACTOR = Math.sqrt(3); +export function storageCoordinatesToRenderCoordinates({line, cell}: StorageCoordinates, renderOffsets: RenderOffsets): RenderCoordinates { + const {orientation, indentedLines} = renderOffsets.displayMode; + + const flatLength = renderOffsets.size * FLAT_AXIS_FACTOR; + const pointyLength = renderOffsets.size * POINTY_AXIS_FACTOR; + + const flatStorageCoordinate = cell; + const pointyStorageCoordinate = line; + + const lineParity = (pointyStorageCoordinate % 2) === 0 ? LineParity.EVEN : LineParity.ODD; + const isIndented = lineParity === indentedLines; + + const flatOffset = orientation === HexagonOrientation.FLAT_TOP ? renderOffsets.top : renderOffsets.left; + const pointyOffset = orientation === HexagonOrientation.POINTY_TOP ? renderOffsets.top : renderOffsets.left; + + const pointyStart = pointyOffset + pointyStorageCoordinate * pointyLength * 3 / 4; + const pointyFirstThird = pointyStart + pointyLength / 4 + const pointyLastThird = pointyStart + pointyLength * 3 / 4 + const pointyEnd = pointyStart + pointyLength; + + const flatStart = flatOffset + flatLength * ((isIndented ? 0.5 : 0) + flatStorageCoordinate); + const flatCenter = flatStart + flatLength / 2; + const flatEnd = flatStart + flatLength; + + return { + orientation, + pointyStart, + pointyFirstThird, + pointyLastThird, + pointyEnd, + flatStart, + flatCenter, + flatEnd + } +} + +export function sizeFromLinesAndCells({offsets, lines, cells, rightMargin = 0, bottomMargin = 0}: {offsets: RenderOffsets, lines: number, cells: number, rightMargin?: number, bottomMargin?: number}): RenderSize { + const { + top: topMargin, + left: leftMargin, + displayMode: { + orientation, + indentedLines + } + } = offsets; + const flatMargins = orientation === HexagonOrientation.FLAT_TOP ? topMargin + bottomMargin : leftMargin + rightMargin; + const pointyMargins = orientation === HexagonOrientation.POINTY_TOP ? topMargin + bottomMargin : leftMargin + rightMargin; + + const flatCellLength = offsets.size * FLAT_AXIS_FACTOR; + const pointyCellLength = offsets.size * POINTY_AXIS_FACTOR; + + const hasIndents = lines > 1 || indentedLines === LineParity.EVEN + + // Every line will be 3/4 of a cell length apart in the pointy direction; + // however, the last line is still one full pointy cell long. + const pointyLength = pointyMargins + ((cells - 1) * pointyCellLength * 3 / 4) + pointyCellLength; + // Every cell will be one full cell length apart in the flat direction; + // however, if there are indents, another half cell is needed to accommodate them. + const flatLength = flatMargins + lines * flatCellLength + (hasIndents ? flatCellLength / 2 : 0); + + return orientation === HexagonOrientation.FLAT_TOP ? { width: pointyLength, height: flatLength } : { width: flatLength, height: pointyLength } +} + +export function renderCoordinatesToPolygonPoints(coords: RenderCoordinates): string { + if (coords.orientation === HexagonOrientation.FLAT_TOP) { + return `${coords.pointyStart},${coords.flatCenter} ${coords.pointyFirstThird},${coords.flatStart} ${coords.pointyLastThird},${coords.flatStart} ${coords.pointyEnd},${coords.flatCenter} ${coords.pointyLastThird},${coords.flatEnd} ${coords.pointyFirstThird},${coords.flatEnd}` + } else /* if (coords.orientation === HexagonOrientation.POINTY_TOP) */ { + return `${coords.flatCenter},${coords.pointyStart} ${coords.flatStart},${coords.pointyFirstThird} ${coords.flatStart},${coords.pointyLastThird} ${coords.flatCenter},${coords.pointyEnd} ${coords.flatEnd},${coords.pointyLastThird} ${coords.flatEnd},${coords.pointyFirstThird}` + } +} \ No newline at end of file diff --git a/client/src/state/HexMap.ts b/client/src/state/HexMap.ts new file mode 100644 index 0000000..0792f66 --- /dev/null +++ b/client/src/state/HexMap.ts @@ -0,0 +1,106 @@ +/** Data associated with a single cell of the map. */ +import {StorageCoordinates} from "./Coordinates"; + +export interface HexCell { + /** A color in #RRGGBBAA format, or null indicating that the cell should simply be hidden altogether. */ + readonly color: string +} + +export const EMPTY_CELL: HexCell = { + color: "#FFFFFFFF" +} + +export type HexLine = readonly (HexCell|null)[] + +export enum HexagonOrientation { + /** + * The pointy side is vertical and the flat side is horizontal. + * Use the Y axis for pointy coordinates and the X axis for flat coordinates. + * Lines are rows that go straight across the X axis. + */ + POINTY_TOP = "POINTY_TOP", + /** + * The pointy side is horizontal and the flat side is vertical. + * Use the Y axis for flat coordinates and the X axis for pointy coordinates. + * Lines are columns that go straight down the Y axis. + */ + FLAT_TOP = "FLAT_TOP", +} + +/** Which lines should be indented - odds or evens. */ +export enum LineParity { + /** Odd lines (1, 3, 5, ...) are staggered inward (right or down) by 1/2 cell. */ + ODD = "ODD", + /** Even lines (0, 2, 4, ...) are staggered inward (right or down) by 1/2 cell. */ + EVEN = "EVEN" +} + +/** The type of map this map is. */ +export interface HexMapRepresentation { + readonly orientation: HexagonOrientation + readonly indentedLines: LineParity +} + +/** Data corresponding to an entire hex map. */ +export interface HexMap { + /** The way the map is displayed, which also affects how coordinates are calculated. */ + readonly displayMode: HexMapRepresentation + /** + * The number of lines on the map. + * In ROWS and EVEN_ROWS mode, this is the height of the map. + * In COLUMNS and EVEN_COLUMNS mode, this is the width of the map. + */ + readonly lines: number + /** + * The number of cells per line. + * In ROWS and EVEN_ROWS mode, this is the width of the map. + * In COLUMNS and EVEN_COLUMNS mode, this is the height of the map. + */ + readonly cells_per_line: number + /** + * The list of tile lines. There are always exactly {lines} lines. + * Lines have a constant length; there are always exactly {cells_per_line} cells in a line. + * In COLUMNS and EVEN_COLUMNS mode, lines represent columns. + * In ROWS and EVEN_ROWS mode, lines represent rows. + */ + readonly lineCells: readonly HexLine[] + /** + * A unique identifier for this map. Lets the client know when it is connecting to a different map with the same + * name as this one, and its old map has been destroyed. + */ + readonly guid: string +} + +export function initializeMap({lines, cells_per_line, displayMode, guid}: {lines: number, cells_per_line: number, displayMode: HexMapRepresentation, guid: string}): HexMap { + const lineCells: HexLine[] = []; + const emptyLine: HexCell[] = []; + for (let cell = 0; cell < cells_per_line; cell += 1) { + emptyLine.push(EMPTY_CELL) + } + for (let line = 0; line < lines; line += 1) { + lineCells.push(emptyLine) + } + return { + lines, + cells_per_line, + displayMode, + lineCells, + guid + } +} + +export function isValidCoordinate(map: HexMap, {line, cell}: StorageCoordinates) { + return line >= 0 && line < map.lines && Number.isInteger(line) + && cell >= 0 && cell < map.cells_per_line && Number.isInteger(cell); +} + +export function areCellsEquivalent(left: HexCell|null, right: HexCell|null): boolean { + return left === right || (left?.color === right?.color) +} + +export function getCell(map: HexMap, {line, cell}: StorageCoordinates): HexCell|null { + if (!isValidCoordinate(map, {line, cell})) { + return null + } + return map.lineCells[line][cell] +} \ No newline at end of file diff --git a/client/src/state/NetworkState.ts b/client/src/state/NetworkState.ts new file mode 100644 index 0000000..7f97495 --- /dev/null +++ b/client/src/state/NetworkState.ts @@ -0,0 +1,65 @@ +import {ClientHelloAction, ClientRefreshAction, SendableAction, SentAction} from "../actions/NetworkAction"; +import {SyncedState} from "./SyncedState"; + +export enum ServerConnectionState { + /** Used when the client is going through the WebSockets connection process and sending a Hello. */ + CONNECTING = "CONNECTING", + /** Used when the client has sent a hello, and is waiting for the server to respond. */ + AWAITING_HELLO = "AWAITING_HELLO", + /** Used when the client has sent a refresh request, and is waiting for the server to respond. */ + AWAITING_REFRESH = "AWAITING_REFRESH", + /** Used when the client is connected and everything is normal. */ + CONNECTED = "CONNECTED", + /** + * Used when the client is disconnected and not currently connecting, + * such as when waiting for the automatic reconnect, or if the browser is currently in offline mode, + * or if the client was disconnected due to a protocol error. + */ + OFFLINE = "OFFLINE", +} + +export interface NetworkState { + /** + * The current state of the server, as this client knows it. + * Used to keep a clean state for applying pending actions to if, for any reason, the current state needs to be + * recalculated from the server and pending states (e.g., if a server action comes in, or a pending action + * is applied). + * + * Null iff the connection has never been established. + */ + readonly serverState: SyncedState|null + /** + * The current state of the connection. + */ + readonly connectionState: ServerConnectionState + /** + * A special action that should take precedence over sending more actions. + */ + readonly specialMessage: ClientHelloAction|ClientRefreshAction|null + /** + * The ID of the next ClientSentAction to be created. + */ + readonly nextID: number + /** + * Messages that were sent to the server but have not yet received a response. + * These come before pendingActions. + */ + readonly sentActions: readonly SentAction[] + /** Yet-unsent actions that originated here. These come after sentActions. */ + readonly pendingActions: readonly SendableAction[] + /** + * The error code of the close message. + * Non-null if and only if the current state is OFFLINE or REJECTED. + * -1 means no onClose was called, and the reason is a stringized error from onError instead. + */ + readonly goodbyeCode: number | null + /** + * The error reason of the close message. + * Non-null if and only if the current state is OFFLINE or REJECTED. + */ + readonly goodbyeReason: string | null + /** The time the client will attempt to reconnect, if at all. */ + readonly autoReconnectAt: Date | null + /** The number of attempts at reconnecting. */ + readonly reconnectAttempts: number | null +} \ No newline at end of file diff --git a/client/src/state/SyncedState.ts b/client/src/state/SyncedState.ts new file mode 100644 index 0000000..f81adc6 --- /dev/null +++ b/client/src/state/SyncedState.ts @@ -0,0 +1,7 @@ +import {HexMap} from "./HexMap"; +import {UserState} from "./UserState"; + +export interface SyncedState { + readonly map: HexMap + readonly user: UserState +} \ No newline at end of file diff --git a/client/src/state/UserState.ts b/client/src/state/UserState.ts new file mode 100644 index 0000000..38aacc0 --- /dev/null +++ b/client/src/state/UserState.ts @@ -0,0 +1,3 @@ +export interface UserState { + readonly activeColor: string +} \ No newline at end of file diff --git a/client/src/ui/HexColorPicker.tsx b/client/src/ui/HexColorPicker.tsx new file mode 100644 index 0000000..529e05e --- /dev/null +++ b/client/src/ui/HexColorPicker.tsx @@ -0,0 +1,79 @@ +import {CustomPicker, InjectedColorProps} from "react-color"; +import {ReactElement, useMemo} from "react"; +import { + renderCoordinatesToPolygonPoints, + RenderOffsets, + storageCoordinatesToRenderCoordinates +} from "../state/Coordinates"; +import {HexagonOrientation, LineParity} from "../state/HexMap"; + +function HexSwatch({color, index, offsets, classNames, onClick}: {color: string, index: number, offsets: RenderOffsets, classNames?: readonly string[], onClick: () => void}): ReactElement { + const renderCoordinates = useMemo(() => storageCoordinatesToRenderCoordinates({line: index, cell: 0}, offsets), [index, offsets]); + const points = useMemo(() => renderCoordinatesToPolygonPoints(renderCoordinates), [renderCoordinates]); + return e.preventDefault()}/> +} + +const ACTIVE_OFFSETS: RenderOffsets = { + displayMode: { + orientation: HexagonOrientation.POINTY_TOP, + indentedLines: LineParity.ODD + }, + top: 13, + left: 13, + size: 50, +} + +const SWATCH_OFFSETS: RenderOffsets = { + displayMode: { + orientation: HexagonOrientation.FLAT_TOP, + indentedLines: LineParity.EVEN + }, + top: 34, + left: 110, + size: 30, +} + +const COLORS: readonly string[] = [ + "#000000FF", "#555555FF", + "#800000FF", "#FF0000FF", + "#008000FF", "#00FF00FF", + "#808000FF", "#FFFF00FF", + "#000080FF", "#0000FFFF", + "#800080FF", "#FF00FFFF", + "#008080FF", "#00FFFFFF", + "#AAAAAAFF", "#FFFFFFFF", +] + +function normalizeColor(hex: string): string { + hex = hex.toUpperCase() + if (hex.length === 4) { + return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}FF` + } + if (hex.length === 5) { + return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}${hex[4]}${hex[4]}` + } + if (hex.length === 7) { + return `${hex}FF` + } + return hex +} + +function HexColorPicker({ hex, onChange }: InjectedColorProps): ReactElement { + const selected = COLORS.indexOf(normalizeColor(hex || "#INVALID")) + const swatches = COLORS.map((color, index) => + index === selected ? null : onChange !== undefined ? onChange(color) : null} /> + ) + return + + null} /> + {swatches} + {selected !== -1 ? onChange !== undefined ? onChange(COLORS[selected]) : null} /> : null} + +} + +export default CustomPicker(HexColorPicker); \ No newline at end of file diff --git a/client/src/ui/HexMapRenderer.tsx b/client/src/ui/HexMapRenderer.tsx new file mode 100644 index 0000000..4194d30 --- /dev/null +++ b/client/src/ui/HexMapRenderer.tsx @@ -0,0 +1,22 @@ +import React, {ReactElement} from "react"; +import {getCell, HexMap} from "../state/HexMap"; +import {HexTileRenderer} from "./HexTileRenderer"; +import {RenderOffsets, storageCoordinatesToKey} from "../state/Coordinates"; + +function HexMapRenderer({map, offsets}: {map: HexMap, offsets: RenderOffsets}) { + const tiles: ReactElement[] = []; + for (let line = 0; line < map.lines; line += 1) { + for (let cell = 0; cell < map.cells_per_line; cell += 1) { + const coords = {line, cell}; + const cellData = getCell(map, coords); + if (cellData !== null) { + tiles.push( + + ); + } + } + } + return {tiles} +} + +export default HexMapRenderer \ No newline at end of file diff --git a/client/src/ui/HexTileRenderer.tsx b/client/src/ui/HexTileRenderer.tsx new file mode 100644 index 0000000..81addc1 --- /dev/null +++ b/client/src/ui/HexTileRenderer.tsx @@ -0,0 +1,45 @@ +import {MouseEvent as ReactMouseEvent, ReactElement, useContext, useMemo} from "react"; +import {HexCell} from "../state/HexMap"; +import { + renderCoordinatesToPolygonPoints, RenderOffsets, + StorageCoordinates, + storageCoordinatesToRenderCoordinates +} from "../state/Coordinates"; +import {DispatchContext} from "./context/DispatchContext"; +import {TILE_PAINT, TILE_REMOVE} from "../actions/TileAction"; + +export function HexTileRenderer({classNames, coords, cell, offsets}: {classNames?: readonly string[], coords: StorageCoordinates, cell: HexCell, offsets: RenderOffsets}): ReactElement { + const dispatch = useContext(DispatchContext); + + const LMB = 1; + const RMB = 2; + + function onMouse(e: ReactMouseEvent) { + if (dispatch === null) { + return; + } + if (e.buttons === LMB) { + dispatch({ + type: TILE_PAINT, + at: coords, + }); + } + if (e.buttons === RMB) { + dispatch({ + type: TILE_REMOVE, + at: coords, + }); + } + } + + const renderCoordinates = useMemo(() => storageCoordinatesToRenderCoordinates(coords, offsets), [coords, offsets]); + const points = useMemo(() => renderCoordinatesToPolygonPoints(renderCoordinates), [renderCoordinates]) + + return e.preventDefault()}/> +} \ No newline at end of file diff --git a/client/src/ui/context/DispatchContext.ts b/client/src/ui/context/DispatchContext.ts new file mode 100644 index 0000000..0f4d797 --- /dev/null +++ b/client/src/ui/context/DispatchContext.ts @@ -0,0 +1,4 @@ +import { createContext } from "react"; +import {AppAction} from "../../actions/AppAction"; + +export const DispatchContext = createContext void)>(null); \ No newline at end of file diff --git a/client/src/ui/debug/ConsoleConnection.tsx b/client/src/ui/debug/ConsoleConnection.tsx new file mode 100644 index 0000000..b877c3c --- /dev/null +++ b/client/src/ui/debug/ConsoleConnection.tsx @@ -0,0 +1,176 @@ +import { + ActionFailure, + CLIENT_SENT, + ClientAction, + ClientSentAction, + isClientSentAction, + SendableAction, + SentAction, + SERVER_FAILED, + SERVER_GOODBYE, + SERVER_HELLO, + SERVER_OK, + SERVER_SENT, + SERVER_SOCKET_STARTUP, + ServerAction, + SocketState +} from "../../actions/NetworkAction"; +import {HexagonOrientation, HexMapRepresentation, initializeMap, LineParity} from "../../state/HexMap"; +import {ReactElement, useContext, useEffect, useRef, useState} from "react"; +import {DispatchContext} from "../context/DispatchContext"; +import {USER_ACTIVE_COLOR} from "../../actions/UserAction"; +import {CELL_COLOR} from "../../actions/CellAction"; + +export enum OrientationConstants { + ROWS = "ROWS", + COLUMNS = "COLUMNS", + EVEN_ROWS = "EVEN_ROWS", + EVEN_COLUMNS = "EVEN_COLUMNS" +} +export function orientationFromString(string: string): HexMapRepresentation { + const normalized = string.toUpperCase().trim() + switch (normalized) { + case OrientationConstants.ROWS: + return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.ODD } + case OrientationConstants.COLUMNS: + return { orientation: HexagonOrientation.FLAT_TOP, indentedLines: LineParity.ODD } + case OrientationConstants.EVEN_ROWS: + return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.EVEN } + case OrientationConstants.EVEN_COLUMNS: + return { orientation: HexagonOrientation.FLAT_TOP, indentedLines: LineParity.EVEN } + default: + return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.ODD } + } +} + +/** Fake "connection" to a "server" that actually just goes back and forth with the console. */ +export class ConsoleConnection { + public receivedMessages: ClientAction[] = [] + private dispatch: (action: ServerAction) => void + + constructor(dispatch: (action: ServerAction) => void) { + this.dispatch = dispatch + } + + receive(action: ClientAction): void { + this.receivedMessages.push(action) + if (isClientSentAction(action)) { + console.log(`Received Sent action containing: ${action.nested.map((value) => `${value.id}/${value.action.type}`).join(", ")}`) + } else { + console.log(`Received: ${action.type}`) + } + } + + public sendSocketConnecting(): void { + this.dispatch({ + type: SERVER_SOCKET_STARTUP, + state: SocketState.CONNECTING + }) + } + + public sendSocketConnected(): void { + this.dispatch({ + type: SERVER_SOCKET_STARTUP, + state: SocketState.OPEN + }) + } + + public sendHello({color = "#0000FF", displayMode = "ROWS", guid = "TotallyCoolGUID", lines = 10, cells = 10}: { + color?: string, + displayMode?: string, + guid?: string, + lines?: number, + cells?: number + } = {}): void { + this.dispatch({ + type: SERVER_HELLO, + version: 1, + state: { + map: initializeMap({ + lines, + cells_per_line: cells, + displayMode: orientationFromString(displayMode), + guid + }), + user: {activeColor: color} + } + }) + } + + public sendOK(ids: readonly number[]): void { + this.dispatch({ + type: SERVER_OK, + ids + }) + } + + public sendFailed(failures: readonly ActionFailure[]): void { + this.dispatch({ + type: SERVER_FAILED, + failures + }) + } + + public sendGoodbye({code = 1000, reason = "Okay, bye then!"}: { code?: number, reason?: string } = {}): void { + this.dispatch({ + type: SERVER_GOODBYE, + code, + reason, + currentTime: new Date() + }) + } + + public sendColorChange(color: string = "#FF0000FF"): void { + this.dispatch({ + type: SERVER_SENT, + actions: [{ + type: USER_ACTIVE_COLOR, + color + }] + }) + } + + public sendColorAtTile(color: string = "#FFFF00FF", line: number = 0, cell: number = 0): void { + this.dispatch({ + type: SERVER_SENT, + actions: [{ + type: CELL_COLOR, + at: { line, cell }, + color + }] + }) + } +} + + +export function ConsoleConnector({specialMessage, pendingMessages, nextID}: {specialMessage: ClientAction|null, pendingMessages: readonly SendableAction[], nextID: number}): ReactElement { + const dispatch = useContext(DispatchContext) + const connector = useRef(new ConsoleConnection(dispatch || (() => null))) + const [lastSpecialMessage, setLastSpecialMessage] = useState(null) + useEffect(() => { + // @ts-ignore + window.fakedServerConnection = connector.current + }, []); + useEffect(() => { + if (dispatch !== null) { + if (pendingMessages.length > 0) { + const sentMessages: SentAction[] = pendingMessages.map((action, index) => { + return { id: index + nextID, action } + }); + const sentMessage: ClientSentAction = { + type: CLIENT_SENT, + nested: sentMessages + }; + connector.current.receive(sentMessage) + dispatch(sentMessage) + } + } + }, [nextID, dispatch, pendingMessages]) + useEffect(() => { + if (specialMessage !== null && specialMessage !== lastSpecialMessage) { + connector.current.receive(specialMessage); + setLastSpecialMessage(specialMessage); + } + }, [specialMessage, lastSpecialMessage, setLastSpecialMessage]) + return
Console connection active
+} \ No newline at end of file diff --git a/client/src/util/ArrayUtils.ts b/client/src/util/ArrayUtils.ts new file mode 100644 index 0000000..6d4a943 --- /dev/null +++ b/client/src/util/ArrayUtils.ts @@ -0,0 +1,7 @@ +export function arrayShallowEqual(left: readonly T[], right: readonly T[]): boolean { + return left.length === right.length && arrayShallowStartsWith(left, right) +} + +export function arrayShallowStartsWith(target: readonly T[], prefix: readonly T[]): boolean { + return target.length >= prefix.length && prefix.every((value, index) => target[index] === value) +} \ No newline at end of file diff --git a/client/src/util/TypeUtils.ts b/client/src/util/TypeUtils.ts new file mode 100644 index 0000000..b5edff0 --- /dev/null +++ b/client/src/util/TypeUtils.ts @@ -0,0 +1,4 @@ +/** Used when Typescript hasn't figured out that we can never reach a particular branch. */ +export function exhaustivenessCheck(_param: never): never { + throw Error("This cannot be...") +} \ No newline at end of file