Wire up the client's websocket connection.

For now, the URL is hardcoded. We'll fix this later.
main
Mari 3 years ago
parent 54b9112546
commit 34e64c7130
  1. 15
      client/package-lock.json
  2. 2
      client/package.json
  3. 4
      client/src/App.css
  4. 30
      client/src/App.tsx
  5. 15
      client/src/actions/ClientAction.ts
  6. 22
      client/src/actions/ServerAction.ts
  7. 3
      client/src/reducers/AppStateReducer.ts
  8. 15
      client/src/reducers/ClientReducer.ts
  9. 4
      client/src/reducers/HexMapReducer.ts
  10. 27
      client/src/reducers/ServerReducer.ts
  11. 2
      client/src/reducers/SyncedStateReducer.ts
  12. 4
      client/src/state/Coordinates.ts
  13. 14
      client/src/state/HexMap.ts
  14. 17
      client/src/state/NetworkState.ts
  15. 15
      client/src/ui/HexColorPicker.tsx
  16. 27
      client/src/util/ColorUtils.ts
  17. 38
      client/src/websocket/ClientToPb.ts
  18. 91
      client/src/websocket/MapFromPb.ts
  19. 9
      client/src/websocket/MapToPb.ts
  20. 50
      client/src/websocket/ServerFromPb.ts
  21. 35
      client/src/websocket/SyncableActionFromPb.ts
  22. 26
      client/src/websocket/SyncableActionToPb.ts
  23. 71
      client/src/websocket/WebsocketReactAdapter.tsx
  24. 67
      client/src/websocket/WebsocketTranslator.ts
  25. 1
      client/tsconfig.json
  26. 6
      mage.sh
  27. 11
      server/action/action.pbconv.go
  28. 5
      server/state/protobuf.go
  29. 6
      server/websocket/client.pbconv.go

@ -16,6 +16,8 @@
"@types/react": "^17.0.13", "@types/react": "^17.0.13",
"@types/react-color": "^3.0.5", "@types/react-color": "^3.0.5",
"@types/react-dom": "^17.0.8", "@types/react-dom": "^17.0.8",
"base64-arraybuffer": "^0.2.0",
"protobufjs": "^6.11.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@ -5209,6 +5211,14 @@
"node": ">=0.10.0" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "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": { "base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",

@ -11,6 +11,8 @@
"@types/react": "^17.0.13", "@types/react": "^17.0.13",
"@types/react-color": "^3.0.5", "@types/react-color": "^3.0.5",
"@types/react-dom": "^17.0.8", "@types/react-dom": "^17.0.8",
"base64-arraybuffer": "^0.2.0",
"protobufjs": "^6.11.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",

@ -61,9 +61,9 @@ body {
.colorPickerBackground { .colorPickerBackground {
fill: lightslategray; fill: lightslategray;
} }
.consoleConnector { .connectionState {
position: absolute; 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; color: black;
top: 0; top: 0;
right: 0; right: 0;

@ -1,18 +1,18 @@
import React, {useMemo, useReducer} from 'react'; import {Dispatch, useMemo, useReducer} from "react";
import './App.css'; import {appStateReducer} from "./reducers/AppStateReducer";
import {DispatchContext} from './ui/context/DispatchContext'; import {AppState} from "./state/AppState";
import {appStateReducer, AppStateReducer} from "./reducers/AppStateReducer";
import {USER_ACTIVE_COLOR} from "./actions/UserAction";
import {ServerConnectionState} from "./state/NetworkState"; import {ServerConnectionState} from "./state/NetworkState";
import HexMapRenderer from "./ui/HexMapRenderer";
import HexColorPicker from "./ui/HexColorPicker";
import {sizeFromLinesAndCells} from "./state/Coordinates"; 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() { function App() {
const [state, dispatch] = useReducer<AppStateReducer, null>( const defaultState: AppState = {
appStateReducer,
null,
() => ({
localState: null, localState: null,
network: { network: {
serverState: null, serverState: null,
@ -25,15 +25,16 @@ function App() {
goodbyeReason: "", goodbyeReason: "",
autoReconnectAt: null, autoReconnectAt: null,
reconnectAttempts: null reconnectAttempts: null
} },
})); };
const [state, dispatch]: [AppState, Dispatch<AppAction>] = useReducer(appStateReducer, defaultState, undefined);
const {user, map} = state.localState || {user: null, map: null} const {user, map} = state.localState || {user: null, map: null}
const offsets = useMemo(() => (!!map ? { const offsets = useMemo(() => (!!map ? {
top: 10, top: 10,
left: 10, left: 10,
size: 50, size: 50,
displayMode: map.displayMode displayMode: map.layout
} : null), [map]) } : null), [map])
const mapElement = !!offsets && !!map ? <HexMapRenderer map={map} offsets={offsets} /> : null; const mapElement = !!offsets && !!map ? <HexMapRenderer map={map} offsets={offsets} /> : null;
const {width, height} = useMemo(() => !!offsets && !!map ? sizeFromLinesAndCells({ const {width, height} = useMemo(() => !!offsets && !!map ? sizeFromLinesAndCells({
@ -47,6 +48,7 @@ function App() {
return ( return (
<div className="App"> <div className="App">
<DispatchContext.Provider value={dispatch}> <DispatchContext.Provider value={dispatch}>
<WebsocketReactAdapter url={"wss://hexmap.deliciousreya.net/map"} state={state.network.connectionState} specialMessage={state.network.specialMessage} pendingMessages={state.network.pendingActions} nextID={state.network.nextID} />
<div className={"scrollBox"}> <div className={"scrollBox"}>
<div className={"centerBox"}> <div className={"centerBox"}>
<svg className={"map"} width={width} height={height} viewBox={`0 0 ${width} ${height}`} onContextMenu={(e) => e.preventDefault()}> <svg className={"map"} width={width} height={height} viewBox={`0 0 ${width} ${height}`} onContextMenu={(e) => e.preventDefault()}>

@ -48,6 +48,17 @@ export function isClientActCommand(action: AppAction): action is ClientActComman
return action.type === CLIENT_ACT 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 type SendableAction = CellColorAction | UserActiveColorAction
export function isSendableAction(action: AppAction): action is SendableAction { export function isSendableAction(action: AppAction): action is SendableAction {
return isCellColorAction(action) || isUserActiveColorAction(action) return isCellColorAction(action) || isUserActiveColorAction(action)
@ -58,7 +69,7 @@ export function isClientCommand(action: AppAction): action is ClientCommand {
return isClientHelloCommand(action) || isClientRefreshCommand(action) || isClientActCommand(action) 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 { export function isClientAction(action: AppAction): action is ClientAction {
return isClientCommand(action) || isClientPendingAction(action) return isClientCommand(action) || isClientPendingAction(action) || isClientGoodbyeCommand(action)
} }

@ -18,7 +18,7 @@ export function isServerHelloCommand(action: AppAction): action is ServerHelloCo
export const SERVER_GOODBYE = "SERVER_GOODBYE" export const SERVER_GOODBYE = "SERVER_GOODBYE"
/** Synthesized out of the close message when the server closes the connection. */ /** 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 readonly type: typeof SERVER_GOODBYE
/** The exit code sent with the close message by the server. */ /** The exit code sent with the close message by the server. */
readonly code: number readonly code: number
@ -27,7 +27,7 @@ export interface ServerGoodbyeCommand extends BaseAction {
/** The current time when this close message was received. */ /** The current time when this close message was received. */
readonly currentTime: Date readonly currentTime: Date
} }
export function isServerGoodbyeCommand(action: AppAction): action is ServerGoodbyeCommand { export function isServerGoodbyeAction(action: AppAction): action is ServerGoodbyeAction {
return action.type === SERVER_GOODBYE return action.type === SERVER_GOODBYE
} }
@ -94,18 +94,28 @@ export function isServerActCommand(action: AppAction): action is ServerActComman
return action.type === SERVER_ACT 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 SyncableAction = SendableAction
export type ServerCommand = export type ServerCommand =
ServerHelloCommand | ServerGoodbyeCommand | ServerRefreshCommand | ServerHelloCommand | ServerRefreshCommand |
ServerOKCommand | ServerFailedCommand | ServerActCommand ServerOKCommand | ServerFailedCommand | ServerActCommand
export function isServerCommand(action: AppAction): action is ServerCommand { 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) || 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 { export function isServerAction(action: AppAction): action is ServerAction {
return isServerCommand(action) || isServerSocketStartupAction(action) return isServerCommand(action) || isServerSocketStartupAction(action) || isServerMalformedAction(action) || isServerGoodbyeAction(action)
} }

@ -3,12 +3,13 @@ import {AppState} from "../state/AppState";
import {isTileAction} from "../actions/TileAction"; import {isTileAction} from "../actions/TileAction";
import {tileReducer} from "./TileReducer"; import {tileReducer} from "./TileReducer";
import {networkReducer} from "./NetworkReducer"; import {networkReducer} from "./NetworkReducer";
import {CLIENT_PENDING, isNetworkAction, isSendableAction} from "../actions/ClientAction"; import {CLIENT_PENDING, isSendableAction} from "../actions/ClientAction";
import {syncedStateReducer} from "./SyncedStateReducer"; import {syncedStateReducer} from "./SyncedStateReducer";
import {exhaustivenessCheck} from "../util/TypeUtils"; import {exhaustivenessCheck} from "../util/TypeUtils";
import {isMapAction, MapAction} from "../actions/MapAction"; import {isMapAction, MapAction} from "../actions/MapAction";
import {isUserAction, UserAction} from "../actions/UserAction"; import {isUserAction, UserAction} from "../actions/UserAction";
import {clientReducer} from "./ClientReducer"; import {clientReducer} from "./ClientReducer";
import {isNetworkAction} from "../actions/NetworkAction";
function appSyncedStateReducer(oldState: AppState, action: MapAction|UserAction): AppState { function appSyncedStateReducer(oldState: AppState, action: MapAction|UserAction): AppState {
if (oldState.localState === null) { if (oldState.localState === null) {

@ -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"; import {NetworkState, ServerConnectionState} from "../state/NetworkState";
// TODO: Verify that only one special message exists at a time. // 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], sentActions: [...oldState.sentActions, ...action.actions],
pendingActions: oldState.pendingActions.slice(action.actions.length), pendingActions: oldState.pendingActions.slice(action.actions.length),
} }
case CLIENT_GOODBYE:
return {
...oldState,
specialMessage: action,
connectionState: ServerConnectionState.AWAITING_GOODBYE,
}
} }
} }

@ -15,7 +15,7 @@ export function replaceCell(oldMap: HexMap, {line, cell}: StorageCoordinates, ne
if (areCellsEquivalent(getCell(oldMap, {line, cell}), newCell)) { if (areCellsEquivalent(getCell(oldMap, {line, cell}), newCell)) {
return oldMap return oldMap
} }
const oldLines = oldMap.lineCells const oldLines = oldMap.layer
const oldLine = oldLines[line] const oldLine = oldLines[line]
const newLine = [ const newLine = [
...oldLine.slice(0, cell), ...oldLine.slice(0, cell),
@ -29,7 +29,7 @@ export function replaceCell(oldMap: HexMap, {line, cell}: StorageCoordinates, ne
] ]
return { return {
...oldMap, ...oldMap,
lineCells: newLines layer: newLines
} }
} }

@ -8,19 +8,23 @@ import {
SERVER_FAILED, SERVER_FAILED,
SERVER_GOODBYE, SERVER_GOODBYE,
SERVER_HELLO, SERVER_HELLO,
SERVER_MALFORMED,
SERVER_OK, SERVER_OK,
SERVER_REFRESH, SERVER_REFRESH,
SERVER_SOCKET_STARTUP, ServerActCommand, SERVER_SOCKET_STARTUP,
ServerAction, ServerFailedCommand, ServerActCommand,
ServerGoodbyeCommand, ServerAction,
ServerFailedCommand,
ServerGoodbyeAction,
ServerHelloCommand, ServerHelloCommand,
ServerMalformedAction,
ServerOKCommand, ServerOKCommand,
ServerRefreshCommand, ServerRefreshCommand,
ServerSocketStartupAction, ServerSocketStartupAction,
SocketState, SocketState,
SyncableAction SyncableAction
} from "../actions/ServerAction"; } from "../actions/ServerAction";
import {CLIENT_HELLO, SendableAction} from "../actions/ClientAction"; import {CLIENT_GOODBYE, CLIENT_HELLO, SendableAction} from "../actions/ClientAction";
interface StateRecalculationInputs { interface StateRecalculationInputs {
/** The original server state before the actions changed. The base on which the actions are all applied. */ /** 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. // TODO: Sort out the correct state and autoReconnectAt based on the time in the action.
return { return {
...oldState, ...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 { 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. // 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. // 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: case SERVER_ACT:
return serverActReducer(oldState, action) return serverActReducer(oldState, action)
case SERVER_MALFORMED:
return {
...oldState,
network: serverMalformedReducer(oldState.network, action)
}
} }
} }

@ -1,10 +1,10 @@
import {SyncedState} from "../state/SyncedState"; import {SyncedState} from "../state/SyncedState";
import {SyncableAction} from "../actions/ClientAction";
import {isMapAction, MapAction} from "../actions/MapAction"; import {isMapAction, MapAction} from "../actions/MapAction";
import {hexMapReducer} from "./HexMapReducer"; import {hexMapReducer} from "./HexMapReducer";
import {isUserAction, UserAction} from "../actions/UserAction"; import {isUserAction, UserAction} from "../actions/UserAction";
import {userReducer} from "./UserReducer"; import {userReducer} from "./UserReducer";
import {exhaustivenessCheck} from "../util/TypeUtils"; import {exhaustivenessCheck} from "../util/TypeUtils";
import {SyncableAction} from "../actions/ServerAction";
export function syncedStateReducer(state: SyncedState, action: (MapAction|UserAction)): SyncedState { export function syncedStateReducer(state: SyncedState, action: (MapAction|UserAction)): SyncedState {
if (isMapAction(action)) { if (isMapAction(action)) {

@ -1,4 +1,4 @@
import {HexagonOrientation, HexMapRepresentation, LineParity} from "./HexMap"; import {HexagonOrientation, HexLayout, LineParity} from "./HexMap";
/** Staggered (storage) coordinates for accessing cell storage. */ /** Staggered (storage) coordinates for accessing cell storage. */
export interface StorageCoordinates { export interface StorageCoordinates {
@ -54,7 +54,7 @@ export interface RenderOffsets {
/** How big each hexagon should be. The "radius" from the center to any vertex. */ /** How big each hexagon should be. The "radius" from the center to any vertex. */
readonly size: number readonly size: number
/** The way the hex map should be displayed. Usually the same as the origin map. */ /** The way the hex map should be displayed. Usually the same as the origin map. */
readonly displayMode: HexMapRepresentation readonly displayMode: HexLayout
} }
export interface RenderSize { export interface RenderSize {

@ -36,7 +36,7 @@ export enum LineParity {
} }
/** The type of map this map is. */ /** The type of map this map is. */
export interface HexMapRepresentation { export interface HexLayout {
readonly orientation: HexagonOrientation readonly orientation: HexagonOrientation
readonly indentedLines: LineParity readonly indentedLines: LineParity
} }
@ -44,7 +44,7 @@ export interface HexMapRepresentation {
/** Data corresponding to an entire hex map. */ /** Data corresponding to an entire hex map. */
export interface HexMap { export interface HexMap {
/** The way the map is displayed, which also affects how coordinates are calculated. */ /** 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. * The number of lines on the map.
* In ROWS and EVEN_ROWS mode, this is the height of 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 COLUMNS and EVEN_COLUMNS mode, lines represent columns.
* In ROWS and EVEN_ROWS mode, lines represent rows. * 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 * 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. * 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 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 lineCells: HexLine[] = [];
const emptyLine: HexCell[] = []; const emptyLine: HexCell[] = [];
for (let cell = 0; cell < cellsPerLine; cell += 1) { for (let cell = 0; cell < cellsPerLine; cell += 1) {
@ -83,8 +83,8 @@ export function initializeMap({lines, cellsPerLine, displayMode, xid}: {lines: n
return { return {
lines, lines,
cellsPerLine: cellsPerLine, cellsPerLine: cellsPerLine,
displayMode, layout: displayMode,
lineCells, layer: lineCells,
xid xid
} }
} }
@ -102,5 +102,5 @@ export function getCell(map: HexMap, {line, cell}: StorageCoordinates): HexCell|
if (!isValidCoordinate(map, {line, cell})) { if (!isValidCoordinate(map, {line, cell})) {
return null return null
} }
return map.lineCells[line][cell] return map.layer[line][cell]
} }

@ -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"; import {SyncedState} from "./SyncedState";
export enum ServerConnectionState { export enum ServerConnectionState {
@ -10,12 +16,17 @@ export enum ServerConnectionState {
AWAITING_REFRESH = "AWAITING_REFRESH", AWAITING_REFRESH = "AWAITING_REFRESH",
/** Used when the client is connected and everything is normal. */ /** Used when the client is connected and everything is normal. */
CONNECTED = "CONNECTED", 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, * 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, * 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. * or if the client was disconnected due to a protocol error.
*/ */
OFFLINE = "OFFLINE", OFFLINE = "OFFLINE"
} }
export interface NetworkState { export interface NetworkState {
@ -35,7 +46,7 @@ export interface NetworkState {
/** /**
* A special action that should take precedence over sending more actions. * 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. * The ID of the next ClientSentAction to be created.
*/ */

@ -6,6 +6,7 @@ import {
storageCoordinatesToRenderCoordinates storageCoordinatesToRenderCoordinates
} from "../state/Coordinates"; } from "../state/Coordinates";
import {HexagonOrientation, LineParity} from "../state/HexMap"; 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 { 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 renderCoordinates = useMemo(() => storageCoordinatesToRenderCoordinates({line: index, cell: 0}, offsets), [index, offsets]);
@ -49,20 +50,6 @@ const COLORS: readonly string[] = [
"#AAAAAAFF", "#FFFFFFFF", "#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 { function HexColorPicker({ hex, onChange }: InjectedColorProps): ReactElement {
const selected = COLORS.indexOf(normalizeColor(hex || "#INVALID")) const selected = COLORS.indexOf(normalizeColor(hex || "#INVALID"))
const swatches = COLORS.map((color, index) => const swatches = COLORS.map((color, index) =>

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

@ -1,6 +1,38 @@
import {ClientCommand} from "../actions/ClientAction"; import {CLIENT_ACT, CLIENT_HELLO, CLIENT_REFRESH, ClientCommand, SentAction} from "../actions/ClientAction";
import {ClientCommandPB} from "../proto/client"; 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,
}
}
} }

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

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

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

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

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

@ -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<ClientHelloCommand | ClientRefreshCommand | ClientGoodbyeAction | null>(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 <div className="connectionState" onClick={() => dispatch ? dispatch({type: CLIENT_REFRESH}) : null}>{state}</div>
}

@ -2,19 +2,27 @@
import {ClientCommand} from "../actions/ClientAction"; import {ClientCommand} from "../actions/ClientAction";
import { import {
SERVER_GOODBYE, SERVER_GOODBYE,
SERVER_MALFORMED,
SERVER_SOCKET_STARTUP, SERVER_SOCKET_STARTUP,
ServerCommand, ServerCommand,
ServerGoodbyeCommand, ServerGoodbyeAction,
ServerMalformedAction,
ServerSocketStartupAction, ServerSocketStartupAction,
SocketState SocketState
} from "../actions/ServerAction"; } 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 url: string
readonly protocols: readonly string[] readonly protocols: readonly string[]
readonly onStartup: (startup: ServerSocketStartupAction) => void readonly onStartup: (startup: ServerSocketStartupAction) => void
readonly onMessage: (command: ServerCommand) => 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 private socket: WebSocket|null
constructor({ constructor({
@ -22,31 +30,36 @@ class WebsocketTranslator {
protocols, protocols,
onStartup, onStartup,
onMessage, onMessage,
onGoodbye onError,
onGoodbye,
}: { }: {
url: string, url: string,
protocols: readonly string[], protocols: readonly string[],
onStartup: (startup: ServerSocketStartupAction) => void, onStartup: (startup: ServerSocketStartupAction) => void,
onMessage: (command: ServerCommand) => void, onMessage: (command: ServerCommand) => void,
onGoodbye: (goodbye: ServerGoodbyeCommand) => void onError: (error: ServerMalformedAction) => void,
onGoodbye: (goodbye: ServerGoodbyeAction) => void,
}) { }) {
this.url = url this.url = url
this.protocols = protocols.slice() this.protocols = protocols.slice()
this.onStartup = onStartup this.onStartup = onStartup
this.onMessage = onMessage this.onMessage = onMessage
this.onError = onError
this.onGoodbye = onGoodbye this.onGoodbye = onGoodbye
this.socket = null this.socket = null
} }
connect() { connect() {
if (this.socket != null) { console.log("entering connect()", this)
if (this.socket !== null) {
throw Error("Already running") throw Error("Already running")
} }
this.socket = new WebSocket(this.url, this.protocols.slice()) this.socket = new WebSocket(this.url, this.protocols.slice())
this.socket.addEventListener("open", this.handleOpen) this.socket.binaryType = "arraybuffer"
this.socket.addEventListener("message", this.handleMessage) this.socket.addEventListener("open", () => this.handleOpen())
this.socket.addEventListener("close", this.handleClose) this.socket.addEventListener("message", (m) => this.handleMessage(m))
this.socket.addEventListener("error", WebsocketTranslator.handleError) this.socket.addEventListener("close", (c) => this.handleClose(c))
this.socket.addEventListener("error", () => this.handleError())
this.onStartup({ this.onStartup({
type: SERVER_SOCKET_STARTUP, type: SERVER_SOCKET_STARTUP,
state: SocketState.CONNECTING, state: SocketState.CONNECTING,
@ -54,14 +67,14 @@ class WebsocketTranslator {
} }
send(message: ClientCommand) { send(message: ClientCommand) {
// TODO: Protoitize the client message and send() it. this.socket?.send(ClientCommandPB.encode(clientToPb(message)).finish())
} }
close(code: number, reason: string) { close(code: number, reason: string) {
this.socket?.close(code, reason) this.socket?.close(code, reason)
} }
private handleOpen(e: Event) { private handleOpen() {
this.onStartup({ this.onStartup({
type: SERVER_SOCKET_STARTUP, type: SERVER_SOCKET_STARTUP,
state: SocketState.OPEN, state: SocketState.OPEN,
@ -69,7 +82,27 @@ class WebsocketTranslator {
} }
private handleMessage(e: MessageEvent) { 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) { private handleClose(e: CloseEvent) {
@ -82,15 +115,7 @@ class WebsocketTranslator {
this.clearSocket() this.clearSocket()
} }
private static handleError(e: Event) {
console.log("Websocket error: ", e)
}
private clearSocket() { 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 this.socket = null
} }
} }

@ -6,6 +6,7 @@
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"downlevelIteration": true,
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,

@ -1,11 +1,15 @@
#!/bin/bash #!/bin/bash
SCRIPTPATH=$(readlink -e ${BASH_SOURCE}) set -eux
PATH=${PATH}:${GOROOT}/bin
SCRIPTPATH=$(readlink -e "${BASH_SOURCE[0]}")
MAINPATH=${SCRIPTPATH%/*} MAINPATH=${SCRIPTPATH%/*}
BUILDTOOLSPATH=${MAINPATH}/buildtools BUILDTOOLSPATH=${MAINPATH}/buildtools
MAGEPATH=${BUILDTOOLSPATH}/mage MAGEPATH=${BUILDTOOLSPATH}/mage
if [[ ! -x "$MAGEPATH" ]]; then if [[ ! -x "$MAGEPATH" ]]; then
echo "go install-ing mage..." echo "go install-ing mage..."
mkdir -p "$BUILDTOOLSPATH"
GOBIN="$BUILDTOOLSPATH" go install github.com/magefile/mage@latest GOBIN="$BUILDTOOLSPATH" go install github.com/magefile/mage@latest
fi fi

@ -1,12 +1,13 @@
package action package action
import "git.reya.zone/reya/hexmap/server/state" import (
"git.reya.zone/reya/hexmap/server/state"
)
func (x *ServerActionPB) ToGo() (Server, error) { func (x *ServerActionPB) ToGo() (Server, error) {
if x == nil {
return nil, nil
}
switch action := x.Action.(type) { switch action := x.Action.(type) {
case nil:
return nil, state.ErrOneofNotSet
case *ServerActionPB_Client: case *ServerActionPB_Client:
return action.Client.ToGo() return action.Client.ToGo()
default: default:
@ -19,6 +20,8 @@ func (x *ClientActionPB) ToGo() (Client, error) {
return nil, nil return nil, nil
} }
switch action := x.Action.(type) { switch action := x.Action.(type) {
case nil:
return nil, state.ErrOneofNotSet
case *ClientActionPB_CellSetColor: case *ClientActionPB_CellSetColor:
return action.CellSetColor.ToGo() return action.CellSetColor.ToGo()
case *ClientActionPB_UserSetActiveColor: case *ClientActionPB_UserSetActiveColor:

@ -0,0 +1,5 @@
package state
import "errors"
var ErrOneofNotSet = errors.New("no value was given for a oneof")

@ -1,7 +1,13 @@
package websocket package websocket
import (
"git.reya.zone/reya/hexmap/server/state"
)
func (x *ClientCommandPB) ToGo() (ClientCommand, error) { func (x *ClientCommandPB) ToGo() (ClientCommand, error) {
switch msg := x.Command.(type) { switch msg := x.Command.(type) {
case nil:
return nil, state.ErrOneofNotSet
case *ClientCommandPB_Hello: case *ClientCommandPB_Hello:
return msg.Hello.ToGo(), nil return msg.Hello.ToGo(), nil
case *ClientCommandPB_Refresh: case *ClientCommandPB_Refresh:

Loading…
Cancel
Save