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-color": "^3.0.5",
"@types/react-dom": "^17.0.8",
"base64-arraybuffer": "^0.2.0",
"protobufjs": "^6.11.2",
"react": "^17.0.2",
"react-color": "^2.19.3",
"react-dom": "^17.0.2",
@ -5209,6 +5211,14 @@
"node": ">=0.10.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz",
"integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ==",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -25666,6 +25676,11 @@
}
}
},
"base64-arraybuffer": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.2.0.tgz",
"integrity": "sha512-7emyCsu1/xiBXgQZrscw/8KPRT44I4Yq9Pe6EGs3aPRTsWuggML1/1DTuZUuIaJPIm1FTDUVXl4x/yW8s0kQDQ=="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",

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

@ -61,9 +61,9 @@ body {
.colorPickerBackground {
fill: lightslategray;
}
.consoleConnector {
.connectionState {
position: absolute;
text-shadow: white 0 0 5px;
text-shadow: white 0 0 5px, white 0 1px 5px, white 0 -1px 5px, white 1px 0 5px, white -1px 0 5px;
color: black;
top: 0;
right: 0;

@ -1,43 +1,44 @@
import React, {useMemo, useReducer} from 'react';
import './App.css';
import {DispatchContext} from './ui/context/DispatchContext';
import {appStateReducer, AppStateReducer} from "./reducers/AppStateReducer";
import {USER_ACTIVE_COLOR} from "./actions/UserAction";
import {Dispatch, useMemo, useReducer} from "react";
import {appStateReducer} from "./reducers/AppStateReducer";
import {AppState} from "./state/AppState";
import {ServerConnectionState} from "./state/NetworkState";
import HexMapRenderer from "./ui/HexMapRenderer";
import HexColorPicker from "./ui/HexColorPicker";
import {sizeFromLinesAndCells} from "./state/Coordinates";
import {AppAction} from "./actions/AppAction";
import {USER_ACTIVE_COLOR} from "./actions/UserAction";
import HexColorPicker from "./ui/HexColorPicker";
import HexMapRenderer from "./ui/HexMapRenderer";
import {DispatchContext} from "./ui/context/DispatchContext";
import "./App.css";
import {WebsocketReactAdapter} from "./websocket/WebsocketReactAdapter";
function App() {
const [state, dispatch] = useReducer<AppStateReducer, null>(
appStateReducer,
null,
() => ({
localState: null,
network: {
serverState: null,
connectionState: ServerConnectionState.OFFLINE,
specialMessage: null,
nextID: 0,
sentActions: [],
pendingActions: [],
goodbyeCode: 1000,
goodbyeReason: "",
autoReconnectAt: null,
reconnectAttempts: null
}
}));
const defaultState: AppState = {
localState: null,
network: {
serverState: null,
connectionState: ServerConnectionState.OFFLINE,
specialMessage: null,
nextID: 0,
sentActions: [],
pendingActions: [],
goodbyeCode: 1000,
goodbyeReason: "",
autoReconnectAt: null,
reconnectAttempts: null
},
};
const [state, dispatch]: [AppState, Dispatch<AppAction>] = useReducer(appStateReducer, defaultState, undefined);
const {user, map} = state.localState || {user: null, map: null}
const offsets = useMemo(() => (!!map ? {
top: 10,
left: 10,
size: 50,
displayMode: map.displayMode
layout: map.layout
} : null), [map])
const mapElement = !!offsets && !!map ? <HexMapRenderer map={map} offsets={offsets} /> : null;
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]);
const colorPickerElement = !!user ? <HexColorPicker color={user?.activeColor} onChangeComplete={(colorResult) => dispatch({
type: USER_ACTIVE_COLOR,
@ -47,6 +48,7 @@ function App() {
return (
<div className="App">
<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={"centerBox"}>
<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
}
export const CLIENT_GOODBYE = "CLIENT_GOODBYE"
/** Synthesized when the client wants to close the connection. */
export interface ClientGoodbyeAction extends BaseAction {
readonly type: typeof CLIENT_GOODBYE
readonly code: number,
readonly reason: string,
}
export function isClientGoodbyeCommand(action: AppAction): action is ClientGoodbyeAction {
return action.type === CLIENT_GOODBYE
}
export type SendableAction = CellColorAction | UserActiveColorAction
export function isSendableAction(action: AppAction): action is SendableAction {
return isCellColorAction(action) || isUserActiveColorAction(action)
@ -58,7 +69,7 @@ export function isClientCommand(action: AppAction): action is ClientCommand {
return isClientHelloCommand(action) || isClientRefreshCommand(action) || isClientActCommand(action)
}
export type ClientAction = ClientCommand | ClientPendingAction
export type ClientAction = ClientCommand | ClientPendingAction | ClientGoodbyeAction
export function isClientAction(action: AppAction): action is ClientAction {
return isClientCommand(action) || isClientPendingAction(action)
return isClientCommand(action) || isClientPendingAction(action) || isClientGoodbyeCommand(action)
}

@ -18,7 +18,7 @@ export function isServerHelloCommand(action: AppAction): action is ServerHelloCo
export const SERVER_GOODBYE = "SERVER_GOODBYE"
/** Synthesized out of the close message when the server closes the connection. */
export interface ServerGoodbyeCommand extends BaseAction {
export interface ServerGoodbyeAction extends BaseAction {
readonly type: typeof SERVER_GOODBYE
/** The exit code sent with the close message by the server. */
readonly code: number
@ -27,7 +27,7 @@ export interface ServerGoodbyeCommand extends BaseAction {
/** The current time when this close message was received. */
readonly currentTime: Date
}
export function isServerGoodbyeCommand(action: AppAction): action is ServerGoodbyeCommand {
export function isServerGoodbyeAction(action: AppAction): action is ServerGoodbyeAction {
return action.type === SERVER_GOODBYE
}
@ -75,7 +75,7 @@ export enum SocketState {
OPEN = "OPEN",
}
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 {
readonly type: typeof SERVER_SOCKET_STARTUP
readonly state: SocketState
@ -94,18 +94,28 @@ export function isServerActCommand(action: AppAction): action is ServerActComman
return action.type === SERVER_ACT
}
export const SERVER_MALFORMED = "SERVER_MALFORMED"
/** Synthesized when the client can't understand a command the server sent, or when an error event appears on the 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 ServerCommand =
ServerHelloCommand | ServerGoodbyeCommand | ServerRefreshCommand |
ServerHelloCommand | ServerRefreshCommand |
ServerOKCommand | ServerFailedCommand | ServerActCommand
export function isServerCommand(action: AppAction): action is ServerCommand {
return isServerHelloCommand(action) || isServerGoodbyeCommand(action) || isServerRefreshCommand(action)
return isServerHelloCommand(action) || isServerRefreshCommand(action)
|| isServerOKCommand(action) || isServerFailedCommand(action) || isServerActCommand(action)
}
export type ServerAction = ServerCommand | ServerSocketStartupAction
export type ServerAction = ServerCommand | ServerSocketStartupAction | ServerMalformedAction | ServerGoodbyeAction
export function isServerAction(action: AppAction): action is ServerAction {
return isServerCommand(action) || isServerSocketStartupAction(action)
return isServerCommand(action) || isServerSocketStartupAction(action) || isServerMalformedAction(action) || isServerGoodbyeAction(action)
}

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

@ -1,4 +1,11 @@
import {CLIENT_HELLO, CLIENT_PENDING, CLIENT_REFRESH, CLIENT_ACT, ClientAction} from "../actions/ClientAction";
import {
CLIENT_ACT,
CLIENT_GOODBYE,
CLIENT_HELLO,
CLIENT_PENDING,
CLIENT_REFRESH,
ClientAction
} from "../actions/ClientAction";
import {NetworkState, ServerConnectionState} from "../state/NetworkState";
// TODO: Verify that only one special message exists at a time.
@ -34,5 +41,11 @@ export function clientReducer(oldState: NetworkState, action: ClientAction): Net
sentActions: [...oldState.sentActions, ...action.actions],
pendingActions: oldState.pendingActions.slice(action.actions.length),
}
case CLIENT_GOODBYE:
return {
...oldState,
specialMessage: action,
connectionState: ServerConnectionState.AWAITING_GOODBYE,
}
}
}

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

@ -8,19 +8,23 @@ import {
SERVER_FAILED,
SERVER_GOODBYE,
SERVER_HELLO,
SERVER_MALFORMED,
SERVER_OK,
SERVER_REFRESH,
SERVER_SOCKET_STARTUP, ServerActCommand,
ServerAction, ServerFailedCommand,
ServerGoodbyeCommand,
SERVER_SOCKET_STARTUP,
ServerActCommand,
ServerAction,
ServerFailedCommand,
ServerGoodbyeAction,
ServerHelloCommand,
ServerMalformedAction,
ServerOKCommand,
ServerRefreshCommand,
ServerSocketStartupAction,
SocketState,
SyncableAction
} from "../actions/ServerAction";
import {CLIENT_HELLO, SendableAction} from "../actions/ClientAction";
import {CLIENT_GOODBYE, CLIENT_HELLO, SendableAction} from "../actions/ClientAction";
interface StateRecalculationInputs {
/** The original server state before the actions changed. The base on which the actions are all applied. */
@ -109,7 +113,7 @@ function serverRefreshReducer(oldState: AppState, action: ServerRefreshCommand):
};
}
function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeCommand): NetworkState {
function serverGoodbyeReducer(oldState: NetworkState, action: ServerGoodbyeAction): NetworkState {
// TODO: Sort out the correct state and autoReconnectAt based on the time in the action.
return {
...oldState,
@ -234,6 +238,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 {
// TODO: Verify that these messages are only received at the proper times and in the proper states.
// e.g., BeginConnecting should only happen when the state is somewhere in the disconnected region.
@ -260,5 +273,10 @@ export function serverReducer(oldState: AppState, action: ServerAction): AppStat
}
case SERVER_ACT:
return serverActReducer(oldState, action)
case SERVER_MALFORMED:
return {
...oldState,
network: serverMalformedReducer(oldState.network, action)
}
}
}

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

@ -1,4 +1,4 @@
import {HexagonOrientation, HexMapRepresentation, LineParity} from "./HexMap";
import {HexagonOrientation, HexLayout, LineParity} from "./HexMap";
/** Staggered (storage) coordinates for accessing cell storage. */
export interface StorageCoordinates {
@ -54,7 +54,7 @@ export interface RenderOffsets {
/** How big each hexagon should be. The "radius" from the center to any vertex. */
readonly size: number
/** The way the hex map should be displayed. Usually the same as the origin map. */
readonly displayMode: HexMapRepresentation
readonly layout: HexLayout
}
export interface RenderSize {
@ -66,7 +66,7 @@ export interface RenderSize {
const POINTY_AXIS_FACTOR = 2;
const FLAT_AXIS_FACTOR = Math.sqrt(3);
export function storageCoordinatesToRenderCoordinates({line, cell}: StorageCoordinates, renderOffsets: RenderOffsets): RenderCoordinates {
const {orientation, indentedLines} = renderOffsets.displayMode;
const {orientation, indentedLines} = renderOffsets.layout;
const flatLength = renderOffsets.size * FLAT_AXIS_FACTOR;
const pointyLength = renderOffsets.size * POINTY_AXIS_FACTOR;
@ -105,7 +105,7 @@ export function sizeFromLinesAndCells({offsets, lines, cells, rightMargin = 0, b
const {
top: topMargin,
left: leftMargin,
displayMode: {
layout: {
orientation,
indentedLines
}
@ -118,12 +118,12 @@ export function sizeFromLinesAndCells({offsets, lines, cells, rightMargin = 0, b
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.
const pointyLength = pointyMargins + ((cells - 1) * pointyCellLength * 3 / 4) + pointyCellLength;
// Every cell will be one full cell length apart in the flat direction;
const pointyLength = pointyMargins + ((lines - 1) * pointyCellLength * 3 / 4) + pointyCellLength;
// 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.
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 }
}

@ -36,7 +36,7 @@ export enum LineParity {
}
/** The type of map this map is. */
export interface HexMapRepresentation {
export interface HexLayout {
readonly orientation: HexagonOrientation
readonly indentedLines: LineParity
}
@ -44,7 +44,7 @@ export interface HexMapRepresentation {
/** Data corresponding to an entire hex map. */
export interface HexMap {
/** The way the map is displayed, which also affects how coordinates are calculated. */
readonly displayMode: HexMapRepresentation
readonly layout: HexLayout
/**
* The number of lines on the map.
* In ROWS and EVEN_ROWS mode, this is the height of the map.
@ -63,7 +63,7 @@ export interface HexMap {
* In COLUMNS and EVEN_COLUMNS mode, lines represent columns.
* In ROWS and EVEN_ROWS mode, lines represent rows.
*/
readonly lineCells: readonly HexLine[]
readonly layer: readonly HexLine[]
/**
* A unique identifier in https://github.com/rs/xid format for this map. Lets the client know when it is connecting
* to a different map with the same name as this one, and its old map has been destroyed.
@ -71,20 +71,20 @@ export interface HexMap {
readonly xid: string
}
export function initializeMap({lines, cellsPerLine, displayMode, xid}: {lines: number, cellsPerLine: number, displayMode: HexMapRepresentation, xid: string}): HexMap {
const lineCells: HexLine[] = [];
export function initializeMap({lines, cellsPerLine, layout, xid}: {lines: number, cellsPerLine: number, layout: HexLayout, xid: string}): HexMap {
const layer: HexLine[] = [];
const emptyLine: HexCell[] = [];
for (let cell = 0; cell < cellsPerLine; cell += 1) {
emptyLine.push(EMPTY_CELL)
}
for (let line = 0; line < lines; line += 1) {
lineCells.push(emptyLine)
layer.push(emptyLine)
}
return {
lines,
cellsPerLine: cellsPerLine,
displayMode,
lineCells,
cellsPerLine,
layout,
layer,
xid
}
}
@ -102,5 +102,5 @@ export function getCell(map: HexMap, {line, cell}: StorageCoordinates): HexCell|
if (!isValidCoordinate(map, {line, cell})) {
return null
}
return map.lineCells[line][cell]
return map.layer[line][cell]
}

@ -1,4 +1,10 @@
import {ClientHelloCommand, ClientRefreshCommand, SendableAction, SentAction} from "../actions/ClientAction";
import {
ClientGoodbyeAction,
ClientHelloCommand,
ClientRefreshCommand,
SendableAction,
SentAction
} from "../actions/ClientAction";
import {SyncedState} from "./SyncedState";
export enum ServerConnectionState {
@ -10,12 +16,17 @@ export enum ServerConnectionState {
AWAITING_REFRESH = "AWAITING_REFRESH",
/** Used when the client is connected and everything is normal. */
CONNECTED = "CONNECTED",
/**
* Used when the client has requested that the connection be closed,
* and is waiting for it to actually be closed.
*/
AWAITING_GOODBYE = "AWAITING_GOODBYE",
/**
* Used when the client is disconnected and not currently connecting,
* such as when waiting for the automatic reconnect, or if the browser is currently in offline mode,
* or if the client was disconnected due to a protocol error.
*/
OFFLINE = "OFFLINE",
OFFLINE = "OFFLINE"
}
export interface NetworkState {
@ -35,7 +46,7 @@ export interface NetworkState {
/**
* A special action that should take precedence over sending more actions.
*/
readonly specialMessage: ClientHelloCommand|ClientRefreshCommand|null
readonly specialMessage: ClientHelloCommand|ClientRefreshCommand|ClientGoodbyeAction|null
/**
* The ID of the next ClientSentAction to be created.
*/

@ -6,6 +6,7 @@ import {
storageCoordinatesToRenderCoordinates
} from "../state/Coordinates";
import {HexagonOrientation, LineParity} from "../state/HexMap";
import {normalizeColor} from "../util/ColorUtils";
function HexSwatch({color, index, offsets, classNames, onClick}: {color: string, index: number, offsets: RenderOffsets, classNames?: readonly string[], onClick: () => void}): ReactElement {
const renderCoordinates = useMemo(() => storageCoordinatesToRenderCoordinates({line: index, cell: 0}, offsets), [index, offsets]);
@ -19,7 +20,7 @@ function HexSwatch({color, index, offsets, classNames, onClick}: {color: string,
}
const ACTIVE_OFFSETS: RenderOffsets = {
displayMode: {
layout: {
orientation: HexagonOrientation.POINTY_TOP,
indentedLines: LineParity.ODD
},
@ -29,7 +30,7 @@ const ACTIVE_OFFSETS: RenderOffsets = {
}
const SWATCH_OFFSETS: RenderOffsets = {
displayMode: {
layout: {
orientation: HexagonOrientation.FLAT_TOP,
indentedLines: LineParity.EVEN
},
@ -49,20 +50,6 @@ const COLORS: readonly string[] = [
"#AAAAAAFF", "#FFFFFFFF",
]
function normalizeColor(hex: string): string {
hex = hex.toUpperCase()
if (hex.length === 4) {
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}FF`
}
if (hex.length === 5) {
return `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}${hex[4]}${hex[4]}`
}
if (hex.length === 7) {
return `${hex}FF`
}
return hex
}
function HexColorPicker({ hex, onChange }: InjectedColorProps): ReactElement {
const selected = COLORS.indexOf(normalizeColor(hex || "#INVALID"))
const swatches = COLORS.map((color, index) =>

@ -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 {ClientCommandPB} from "../proto/client";
import {CLIENT_ACT, CLIENT_HELLO, CLIENT_REFRESH, ClientCommand, SentAction} from "../actions/ClientAction";
import {ClientActPB_IDed, ClientCommandPB} from "../proto/client";
import {sendableActionToPB} from "./SyncableActionToPb";
export function clientToPb(message: ClientCommand): ClientCommandPB {
export function sentActionToPb(message: SentAction): ClientActPB_IDed {
return {
id: message.id,
action: sendableActionToPB(message.action),
}
}
export function clientToPb(message: ClientCommand): ClientCommandPB {
switch (message.type) {
case CLIENT_HELLO:
return {
hello: {
version: message.version,
},
refresh: undefined,
act: undefined,
}
case CLIENT_REFRESH:
return {
refresh: {
},
hello: undefined,
act: undefined,
}
case CLIENT_ACT:
return {
act: {
actions: message.actions.map(sentActionToPb)
},
hello: undefined,
refresh: undefined,
}
}
}

@ -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 {
SERVER_GOODBYE,
SERVER_MALFORMED,
SERVER_SOCKET_STARTUP,
ServerCommand,
ServerGoodbyeCommand,
ServerGoodbyeAction,
ServerMalformedAction,
ServerSocketStartupAction,
SocketState
} from "../actions/ServerAction";
import {clientToPb} from "./ClientToPb";
import {ClientCommandPB} from "../proto/client";
import {Reader} from "protobufjs";
import {ServerCommandPB} from "../proto/server";
import {serverFromPb} from "./ServerFromPb";
class WebsocketTranslator {
export class WebsocketTranslator {
readonly url: string
readonly protocols: readonly string[]
readonly onStartup: (startup: ServerSocketStartupAction) => void
readonly onMessage: (command: ServerCommand) => void
readonly onGoodbye: (goodbye: ServerGoodbyeCommand) => void
readonly onError: (error: ServerMalformedAction) => void
readonly onGoodbye: (goodbye: ServerGoodbyeAction) => void
private socket: WebSocket|null
constructor({
@ -22,31 +30,36 @@ class WebsocketTranslator {
protocols,
onStartup,
onMessage,
onGoodbye
onError,
onGoodbye,
}: {
url: string,
protocols: readonly string[],
onStartup: (startup: ServerSocketStartupAction) => void,
onMessage: (command: ServerCommand) => void,
onGoodbye: (goodbye: ServerGoodbyeCommand) => void
onError: (error: ServerMalformedAction) => void,
onGoodbye: (goodbye: ServerGoodbyeAction) => void,
}) {
this.url = url
this.protocols = protocols.slice()
this.onStartup = onStartup
this.onMessage = onMessage
this.onError = onError
this.onGoodbye = onGoodbye
this.socket = null
}
connect() {
if (this.socket != null) {
console.log("entering connect()", this)
if (this.socket !== null) {
throw Error("Already running")
}
this.socket = new WebSocket(this.url, this.protocols.slice())
this.socket.addEventListener("open", this.handleOpen)
this.socket.addEventListener("message", this.handleMessage)
this.socket.addEventListener("close", this.handleClose)
this.socket.addEventListener("error", WebsocketTranslator.handleError)
this.socket.binaryType = "arraybuffer"
this.socket.addEventListener("open", () => this.handleOpen())
this.socket.addEventListener("message", (m) => this.handleMessage(m))
this.socket.addEventListener("close", (c) => this.handleClose(c))
this.socket.addEventListener("error", () => this.handleError())
this.onStartup({
type: SERVER_SOCKET_STARTUP,
state: SocketState.CONNECTING,
@ -54,14 +67,17 @@ class WebsocketTranslator {
}
send(message: ClientCommand) {
// TODO: Protoitize the client message and send() it.
this.socket?.send(ClientCommandPB.encode(clientToPb(message)).finish())
}
close(code: number, reason: string) {
if (this.socket === null || this.socket.readyState !== WebSocket.OPEN) {
return
}
this.socket?.close(code, reason)
}
private handleOpen(e: Event) {
private handleOpen() {
this.onStartup({
type: SERVER_SOCKET_STARTUP,
state: SocketState.OPEN,
@ -69,7 +85,27 @@ class WebsocketTranslator {
}
private handleMessage(e: MessageEvent) {
// TODO: Parse the server message and pass it to onMessage.
const data: ArrayBuffer = e.data
const reader: Reader = Reader.create(new Uint8Array(data))
const proto = ServerCommandPB.decode(reader)
let decoded;
try {
decoded = serverFromPb((proto))
} catch (e) {
this.onError({
type: SERVER_MALFORMED,
error: e,
})
return
}
this.onMessage(decoded)
}
private handleError() {
this.onError({
type: SERVER_MALFORMED,
error: null
})
}
private handleClose(e: CloseEvent) {
@ -82,15 +118,7 @@ class WebsocketTranslator {
this.clearSocket()
}
private static handleError(e: Event) {
console.log("Websocket error: ", e)
}
private clearSocket() {
this.socket?.removeEventListener("open", this.handleOpen)
this.socket?.removeEventListener("message", this.handleMessage)
this.socket?.removeEventListener("close", this.handleClose)
this.socket?.removeEventListener("error", WebsocketTranslator.handleError)
this.socket = null
}
}

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

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

@ -110,3 +110,16 @@ func (c UserActiveColor) Apply(s *state.Synced) error {
s.User.ActiveColor = c.Color
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
import "git.reya.zone/reya/hexmap/server/state"
import (
"git.reya.zone/reya/hexmap/server/state"
)
func (x *ServerActionPB) ToGo() (Server, error) {
if x == nil {
return nil, nil
}
switch action := x.Action.(type) {
case nil:
return nil, state.ErrOneofNotSet
case *ServerActionPB_Client:
return action.Client.ToGo()
default:
@ -19,6 +20,8 @@ func (x *ClientActionPB) ToGo() (Client, error) {
return nil, nil
}
switch action := x.Action.(type) {
case nil:
return nil, state.ErrOneofNotSet
case *ClientActionPB_CellSetColor:
return action.CellSetColor.ToGo()
case *ClientActionPB_UserSetActiveColor:

@ -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.Debug("Message received, handling", zap.Object("message", raw))
switch msg := raw.(type) {
case JoinRequest:
case *JoinRequest:
r.addClient(msg.id, msg.returnChannel, msg.broadcast, msg.privateChannel)
r.acknowledgeJoin(msg.id, msg.wantCurrentState)
case RefreshRequest:
case *RefreshRequest:
r.sendRefresh(msg.id)
case ApplyRequest:
case *ApplyRequest:
msgLogger.Debug("Received action to apply from client", zap.Uint32("actionId", msg.action.ID))
result := r.applyAction(msg.action.Action)
if result != nil {
if result == nil {
r.broadcastAction(client, msg.action.ID, msg.action.Action)
}
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
// unless we were shutting down, which we're not.
r.acknowledgeLeave(client)
r.closeClient(client)
case StopRequest:
case *StopRequest:
// As requested, we shut down. Our deferred gracefulShutdown will catch us as we fall.
msgLogger.Info("Received StopRequest from client, shutting down")
return
case ShutdownResponse:
case *ShutdownResponse:
// 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")
r.closeClient(client)
@ -95,7 +95,7 @@ func (r *room) acknowledgeJoin(id xid.ID, includeState bool) {
s = r.stateCopy()
}
logger.Debug("Sending JoinResponse to client")
client.outgoingChannel <- JoinResponse{
client.outgoingChannel <- &JoinResponse{
id: r.id,
currentState: s,
}
@ -113,7 +113,7 @@ func (r *room) sendRefresh(id xid.ID) {
logger.Debug("Preparing state copy for client")
s = r.stateCopy()
logger.Debug("Sending RefreshResponse to client")
client.outgoingChannel <- RefreshResponse{
client.outgoingChannel <- &RefreshResponse{
id: r.id,
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.
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))
broadcast := ActionBroadcast{
id: r.id,
@ -138,7 +138,7 @@ func (r *room) broadcastAction(originalClientID xid.ID, originalActionID uint32,
for id, client := range r.clients {
if id.Compare(originalClientID) != 0 {
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
}
logger.Debug("Sending ApplyResponse to client")
client.outgoingChannel <- ApplyResponse{
client.outgoingChannel <- &ApplyResponse{
id: id,
actionID: actionID,
result: error,
@ -171,7 +171,7 @@ func (r *room) acknowledgeLeave(id xid.ID) {
return
}
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.
@ -206,25 +206,25 @@ func (r *room) gracefulShutdown() {
msgLogger := r.logger.With(zap.Stringer("client", client))
msgLogger.Debug("Post-shutdown message received, handling", zap.Object("message", raw))
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?
// 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.
msgLogger.Debug("Received join request from client while shutting down")
r.addClient(msg.id, msg.returnChannel, msg.broadcast, msg.privateChannel)
r.requestShutdownFrom(client)
case RefreshRequest:
case *RefreshRequest:
// Ugh, seriously, now? Fine. You can have this - you might be our friend the persistence actor.
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
// them to tell us they've heard that we're shutting down.
msgLogger.Debug("Received leave request from client while shutting down")
r.acknowledgeLeave(client)
case StopRequest:
case *StopRequest:
// Yes. We're doing that. Check your inbox, I already sent you the shutdown.
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
// else. Therefore, we can remove them now.
// 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 {
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.

@ -1,8 +1,8 @@
package room
import (
"git.reya.zone/reya/hexmap/server/action"
"github.com/rs/xid"
"go.uber.org/zap"
)
// 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
// received in the JoinResponse that will be the first message the Client receives.
RequestStartingState bool
// If sets,
Logger *zap.Logger
}
// 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,
shuttingDown: false,
}
result.outgoingChannel <- JoinRequest{
result.outgoingChannel <- &JoinRequest{
id: result.id,
returnChannel: incomingChannel,
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.
func (c *Client) Refresh() RefreshRequest {
func (c *Client) Refresh() *RefreshRequest {
if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent")
}
return RefreshRequest{
return &RefreshRequest{
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.
// 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
// 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.
func (c *Client) Leave() LeaveRequest {
func (c *Client) Leave() *LeaveRequest {
if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent")
}
c.shuttingDown = true
return LeaveRequest{
return &LeaveRequest{
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
// be handled normally.
// No further messages should be sent after Stop except AcknowledgeShutdown.
func (c *Client) Stop() StopRequest {
func (c *Client) Stop() *StopRequest {
if c.shuttingDown {
panic("Already started shutting down; no new messages should be sent")
}
c.shuttingDown = true
return StopRequest{
return &StopRequest{
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.
// No further messages can be sent after AcknowledgeShutdown; attempting to do so will block forever, as the
// OutgoingChannel has become nil.
func (c *Client) AcknowledgeShutdown() ShutdownResponse {
func (c *Client) AcknowledgeShutdown() *ShutdownResponse {
if c.outgoingChannel == nil {
panic("Already finished shutting down; no new messages should be sent")
}
c.shuttingDown = true
c.outgoingChannel = nil
return ShutdownResponse{
return &ShutdownResponse{
id: c.id,
}
}

@ -1,7 +1,7 @@
package room
import (
"git.reya.zone/reya/hexmap/server/websocket"
action2 "git.reya.zone/reya/hexmap/server/action"
"github.com/rs/xid"
"go.uber.org/zap/zapcore"
)
@ -31,7 +31,7 @@ type JoinRequest struct {
wantCurrentState bool
}
func (j JoinRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
func (j *JoinRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "JoinRequest")
encoder.AddString("id", j.id.String())
encoder.AddBool("broadcast", j.broadcast)
@ -39,7 +39,7 @@ func (j JoinRequest) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil
}
func (j JoinRequest) ClientID() xid.ID {
func (j *JoinRequest) ClientID() xid.ID {
return j.id
}
@ -48,30 +48,30 @@ type RefreshRequest struct {
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("id", r.id.String())
return nil
}
func (r RefreshRequest) ClientID() xid.ID {
func (r *RefreshRequest) ClientID() xid.ID {
return r.id
}
// 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 {
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("id", f.id.String())
return encoder.AddObject("action", f.action)
}
func (f ApplyRequest) ClientID() xid.ID {
func (f *ApplyRequest) ClientID() xid.ID {
return f.id
}
@ -82,13 +82,13 @@ type LeaveRequest struct {
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("id", l.id.String())
return nil
}
func (l LeaveRequest) ClientID() xid.ID {
func (l *LeaveRequest) ClientID() xid.ID {
return l.id
}
@ -98,13 +98,13 @@ type StopRequest struct {
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("id", s.id.String())
return nil
}
func (s StopRequest) ClientID() xid.ID {
func (s *StopRequest) ClientID() xid.ID {
return s.id
}
@ -113,12 +113,12 @@ type ShutdownResponse struct {
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("id", s.id.String())
return nil
}
func (s ShutdownResponse) ClientID() xid.ID {
func (s *ShutdownResponse) ClientID() xid.ID {
return s.id
}

@ -22,18 +22,18 @@ type JoinResponse struct {
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("id", j.id.String())
return encoder.AddObject("currentState", j.currentState)
}
func (j JoinResponse) RoomID() xid.ID {
func (j *JoinResponse) RoomID() xid.ID {
return j.id
}
// 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
}
@ -44,18 +44,18 @@ type RefreshResponse struct {
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("id", r.id.String())
return encoder.AddObject("currentState", r.currentState)
}
func (r RefreshResponse) RoomID() xid.ID {
func (r *RefreshResponse) RoomID() xid.ID {
return r.id
}
// 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
}
@ -68,7 +68,7 @@ type ApplyResponse struct {
result error
}
func (a ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
func (a *ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
encoder.AddString("type", "ApplyResponse")
encoder.AddString("id", a.id.String())
encoder.AddUint32("actionId", a.actionID)
@ -79,17 +79,21 @@ func (a ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
return nil
}
func (a ApplyResponse) RoomID() xid.ID {
func (a *ApplyResponse) RoomID() xid.ID {
return a.id
}
func (a *ApplyResponse) ActionID() uint32 {
return a.actionID
}
// 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
}
// 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
}
@ -101,10 +105,10 @@ type ActionBroadcast struct {
// originalActionID is the ID that the client that sent the action sent.
originalActionID uint32
// 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("id", a.id.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)
}
func (a ActionBroadcast) RoomID() xid.ID {
func (a *ActionBroadcast) RoomID() xid.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.
type LeaveResponse struct {
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("id", l.id.String())
return nil
}
func (l LeaveResponse) RoomID() xid.ID {
func (l *LeaveResponse) RoomID() xid.ID {
return l.id
}
@ -136,12 +149,12 @@ type ShutdownRequest struct {
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("id", s.id.String())
return nil
}
func (s ShutdownRequest) RoomID() xid.ID {
func (s *ShutdownRequest) RoomID() xid.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.
// If the coordinates are out of bounds for this map, an error will be returned.
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))
}
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 &(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.
CellsPerLine uint8 `json:"cellsPerLine"`
// 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 itself is a slice with Lines elements, each of which is a line;
// 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 {
encoder.AddString("id", m.XID.String())
encoder.AddUint8("lines", m.Lines)
encoder.AddUint8("cellsPerLine", m.CellsPerLine)
displayModeErr := encoder.AddObject("displayMode", m.Layout)
displayModeErr := encoder.AddObject("layout", m.Layout)
lineCellsErr := encoder.AddArray("lineCells", m.Layer)
if displayModeErr != nil {
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 (
"git.reya.zone/reya/hexmap/server/action"
@ -36,20 +36,7 @@ func (c ClientRefresh) MarshalLogObject(encoder zapcore.ObjectEncoder) error {
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 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
type IDPairs []action.IDed
func (a IDPairs) MarshalLogArray(encoder zapcore.ArrayEncoder) error {
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) {
switch msg := x.Command.(type) {
case nil:
return nil, state.ErrOneofNotSet
case *ClientCommandPB_Hello:
return msg.Hello.ToGo(), nil
case *ClientCommandPB_Refresh:
@ -13,13 +20,13 @@ func (x *ClientCommandPB) ToGo() (ClientCommand, error) {
}
}
func (x *ClientHelloPB) ToGo() ClientHello {
return ClientHello{
func (x *ClientHelloPB) ToGo() *ClientHello {
return &ClientHello{
Version: x.Version,
}
}
func (c ClientHello) ToClientPB() *ClientCommandPB {
func (c *ClientHello) ToClientPB() *ClientCommandPB {
return &ClientCommandPB{
Command: &ClientCommandPB_Hello{
Hello: &ClientHelloPB{
@ -29,11 +36,11 @@ func (c ClientHello) ToClientPB() *ClientCommandPB {
}
}
func (*ClientRefreshPB) ToGo() ClientRefresh {
return ClientRefresh{}
func (*ClientRefreshPB) ToGo() *ClientRefresh {
return &ClientRefresh{}
}
func (c ClientRefresh) ToClientPB() *ClientCommandPB {
func (c *ClientRefresh) ToClientPB() *ClientCommandPB {
return &ClientCommandPB{
Command: &ClientCommandPB_Refresh{
Refresh: &ClientRefreshPB{},
@ -41,42 +48,38 @@ func (c ClientRefresh) ToClientPB() *ClientCommandPB {
}
}
func (x *ClientActPB_IDed) ToGo() (IDed, error) {
action, err := x.Action.ToGo()
func (x *ClientActPB_IDed) ToGo() (action.IDed, error) {
act, err := x.Action.ToGo()
if err != nil {
return IDed{}, nil
return action.IDed{}, nil
}
return IDed{
return action.IDed{
ID: x.Id,
Action: action,
Action: act,
}, nil
}
func (i IDed) ToPB() *ClientActPB_IDed {
return &ClientActPB_IDed{
Id: i.ID,
Action: i.Action.ToClientPB(),
}
}
func (x *ClientActPB) ToGo() (ClientAct, error) {
func (x *ClientActPB) ToGo() (*ClientAct, error) {
actions := make(IDPairs, len(x.Actions))
for index, ided := range x.Actions {
action, err := ided.ToGo()
if err != nil {
return ClientAct{}, err
return nil, err
}
actions[index] = action
}
return ClientAct{
return &ClientAct{
Actions: actions,
}, nil
}
func (c ClientAct) ToClientPB() *ClientCommandPB {
func (c *ClientAct) ToClientPB() *ClientCommandPB {
actions := make([]*ClientActPB_IDed, len(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{
Command: &ClientCommandPB_Act{
@ -87,10 +90,10 @@ func (c ClientAct) ToClientPB() *ClientCommandPB {
}
}
func (ClientMalformed) ToClientPB() *ClientCommandPB {
func (*ClientMalformed) ToClientPB() *ClientCommandPB {
return nil
}
func (SocketClosed) ToClientPB() *ClientCommandPB {
func (*SocketClosed) ToClientPB() *ClientCommandPB {
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 (
"fmt"
@ -33,6 +33,11 @@ func (r *reader) act() {
defer r.shutdown()
// Our first deadline starts immediately, so let the writer know.
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.logger.Debug("Received pong, extending read deadline")
r.updateDeadlines()
@ -44,18 +49,18 @@ func (r *reader) act() {
for {
messageType, messageData, err := r.conn.ReadMessage()
if err != nil {
var closure SocketClosed
var closure *SocketClosed
if websocket.IsCloseError(err, StandardClientCloseTypes...) {
typedErr := err.(*websocket.CloseError)
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...) {
typedErr := err.(*websocket.CloseError)
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 {
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.channel <- closure
@ -75,7 +80,7 @@ func (r *reader) parseCommand(socketType int, data []byte) ClientCommand {
MessageType: socketType,
}
r.logger.Error("Received command with unknown WebSocket message type", zap.Error(err))
return ClientMalformed{
return &ClientMalformed{
Error: err,
}
}
@ -83,13 +88,13 @@ func (r *reader) parseCommand(socketType int, data []byte) ClientCommand {
var cmdPb ClientCommandPB
err := proto.Unmarshal(data, &cmdPb)
if err != nil {
return ClientMalformed{
return &ClientMalformed{
Error: err,
}
}
cmd, err := (&cmdPb).ToGo()
if err != nil {
return ClientMalformed{Error: err}
return &ClientMalformed{Error: err}
}
return cmd
}

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

@ -1,4 +1,4 @@
package websocket
package ws
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()
if err != nil {
return ServerHello{}, err
return nil, err
}
return ServerHello{
return &ServerHello{
Version: x.Version,
State: &state,
}, nil
}
func (s ServerHello) ToServerPB() *ServerCommandPB {
func (s *ServerHello) ToServerPB() *ServerCommandPB {
return &ServerCommandPB{
Command: &ServerCommandPB_Hello{
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()
if err != nil {
return ServerRefresh{}, err
return nil, err
}
return ServerRefresh{
return &ServerRefresh{
State: &state,
}, nil
}
func (s ServerRefresh) ToServerPB() *ServerCommandPB {
func (s *ServerRefresh) ToServerPB() *ServerCommandPB {
return &ServerCommandPB{
Command: &ServerCommandPB_Refresh{
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))
copy(ids, x.Ids)
return ServerOK{
return &ServerOK{
IDs: ids,
}
}
func (s ServerOK) ToServerPB() *ServerCommandPB {
func (s *ServerOK) ToServerPB() *ServerCommandPB {
ids := make([]uint32, len(s.IDs))
copy(ids, s.IDs)
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))
copy(ids, x.Ids)
return ServerFailed{
return &ServerFailed{
IDs: ids,
Error: x.Error,
}
}
func (s ServerFailed) ToServerPB() *ServerCommandPB {
func (s *ServerFailed) ToServerPB() *ServerCommandPB {
ids := make([]uint32, len(s.IDs))
copy(ids, s.IDs)
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))
for index, act := range x.Actions {
convertedAct, err := act.ToGo()
if err != nil {
return ServerAct{}, err
return nil, err
}
actions[index] = convertedAct
}
return ServerAct{
return &ServerAct{
Actions: actions,
}, nil
}
func (s ServerAct) ToServerPB() *ServerCommandPB {
func (s *ServerAct) ToServerPB() *ServerCommandPB {
actions := make([]*action.ServerActionPB, len(s.Actions))
for index, act := range s.Actions {
actions[index] = act.ToServerPB()
@ -131,6 +131,6 @@ func (s ServerAct) ToServerPB() *ServerCommandPB {
}
}
func (SocketClosed) ToServerPB() *ServerCommandPB {
func (*SocketClosed) ToServerPB() *ServerCommandPB {
return nil
}

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

@ -1,4 +1,4 @@
package websocket
package ws
import (
"github.com/gorilla/websocket"
@ -9,7 +9,7 @@ import (
)
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
// 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.
@ -66,7 +66,7 @@ func (w *writer) act() {
}
case raw := <-w.channel:
switch msg := raw.(type) {
case SocketClosed:
case *SocketClosed:
w.logger.Debug("Received close message, forwarding and shutting down", zap.Object("msg", msg))
w.sendClose(msg)
// 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.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 {
w.logger.Error("Error while getting writer from connection", zap.Error(err))
return
@ -127,9 +127,9 @@ func (w *writer) send(msg ServerCommand) {
// 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.
func (w *writer) sendClose(msg SocketClosed) {
func (w *writer) sendClose(msg *SocketClosed) {
w.logger.Debug("Shutting down the writer channel")
close(w.channel)
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() {
w.logger.Debug("Sending ping")
err := w.conn.WriteControl(websocket.PingMessage, []byte(PingData), time.Now().Add(ControlTimeLimit))
@ -176,7 +176,7 @@ func (w *writer) gracefulShutdown() {
}
case raw := <-w.channel:
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.sendClose(msg)
default:
Loading…
Cancel
Save