Compare commits

...

2 Commits

Author SHA1 Message Date
Mari ab29ec20be Hacky TURBO CODING MODE minimum viable project 3 years ago
Mari 34e64c7130 Wire up the client's websocket connection. 3 years ago
  1. 15
      client/package-lock.json
  2. 2
      client/package.json
  3. 4
      client/src/App.css
  4. 56
      client/src/App.tsx
  5. 15
      client/src/actions/ClientAction.ts
  6. 24
      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. 28
      client/src/reducers/ServerReducer.ts
  11. 2
      client/src/reducers/SyncedStateReducer.ts
  12. 16
      client/src/state/Coordinates.ts
  13. 20
      client/src/state/HexMap.ts
  14. 17
      client/src/state/NetworkState.ts
  15. 19
      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. 72
      client/src/websocket/WebsocketReactAdapter.tsx
  24. 72
      client/src/websocket/WebsocketTranslator.ts
  25. 1
      client/tsconfig.json
  26. 6
      mage.sh
  27. 13
      server/action/action.go
  28. 11
      server/action/action.pbconv.go
  29. 193
      server/host/HttpServer.go
  30. 38
      server/room/actor.go
  31. 32
      server/room/client.go
  32. 30
      server/room/clientmessage.go
  33. 47
      server/room/message.go
  34. 35
      server/state/map.go
  35. 5
      server/state/protobuf.go
  36. 37
      server/websocket/connection.go
  37. 17
      server/ws/client.go
  38. 55
      server/ws/client.pbconv.go
  39. 276
      server/ws/connection.go
  40. 21
      server/ws/reader.go
  41. 2
      server/ws/server.go
  42. 40
      server/ws/server.pbconv.go
  43. 15
      server/ws/shared.go
  44. 16
      server/ws/writer.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,43 +1,44 @@
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, localState: null,
null, network: {
() => ({ serverState: null,
localState: null, connectionState: ServerConnectionState.OFFLINE,
network: { specialMessage: null,
serverState: null, nextID: 0,
connectionState: ServerConnectionState.OFFLINE, sentActions: [],
specialMessage: null, pendingActions: [],
nextID: 0, goodbyeCode: 1000,
sentActions: [], goodbyeReason: "",
pendingActions: [], autoReconnectAt: null,
goodbyeCode: 1000, reconnectAttempts: null
goodbyeReason: "", },
autoReconnectAt: 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 layout: 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({
bottomMargin: 10, cells: map.cellsPerLine, lines: map.lines, offsets: offsets, rightMargin: 10 bottomMargin: 10, cells: map.cellsPerLine, lines: map.lines, offsets: offsets, rightMargin: 10,
}) : {width: 1, height: 1}, [map, offsets]); }) : {width: 1, height: 1}, [map, offsets]);
const colorPickerElement = !!user ? <HexColorPicker color={user?.activeColor} onChangeComplete={(colorResult) => dispatch({ const colorPickerElement = !!user ? <HexColorPicker color={user?.activeColor} onChangeComplete={(colorResult) => dispatch({
type: USER_ACTIVE_COLOR, type: USER_ACTIVE_COLOR,
@ -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
} }
@ -75,7 +75,7 @@ export enum SocketState {
OPEN = "OPEN", OPEN = "OPEN",
} }
export const SERVER_SOCKET_STARTUP = "SERVER_SOCKET_STARTUP" export const SERVER_SOCKET_STARTUP = "SERVER_SOCKET_STARTUP"
/** Synthesized when the websocket begins connecting, i.e., enters the Connecting or Open states.. */ /** Synthesized when the ws begins connecting, i.e., enters the Connecting or Open states.. */
export interface ServerSocketStartupAction extends BaseAction { export interface ServerSocketStartupAction extends BaseAction {
readonly type: typeof SERVER_SOCKET_STARTUP readonly type: typeof SERVER_SOCKET_STARTUP
readonly state: SocketState readonly state: SocketState
@ -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 ws. */
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,15 @@ function serverActReducer(oldState: AppState, action: ServerActCommand): AppStat
}; };
} }
function serverMalformedReducer(oldState: NetworkState, action: ServerMalformedAction): NetworkState {
console.log("Got serverMalformed", action.error)
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 +273,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 layout: HexLayout
} }
export interface RenderSize { export interface RenderSize {
@ -66,7 +66,7 @@ export interface RenderSize {
const POINTY_AXIS_FACTOR = 2; const POINTY_AXIS_FACTOR = 2;
const FLAT_AXIS_FACTOR = Math.sqrt(3); const FLAT_AXIS_FACTOR = Math.sqrt(3);
export function storageCoordinatesToRenderCoordinates({line, cell}: StorageCoordinates, renderOffsets: RenderOffsets): RenderCoordinates { export function storageCoordinatesToRenderCoordinates({line, cell}: StorageCoordinates, renderOffsets: RenderOffsets): RenderCoordinates {
const {orientation, indentedLines} = renderOffsets.displayMode; const {orientation, indentedLines} = renderOffsets.layout;
const flatLength = renderOffsets.size * FLAT_AXIS_FACTOR; const flatLength = renderOffsets.size * FLAT_AXIS_FACTOR;
const pointyLength = renderOffsets.size * POINTY_AXIS_FACTOR; const pointyLength = renderOffsets.size * POINTY_AXIS_FACTOR;
@ -105,7 +105,7 @@ export function sizeFromLinesAndCells({offsets, lines, cells, rightMargin = 0, b
const { const {
top: topMargin, top: topMargin,
left: leftMargin, left: leftMargin,
displayMode: { layout: {
orientation, orientation,
indentedLines indentedLines
} }
@ -118,12 +118,12 @@ export function sizeFromLinesAndCells({offsets, lines, cells, rightMargin = 0, b
const hasIndents = lines > 1 || indentedLines === LineParity.EVEN const hasIndents = lines > 1 || indentedLines === LineParity.EVEN
// Every line will be 3/4 of a cell length apart in the pointy direction; // Every cell will be 3/4 of a cell length apart in the pointy direction;
// however, the last line is still one full pointy cell long. // however, the last line is still one full pointy cell long.
const pointyLength = pointyMargins + ((cells - 1) * pointyCellLength * 3 / 4) + pointyCellLength; const pointyLength = pointyMargins + ((lines - 1) * pointyCellLength * 3 / 4) + pointyCellLength;
// Every cell will be one full cell length apart in the flat direction; // Every line will be one full cell length apart in the flat direction;
// however, if there are indents, another half cell is needed to accommodate them. // however, if there are indents, another half cell is needed to accommodate them.
const flatLength = flatMargins + lines * flatCellLength + (hasIndents ? flatCellLength / 2 : 0); const flatLength = flatMargins + cells * flatCellLength + (hasIndents ? flatCellLength / 2 : 0);
return orientation === HexagonOrientation.FLAT_TOP ? { width: pointyLength, height: flatLength } : { width: flatLength, height: pointyLength } return orientation === HexagonOrientation.FLAT_TOP ? { width: pointyLength, height: flatLength } : { width: flatLength, height: pointyLength }
} }

@ -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,20 +71,20 @@ 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, layout, xid}: {lines: number, cellsPerLine: number, layout: HexLayout, xid: string}): HexMap {
const lineCells: HexLine[] = []; const layer: HexLine[] = [];
const emptyLine: HexCell[] = []; const emptyLine: HexCell[] = [];
for (let cell = 0; cell < cellsPerLine; cell += 1) { for (let cell = 0; cell < cellsPerLine; cell += 1) {
emptyLine.push(EMPTY_CELL) emptyLine.push(EMPTY_CELL)
} }
for (let line = 0; line < lines; line += 1) { for (let line = 0; line < lines; line += 1) {
lineCells.push(emptyLine) layer.push(emptyLine)
} }
return { return {
lines, lines,
cellsPerLine: cellsPerLine, cellsPerLine,
displayMode, layout,
lineCells, layer,
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]);
@ -19,7 +20,7 @@ function HexSwatch({color, index, offsets, classNames, onClick}: {color: string,
} }
const ACTIVE_OFFSETS: RenderOffsets = { const ACTIVE_OFFSETS: RenderOffsets = {
displayMode: { layout: {
orientation: HexagonOrientation.POINTY_TOP, orientation: HexagonOrientation.POINTY_TOP,
indentedLines: LineParity.ODD indentedLines: LineParity.ODD
}, },
@ -29,7 +30,7 @@ const ACTIVE_OFFSETS: RenderOffsets = {
} }
const SWATCH_OFFSETS: RenderOffsets = { const SWATCH_OFFSETS: RenderOffsets = {
displayMode: { layout: {
orientation: HexagonOrientation.FLAT_TOP, orientation: HexagonOrientation.FLAT_TOP,
indentedLines: LineParity.EVEN indentedLines: LineParity.EVEN
}, },
@ -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: map.lines,
cellsPerLine: map.cellsPerLine,
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,72 @@
import {ReactElement, useContext, useEffect, useRef, useState} from "react";
import {
CLIENT_ACT,
CLIENT_REFRESH,
ClientActCommand,
ClientGoodbyeAction,
ClientHelloCommand,
ClientRefreshCommand, isClientGoodbyeCommand,
isClientHelloCommand,
isClientRefreshCommand,
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 (specialMessage !== null && specialMessage !== lastSpecialMessage) {
if ((state === ServerConnectionState.AWAITING_HELLO && isClientHelloCommand(specialMessage))
|| (state === ServerConnectionState.AWAITING_REFRESH && isClientRefreshCommand(specialMessage))) {
connector.current.send(specialMessage);
} else if (state === ServerConnectionState.AWAITING_GOODBYE && 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>
}

@ -1,20 +1,28 @@
/** Translates between websocket messages and Commands. */ /** Translates between ws messages and Commands. */
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,17 @@ 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) {
if (this.socket === null || this.socket.readyState !== WebSocket.OPEN) {
return
}
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 +85,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 +118,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

@ -110,3 +110,16 @@ func (c UserActiveColor) Apply(s *state.Synced) error {
s.User.ActiveColor = c.Color s.User.ActiveColor = c.Color
return nil return nil
} }
// IDed contains a pair of ID and Action, as sent by the client.
type IDed struct {
// ID contains the arbitrary ID that was sent by the client, for identifying the action in future messages.
ID uint32 `json:"id"`
// Action contains the action that was actually being sent.
Action Client `json:"action"`
}
func (i IDed) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddUint32("id", i.ID)
return encoder.AddObject("action", i.Action)
}

@ -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,193 @@
package main
import (
"context"
"git.reya.zone/reya/hexmap/server/room"
"git.reya.zone/reya/hexmap/server/state"
"git.reya.zone/reya/hexmap/server/ws"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
)
const SaveDir = "/home/reya/hexmaps"
func save(m state.HexMap, l *zap.Logger) error {
filename := filepath.Join(SaveDir, "map."+strconv.FormatInt(time.Now().Unix(), 16))
l.Debug("Saving to file", zap.String("filename", filename))
marshaled, err := proto.Marshal(m.ToPB())
l.Debug("Marshaled proto")
if err != nil {
return err
}
l.Debug("Opening file")
file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0x644)
if err != nil {
return err
}
l.Debug("Writing to file")
_, err = file.Write(marshaled)
if err != nil {
return err
}
l.Debug("Closing file")
err = file.Close()
if err != nil {
return err
}
l.Info("Saved to file", zap.String("filename", filename))
return nil
}
func load(l *zap.Logger) (*state.HexMap, error) {
filename := filepath.Join(SaveDir, "map.LOAD")
l.Debug("Loading from file", zap.String("filename", filename))
file, err := os.Open(filename)
if err != nil {
return nil, err
}
l.Debug("Reading file")
marshaled, err := ioutil.ReadAll(file)
if err != nil {
return nil, err
}
pb := &state.HexMapPB{}
l.Debug("Extracting protobuf from file")
err = proto.Unmarshal(marshaled, pb)
if err != nil {
return nil, err
}
l.Debug("Closing file")
err = file.Close()
if err != nil {
return nil, err
}
m, err := pb.ToGo()
if err != nil {
return nil, err
}
l.Info("Loaded from file", zap.String("filename", filename))
return &m, nil
}
func BackupMap(client *room.Client, l *zap.Logger) {
var err error
myState := &state.Synced{}
l.Info("Starting backup system")
for {
msg := <-client.IncomingChannel()
switch typedMsg := msg.(type) {
case *room.JoinResponse:
myState = typedMsg.CurrentState()
err := save(myState.Map, l)
if err != nil {
l.Error("Failed saving during join response", zap.Error(err))
}
case *room.ActionBroadcast:
err = typedMsg.Action().Apply(myState)
if err == nil {
err = save(myState.Map, l)
if err != nil {
l.Error("Failed saving during action broadcast", zap.Error(err))
}
}
case *room.ShutdownRequest:
client.OutgoingChannel() <- client.AcknowledgeShutdown()
return
}
}
}
func ServeWS(logger *zap.Logger) (err error) {
m := http.NewServeMux()
httpLogger := logger.Named("HTTP")
hexes, err := load(logger)
if err != nil {
hexes = state.NewHexMap(state.Layout{
Orientation: state.PointyTop,
IndentedLines: state.EvenLines,
}, 25, 10)
}
rm := room.New(room.NewOptions{
BaseLogger: logger.Named("Room"),
StartingState: &state.Synced{
Map: *hexes,
User: state.UserState{
ActiveColor: state.Color{
R: 0,
G: 0,
B: 0,
A: 255,
},
},
},
StartingClientOptions: room.NewClientOptions{
IncomingChannel: nil,
AcceptBroadcasts: true,
RequestStartingState: true,
},
})
go BackupMap(rm, logger.Named("BackupMap"))
m.Handle("/map", &ws.HTTPHandler{
Upgrader: websocket.Upgrader{
Subprotocols: []string{"v1.hexmap.deliciousreya.net"},
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://hexmap.deliciousreya.net"
},
},
Logger: logger.Named("WS"),
Room: rm,
})
srv := http.Server{
Addr: "127.0.0.1:5238",
Handler: m,
ErrorLog: zap.NewStdLog(httpLogger),
}
m.HandleFunc("/exit", func(writer http.ResponseWriter, request *http.Request) {
// Some light dissuasion of accidental probing.
// To keep good people out.
if request.FormValue("superSecretPassword") != "Gesture/Retrial5/Untrained/Countable/Extrude/Jeep/Cheese/Carbon" {
writer.WriteHeader(403)
_, err = writer.Write([]byte("... What are you trying to pull?"))
return
}
writer.WriteHeader(200)
_, err := writer.Write([]byte("OK, shutting down, bye!"))
if err != nil {
logger.Warn("Error while writing goodbye response", zap.Error(err))
}
time.AfterFunc(500*time.Millisecond, func() {
err := srv.Shutdown(context.Background())
if err != nil {
logger.Error("Error while shutting down the server", zap.Error(err))
}
})
})
err = srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
return err
}
rm.OutgoingChannel() <- rm.Stop()
for {
msg := <-rm.IncomingChannel()
switch msg.(type) {
case *room.ShutdownRequest:
rm.OutgoingChannel() <- rm.AcknowledgeShutdown()
return nil
}
}
}
func main() {
logger, err := zap.NewDevelopment()
err = ServeWS(logger)
if err != nil {
logger.Fatal("Error while serving HTTP", zap.Error(err))
}
}

@ -18,28 +18,28 @@ func (r *room) act() {
msgLogger := r.logger.With(zap.Stringer("client", client)) msgLogger := r.logger.With(zap.Stringer("client", client))
msgLogger.Debug("Message received, handling", zap.Object("message", raw)) msgLogger.Debug("Message received, handling", zap.Object("message", raw))
switch msg := raw.(type) { switch msg := raw.(type) {
case JoinRequest: case *JoinRequest:
r.addClient(msg.id, msg.returnChannel, msg.broadcast, msg.privateChannel) r.addClient(msg.id, msg.returnChannel, msg.broadcast, msg.privateChannel)
r.acknowledgeJoin(msg.id, msg.wantCurrentState) r.acknowledgeJoin(msg.id, msg.wantCurrentState)
case RefreshRequest: case *RefreshRequest:
r.sendRefresh(msg.id) r.sendRefresh(msg.id)
case ApplyRequest: case *ApplyRequest:
msgLogger.Debug("Received action to apply from client", zap.Uint32("actionId", msg.action.ID)) msgLogger.Debug("Received action to apply from client", zap.Uint32("actionId", msg.action.ID))
result := r.applyAction(msg.action.Action) result := r.applyAction(msg.action.Action)
if result != nil { if result == nil {
r.broadcastAction(client, msg.action.ID, msg.action.Action) r.broadcastAction(client, msg.action.ID, msg.action.Action)
} }
r.acknowledgeAction(client, msg.action.ID, result) r.acknowledgeAction(client, msg.action.ID, result)
case LeaveRequest: case *LeaveRequest:
// So long, then. We can close immediately here; they promised not to send any more messages after this // So long, then. We can close immediately here; they promised not to send any more messages after this
// unless we were shutting down, which we're not. // unless we were shutting down, which we're not.
r.acknowledgeLeave(client) r.acknowledgeLeave(client)
r.closeClient(client) r.closeClient(client)
case StopRequest: case *StopRequest:
// As requested, we shut down. Our deferred gracefulShutdown will catch us as we fall. // As requested, we shut down. Our deferred gracefulShutdown will catch us as we fall.
msgLogger.Info("Received StopRequest from client, shutting down") msgLogger.Info("Received StopRequest from client, shutting down")
return return
case ShutdownResponse: case *ShutdownResponse:
// Uh... thank... you. I'm not... Never mind. I guess this means you're leaving? // Uh... thank... you. I'm not... Never mind. I guess this means you're leaving?
msgLogger.Error("Received unexpected ShutdownResponse from client while not shutting down") msgLogger.Error("Received unexpected ShutdownResponse from client while not shutting down")
r.closeClient(client) r.closeClient(client)
@ -95,7 +95,7 @@ func (r *room) acknowledgeJoin(id xid.ID, includeState bool) {
s = r.stateCopy() s = r.stateCopy()
} }
logger.Debug("Sending JoinResponse to client") logger.Debug("Sending JoinResponse to client")
client.outgoingChannel <- JoinResponse{ client.outgoingChannel <- &JoinResponse{
id: r.id, id: r.id,
currentState: s, currentState: s,
} }
@ -113,7 +113,7 @@ func (r *room) sendRefresh(id xid.ID) {
logger.Debug("Preparing state copy for client") logger.Debug("Preparing state copy for client")
s = r.stateCopy() s = r.stateCopy()
logger.Debug("Sending RefreshResponse to client") logger.Debug("Sending RefreshResponse to client")
client.outgoingChannel <- RefreshResponse{ client.outgoingChannel <- &RefreshResponse{
id: r.id, id: r.id,
currentState: s, currentState: s,
} }
@ -126,7 +126,7 @@ func (r *room) applyAction(action action.Action) error {
} }
// broadcastAction sends an action to everyone other than the original client which requested it. // broadcastAction sends an action to everyone other than the original client which requested it.
func (r *room) broadcastAction(originalClientID xid.ID, originalActionID uint32, action action.Action) { func (r *room) broadcastAction(originalClientID xid.ID, originalActionID uint32, action action.Server) {
logger := r.logger.With(zap.Stringer("originalClient", originalClientID), zap.Uint32("actionID", originalActionID), zap.Object("action", action)) logger := r.logger.With(zap.Stringer("originalClient", originalClientID), zap.Uint32("actionID", originalActionID), zap.Object("action", action))
broadcast := ActionBroadcast{ broadcast := ActionBroadcast{
id: r.id, id: r.id,
@ -138,7 +138,7 @@ func (r *room) broadcastAction(originalClientID xid.ID, originalActionID uint32,
for id, client := range r.clients { for id, client := range r.clients {
if id.Compare(originalClientID) != 0 { if id.Compare(originalClientID) != 0 {
logger.Debug("Sending ActionBroadcast to client", zap.Stringer("client", id)) logger.Debug("Sending ActionBroadcast to client", zap.Stringer("client", id))
client.outgoingChannel <- broadcast client.outgoingChannel <- &broadcast
} }
} }
} }
@ -153,7 +153,7 @@ func (r *room) acknowledgeAction(id xid.ID, actionID uint32, error error) {
return return
} }
logger.Debug("Sending ApplyResponse to client") logger.Debug("Sending ApplyResponse to client")
client.outgoingChannel <- ApplyResponse{ client.outgoingChannel <- &ApplyResponse{
id: id, id: id,
actionID: actionID, actionID: actionID,
result: error, result: error,
@ -171,7 +171,7 @@ func (r *room) acknowledgeLeave(id xid.ID) {
return return
} }
logger.Debug("Sending LeaveResponse to client") logger.Debug("Sending LeaveResponse to client")
client.outgoingChannel <- LeaveResponse{id: r.id} client.outgoingChannel <- &LeaveResponse{id: r.id}
} }
// closeClient causes the room to remove the client with the given id from its clients. // closeClient causes the room to remove the client with the given id from its clients.
@ -206,25 +206,25 @@ func (r *room) gracefulShutdown() {
msgLogger := r.logger.With(zap.Stringer("client", client)) msgLogger := r.logger.With(zap.Stringer("client", client))
msgLogger.Debug("Post-shutdown message received, handling", zap.Object("message", raw)) msgLogger.Debug("Post-shutdown message received, handling", zap.Object("message", raw))
switch msg := raw.(type) { switch msg := raw.(type) {
case JoinRequest: case *JoinRequest:
// Don't you hate it when someone comes to the desk right as you're getting ready to pack up? // Don't you hate it when someone comes to the desk right as you're getting ready to pack up?
// Can't ignore them - they have our channel, and they might be sending things to it. We have to add them // Can't ignore them - they have our channel, and they might be sending things to it. We have to add them
// and then immediately send them a ShutdownRequest and wait for them to answer it. // and then immediately send them a ShutdownRequest and wait for them to answer it.
msgLogger.Debug("Received join request from client while shutting down") msgLogger.Debug("Received join request from client while shutting down")
r.addClient(msg.id, msg.returnChannel, msg.broadcast, msg.privateChannel) r.addClient(msg.id, msg.returnChannel, msg.broadcast, msg.privateChannel)
r.requestShutdownFrom(client) r.requestShutdownFrom(client)
case RefreshRequest: case *RefreshRequest:
// Ugh, seriously, now? Fine. You can have this - you might be our friend the persistence actor. // Ugh, seriously, now? Fine. You can have this - you might be our friend the persistence actor.
r.sendRefresh(client) r.sendRefresh(client)
case LeaveRequest: case *LeaveRequest:
// We sent them a shutdown already, so unfortunately, we can't close them immediately. We have to wait for // We sent them a shutdown already, so unfortunately, we can't close them immediately. We have to wait for
// them to tell us they've heard that we're shutting down. // them to tell us they've heard that we're shutting down.
msgLogger.Debug("Received leave request from client while shutting down") msgLogger.Debug("Received leave request from client while shutting down")
r.acknowledgeLeave(client) r.acknowledgeLeave(client)
case StopRequest: case *StopRequest:
// Yes. We're doing that. Check your inbox, I already sent you the shutdown. // Yes. We're doing that. Check your inbox, I already sent you the shutdown.
msgLogger.Debug("Received stop request from client while shutting down") msgLogger.Debug("Received stop request from client while shutting down")
case ShutdownResponse: case *ShutdownResponse:
// The only way we would be getting one of these is if the client knows it doesn't have to send us anything // The only way we would be getting one of these is if the client knows it doesn't have to send us anything
// else. Therefore, we can remove them now. // else. Therefore, we can remove them now.
// Similarly, we know that they'll receive the LeaveResponse they need and shut down. // Similarly, we know that they'll receive the LeaveResponse they need and shut down.
@ -261,7 +261,7 @@ func (r *room) requestShutdownFrom(id xid.ID) {
if !ok { if !ok {
r.logger.Error("No such client when requesting shutdown from client", clientField) r.logger.Error("No such client when requesting shutdown from client", clientField)
} }
client.outgoingChannel <- shutdown client.outgoingChannel <- &shutdown
} }
// finalShutdown causes the room to do any final cleanup not involving its clients before stopping. // finalShutdown causes the room to do any final cleanup not involving its clients before stopping.

@ -1,8 +1,8 @@
package room package room
import ( import (
"git.reya.zone/reya/hexmap/server/action"
"github.com/rs/xid" "github.com/rs/xid"
"go.uber.org/zap"
) )
// internalClient is used by the room itself to track information about a client. // internalClient is used by the room itself to track information about a client.
@ -28,8 +28,6 @@ type NewClientOptions struct {
// If RequestStartingState is true, the room will send a copy of the current state as of when the JoinRequest was // If RequestStartingState is true, the room will send a copy of the current state as of when the JoinRequest was
// received in the JoinResponse that will be the first message the Client receives. // received in the JoinResponse that will be the first message the Client receives.
RequestStartingState bool RequestStartingState bool
// If sets,
Logger *zap.Logger
} }
// Client is the structure used by clients external to the Room package to communicate with the Room. // Client is the structure used by clients external to the Room package to communicate with the Room.
@ -91,7 +89,7 @@ func newClientForRoom(roomId xid.ID, outgoingChannel chan<- ClientMessage, opts
outgoingChannel: outgoingChannel, outgoingChannel: outgoingChannel,
shuttingDown: false, shuttingDown: false,
} }
result.outgoingChannel <- JoinRequest{ result.outgoingChannel <- &JoinRequest{
id: result.id, id: result.id,
returnChannel: incomingChannel, returnChannel: incomingChannel,
privateChannel: privateChannel, privateChannel: privateChannel,
@ -111,26 +109,36 @@ func (c *Client) NewClient(opts NewClientOptions) *Client {
} }
// Refresh creates a message which causes the client to request a fresh copy of the state. // Refresh creates a message which causes the client to request a fresh copy of the state.
func (c *Client) Refresh() RefreshRequest { func (c *Client) Refresh() *RefreshRequest {
if c.shuttingDown { if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent") panic("Already started shutting down; no new messages should be sent")
} }
return RefreshRequest{ return &RefreshRequest{
id: c.id, id: c.id,
} }
} }
func (c *Client) Apply(a action.IDed) *ApplyRequest {
if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent")
}
return &ApplyRequest{
id: c.id,
action: a,
}
}
// Leave creates a message which causes the local client to signal that it is shutting down. // Leave creates a message which causes the local client to signal that it is shutting down.
// It is important to Leave to avoid dangling clients having messages sent to nothing. // It is important to Leave to avoid dangling clients having messages sent to nothing.
// After sending Leave, the client must confirm that it has been removed by waiting for a LeaveResponse, accompanied by // After sending Leave, the client must confirm that it has been removed by waiting for a LeaveResponse, accompanied by
// the closing of the Client's IncomingChannel if it was a private channel. // the closing of the Client's IncomingChannel if it was a private channel.
// No further messages should be sent after Leave except AcknowledgeShutdown if Leave and requestShutdown crossed paths in midair. // No further messages should be sent after Leave except AcknowledgeShutdown if Leave and requestShutdown crossed paths in midair.
func (c *Client) Leave() LeaveRequest { func (c *Client) Leave() *LeaveRequest {
if c.shuttingDown { if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent") panic("Already started shutting down; no new messages should be sent")
} }
c.shuttingDown = true c.shuttingDown = true
return LeaveRequest{ return &LeaveRequest{
id: c.id, id: c.id,
} }
} }
@ -140,12 +148,12 @@ func (c *Client) Leave() LeaveRequest {
// After sending Stop, the client must confirm that it has been removed by waiting for a ShutdownRequest, which should // After sending Stop, the client must confirm that it has been removed by waiting for a ShutdownRequest, which should
// be handled normally. // be handled normally.
// No further messages should be sent after Stop except AcknowledgeShutdown. // No further messages should be sent after Stop except AcknowledgeShutdown.
func (c *Client) Stop() StopRequest { func (c *Client) Stop() *StopRequest {
if c.shuttingDown { if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent") panic("Already started shutting down; no new messages should be sent")
} }
c.shuttingDown = true c.shuttingDown = true
return StopRequest{ return &StopRequest{
id: c.id, id: c.id,
} }
} }
@ -153,13 +161,13 @@ func (c *Client) Stop() StopRequest {
// AcknowledgeShutdown causes the local client to signal that it has acknowledged that the room is shutting down. // AcknowledgeShutdown causes the local client to signal that it has acknowledged that the room is shutting down.
// No further messages can be sent after AcknowledgeShutdown; attempting to do so will block forever, as the // No further messages can be sent after AcknowledgeShutdown; attempting to do so will block forever, as the
// OutgoingChannel has become nil. // OutgoingChannel has become nil.
func (c *Client) AcknowledgeShutdown() ShutdownResponse { func (c *Client) AcknowledgeShutdown() *ShutdownResponse {
if c.outgoingChannel == nil { if c.outgoingChannel == nil {
panic("Already finished shutting down; no new messages should be sent") panic("Already finished shutting down; no new messages should be sent")
} }
c.shuttingDown = true c.shuttingDown = true
c.outgoingChannel = nil c.outgoingChannel = nil
return ShutdownResponse{ return &ShutdownResponse{
id: c.id, id: c.id,
} }
} }

@ -1,7 +1,7 @@
package room package room
import ( import (
"git.reya.zone/reya/hexmap/server/websocket" action2 "git.reya.zone/reya/hexmap/server/action"
"github.com/rs/xid" "github.com/rs/xid"
"go.uber.org/zap/zapcore" "go.uber.org/zap/zapcore"
) )
@ -31,7 +31,7 @@ type JoinRequest struct {
wantCurrentState bool wantCurrentState bool
} }
func (j JoinRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (j *JoinRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "JoinRequest") encoder.AddString("type", "JoinRequest")
encoder.AddString("id", j.id.String()) encoder.AddString("id", j.id.String())
encoder.AddBool("broadcast", j.broadcast) encoder.AddBool("broadcast", j.broadcast)
@ -39,7 +39,7 @@ func (j JoinRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil return nil
} }
func (j JoinRequest) ClientID() xid.ID { func (j *JoinRequest) ClientID() xid.ID {
return j.id return j.id
} }
@ -48,30 +48,30 @@ type RefreshRequest struct {
id xid.ID id xid.ID
} }
func (r RefreshRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (r *RefreshRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "RefreshRequest") encoder.AddString("type", "RefreshRequest")
encoder.AddString("id", r.id.String()) encoder.AddString("id", r.id.String())
return nil return nil
} }
func (r RefreshRequest) ClientID() xid.ID { func (r *RefreshRequest) ClientID() xid.ID {
return r.id return r.id
} }
// ApplyRequest is the message sent on the room's IncomingChannel by a client which has received an action from the // ApplyRequest is the message sent on the room's IncomingChannel by a client which has received an action from the
// websocket. // ws.
type ApplyRequest struct { type ApplyRequest struct {
id xid.ID id xid.ID
action websocket.IDed action action2.IDed
} }
func (f ApplyRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (f *ApplyRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "ApplyRequest") encoder.AddString("type", "ApplyRequest")
encoder.AddString("id", f.id.String()) encoder.AddString("id", f.id.String())
return encoder.AddObject("action", f.action) return encoder.AddObject("action", f.action)
} }
func (f ApplyRequest) ClientID() xid.ID { func (f *ApplyRequest) ClientID() xid.ID {
return f.id return f.id
} }
@ -82,13 +82,13 @@ type LeaveRequest struct {
id xid.ID id xid.ID
} }
func (l LeaveRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (l *LeaveRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "LeaveRequest") encoder.AddString("type", "LeaveRequest")
encoder.AddString("id", l.id.String()) encoder.AddString("id", l.id.String())
return nil return nil
} }
func (l LeaveRequest) ClientID() xid.ID { func (l *LeaveRequest) ClientID() xid.ID {
return l.id return l.id
} }
@ -98,13 +98,13 @@ type StopRequest struct {
id xid.ID id xid.ID
} }
func (s StopRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (s *StopRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "StopRequest") encoder.AddString("type", "StopRequest")
encoder.AddString("id", s.id.String()) encoder.AddString("id", s.id.String())
return nil return nil
} }
func (s StopRequest) ClientID() xid.ID { func (s *StopRequest) ClientID() xid.ID {
return s.id return s.id
} }
@ -113,12 +113,12 @@ type ShutdownResponse struct {
id xid.ID id xid.ID
} }
func (s ShutdownResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (s *ShutdownResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "ShutdownResponse") encoder.AddString("type", "ShutdownResponse")
encoder.AddString("id", s.id.String()) encoder.AddString("id", s.id.String())
return nil return nil
} }
func (s ShutdownResponse) ClientID() xid.ID { func (s *ShutdownResponse) ClientID() xid.ID {
return s.id return s.id
} }

@ -22,18 +22,18 @@ type JoinResponse struct {
currentState *state.Synced currentState *state.Synced
} }
func (j JoinResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (j *JoinResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "JoinResponse") encoder.AddString("type", "JoinResponse")
encoder.AddString("id", j.id.String()) encoder.AddString("id", j.id.String())
return encoder.AddObject("currentState", j.currentState) return encoder.AddObject("currentState", j.currentState)
} }
func (j JoinResponse) RoomID() xid.ID { func (j *JoinResponse) RoomID() xid.ID {
return j.id return j.id
} }
// CurrentState returns the state of the room as of when the JoinRequest was processed. // CurrentState returns the state of the room as of when the JoinRequest was processed.
func (j JoinResponse) CurrentState() *state.Synced { func (j *JoinResponse) CurrentState() *state.Synced {
return j.currentState return j.currentState
} }
@ -44,18 +44,18 @@ type RefreshResponse struct {
currentState *state.Synced currentState *state.Synced
} }
func (r RefreshResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (r *RefreshResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "JoinResponse") encoder.AddString("type", "JoinResponse")
encoder.AddString("id", r.id.String()) encoder.AddString("id", r.id.String())
return encoder.AddObject("currentState", r.currentState) return encoder.AddObject("currentState", r.currentState)
} }
func (r RefreshResponse) RoomID() xid.ID { func (r *RefreshResponse) RoomID() xid.ID {
return r.id return r.id
} }
// CurrentState returns the state of the room as of when the RefreshRequest was processed. // CurrentState returns the state of the room as of when the RefreshRequest was processed.
func (r RefreshResponse) CurrentState() *state.Synced { func (r *RefreshResponse) CurrentState() *state.Synced {
return r.currentState return r.currentState
} }
@ -68,7 +68,7 @@ type ApplyResponse struct {
result error result error
} }
func (a ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (a *ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "ApplyResponse") encoder.AddString("type", "ApplyResponse")
encoder.AddString("id", a.id.String()) encoder.AddString("id", a.id.String())
encoder.AddUint32("actionId", a.actionID) encoder.AddUint32("actionId", a.actionID)
@ -79,17 +79,21 @@ func (a ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil return nil
} }
func (a ApplyResponse) RoomID() xid.ID { func (a *ApplyResponse) RoomID() xid.ID {
return a.id return a.id
} }
func (a *ApplyResponse) ActionID() uint32 {
return a.actionID
}
// Success returns true if the action succeeded, false if it failed. // Success returns true if the action succeeded, false if it failed.
func (a ApplyResponse) Success() bool { func (a *ApplyResponse) Success() bool {
return a.result == nil return a.result == nil
} }
// Failure returns the error if the action failed, or nil if it succeeded. // Failure returns the error if the action failed, or nil if it succeeded.
func (a ApplyResponse) Failure() error { func (a *ApplyResponse) Failure() error {
return a.result return a.result
} }
@ -101,10 +105,10 @@ type ActionBroadcast struct {
// originalActionID is the ID that the client that sent the action sent. // originalActionID is the ID that the client that sent the action sent.
originalActionID uint32 originalActionID uint32
// action is the action that succeeded. // action is the action that succeeded.
action action.Action action action.Server
} }
func (a ActionBroadcast) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (a *ActionBroadcast) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "ActionBroadcast") encoder.AddString("type", "ActionBroadcast")
encoder.AddString("id", a.id.String()) encoder.AddString("id", a.id.String())
encoder.AddString("originalClientId", a.originalClientID.String()) encoder.AddString("originalClientId", a.originalClientID.String())
@ -112,22 +116,31 @@ func (a ActionBroadcast) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return encoder.AddObject("action", a.action) return encoder.AddObject("action", a.action)
} }
func (a ActionBroadcast) RoomID() xid.ID { func (a *ActionBroadcast) RoomID() xid.ID {
return a.id return a.id
} }
func (a *ActionBroadcast) OriginalClientID() xid.ID {
return a.originalClientID
}
func (a *ActionBroadcast) OriginalActionID() uint32 {
return a.originalActionID
}
func (a *ActionBroadcast) Action() action.Server {
return a.action
}
// LeaveResponse is the message sent by the room when it has accepted that a client has left, and will send it no further messages. // LeaveResponse is the message sent by the room when it has accepted that a client has left, and will send it no further messages.
type LeaveResponse struct { type LeaveResponse struct {
id xid.ID id xid.ID
} }
func (l LeaveResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (l *LeaveResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "LeaveResponse") encoder.AddString("type", "LeaveResponse")
encoder.AddString("id", l.id.String()) encoder.AddString("id", l.id.String())
return nil return nil
} }
func (l LeaveResponse) RoomID() xid.ID { func (l *LeaveResponse) RoomID() xid.ID {
return l.id return l.id
} }
@ -136,12 +149,12 @@ type ShutdownRequest struct {
id xid.ID id xid.ID
} }
func (s ShutdownRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (s *ShutdownRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "ShutdownRequest") encoder.AddString("type", "ShutdownRequest")
encoder.AddString("id", s.id.String()) encoder.AddString("id", s.id.String())
return nil return nil
} }
func (s ShutdownRequest) RoomID() xid.ID { func (s *ShutdownRequest) RoomID() xid.ID {
return s.id return s.id
} }

@ -119,11 +119,11 @@ func (l HexLayer) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
// GetCellAt returns a reference to the cell at the given coordinates. // GetCellAt returns a reference to the cell at the given coordinates.
// If the coordinates are out of bounds for this map, an error will be returned. // If the coordinates are out of bounds for this map, an error will be returned.
func (l HexLayer) GetCellAt(c StorageCoordinates) (*HexCell, error) { func (l HexLayer) GetCellAt(c StorageCoordinates) (*HexCell, error) {
if int(c.Line) > len(l) { if int(c.Line) >= len(l) {
return nil, fmt.Errorf("line %d out of bounds (%d)", c.Line, len(l)) return nil, fmt.Errorf("line %d out of bounds (%d)", c.Line, len(l))
} }
line := l[c.Line] line := l[c.Line]
if int(c.Cell) > len(line) { if int(c.Cell) >= len(line) {
return nil, fmt.Errorf("cell %d out of bounds (%d)", c.Cell, len(line)) return nil, fmt.Errorf("cell %d out of bounds (%d)", c.Cell, len(line))
} }
return &(line[c.Cell]), nil return &(line[c.Cell]), nil
@ -149,18 +149,43 @@ type HexMap struct {
// This is the number of cells joined together, flat-edge to flat-edge, in each line. // This is the number of cells joined together, flat-edge to flat-edge, in each line.
CellsPerLine uint8 `json:"cellsPerLine"` CellsPerLine uint8 `json:"cellsPerLine"`
// Layout is the orientation and line parity used to display the map. // Layout is the orientation and line parity used to display the map.
Layout Layout `json:"displayMode"` Layout Layout `json:"layout"`
// Layer contains the actual map data. // Layer contains the actual map data.
// Layer itself is a slice with Lines elements, each of which is a line; // Layer itself is a slice with Lines elements, each of which is a line;
// each of those lines is a slice of CellsPerLine cells. // each of those lines is a slice of CellsPerLine cells.
Layer HexLayer `json:"lineCells"` Layer HexLayer `json:"layer"`
}
func NewHexMap(layout Layout, lines uint8, cellsPerLine uint8) *HexMap {
layer := make(HexLayer, lines)
for index := range layer {
line := make(HexLine, cellsPerLine)
for index := range line {
line[index] = HexCell{
Color: Color{
R: 255,
G: 255,
B: 255,
A: 255,
},
}
}
layer[index] = line
}
return &HexMap{
XID: xid.New(),
Layout: layout,
Lines: lines,
CellsPerLine: cellsPerLine,
Layer: layer,
}
} }
func (m HexMap) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (m HexMap) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("id", m.XID.String()) encoder.AddString("id", m.XID.String())
encoder.AddUint8("lines", m.Lines) encoder.AddUint8("lines", m.Lines)
encoder.AddUint8("cellsPerLine", m.CellsPerLine) encoder.AddUint8("cellsPerLine", m.CellsPerLine)
displayModeErr := encoder.AddObject("displayMode", m.Layout) displayModeErr := encoder.AddObject("layout", m.Layout)
lineCellsErr := encoder.AddArray("lineCells", m.Layer) lineCellsErr := encoder.AddArray("lineCells", m.Layer)
if displayModeErr != nil { if displayModeErr != nil {
return displayModeErr return displayModeErr

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

@ -1,37 +0,0 @@
package websocket
import (
"github.com/gorilla/websocket"
"time"
)
const (
// ReadTimeLimit is the maximum time the server is willing to wait after receiving a message before receiving another one.
ReadTimeLimit = 60 * time.Second
// WriteTimeLimit is the maximum time the server is willing to wait to send a message.
WriteTimeLimit = 10 * time.Second
// ControlTimeLimit is the maximum time the server is willing to wait to send a control message like Ping or Close.
ControlTimeLimit = (WriteTimeLimit * 5) / 10
// PingDelay is the time between pings.
// It must be less than ReadTimeLimit to account for latency and delays on either side.
PingDelay = (ReadTimeLimit * 7) / 10
)
// A Connection corresponds to a pair of actors.
type Connection struct {
conn *websocket.Conn
r reader
w writer
}
// ReadChannel returns the channel that can be used to read client messages from the connection.
// After receiving SocketClosed, the reader will close its channel.
func (c *Connection) ReadChannel() <-chan ClientCommand {
return c.r.channel
}
// WriteChannel returns the channel that can be used to send server messages on the connection.
// After sending SocketClosed, the writer will close its channel; do not send any further messages on the channel.
func (c *Connection) WriteChannel() chan<- ServerCommand {
return c.w.channel
}

@ -1,4 +1,4 @@
package websocket package ws
import ( import (
"git.reya.zone/reya/hexmap/server/action" "git.reya.zone/reya/hexmap/server/action"
@ -36,20 +36,7 @@ func (c ClientRefresh) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil return nil
} }
// IDed contains a pair of ID and Action, as sent by the client. type IDPairs []action.IDed
type IDed struct {
// ID contains the arbitrary ID that was sent by the client, for identifying the action in future messages.
ID uint32 `json:"id"`
// Action contains the action that was actually being sent.
Action action.Client `json:"action"`
}
func (i IDed) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddUint32("id", i.ID)
return encoder.AddObject("action", i.Action)
}
type IDPairs []IDed
func (a IDPairs) MarshalLogArray(encoder zapcore.ArrayEncoder) error { func (a IDPairs) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
var finalErr error = nil var finalErr error = nil

@ -1,7 +1,14 @@
package websocket package ws
import (
"git.reya.zone/reya/hexmap/server/action"
"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:
@ -13,13 +20,13 @@ func (x *ClientCommandPB) ToGo() (ClientCommand, error) {
} }
} }
func (x *ClientHelloPB) ToGo() ClientHello { func (x *ClientHelloPB) ToGo() *ClientHello {
return ClientHello{ return &ClientHello{
Version: x.Version, Version: x.Version,
} }
} }
func (c ClientHello) ToClientPB() *ClientCommandPB { func (c *ClientHello) ToClientPB() *ClientCommandPB {
return &ClientCommandPB{ return &ClientCommandPB{
Command: &ClientCommandPB_Hello{ Command: &ClientCommandPB_Hello{
Hello: &ClientHelloPB{ Hello: &ClientHelloPB{
@ -29,11 +36,11 @@ func (c ClientHello) ToClientPB() *ClientCommandPB {
} }
} }
func (*ClientRefreshPB) ToGo() ClientRefresh { func (*ClientRefreshPB) ToGo() *ClientRefresh {
return ClientRefresh{} return &ClientRefresh{}
} }
func (c ClientRefresh) ToClientPB() *ClientCommandPB { func (c *ClientRefresh) ToClientPB() *ClientCommandPB {
return &ClientCommandPB{ return &ClientCommandPB{
Command: &ClientCommandPB_Refresh{ Command: &ClientCommandPB_Refresh{
Refresh: &ClientRefreshPB{}, Refresh: &ClientRefreshPB{},
@ -41,42 +48,38 @@ func (c ClientRefresh) ToClientPB() *ClientCommandPB {
} }
} }
func (x *ClientActPB_IDed) ToGo() (IDed, error) { func (x *ClientActPB_IDed) ToGo() (action.IDed, error) {
action, err := x.Action.ToGo() act, err := x.Action.ToGo()
if err != nil { if err != nil {
return IDed{}, nil return action.IDed{}, nil
} }
return IDed{ return action.IDed{
ID: x.Id, ID: x.Id,
Action: action, Action: act,
}, nil }, nil
} }
func (i IDed) ToPB() *ClientActPB_IDed { func (x *ClientActPB) ToGo() (*ClientAct, error) {
return &ClientActPB_IDed{
Id: i.ID,
Action: i.Action.ToClientPB(),
}
}
func (x *ClientActPB) ToGo() (ClientAct, error) {
actions := make(IDPairs, len(x.Actions)) actions := make(IDPairs, len(x.Actions))
for index, ided := range x.Actions { for index, ided := range x.Actions {
action, err := ided.ToGo() action, err := ided.ToGo()
if err != nil { if err != nil {
return ClientAct{}, err return nil, err
} }
actions[index] = action actions[index] = action
} }
return ClientAct{ return &ClientAct{
Actions: actions, Actions: actions,
}, nil }, nil
} }
func (c ClientAct) ToClientPB() *ClientCommandPB { func (c *ClientAct) ToClientPB() *ClientCommandPB {
actions := make([]*ClientActPB_IDed, len(c.Actions)) actions := make([]*ClientActPB_IDed, len(c.Actions))
for index, ided := range c.Actions { for index, ided := range c.Actions {
actions[index] = ided.ToPB() actions[index] = &ClientActPB_IDed{
Id: ided.ID,
Action: ided.Action.ToClientPB(),
}
} }
return &ClientCommandPB{ return &ClientCommandPB{
Command: &ClientCommandPB_Act{ Command: &ClientCommandPB_Act{
@ -87,10 +90,10 @@ func (c ClientAct) ToClientPB() *ClientCommandPB {
} }
} }
func (ClientMalformed) ToClientPB() *ClientCommandPB { func (*ClientMalformed) ToClientPB() *ClientCommandPB {
return nil return nil
} }
func (SocketClosed) ToClientPB() *ClientCommandPB { func (*SocketClosed) ToClientPB() *ClientCommandPB {
return nil return nil
} }

@ -0,0 +1,276 @@
package ws
import (
"git.reya.zone/reya/hexmap/server/action"
"git.reya.zone/reya/hexmap/server/room"
"github.com/gorilla/websocket"
"go.uber.org/zap"
"net/http"
"time"
)
const (
// ReadTimeLimit is the maximum time the server is willing to wait after receiving a message before receiving another one.
ReadTimeLimit = 60 * time.Second
// WriteTimeLimit is the maximum time the server is willing to wait to send a message.
WriteTimeLimit = 10 * time.Second
// ControlTimeLimit is the maximum time the server is willing to wait to send a control message like Ping or Close.
ControlTimeLimit = (WriteTimeLimit * 5) / 10
// PingDelay is the time between pings.
// It must be less than ReadTimeLimit to account for latency and delays on either side.
PingDelay = (ReadTimeLimit * 7) / 10
)
type HTTPHandler struct {
Upgrader websocket.Upgrader
Logger *zap.Logger
Room *room.Client
}
func destroyBadProtocolSocket(c *websocket.Conn, logger *zap.Logger) {
err := c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseProtocolError, "Invalid subprotocols"), time.Now().Add(ControlTimeLimit))
if err != nil {
logger.Error("Failed to write close message")
}
err = c.SetReadDeadline(time.Now().Add(ControlTimeLimit))
if err != nil {
logger.Error("Failed to set read deadline")
}
for {
_, _, err := c.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseProtocolError) {
logger.Error("Websocket connection shut down ignominiously", zap.Error(err))
}
return
}
}
}
func (h *HTTPHandler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) {
c, err := h.Upgrader.Upgrade(responseWriter, request, http.Header{})
if err != nil {
h.Logger.Error("Failed to upgrade ws connection", zap.Error(err))
return
}
if c.Subprotocol() == "" {
h.Logger.Error("No matching subprotocol", zap.String("clientProtocols", request.Header.Get("Sec-Websocket-Protocol")))
go destroyBadProtocolSocket(c, h.Logger)
return
}
result := NewConnection(c, h.Logger.Named("Connection"))
exchange(result, h.Logger.Named("Link"), func(o room.NewClientOptions) *room.Client {
return h.Room.NewClient(o)
})
}
func exchange(c *Connection, l *zap.Logger, clientMaker func(options room.NewClientOptions) *room.Client) {
wsr := c.ReadChannel()
wsw := c.WriteChannel()
l.Info("Connection established")
closeWith := &SocketClosed{
Code: websocket.CloseAbnormalClosure,
Text: "I don't know what happened. But goodbye!",
}
defer func() {
l.Info("Shutting down")
// Wait for the websocket connection to shut down.
wsw <- closeWith
if wsr != nil {
for {
msg := <-wsr
switch msg.(type) {
case *SocketClosed:
return
}
}
}
}()
// State 1: Waiting for a hello.
// Anything else is death.
cmd := <-wsr
switch typedCmd := cmd.(type) {
case *ClientHello:
if typedCmd.Version != ProtocolVersion {
l.Warn("Bad Hello version")
// Disgusting. I can't even look at you.
closeWith = &SocketClosed{
Code: websocket.CloseProtocolError,
Text: "Wrong protocol version",
}
return
}
l.Info("Got Hello")
default:
l.Warn("Got NON-hello")
closeWith = &SocketClosed{
Code: websocket.CloseProtocolError,
Text: "You don't even say hello?",
}
return
}
l.Info("Waiting for room.")
// State 2: Waiting for the room to notice us.
rm := clientMaker(room.NewClientOptions{
AcceptBroadcasts: true,
RequestStartingState: true,
})
rmr := rm.IncomingChannel()
rmw := rm.OutgoingChannel()
var leaveWith room.ClientMessage = nil
defer func() {
l.Info("Leaving room")
if leaveWith == nil {
leaveWith = rm.Leave()
}
rmw <- leaveWith
if _, ok := leaveWith.(*room.ShutdownResponse); ok {
// The room was already shutting down.
return
}
for {
msg := <-rmr
switch msg.(type) {
case *room.LeaveResponse:
return
case *room.ShutdownRequest:
rmw <- rm.AcknowledgeShutdown()
return
}
}
}()
l.Info("Waiting for JoinResponse")
msg := <-rmr
switch typedMsg := msg.(type) {
case *room.JoinResponse:
l.Info("Got JoinResponse")
wsw <- &ServerHello{
Version: ProtocolVersion,
State: typedMsg.CurrentState(),
}
case *room.ShutdownRequest:
l.Info("Got ShutdownRequest")
// Room was shutting down when we joined, oops!
closeWith = &SocketClosed{
Code: websocket.CloseGoingAway,
Text: "Shutting down right as you joined. Sorry!",
}
return
default:
l.Info("Got non-JoinResponse/ShutdownRequest")
// Uh. That's concerning. We don't have anything to send our client.
// Let's just give up.
return
}
l.Info("Waiting for messages")
for {
select {
case cmd := <-wsr:
switch typedCmd := cmd.(type) {
case *ClientHello:
l.Info("Got unnecessary ClientHello")
// Huh???
closeWith = &SocketClosed{
Code: websocket.CloseProtocolError,
Text: "Enough hellos. Goodbye.",
}
return
case *ClientRefresh:
l.Info("Got ClientRefresh")
rmw <- rm.Refresh()
case *ClientAct:
l.Info("Got ClientAct")
for _, act := range typedCmd.Actions {
rmw <- rm.Apply(act)
}
case *SocketClosed:
l.Info("Got SocketClosed", zap.Object("close", typedCmd))
closeWith = typedCmd
return
case *ClientMalformed:
l.Warn("Got ClientMalformed")
return
}
case msg := <-rmr:
switch typedMsg := msg.(type) {
case *room.JoinResponse:
// Huh????
l.Info("Got unnecesary JoinResponse")
return
case *room.RefreshResponse:
l.Info("Got RefreshResponse")
wsw <- &ServerRefresh{
State: typedMsg.CurrentState(),
}
case *room.ApplyResponse:
l.Info("Got ApplyResponse")
if typedMsg.Success() {
wsw <- &ServerOK{
IDs: []uint32{typedMsg.ActionID()},
}
} else {
wsw <- &ServerFailed{
IDs: []uint32{typedMsg.ActionID()},
Error: typedMsg.Failure().Error(),
}
}
case *room.ActionBroadcast:
l.Info("Got ActionBroadcast")
wsw <- &ServerAct{
Actions: action.ServerSlice{typedMsg.Action()},
}
case *room.LeaveResponse:
l.Info("Got odd LeaveResponse")
// Oh. u_u I wasn't- okay.
return
case *room.ShutdownRequest:
// Oh. Oh! Okay! Sorry!
l.Info("Got ShutdownRequest")
leaveWith = rm.AcknowledgeShutdown()
return
}
}
}
}
// A Connection corresponds to a pair of actors.
type Connection struct {
r reader
w writer
}
func NewConnection(conn *websocket.Conn, logger *zap.Logger) *Connection {
readChan := make(chan time.Time)
out := &Connection{
r: reader{
conn: conn,
channel: make(chan ClientCommand),
readNotifications: readChan,
logger: logger.Named("reader"),
},
w: writer{
conn: conn,
channel: make(chan ServerCommand),
readNotifications: readChan,
timer: nil,
nextPingAt: time.Time{},
logger: logger.Named("writer"),
},
}
go out.r.act()
go out.w.act()
return out
}
// ReadChannel returns the channel that can be used to read client messages from the connection.
// After receiving SocketClosed, the reader will close its channel.
func (c *Connection) ReadChannel() <-chan ClientCommand {
return c.r.channel
}
// WriteChannel returns the channel that can be used to send server messages on the connection.
// After sending SocketClosed, the writer will close its channel; do not send any further messages on the channel.
func (c *Connection) WriteChannel() chan<- ServerCommand {
return c.w.channel
}

@ -1,4 +1,4 @@
package websocket package ws
import ( import (
"fmt" "fmt"
@ -33,6 +33,11 @@ func (r *reader) act() {
defer r.shutdown() defer r.shutdown()
// Our first deadline starts immediately, so let the writer know. // Our first deadline starts immediately, so let the writer know.
r.updateDeadlines() r.updateDeadlines()
// The reader should not automatically respond with close messages.
// It should simply let the close message be returned as an error.
r.conn.SetCloseHandler(func(code int, text string) error {
return nil
})
r.conn.SetPongHandler(func(appData string) error { r.conn.SetPongHandler(func(appData string) error {
r.logger.Debug("Received pong, extending read deadline") r.logger.Debug("Received pong, extending read deadline")
r.updateDeadlines() r.updateDeadlines()
@ -44,18 +49,18 @@ func (r *reader) act() {
for { for {
messageType, messageData, err := r.conn.ReadMessage() messageType, messageData, err := r.conn.ReadMessage()
if err != nil { if err != nil {
var closure SocketClosed var closure *SocketClosed
if websocket.IsCloseError(err, StandardClientCloseTypes...) { if websocket.IsCloseError(err, StandardClientCloseTypes...) {
typedErr := err.(*websocket.CloseError) typedErr := err.(*websocket.CloseError)
r.logger.Debug("Received normal close message, shutting down", zap.Int("code", typedErr.Code), zap.String("text", typedErr.Text)) r.logger.Debug("Received normal close message, shutting down", zap.Int("code", typedErr.Code), zap.String("text", typedErr.Text))
closure = SocketClosed{Code: typedErr.Code, Text: typedErr.Text} closure = &SocketClosed{Code: typedErr.Code, Text: typedErr.Text}
} else if websocket.IsUnexpectedCloseError(err, StandardClientCloseTypes...) { } else if websocket.IsUnexpectedCloseError(err, StandardClientCloseTypes...) {
typedErr := err.(*websocket.CloseError) typedErr := err.(*websocket.CloseError)
r.logger.Warn("Received unexpected close message, shutting down", zap.Int("code", typedErr.Code), zap.String("text", typedErr.Text)) r.logger.Warn("Received unexpected close message, shutting down", zap.Int("code", typedErr.Code), zap.String("text", typedErr.Text))
closure = SocketClosed{Code: typedErr.Code, Text: typedErr.Text} closure = &SocketClosed{Code: typedErr.Code, Text: typedErr.Text}
} else { } else {
r.logger.Error("Error while reading message, shutting down", zap.Error(err)) r.logger.Error("Error while reading message, shutting down", zap.Error(err))
closure = SocketClosed{Error: err} closure = &SocketClosed{Error: err}
} }
r.logger.Debug("Sending close message to reader", zap.Object("closeMessage", closure)) r.logger.Debug("Sending close message to reader", zap.Object("closeMessage", closure))
r.channel <- closure r.channel <- closure
@ -75,7 +80,7 @@ func (r *reader) parseCommand(socketType int, data []byte) ClientCommand {
MessageType: socketType, MessageType: socketType,
} }
r.logger.Error("Received command with unknown WebSocket message type", zap.Error(err)) r.logger.Error("Received command with unknown WebSocket message type", zap.Error(err))
return ClientMalformed{ return &ClientMalformed{
Error: err, Error: err,
} }
} }
@ -83,13 +88,13 @@ func (r *reader) parseCommand(socketType int, data []byte) ClientCommand {
var cmdPb ClientCommandPB var cmdPb ClientCommandPB
err := proto.Unmarshal(data, &cmdPb) err := proto.Unmarshal(data, &cmdPb)
if err != nil { if err != nil {
return ClientMalformed{ return &ClientMalformed{
Error: err, Error: err,
} }
} }
cmd, err := (&cmdPb).ToGo() cmd, err := (&cmdPb).ToGo()
if err != nil { if err != nil {
return ClientMalformed{Error: err} return &ClientMalformed{Error: err}
} }
return cmd return cmd
} }

@ -1,4 +1,4 @@
package websocket package ws
import ( import (
"git.reya.zone/reya/hexmap/server/action" "git.reya.zone/reya/hexmap/server/action"

@ -1,4 +1,4 @@
package websocket package ws
import "git.reya.zone/reya/hexmap/server/action" import "git.reya.zone/reya/hexmap/server/action"
@ -19,18 +19,18 @@ func (x *ServerCommandPB) ToGo() (ServerCommand, error) {
} }
} }
func (x *ServerHelloPB) ToGo() (ServerHello, error) { func (x *ServerHelloPB) ToGo() (*ServerHello, error) {
state, err := x.State.ToGo() state, err := x.State.ToGo()
if err != nil { if err != nil {
return ServerHello{}, err return nil, err
} }
return ServerHello{ return &ServerHello{
Version: x.Version, Version: x.Version,
State: &state, State: &state,
}, nil }, nil
} }
func (s ServerHello) ToServerPB() *ServerCommandPB { func (s *ServerHello) ToServerPB() *ServerCommandPB {
return &ServerCommandPB{ return &ServerCommandPB{
Command: &ServerCommandPB_Hello{ Command: &ServerCommandPB_Hello{
Hello: &ServerHelloPB{ Hello: &ServerHelloPB{
@ -41,17 +41,17 @@ func (s ServerHello) ToServerPB() *ServerCommandPB {
} }
} }
func (x *ServerRefreshPB) ToGo() (ServerRefresh, error) { func (x *ServerRefreshPB) ToGo() (*ServerRefresh, error) {
state, err := x.State.ToGo() state, err := x.State.ToGo()
if err != nil { if err != nil {
return ServerRefresh{}, err return nil, err
} }
return ServerRefresh{ return &ServerRefresh{
State: &state, State: &state,
}, nil }, nil
} }
func (s ServerRefresh) ToServerPB() *ServerCommandPB { func (s *ServerRefresh) ToServerPB() *ServerCommandPB {
return &ServerCommandPB{ return &ServerCommandPB{
Command: &ServerCommandPB_Refresh{ Command: &ServerCommandPB_Refresh{
Refresh: &ServerRefreshPB{ Refresh: &ServerRefreshPB{
@ -61,15 +61,15 @@ func (s ServerRefresh) ToServerPB() *ServerCommandPB {
} }
} }
func (x *ServerOKPB) ToGo() ServerOK { func (x *ServerOKPB) ToGo() *ServerOK {
ids := make(IDSlice, len(x.Ids)) ids := make(IDSlice, len(x.Ids))
copy(ids, x.Ids) copy(ids, x.Ids)
return ServerOK{ return &ServerOK{
IDs: ids, IDs: ids,
} }
} }
func (s ServerOK) ToServerPB() *ServerCommandPB { func (s *ServerOK) ToServerPB() *ServerCommandPB {
ids := make([]uint32, len(s.IDs)) ids := make([]uint32, len(s.IDs))
copy(ids, s.IDs) copy(ids, s.IDs)
return &ServerCommandPB{ return &ServerCommandPB{
@ -81,16 +81,16 @@ func (s ServerOK) ToServerPB() *ServerCommandPB {
} }
} }
func (x *ServerFailedPB) ToGo() ServerFailed { func (x *ServerFailedPB) ToGo() *ServerFailed {
ids := make(IDSlice, len(x.Ids)) ids := make(IDSlice, len(x.Ids))
copy(ids, x.Ids) copy(ids, x.Ids)
return ServerFailed{ return &ServerFailed{
IDs: ids, IDs: ids,
Error: x.Error, Error: x.Error,
} }
} }
func (s ServerFailed) ToServerPB() *ServerCommandPB { func (s *ServerFailed) ToServerPB() *ServerCommandPB {
ids := make([]uint32, len(s.IDs)) ids := make([]uint32, len(s.IDs))
copy(ids, s.IDs) copy(ids, s.IDs)
return &ServerCommandPB{ return &ServerCommandPB{
@ -103,21 +103,21 @@ func (s ServerFailed) ToServerPB() *ServerCommandPB {
} }
} }
func (x *ServerActPB) ToGo() (ServerAct, error) { func (x *ServerActPB) ToGo() (*ServerAct, error) {
actions := make(action.ServerSlice, len(x.Actions)) actions := make(action.ServerSlice, len(x.Actions))
for index, act := range x.Actions { for index, act := range x.Actions {
convertedAct, err := act.ToGo() convertedAct, err := act.ToGo()
if err != nil { if err != nil {
return ServerAct{}, err return nil, err
} }
actions[index] = convertedAct actions[index] = convertedAct
} }
return ServerAct{ return &ServerAct{
Actions: actions, Actions: actions,
}, nil }, nil
} }
func (s ServerAct) ToServerPB() *ServerCommandPB { func (s *ServerAct) ToServerPB() *ServerCommandPB {
actions := make([]*action.ServerActionPB, len(s.Actions)) actions := make([]*action.ServerActionPB, len(s.Actions))
for index, act := range s.Actions { for index, act := range s.Actions {
actions[index] = act.ToServerPB() actions[index] = act.ToServerPB()
@ -131,6 +131,6 @@ func (s ServerAct) ToServerPB() *ServerCommandPB {
} }
} }
func (SocketClosed) ToServerPB() *ServerCommandPB { func (*SocketClosed) ToServerPB() *ServerCommandPB {
return nil return nil
} }

@ -1,4 +1,4 @@
package websocket package ws
import ( import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@ -6,9 +6,8 @@ import (
) )
const ( const (
PingData string = "are you still there?" PingData string = "are you still there?"
ProtocolVersion uint32 = 1
GoodbyeType = "GOODBYE"
) )
var StandardClientCloseTypes = []int{websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure} var StandardClientCloseTypes = []int{websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure}
@ -28,15 +27,11 @@ type SocketClosed struct {
} }
func (c SocketClosed) MarshalLogObject(encoder zapcore.ObjectEncoder) error { func (c SocketClosed) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", GoodbyeType) encoder.AddString("type", "GOODBYE")
encoder.AddInt16("code", int16(c.Code)) encoder.AddInt("code", c.Code)
encoder.AddString("text", c.Text) encoder.AddString("text", c.Text)
if c.Error != nil { if c.Error != nil {
encoder.AddString("error", c.Error.Error()) encoder.AddString("error", c.Error.Error())
} }
return nil return nil
} }
func (c SocketClosed) ServerType() ServerMessageType {
return GoodbyeType
}

@ -1,4 +1,4 @@
package websocket package ws
import ( import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@ -9,7 +9,7 @@ import (
) )
type writer struct { type writer struct {
// conn is the websocket connection that this writer is responsible for writing on. // conn is the ws connection that this writer is responsible for writing on.
conn *websocket.Conn conn *websocket.Conn
// channel is the channel used to receive server messages to be sent to the client. // channel is the channel used to receive server messages to be sent to the client.
// When it receives a SocketClosed, the process on the sending end promises not to send any further messages, as the writer will close it right after. // When it receives a SocketClosed, the process on the sending end promises not to send any further messages, as the writer will close it right after.
@ -66,7 +66,7 @@ func (w *writer) act() {
} }
case raw := <-w.channel: case raw := <-w.channel:
switch msg := raw.(type) { switch msg := raw.(type) {
case SocketClosed: case *SocketClosed:
w.logger.Debug("Received close message, forwarding and shutting down", zap.Object("msg", msg)) w.logger.Debug("Received close message, forwarding and shutting down", zap.Object("msg", msg))
w.sendClose(msg) w.sendClose(msg)
// bye bye, we'll graceful shutdown because we deferred it // bye bye, we'll graceful shutdown because we deferred it
@ -106,7 +106,7 @@ func (w *writer) send(msg ServerCommand) {
w.logger.Error("Error while setting write deadline", zap.Time("writeDeadline", writeDeadline), zap.Object("msg", msg), zap.Error(err)) w.logger.Error("Error while setting write deadline", zap.Time("writeDeadline", writeDeadline), zap.Object("msg", msg), zap.Error(err))
} }
w.logger.Debug("Opening message writer to send command", zap.Object("msg", msg)) w.logger.Debug("Opening message writer to send command", zap.Object("msg", msg))
writer, err := w.conn.NextWriter(websocket.TextMessage) writer, err := w.conn.NextWriter(websocket.BinaryMessage)
if err != nil { if err != nil {
w.logger.Error("Error while getting writer from connection", zap.Error(err)) w.logger.Error("Error while getting writer from connection", zap.Error(err))
return return
@ -127,9 +127,9 @@ func (w *writer) send(msg ServerCommand) {
// Deferred close happens now // Deferred close happens now
} }
// sendClose sends a close message on the websocket connection, but does not actually close the connection. // sendClose sends a close message on the ws connection, but does not actually close the connection.
// It does, however, close the incoming message channel to the writer. // It does, however, close the incoming message channel to the writer.
func (w *writer) sendClose(msg SocketClosed) { func (w *writer) sendClose(msg *SocketClosed) {
w.logger.Debug("Shutting down the writer channel") w.logger.Debug("Shutting down the writer channel")
close(w.channel) close(w.channel)
w.channel = nil w.channel = nil
@ -140,7 +140,7 @@ func (w *writer) sendClose(msg SocketClosed) {
} }
} }
// sendPing sends a ping message on the websocket connection. The content is arbitrary. // sendPing sends a ping message on the ws connection. The content is arbitrary.
func (w *writer) sendPing() { func (w *writer) sendPing() {
w.logger.Debug("Sending ping") w.logger.Debug("Sending ping")
err := w.conn.WriteControl(websocket.PingMessage, []byte(PingData), time.Now().Add(ControlTimeLimit)) err := w.conn.WriteControl(websocket.PingMessage, []byte(PingData), time.Now().Add(ControlTimeLimit))
@ -176,7 +176,7 @@ func (w *writer) gracefulShutdown() {
} }
case raw := <-w.channel: case raw := <-w.channel:
switch msg := raw.(type) { switch msg := raw.(type) {
case SocketClosed: case *SocketClosed:
w.logger.Debug("Received close message from channel while shutting down, forwarding", zap.Object("msg", msg)) w.logger.Debug("Received close message from channel while shutting down, forwarding", zap.Object("msg", msg))
w.sendClose(msg) w.sendClose(msg)
default: default:
Loading…
Cancel
Save