Draw the rest of the client

main
Mari 3 years ago
parent f5a9f3fde6
commit bd714aa1e8
  1. 130
      client/package-lock.json
  2. 2
      client/package.json
  3. 100
      client/src/App.css
  4. 71
      client/src/App.tsx
  5. 6
      client/src/actions/AppAction.ts
  6. 3
      client/src/actions/BaseAction.ts
  7. 34
      client/src/actions/CellAction.ts
  8. 8
      client/src/actions/MapAction.ts
  9. 175
      client/src/actions/NetworkAction.ts
  10. 28
      client/src/actions/TileAction.ts
  11. 18
      client/src/actions/UserAction.ts
  12. 1
      client/src/logo.svg
  13. 49
      client/src/reducers/AppStateReducer.ts
  14. 38
      client/src/reducers/ClientReducer.ts
  15. 45
      client/src/reducers/HexMapReducer.ts
  16. 20
      client/src/reducers/NetworkReducer.ts
  17. 265
      client/src/reducers/ServerReducer.ts
  18. 50
      client/src/reducers/SyncedStateReducer.ts
  19. 31
      client/src/reducers/TileReducer.ts
  20. 15
      client/src/reducers/UserReducer.ts
  21. 8
      client/src/state/AppState.ts
  22. 155
      client/src/state/Coordinates.ts
  23. 106
      client/src/state/HexMap.ts
  24. 65
      client/src/state/NetworkState.ts
  25. 7
      client/src/state/SyncedState.ts
  26. 3
      client/src/state/UserState.ts
  27. 79
      client/src/ui/HexColorPicker.tsx
  28. 22
      client/src/ui/HexMapRenderer.tsx
  29. 45
      client/src/ui/HexTileRenderer.tsx
  30. 4
      client/src/ui/context/DispatchContext.ts
  31. 176
      client/src/ui/debug/ConsoleConnection.tsx
  32. 7
      client/src/util/ArrayUtils.ts
  33. 4
      client/src/util/TypeUtils.ts

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

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

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

@ -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>(
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 ? <HexMapRenderer map={map} offsets={offsets} /> : 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 ? <HexColorPicker color={user?.activeColor} onChangeComplete={(colorResult) => dispatch({
type: USER_ACTIVE_COLOR,
color: colorResult.hex
})} /> : null
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
<DispatchContext.Provider value={dispatch}>
<div className={"scrollbox"}>
<div className={"centerbox"}>
<svg className={"map"} width={width} height={height} viewBox={`0 0 ${width} ${height}`} onContextMenu={(e) => e.preventDefault()}>
{mapElement}
</svg>
</div>
</div>
{colorPickerElement}
<ConsoleConnector specialMessage={state.network.specialMessage} pendingMessages={state.network.pendingActions} nextID={state.network.nextID} />
</DispatchContext.Provider>
</div>
);
}

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

@ -0,0 +1,3 @@
export interface BaseAction {
readonly type: string
}

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

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

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

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

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

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

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

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

@ -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<HexCell>): 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;

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

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

@ -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<T> {
state: SyncedState
appliedActions: readonly T[]
}
/** Applies a sequence of actions, skipping failed actions, and returns an array of the successful ones. */
export function applySyncableActions<T extends SyncableAction>(state: SyncedState, actions: readonly T[]): SyncableActionApplicationResults<T> {
return actions.reduce(({state, appliedActions}: SyncableActionApplicationResults<T>, 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: []})
}

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

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

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

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

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

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

@ -0,0 +1,7 @@
import {HexMap} from "./HexMap";
import {UserState} from "./UserState";
export interface SyncedState {
readonly map: HexMap
readonly user: UserState
}

@ -0,0 +1,3 @@
export interface UserState {
readonly activeColor: string
}

@ -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 <polygon
className={classNames !== undefined ? `hexagon swatch ${classNames.join(" ")}` : "hexagon swatch"}
fill={color}
points={points}
onClick={onClick}
onContextMenu={(e) => 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 : <HexSwatch key={index} classNames={["preset"]} color={color} index={index} offsets={SWATCH_OFFSETS} onClick={() => onChange !== undefined ? onChange(color) : null} />
)
return <svg viewBox={"0 0 855 126"} width={"855"} height={"126"} className={"hexColorPicker"}>
<rect className={"colorPickerBackground"} x={0} y={0} width={855} height={126} rx={15} />
<HexSwatch classNames={["active"]} color={hex || "#000000FF"} index={0} offsets={ACTIVE_OFFSETS} onClick={() => null} />
{swatches}
{selected !== -1 ? <HexSwatch key={selected} classNames={["preset", "selected"]} color={COLORS[selected]} index={selected} offsets={SWATCH_OFFSETS} onClick={() => onChange !== undefined ? onChange(COLORS[selected]) : null} /> : null}
</svg>
}
export default CustomPicker(HexColorPicker);

@ -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(
<HexTileRenderer key={storageCoordinatesToKey(coords)} coords={coords} cell={cellData} offsets={offsets} />
);
}
}
}
return <g>{tiles}</g>
}
export default HexMapRenderer

@ -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<SVGPolygonElement, MouseEvent>) {
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 <polygon
className={classNames !== undefined ? `hexagon mapTile ${classNames.join(" ")}` : "hexagon mapTile"}
fill={cell.color}
points={points}
onMouseDown={onMouse}
onMouseMove={onMouse}
onContextMenu={(e) => e.preventDefault()}/>
}

@ -0,0 +1,4 @@
import { createContext } from "react";
import {AppAction} from "../../actions/AppAction";
export const DispatchContext = createContext<null|((action: AppAction) => void)>(null);

@ -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<ClientAction|null>(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 <div className="consoleConnector">Console connection active</div>
}

@ -0,0 +1,7 @@
export function arrayShallowEqual<T>(left: readonly T[], right: readonly T[]): boolean {
return left.length === right.length && arrayShallowStartsWith(left, right)
}
export function arrayShallowStartsWith<T>(target: readonly T[], prefix: readonly T[]): boolean {
return target.length >= prefix.length && prefix.every((value, index) => target[index] === value)
}

@ -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...")
}
Loading…
Cancel
Save