diff --git a/client/package-lock.json b/client/package-lock.json index 92ea28e..c0db8d1 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -16,6 +16,8 @@ "@types/react": "^17.0.13", "@types/react-color": "^3.0.5", "@types/react-dom": "^17.0.8", + "base64-arraybuffer": "^0.2.0", + "protobufjs": "^6.11.2", "react": "^17.0.2", "react-color": "^2.19.3", "react-dom": "^17.0.2", @@ -5209,6 +5211,14 @@ "node": ">=0.10.0" } }, + "node_modules/base64-arraybuffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz", + "integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -25666,6 +25676,11 @@ } } }, + "base64-arraybuffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz", + "integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", diff --git a/client/package.json b/client/package.json index 9a45788..da35f80 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,8 @@ "@types/react": "^17.0.13", "@types/react-color": "^3.0.5", "@types/react-dom": "^17.0.8", + "base64-arraybuffer": "^0.2.0", + "protobufjs": "^6.11.2", "react": "^17.0.2", "react-color": "^2.19.3", "react-dom": "^17.0.2", diff --git a/client/src/App.css b/client/src/App.css index 1907215..23e627f 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -61,9 +61,9 @@ body { .colorPickerBackground { fill: lightslategray; } -.consoleConnector { +.connectionState { position: absolute; - text-shadow: white 0 0 5px; + text-shadow: white 0 0 5px, white 0 1px 5px, white 0 -1px 5px, white 1px 0 5px, white -1px 0 5px; color: black; top: 0; right: 0; diff --git a/client/src/App.tsx b/client/src/App.tsx index c336b6e..963ecc9 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,39 +1,40 @@ -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 {Dispatch, useMemo, useReducer} from "react"; +import {appStateReducer} from "./reducers/AppStateReducer"; +import {AppState} from "./state/AppState"; import {ServerConnectionState} from "./state/NetworkState"; -import HexMapRenderer from "./ui/HexMapRenderer"; -import HexColorPicker from "./ui/HexColorPicker"; import {sizeFromLinesAndCells} from "./state/Coordinates"; +import {AppAction} from "./actions/AppAction"; +import {USER_ACTIVE_COLOR} from "./actions/UserAction"; +import HexColorPicker from "./ui/HexColorPicker"; +import HexMapRenderer from "./ui/HexMapRenderer"; +import {DispatchContext} from "./ui/context/DispatchContext"; +import "./App.css"; +import {WebsocketReactAdapter} from "./websocket/WebsocketReactAdapter"; 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 defaultState: AppState = { + localState: null, + network: { + serverState: null, + connectionState: ServerConnectionState.OFFLINE, + specialMessage: null, + nextID: 0, + sentActions: [], + pendingActions: [], + goodbyeCode: 1000, + goodbyeReason: "", + autoReconnectAt: null, + reconnectAttempts: null + }, + }; + const [state, dispatch]: [AppState, Dispatch] = useReducer(appStateReducer, defaultState, undefined); const {user, map} = state.localState || {user: null, map: null} const offsets = useMemo(() => (!!map ? { top: 10, left: 10, size: 50, - displayMode: map.displayMode + displayMode: map.layout } : null), [map]) const mapElement = !!offsets && !!map ? : null; const {width, height} = useMemo(() => !!offsets && !!map ? sizeFromLinesAndCells({ @@ -47,6 +48,7 @@ function App() { return (
+
e.preventDefault()}> diff --git a/client/src/actions/ClientAction.ts b/client/src/actions/ClientAction.ts index 7f69226..26900d8 100644 --- a/client/src/actions/ClientAction.ts +++ b/client/src/actions/ClientAction.ts @@ -48,6 +48,17 @@ export function isClientActCommand(action: AppAction): action is ClientActComman return action.type === CLIENT_ACT } +export const CLIENT_GOODBYE = "CLIENT_GOODBYE" +/** Synthesized when the client wants to close the connection. */ +export interface ClientGoodbyeAction extends BaseAction { + readonly type: typeof CLIENT_GOODBYE + readonly code: number, + readonly reason: string, +} +export function isClientGoodbyeCommand(action: AppAction): action is ClientGoodbyeAction { + return action.type === CLIENT_GOODBYE +} + export type SendableAction = CellColorAction | UserActiveColorAction export function isSendableAction(action: AppAction): action is SendableAction { return isCellColorAction(action) || isUserActiveColorAction(action) @@ -58,7 +69,7 @@ export function isClientCommand(action: AppAction): action is ClientCommand { return isClientHelloCommand(action) || isClientRefreshCommand(action) || isClientActCommand(action) } -export type ClientAction = ClientCommand | ClientPendingAction +export type ClientAction = ClientCommand | ClientPendingAction | ClientGoodbyeAction export function isClientAction(action: AppAction): action is ClientAction { - return isClientCommand(action) || isClientPendingAction(action) + return isClientCommand(action) || isClientPendingAction(action) || isClientGoodbyeCommand(action) } \ No newline at end of file diff --git a/client/src/actions/ServerAction.ts b/client/src/actions/ServerAction.ts index d2027a3..3f1a3ed 100644 --- a/client/src/actions/ServerAction.ts +++ b/client/src/actions/ServerAction.ts @@ -18,7 +18,7 @@ export function isServerHelloCommand(action: AppAction): action is ServerHelloCo export const SERVER_GOODBYE = "SERVER_GOODBYE" /** Synthesized out of the close message when the server closes the connection. */ -export interface ServerGoodbyeCommand extends BaseAction { +export interface ServerGoodbyeAction extends BaseAction { readonly type: typeof SERVER_GOODBYE /** The exit code sent with the close message by the server. */ readonly code: number @@ -27,7 +27,7 @@ export interface ServerGoodbyeCommand extends BaseAction { /** The current time when this close message was received. */ readonly currentTime: Date } -export function isServerGoodbyeCommand(action: AppAction): action is ServerGoodbyeCommand { +export function isServerGoodbyeAction(action: AppAction): action is ServerGoodbyeAction { return action.type === SERVER_GOODBYE } @@ -94,18 +94,28 @@ export function isServerActCommand(action: AppAction): action is ServerActComman return action.type === SERVER_ACT } +export const SERVER_MALFORMED = "SERVER_MALFORMED" +/** Synthesized when the client can't understand a command the server sent, or when an error event appears on the websocket. */ +export interface ServerMalformedAction extends BaseAction { + readonly type: typeof SERVER_MALFORMED, + readonly error: Error | null, +} +export function isServerMalformedAction(action: AppAction): action is ServerMalformedAction { + return action.type === SERVER_MALFORMED +} + export type SyncableAction = SendableAction export type ServerCommand = - ServerHelloCommand | ServerGoodbyeCommand | ServerRefreshCommand | + ServerHelloCommand | ServerRefreshCommand | ServerOKCommand | ServerFailedCommand | ServerActCommand export function isServerCommand(action: AppAction): action is ServerCommand { - return isServerHelloCommand(action) || isServerGoodbyeCommand(action) || isServerRefreshCommand(action) + return isServerHelloCommand(action) || isServerRefreshCommand(action) || isServerOKCommand(action) || isServerFailedCommand(action) || isServerActCommand(action) } -export type ServerAction = ServerCommand | ServerSocketStartupAction +export type ServerAction = ServerCommand | ServerSocketStartupAction | ServerMalformedAction | ServerGoodbyeAction export function isServerAction(action: AppAction): action is ServerAction { - return isServerCommand(action) || isServerSocketStartupAction(action) + return isServerCommand(action) || isServerSocketStartupAction(action) || isServerMalformedAction(action) || isServerGoodbyeAction(action) } \ No newline at end of file diff --git a/client/src/reducers/AppStateReducer.ts b/client/src/reducers/AppStateReducer.ts index 1cbdf25..67fb9ae 100644 --- a/client/src/reducers/AppStateReducer.ts +++ b/client/src/reducers/AppStateReducer.ts @@ -3,12 +3,13 @@ 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/ClientAction"; +import {CLIENT_PENDING, isSendableAction} from "../actions/ClientAction"; 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"; +import {isNetworkAction} from "../actions/NetworkAction"; function appSyncedStateReducer(oldState: AppState, action: MapAction|UserAction): AppState { if (oldState.localState === null) { diff --git a/client/src/reducers/ClientReducer.ts b/client/src/reducers/ClientReducer.ts index 002b631..419a43c 100644 --- a/client/src/reducers/ClientReducer.ts +++ b/client/src/reducers/ClientReducer.ts @@ -1,4 +1,11 @@ -import {CLIENT_HELLO, CLIENT_PENDING, CLIENT_REFRESH, CLIENT_ACT, ClientAction} from "../actions/ClientAction"; +import { + CLIENT_ACT, + CLIENT_GOODBYE, + CLIENT_HELLO, + CLIENT_PENDING, + CLIENT_REFRESH, + ClientAction +} from "../actions/ClientAction"; import {NetworkState, ServerConnectionState} from "../state/NetworkState"; // TODO: Verify that only one special message exists at a time. @@ -34,5 +41,11 @@ export function clientReducer(oldState: NetworkState, action: ClientAction): Net sentActions: [...oldState.sentActions, ...action.actions], pendingActions: oldState.pendingActions.slice(action.actions.length), } + case CLIENT_GOODBYE: + return { + ...oldState, + specialMessage: action, + connectionState: ServerConnectionState.AWAITING_GOODBYE, + } } } \ No newline at end of file diff --git a/client/src/reducers/HexMapReducer.ts b/client/src/reducers/HexMapReducer.ts index 8c16c00..214c7d7 100644 --- a/client/src/reducers/HexMapReducer.ts +++ b/client/src/reducers/HexMapReducer.ts @@ -15,7 +15,7 @@ export function replaceCell(oldMap: HexMap, {line, cell}: StorageCoordinates, ne if (areCellsEquivalent(getCell(oldMap, {line, cell}), newCell)) { return oldMap } - const oldLines = oldMap.lineCells + const oldLines = oldMap.layer const oldLine = oldLines[line] const newLine = [ ...oldLine.slice(0, cell), @@ -29,7 +29,7 @@ export function replaceCell(oldMap: HexMap, {line, cell}: StorageCoordinates, ne ] return { ...oldMap, - lineCells: newLines + layer: newLines } } diff --git a/client/src/reducers/ServerReducer.ts b/client/src/reducers/ServerReducer.ts index b1c90d5..ea8d32b 100644 --- a/client/src/reducers/ServerReducer.ts +++ b/client/src/reducers/ServerReducer.ts @@ -8,19 +8,23 @@ import { SERVER_FAILED, SERVER_GOODBYE, SERVER_HELLO, + SERVER_MALFORMED, SERVER_OK, SERVER_REFRESH, - SERVER_SOCKET_STARTUP, ServerActCommand, - ServerAction, ServerFailedCommand, - ServerGoodbyeCommand, + SERVER_SOCKET_STARTUP, + ServerActCommand, + ServerAction, + ServerFailedCommand, + ServerGoodbyeAction, ServerHelloCommand, + ServerMalformedAction, ServerOKCommand, ServerRefreshCommand, ServerSocketStartupAction, SocketState, SyncableAction } from "../actions/ServerAction"; -import {CLIENT_HELLO, SendableAction} from "../actions/ClientAction"; +import {CLIENT_GOODBYE, CLIENT_HELLO, SendableAction} from "../actions/ClientAction"; interface StateRecalculationInputs { /** The original server state before the actions changed. The base on which the actions are all applied. */ @@ -109,7 +113,7 @@ function serverRefreshReducer(oldState: AppState, action: ServerRefreshCommand): }; } -function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeCommand): NetworkState { +function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeAction): NetworkState { // TODO: Sort out the correct state and autoReconnectAt based on the time in the action. return { ...oldState, @@ -234,6 +238,14 @@ function serverActReducer(oldState: AppState, action: ServerActCommand): AppStat }; } +function serverMalformedReducer(oldState: NetworkState, action: ServerMalformedAction): NetworkState { + return clientReducer(oldState, { + type: CLIENT_GOODBYE, + code: 1002, // protocol error + reason: action.error?.message || "Unknown error" + }); +} + 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. @@ -260,5 +272,10 @@ export function serverReducer(oldState: AppState, action: ServerAction): AppStat } case SERVER_ACT: return serverActReducer(oldState, action) + case SERVER_MALFORMED: + return { + ...oldState, + network: serverMalformedReducer(oldState.network, action) + } } } \ No newline at end of file diff --git a/client/src/reducers/SyncedStateReducer.ts b/client/src/reducers/SyncedStateReducer.ts index 0cc8d12..0a07bc3 100644 --- a/client/src/reducers/SyncedStateReducer.ts +++ b/client/src/reducers/SyncedStateReducer.ts @@ -1,10 +1,10 @@ import {SyncedState} from "../state/SyncedState"; -import {SyncableAction} from "../actions/ClientAction"; 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"; +import {SyncableAction} from "../actions/ServerAction"; export function syncedStateReducer(state: SyncedState, action: (MapAction|UserAction)): SyncedState { if (isMapAction(action)) { diff --git a/client/src/state/Coordinates.ts b/client/src/state/Coordinates.ts index 49e9784..af12fcd 100644 --- a/client/src/state/Coordinates.ts +++ b/client/src/state/Coordinates.ts @@ -1,4 +1,4 @@ -import {HexagonOrientation, HexMapRepresentation, LineParity} from "./HexMap"; +import {HexagonOrientation, HexLayout, LineParity} from "./HexMap"; /** Staggered (storage) coordinates for accessing cell storage. */ export interface StorageCoordinates { @@ -54,7 +54,7 @@ export interface RenderOffsets { /** 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 + readonly displayMode: HexLayout } export interface RenderSize { diff --git a/client/src/state/HexMap.ts b/client/src/state/HexMap.ts index eecb21e..1c0f37b 100644 --- a/client/src/state/HexMap.ts +++ b/client/src/state/HexMap.ts @@ -36,7 +36,7 @@ export enum LineParity { } /** The type of map this map is. */ -export interface HexMapRepresentation { +export interface HexLayout { readonly orientation: HexagonOrientation readonly indentedLines: LineParity } @@ -44,7 +44,7 @@ export interface HexMapRepresentation { /** 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 + readonly layout: HexLayout /** * The number of lines on the map. * In ROWS and EVEN_ROWS mode, this is the height of the map. @@ -63,7 +63,7 @@ export interface HexMap { * In COLUMNS and EVEN_COLUMNS mode, lines represent columns. * In ROWS and EVEN_ROWS mode, lines represent rows. */ - readonly lineCells: readonly HexLine[] + readonly layer: readonly HexLine[] /** * A unique identifier in https://github.com/rs/xid format 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. @@ -71,7 +71,7 @@ export interface HexMap { readonly xid: string } -export function initializeMap({lines, cellsPerLine, displayMode, xid}: {lines: number, cellsPerLine: number, displayMode: HexMapRepresentation, xid: string}): HexMap { +export function initializeMap({lines, cellsPerLine, displayMode, xid}: {lines: number, cellsPerLine: number, displayMode: HexLayout, xid: string}): HexMap { const lineCells: HexLine[] = []; const emptyLine: HexCell[] = []; for (let cell = 0; cell < cellsPerLine; cell += 1) { @@ -83,8 +83,8 @@ export function initializeMap({lines, cellsPerLine, displayMode, xid}: {lines: n return { lines, cellsPerLine: cellsPerLine, - displayMode, - lineCells, + layout: displayMode, + layer: lineCells, xid } } @@ -102,5 +102,5 @@ export function getCell(map: HexMap, {line, cell}: StorageCoordinates): HexCell| if (!isValidCoordinate(map, {line, cell})) { return null } - return map.lineCells[line][cell] + return map.layer[line][cell] } \ No newline at end of file diff --git a/client/src/state/NetworkState.ts b/client/src/state/NetworkState.ts index 43373f1..2ae1e2d 100644 --- a/client/src/state/NetworkState.ts +++ b/client/src/state/NetworkState.ts @@ -1,4 +1,10 @@ -import {ClientHelloCommand, ClientRefreshCommand, SendableAction, SentAction} from "../actions/ClientAction"; +import { + ClientGoodbyeAction, + ClientHelloCommand, + ClientRefreshCommand, + SendableAction, + SentAction +} from "../actions/ClientAction"; import {SyncedState} from "./SyncedState"; export enum ServerConnectionState { @@ -10,12 +16,17 @@ export enum ServerConnectionState { AWAITING_REFRESH = "AWAITING_REFRESH", /** Used when the client is connected and everything is normal. */ CONNECTED = "CONNECTED", + /** + * Used when the client has requested that the connection be closed, + * and is waiting for it to actually be closed. + */ + AWAITING_GOODBYE = "AWAITING_GOODBYE", /** * 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", + OFFLINE = "OFFLINE" } export interface NetworkState { @@ -35,7 +46,7 @@ export interface NetworkState { /** * A special action that should take precedence over sending more actions. */ - readonly specialMessage: ClientHelloCommand|ClientRefreshCommand|null + readonly specialMessage: ClientHelloCommand|ClientRefreshCommand|ClientGoodbyeAction|null /** * The ID of the next ClientSentAction to be created. */ diff --git a/client/src/ui/HexColorPicker.tsx b/client/src/ui/HexColorPicker.tsx index 529e05e..7970a37 100644 --- a/client/src/ui/HexColorPicker.tsx +++ b/client/src/ui/HexColorPicker.tsx @@ -6,6 +6,7 @@ import { storageCoordinatesToRenderCoordinates } from "../state/Coordinates"; import {HexagonOrientation, LineParity} from "../state/HexMap"; +import {normalizeColor} from "../util/ColorUtils"; 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]); @@ -49,20 +50,6 @@ const COLORS: readonly string[] = [ "#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) => diff --git a/client/src/util/ColorUtils.ts b/client/src/util/ColorUtils.ts new file mode 100644 index 0000000..e0f2612 --- /dev/null +++ b/client/src/util/ColorUtils.ts @@ -0,0 +1,27 @@ +export 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 +} + +export function packHexColor(color: string): number { + return parseInt(normalizeColor(color).substring(1), 16) +} + +export function unpackHexColor(color: number): string { + if (color > 2**32 || Math.floor(color) !== color) { + throw Error("Packed color was too large or not an integer") + } + // this is 1 << 32, so it will produce a single hex digit above the others, even if the + // R/G/B is 0 - which we can then trim off and replace with our # + // a neat insight from https://stackoverflow.com/a/13397771 + return "#" + ((color + 0x100000000).toString(16).substring(1)) +} \ No newline at end of file diff --git a/client/src/websocket/ClientToPb.ts b/client/src/websocket/ClientToPb.ts index 7a82651..bf84175 100644 --- a/client/src/websocket/ClientToPb.ts +++ b/client/src/websocket/ClientToPb.ts @@ -1,6 +1,38 @@ -import {ClientCommand} from "../actions/ClientAction"; -import {ClientCommandPB} from "../proto/client"; +import {CLIENT_ACT, CLIENT_HELLO, CLIENT_REFRESH, ClientCommand, SentAction} from "../actions/ClientAction"; +import {ClientActPB_IDed, ClientCommandPB} from "../proto/client"; +import {sendableActionToPB} from "./SyncableActionToPb"; -export function clientToPb(message: ClientCommand): ClientCommandPB { +export function sentActionToPb(message: SentAction): ClientActPB_IDed { + return { + id: message.id, + action: sendableActionToPB(message.action), + } +} +export function clientToPb(message: ClientCommand): ClientCommandPB { + switch (message.type) { + case CLIENT_HELLO: + return { + hello: { + version: message.version, + }, + refresh: undefined, + act: undefined, + } + case CLIENT_REFRESH: + return { + refresh: { + }, + hello: undefined, + act: undefined, + } + case CLIENT_ACT: + return { + act: { + actions: message.actions.map(sentActionToPb) + }, + hello: undefined, + refresh: undefined, + } + } } \ No newline at end of file diff --git a/client/src/websocket/MapFromPb.ts b/client/src/websocket/MapFromPb.ts new file mode 100644 index 0000000..0529830 --- /dev/null +++ b/client/src/websocket/MapFromPb.ts @@ -0,0 +1,91 @@ +import {SyncableStatePB} from "../proto/state"; +import {SyncedState} from "../state/SyncedState"; +import {HexagonOrientation, HexCell, HexLayout, HexMap, LineParity} from "../state/HexMap"; +import {UserStatePB} from "../proto/user"; +import { + HexCellPB, + HexMapPB, + HexMapPB_Layout, + HexMapPB_Layout_LineParity, + HexMapPB_Layout_Orientation +} from "../proto/map"; +import {UserState} from "../state/UserState"; +import {unpackHexColor} from "../util/ColorUtils"; +import {encode} from "base64-arraybuffer"; +import {StorageCoordinatesPB} from "../proto/coords"; +import {StorageCoordinates} from "../state/Coordinates"; + +export function storageCoordsFromPb(coords: StorageCoordinatesPB): StorageCoordinates { + return { + line: coords.line, + cell: coords.cell, + }; +} + +function orientationFromPb(orientation: HexMapPB_Layout_Orientation): HexagonOrientation { + switch (orientation) { + case HexMapPB_Layout_Orientation.FLAT_TOP: + return HexagonOrientation.FLAT_TOP + case HexMapPB_Layout_Orientation.POINTY_TOP: + return HexagonOrientation.POINTY_TOP + case HexMapPB_Layout_Orientation.UNKNOWN_ORIENTATION: + case HexMapPB_Layout_Orientation.UNRECOGNIZED: + throw Error("unknown orientation") + } +} + +function lineParityFromPb(parity: HexMapPB_Layout_LineParity): LineParity { + switch (parity) { + case HexMapPB_Layout_LineParity.EVEN: + return LineParity.EVEN + case HexMapPB_Layout_LineParity.ODD: + return LineParity.ODD + case HexMapPB_Layout_LineParity.UNKNOWN_LINE: + case HexMapPB_Layout_LineParity.UNRECOGNIZED: + throw Error("unknown line parity") + } +} + +function layoutFromPb(layout: HexMapPB_Layout): HexLayout { + return { + orientation: orientationFromPb(layout.orientation), + indentedLines: lineParityFromPb(layout.indentedLines), + } +} + +function cellFromPb(cell: HexCellPB): HexCell { + return { + color: unpackHexColor(cell.color) + } +} + +function mapFromPb(map: HexMapPB): HexMap { + if (!map.layout || !map.layer) { + throw Error("HexMapPB did not have layout and layer"); + } + return { + xid: encode(map.xid), + lines: 0, + cellsPerLine: 0, + layout: layoutFromPb(map.layout), + layer: map.layer.lines.map((line): HexCell[] => { + return line.cells.map(cellFromPb) + }), + }; +} + +function userFromPb(user: UserStatePB): UserState { + return { + activeColor: unpackHexColor(user.color) + } +} + +export function stateFromPb(state: SyncableStatePB): SyncedState { + if (!state.map || !state.user) { + throw Error("SyncableStatePB did not have map and user") + } + return { + map: mapFromPb(state.map), + user: userFromPb(state.user), + } +} \ No newline at end of file diff --git a/client/src/websocket/MapToPb.ts b/client/src/websocket/MapToPb.ts new file mode 100644 index 0000000..2c8c24e --- /dev/null +++ b/client/src/websocket/MapToPb.ts @@ -0,0 +1,9 @@ +import {StorageCoordinates} from "../state/Coordinates"; +import {StorageCoordinatesPB} from "../proto/coords"; + +export function storageCoordsToPb(storageCoords: StorageCoordinates): StorageCoordinatesPB { + return { + line: storageCoords.line, + cell: storageCoords.cell, + } +} \ No newline at end of file diff --git a/client/src/websocket/ServerFromPb.ts b/client/src/websocket/ServerFromPb.ts new file mode 100644 index 0000000..27f9cfa --- /dev/null +++ b/client/src/websocket/ServerFromPb.ts @@ -0,0 +1,50 @@ +import {ServerCommandPB} from "../proto/server"; +import { + SERVER_ACT, + SERVER_FAILED, + SERVER_HELLO, + SERVER_OK, + SERVER_REFRESH, + ServerCommand +} from "../actions/ServerAction"; +import {stateFromPb} from "./MapFromPb"; +import {syncableActionFromPb} from "./SyncableActionFromPb"; + +export function serverFromPb(pb: ServerCommandPB): ServerCommand { + if (pb.hello) { + if (!pb.hello.state) { + throw Error("No state for Server Hello") + } + return { + type: SERVER_HELLO, + version:pb.hello.version, + state: stateFromPb(pb.hello.state), + } + } else if (pb.refresh) { + if (!pb.refresh.state) { + throw Error("No state for Server Refresh") + } + return { + type: SERVER_REFRESH, + state: stateFromPb(pb.refresh.state), + } + } else if (pb.ok) { + return { + type: SERVER_OK, + ids: pb.ok.ids, + } + } else if (pb.failed) { + return { + type: SERVER_FAILED, + ids: pb.failed.ids, + error: pb.failed.error, + } + } else if (pb.act) { + return { + type: SERVER_ACT, + actions: pb.act.actions.map(syncableActionFromPb) + } + } else { + throw Error("No actual commands set on command") + } +} \ No newline at end of file diff --git a/client/src/websocket/SyncableActionFromPb.ts b/client/src/websocket/SyncableActionFromPb.ts new file mode 100644 index 0000000..7cc3ab5 --- /dev/null +++ b/client/src/websocket/SyncableActionFromPb.ts @@ -0,0 +1,35 @@ +import {ClientActionPB, ServerActionPB} from "../proto/action"; +import {SyncableAction} from "../actions/ServerAction"; +import {SendableAction} from "../actions/ClientAction"; +import {unpackHexColor} from "../util/ColorUtils"; +import {USER_ACTIVE_COLOR} from "../actions/UserAction"; +import {CELL_COLOR} from "../actions/CellAction"; +import {storageCoordsFromPb} from "./MapFromPb"; + +function sendableActionFromPB(action: ClientActionPB): SendableAction { + if (action.cellSetColor) { + if (!action.cellSetColor.at) { + throw Error("No location set in cellSetColor") + } + return { + type: CELL_COLOR, + at: storageCoordsFromPb(action.cellSetColor.at), + color: unpackHexColor(action.cellSetColor.color), + } + } else if (action.userSetActiveColor) { + return { + type: USER_ACTIVE_COLOR, + color: unpackHexColor(action.userSetActiveColor.color) + } + } else { + throw Error("No action set in ClientAction") + } +} + +export function syncableActionFromPb(action: ServerActionPB): SyncableAction { + if (action.client) { + return sendableActionFromPB(action.client) + } else { + throw Error("No action set in ServerAction") + } +} \ No newline at end of file diff --git a/client/src/websocket/SyncableActionToPb.ts b/client/src/websocket/SyncableActionToPb.ts new file mode 100644 index 0000000..83d037a --- /dev/null +++ b/client/src/websocket/SyncableActionToPb.ts @@ -0,0 +1,26 @@ +import {ClientActionPB} from "../proto/action"; +import {SendableAction} from "../actions/ClientAction"; +import {packHexColor} from "../util/ColorUtils"; +import {storageCoordsToPb} from "./MapToPb"; +import {USER_ACTIVE_COLOR} from "../actions/UserAction"; +import {CELL_COLOR} from "../actions/CellAction"; + +export function sendableActionToPB(action: SendableAction): ClientActionPB { + switch (action.type) { + case CELL_COLOR: + return { + cellSetColor: { + at: storageCoordsToPb(action.at), + color: packHexColor(action.color), + }, + userSetActiveColor: undefined, + } + case USER_ACTIVE_COLOR: + return { + userSetActiveColor: { + color: packHexColor(action.color) + }, + cellSetColor: undefined, + } + } +} \ No newline at end of file diff --git a/client/src/websocket/WebsocketReactAdapter.tsx b/client/src/websocket/WebsocketReactAdapter.tsx new file mode 100644 index 0000000..11d4a74 --- /dev/null +++ b/client/src/websocket/WebsocketReactAdapter.tsx @@ -0,0 +1,71 @@ +import {ReactElement, useContext, useEffect, useRef, useState} from "react"; +import { + CLIENT_ACT, + CLIENT_REFRESH, + ClientActCommand, + ClientGoodbyeAction, + ClientHelloCommand, + ClientRefreshCommand, + isClientCommand, + isClientGoodbyeCommand, + SendableAction, + SentAction +} from "../actions/ClientAction"; +import {DispatchContext} from "../ui/context/DispatchContext"; +import {WebsocketTranslator} from "./WebsocketTranslator"; +import {ServerConnectionState} from "../state/NetworkState"; +import {SERVER_SOCKET_STARTUP, SocketState} from "../actions/ServerAction"; + +export function WebsocketReactAdapter({url, protocols = ["v1.hexmap.deliciousreya.net"], state, specialMessage, pendingMessages, nextID}: { + url: string, + protocols?: readonly string[], + state: ServerConnectionState, + specialMessage: ClientHelloCommand | ClientRefreshCommand | ClientGoodbyeAction | null, + pendingMessages: readonly SendableAction[], + nextID: number +}): ReactElement { + const dispatch = useContext(DispatchContext) + if (dispatch === null) { + throw Error("What the heck?! No dispatch?! I quit!") + } + const connector = useRef(new WebsocketTranslator({ + url, + protocols, + onStartup: dispatch, + onMessage: dispatch, + onError: dispatch, + onGoodbye: dispatch, + })) + useEffect(() => { + connector.current.connect(); + dispatch({ + type: SERVER_SOCKET_STARTUP, + state: SocketState.CONNECTING, + }); + }, [dispatch]) + const [lastSpecialMessage, setLastSpecialMessage] = useState(null) + useEffect(() => { + if (state === ServerConnectionState.CONNECTED && pendingMessages.length > 0) { + const sentMessages: SentAction[] = pendingMessages.map((action, index) => { + return {id: index + nextID, action} + }); + const sentMessage: ClientActCommand = { + type: CLIENT_ACT, + actions: sentMessages, + }; + connector.current.send(sentMessage) + dispatch(sentMessage) + } + }, [nextID, dispatch, pendingMessages, state]) + useEffect(() => { + if (state === ServerConnectionState.CONNECTED && specialMessage !== null && specialMessage !== lastSpecialMessage) { + if (isClientCommand(specialMessage)) { + connector.current.send(specialMessage); + } else if (isClientGoodbyeCommand(specialMessage)) { + connector.current.close(specialMessage.code, specialMessage.reason) + } + setLastSpecialMessage(specialMessage); + } + }, [specialMessage, lastSpecialMessage, setLastSpecialMessage, state]) + return
dispatch ? dispatch({type: CLIENT_REFRESH}) : null}>{state}
+} \ No newline at end of file diff --git a/client/src/websocket/WebsocketTranslator.ts b/client/src/websocket/WebsocketTranslator.ts index 01537ae..371e14f 100644 --- a/client/src/websocket/WebsocketTranslator.ts +++ b/client/src/websocket/WebsocketTranslator.ts @@ -2,19 +2,27 @@ import {ClientCommand} from "../actions/ClientAction"; import { SERVER_GOODBYE, + SERVER_MALFORMED, SERVER_SOCKET_STARTUP, ServerCommand, - ServerGoodbyeCommand, + ServerGoodbyeAction, + ServerMalformedAction, ServerSocketStartupAction, SocketState } from "../actions/ServerAction"; +import {clientToPb} from "./ClientToPb"; +import {ClientCommandPB} from "../proto/client"; +import {Reader} from "protobufjs"; +import {ServerCommandPB} from "../proto/server"; +import {serverFromPb} from "./ServerFromPb"; -class WebsocketTranslator { +export class WebsocketTranslator { readonly url: string readonly protocols: readonly string[] readonly onStartup: (startup: ServerSocketStartupAction) => void readonly onMessage: (command: ServerCommand) => void - readonly onGoodbye: (goodbye: ServerGoodbyeCommand) => void + readonly onError: (error: ServerMalformedAction) => void + readonly onGoodbye: (goodbye: ServerGoodbyeAction) => void private socket: WebSocket|null constructor({ @@ -22,31 +30,36 @@ class WebsocketTranslator { protocols, onStartup, onMessage, - onGoodbye + onError, + onGoodbye, }: { url: string, protocols: readonly string[], onStartup: (startup: ServerSocketStartupAction) => void, onMessage: (command: ServerCommand) => void, - onGoodbye: (goodbye: ServerGoodbyeCommand) => void + onError: (error: ServerMalformedAction) => void, + onGoodbye: (goodbye: ServerGoodbyeAction) => void, }) { this.url = url this.protocols = protocols.slice() this.onStartup = onStartup this.onMessage = onMessage + this.onError = onError this.onGoodbye = onGoodbye this.socket = null } connect() { - if (this.socket != null) { + console.log("entering connect()", this) + if (this.socket !== null) { throw Error("Already running") } this.socket = new WebSocket(this.url, this.protocols.slice()) - this.socket.addEventListener("open", this.handleOpen) - this.socket.addEventListener("message", this.handleMessage) - this.socket.addEventListener("close", this.handleClose) - this.socket.addEventListener("error", WebsocketTranslator.handleError) + this.socket.binaryType = "arraybuffer" + this.socket.addEventListener("open", () => this.handleOpen()) + this.socket.addEventListener("message", (m) => this.handleMessage(m)) + this.socket.addEventListener("close", (c) => this.handleClose(c)) + this.socket.addEventListener("error", () => this.handleError()) this.onStartup({ type: SERVER_SOCKET_STARTUP, state: SocketState.CONNECTING, @@ -54,14 +67,14 @@ class WebsocketTranslator { } send(message: ClientCommand) { - // TODO: Protoitize the client message and send() it. + this.socket?.send(ClientCommandPB.encode(clientToPb(message)).finish()) } close(code: number, reason: string) { this.socket?.close(code, reason) } - private handleOpen(e: Event) { + private handleOpen() { this.onStartup({ type: SERVER_SOCKET_STARTUP, state: SocketState.OPEN, @@ -69,7 +82,27 @@ class WebsocketTranslator { } private handleMessage(e: MessageEvent) { - // TODO: Parse the server message and pass it to onMessage. + const data: ArrayBuffer = e.data + const reader: Reader = Reader.create(new Uint8Array(data)) + const proto = ServerCommandPB.decode(reader) + let decoded; + try { + decoded = serverFromPb((proto)) + } catch (e) { + this.onError({ + type: SERVER_MALFORMED, + error: e, + }) + return + } + this.onMessage(decoded) + } + + private handleError() { + this.onError({ + type: SERVER_MALFORMED, + error: null + }) } private handleClose(e: CloseEvent) { @@ -82,15 +115,7 @@ class WebsocketTranslator { this.clearSocket() } - private static handleError(e: Event) { - console.log("Websocket error: ", e) - } - private clearSocket() { - this.socket?.removeEventListener("open", this.handleOpen) - this.socket?.removeEventListener("message", this.handleMessage) - this.socket?.removeEventListener("close", this.handleClose) - this.socket?.removeEventListener("error", WebsocketTranslator.handleError) this.socket = null } } \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json index a273b0c..0a1b50a 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -6,6 +6,7 @@ "dom.iterable", "esnext" ], + "downlevelIteration": true, "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, diff --git a/mage.sh b/mage.sh index fb6b15e..bc7122d 100755 --- a/mage.sh +++ b/mage.sh @@ -1,11 +1,15 @@ #!/bin/bash -SCRIPTPATH=$(readlink -e ${BASH_SOURCE}) +set -eux + +PATH=${PATH}:${GOROOT}/bin +SCRIPTPATH=$(readlink -e "${BASH_SOURCE[0]}") MAINPATH=${SCRIPTPATH%/*} BUILDTOOLSPATH=${MAINPATH}/buildtools MAGEPATH=${BUILDTOOLSPATH}/mage if [[ ! -x "$MAGEPATH" ]]; then echo "go install-ing mage..." + mkdir -p "$BUILDTOOLSPATH" GOBIN="$BUILDTOOLSPATH" go install github.com/magefile/mage@latest fi diff --git a/server/action/action.pbconv.go b/server/action/action.pbconv.go index 68e5afb..9a9dc20 100644 --- a/server/action/action.pbconv.go +++ b/server/action/action.pbconv.go @@ -1,12 +1,13 @@ package action -import "git.reya.zone/reya/hexmap/server/state" +import ( + "git.reya.zone/reya/hexmap/server/state" +) func (x *ServerActionPB) ToGo() (Server, error) { - if x == nil { - return nil, nil - } switch action := x.Action.(type) { + case nil: + return nil, state.ErrOneofNotSet case *ServerActionPB_Client: return action.Client.ToGo() default: @@ -19,6 +20,8 @@ func (x *ClientActionPB) ToGo() (Client, error) { return nil, nil } switch action := x.Action.(type) { + case nil: + return nil, state.ErrOneofNotSet case *ClientActionPB_CellSetColor: return action.CellSetColor.ToGo() case *ClientActionPB_UserSetActiveColor: diff --git a/server/state/protobuf.go b/server/state/protobuf.go new file mode 100644 index 0000000..05b322c --- /dev/null +++ b/server/state/protobuf.go @@ -0,0 +1,5 @@ +package state + +import "errors" + +var ErrOneofNotSet = errors.New("no value was given for a oneof") diff --git a/server/websocket/client.pbconv.go b/server/websocket/client.pbconv.go index 29b1cd6..b8f14d3 100644 --- a/server/websocket/client.pbconv.go +++ b/server/websocket/client.pbconv.go @@ -1,7 +1,13 @@ package websocket +import ( + "git.reya.zone/reya/hexmap/server/state" +) + func (x *ClientCommandPB) ToGo() (ClientCommand, error) { switch msg := x.Command.(type) { + case nil: + return nil, state.ErrOneofNotSet case *ClientCommandPB_Hello: return msg.Hello.ToGo(), nil case *ClientCommandPB_Refresh: