From a83c9688bb04d1767c9ea1ad92e12133a095818f Mon Sep 17 00:00:00 2001 From: Mari Date: Fri, 16 Jul 2021 09:59:42 -0400 Subject: [PATCH] Finish implementing protobuf conversion routines for the server --- .idea/hexmap.iml | 1 + build/build.go | 24 +-- client/.gitignore | 3 + client/magefile.go | 103 +++++++++++- client/src/App.css | 12 +- client/src/App.tsx | 6 +- client/src/actions/NetworkAction.ts | 8 - client/src/reducers/HexMapReducer.ts | 4 +- client/src/state/Coordinates.ts | 18 --- client/src/ui/debug/ConsoleConnection.tsx | 176 --------------------- client/src/util/ArrayUtils.ts | 4 - mage.sh | 6 +- magefile.go | 26 ++- proto/action.proto | 19 ++- proto/client.proto | 23 ++- proto/coords.proto | 2 +- proto/magefile.go | 56 +++++++ proto/map.proto | 28 ++-- proto/server.proto | 37 ++--- proto/state.proto | 11 ++ proto/user.proto | 2 +- server/.gitignore | 2 +- server/action/action.go | 83 ++++++++-- server/action/action.pbconv.go | 75 +++++++++ server/action/map.go | 45 ------ server/action/user.go | 33 ---- server/magefile.go | 25 ++- server/room/actor.go | 10 +- server/room/client.go | 6 +- server/room/clientmessage.go | 2 +- server/room/message.go | 8 +- server/state/color.go | 46 ++++++ server/state/color.pbconv.go | 16 ++ server/state/{coordinates.go => coords.go} | 4 +- server/state/coords.pbconv.go | 25 +++ server/state/hexcolor.go | 104 ------------ server/state/hexorientation.go | 51 ------ server/state/lineparity.go | 49 ------ server/state/{hexmap.go => map.go} | 80 ++++++++-- server/state/map.pbconv.go | 142 +++++++++++++++++ server/state/{synced.go => state.go} | 4 +- server/state/state.pbconv.go | 20 +++ server/state/user.go | 16 +- server/state/user.pbconv.go | 13 ++ server/websocket/client.go | 46 ++---- server/websocket/client.pbconv.go | 96 +++++++++++ server/websocket/reader.go | 99 +++--------- server/websocket/server.go | 53 ++----- server/websocket/server.pbconv.go | 136 ++++++++++++++++ server/websocket/shared.go | 8 +- server/websocket/writer.go | 34 ++-- 51 files changed, 1072 insertions(+), 828 deletions(-) delete mode 100644 client/src/ui/debug/ConsoleConnection.tsx create mode 100644 proto/magefile.go create mode 100644 proto/state.proto create mode 100644 server/action/action.pbconv.go delete mode 100644 server/action/map.go delete mode 100644 server/action/user.go create mode 100644 server/state/color.go create mode 100644 server/state/color.pbconv.go rename server/state/{coordinates.go => coords.go} (93%) create mode 100644 server/state/coords.pbconv.go delete mode 100644 server/state/hexcolor.go delete mode 100644 server/state/hexorientation.go delete mode 100644 server/state/lineparity.go rename server/state/{hexmap.go => map.go} (58%) create mode 100644 server/state/map.pbconv.go rename server/state/{synced.go => state.go} (90%) create mode 100644 server/state/state.pbconv.go create mode 100644 server/state/user.pbconv.go create mode 100644 server/websocket/client.pbconv.go create mode 100644 server/websocket/server.pbconv.go diff --git a/.idea/hexmap.iml b/.idea/hexmap.iml index 1accc2b..3787fb9 100644 --- a/.idea/hexmap.iml +++ b/.idea/hexmap.iml @@ -13,6 +13,7 @@ + diff --git a/build/build.go b/build/build.go index 34d7231..4fc9ef6 100644 --- a/build/build.go +++ b/build/build.go @@ -8,20 +8,10 @@ import ( "path/filepath" ) -func GetBuildToolsDir() (string, error) { - basedir, err := os.Getwd() - if err != nil { - return "", err - } - return filepath.Join(basedir, "buildtools"), nil -} +const ToolsDir = "buildtools" -func HasExecutableInBuildtools(name string) (bool, error) { - tooldir, err := GetBuildToolsDir() - if err != nil { - return false, err - } - info, err := os.Stat(filepath.Join(tooldir, name)) +func HasExecutableInTools(name string) (bool, error) { + info, err := os.Stat(filepath.Join(ToolsDir, name)) if err != nil { if os.IsNotExist(err) { return false, nil @@ -29,7 +19,7 @@ func HasExecutableInBuildtools(name string) (bool, error) { return false, err } } - if info.Mode() & 0100 != 0 { + if info.Mode()&0100 != 0 { return true, nil } else { return false, nil @@ -37,15 +27,15 @@ func HasExecutableInBuildtools(name string) (bool, error) { } func InstallGoExecutable(ctx context.Context, packageNameWithVersion string) error { - tooldir, err := GetBuildToolsDir() + tooldir, err := filepath.Abs(ToolsDir) if err != nil { return err } cmd := exec.CommandContext(ctx, "go", "install", packageNameWithVersion) - cmd.Env = append(os.Environ(), "GOBIN=" + tooldir) + cmd.Env = append(os.Environ(), "GOBIN="+tooldir) cmd.Stderr = os.Stderr if mg.Verbose() { cmd.Stdout = os.Stdout } return cmd.Run() -} \ No newline at end of file +} diff --git a/client/.gitignore b/client/.gitignore index 4d29575..e314dd4 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -11,6 +11,9 @@ # production /build +# Generated protocol buffers files +/src/proto + # misc .DS_Store .env.local diff --git a/client/magefile.go b/client/magefile.go index d804115..0f9b681 100644 --- a/client/magefile.go +++ b/client/magefile.go @@ -1,18 +1,109 @@ package client import ( + "context" + "git.reya.zone/reya/hexmap/build" + "git.reya.zone/reya/hexmap/proto" "github.com/magefile/mage/mg" + "github.com/magefile/mage/target" + "os" + "os/exec" + "path/filepath" ) -const ProtocPluginPath = "client/node_modules/.bin/protoc-gen-ts_proto" +func NPMInstall(ctx context.Context) error { + var packageLockOutOfDate, nodeModulesOutOfDate bool + var err error + if packageLockOutOfDate, err = target.Path("client/package-lock.json", "client/package-lock.json"); err != nil { + return err + } + if nodeModulesOutOfDate, err = target.Dir("client/node_modules", "client/package.json"); err != nil { + return err + } + if !(packageLockOutOfDate || nodeModulesOutOfDate) { + return nil + } + cmd := exec.CommandContext(ctx, "npm", "install") + cmd.Dir, err = filepath.Abs("client") + if err != nil { + return err + } + cmd.Stderr = os.Stderr + if mg.Verbose() { + cmd.Stdout = os.Stdout + } + return nil +} + +func ProtocFlags() ([]string, error) { + buildPath, err := filepath.Abs(filepath.Join(build.ToolsDir, "protoc-gen-ts_proto")) + if err != nil { + return nil, err + } + return []string{ + "--plugin=" + buildPath, + "--ts_proto_out=client/src/proto/", + "--ts_proto_opt=env=browser", + "--ts_proto_opt=esModuleInterop=true", + }, nil +} type Protobuf mg.Namespace -// TODO: NPMInstall -// TODO: Protobuf:InstallTSPlugin -// TODO: Protobuf:InstallPlugins -// TODO: Protobuf:Build -// TODO: Protobuf:Clean +const TSPluginNPMLocation = "client/node_modules/.bin/protoc-gen-ts_proto" + +func (Protobuf) InstallTSPlugin(ctx context.Context) error { + mg.CtxDeps(ctx, NPMInstall) + buildPath, err := filepath.Abs(filepath.Join(build.ToolsDir, "protoc-gen-ts_proto")) + if err != nil { + return err + } + sourcePath, err := filepath.Abs(TSPluginNPMLocation) + if err != nil { + return err + } + // Errors here just mean we move on to the next step - this could not exist or not be a link, we don't care. + // The important part is the later parts. + if linkPath, err := os.Readlink(buildPath); err == nil && linkPath == sourcePath { + return nil + } + // Remove whatever's in the way, if necessary. + err = os.Remove(buildPath) + if err != nil && !os.IsNotExist(err) { + return err + } + err = os.Symlink(sourcePath, buildPath) + if err != nil && !os.IsExist(err) { + return err + } + return nil +} + +func (Protobuf) MakeProtoDir() error { + if err := os.Mkdir("client/src/proto", 0755); err != nil && !os.IsExist(err) { + return err + } + return nil +} + +func (Protobuf) InstallPlugins(ctx context.Context) error { + mg.CtxDeps(ctx, Protobuf.InstallTSPlugin, Protobuf.MakeProtoDir) + return nil +} + +func (Protobuf) Build(ctx context.Context) error { + mg.SerialCtxDeps(ctx, Protobuf.Clean, Protobuf.InstallPlugins) + return proto.Compile(ctx, []proto.ProtocFlagsFunc{ProtocFlags}) +} + +func (Protobuf) Clean() error { + err := os.RemoveAll("client/src/proto") + if err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + // TODO: Build // TODO: Clean // TODO: Serve diff --git a/client/src/App.css b/client/src/App.css index 00ac1a7..1907215 100644 --- a/client/src/App.css +++ b/client/src/App.css @@ -3,7 +3,7 @@ body { background-color: #282c34; } -.App, .scrollbox { +.App, .scrollBox { position: absolute; top: 0; bottom: 0; @@ -11,11 +11,11 @@ body { right: 0; } -.scrollbox { +.scrollBox { overflow-x: scroll; overflow-y: scroll; } -.centerbox { +.centerBox { display: flex; align-items: center; justify-content: center; @@ -72,7 +72,7 @@ body { .hexColorPicker { width: 30vw; } - .centerbox { + .centerBox { padding-bottom: calc(2vh + (30vw * 126 / 855)); } } @@ -80,7 +80,7 @@ body { .hexColorPicker { width: 300px; } - .centerbox { + .centerBox { padding-bottom: calc(2vh + (300px * 126 / 855)); } } @@ -88,7 +88,7 @@ body { .hexColorPicker { width: 98vw; } - .centerbox { + .centerBox { padding-bottom: calc(2vh + (98vw * 126 / 855)); } } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index ecaa079..c336b6e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -7,7 +7,6 @@ import {ServerConnectionState} from "./state/NetworkState"; import HexMapRenderer from "./ui/HexMapRenderer"; import HexColorPicker from "./ui/HexColorPicker"; import {sizeFromLinesAndCells} from "./state/Coordinates"; -import {ConsoleConnector} from "./ui/debug/ConsoleConnection"; function App() { const [state, dispatch] = useReducer( @@ -48,15 +47,14 @@ function App() { return (
-
-
+
+
e.preventDefault()}> {mapElement}
{colorPickerElement} -
); diff --git a/client/src/actions/NetworkAction.ts b/client/src/actions/NetworkAction.ts index 92a6dc3..2f041c9 100644 --- a/client/src/actions/NetworkAction.ts +++ b/client/src/actions/NetworkAction.ts @@ -60,11 +60,6 @@ export function isServerOKAction(action: AppAction): action is ServerOKAction { return action.type === SERVER_OK } -export interface ActionFailure { - readonly id: number - readonly error: string -} - export const SERVER_FAILED = "SERVER_FAILED" /** Sent in response to the client's ClientNestedAction, if it fails and has not been applied to the server map. */ export interface ServerFailedAction extends BaseAction { @@ -103,9 +98,6 @@ export function isServerActAction(action: AppAction): action is ServerActAction } export type SyncableAction = SendableAction -export function isSyncableAction(action: AppAction): action is SyncableAction { - return isSendableAction(action) -} export type ServerAction = ServerHelloAction | ServerGoodbyeAction | ServerRefreshAction | diff --git a/client/src/reducers/HexMapReducer.ts b/client/src/reducers/HexMapReducer.ts index 32ad5d2..8c16c00 100644 --- a/client/src/reducers/HexMapReducer.ts +++ b/client/src/reducers/HexMapReducer.ts @@ -40,6 +40,4 @@ export function hexMapReducer(oldState: HexMap, action: MapAction): HexMap { case CELL_REMOVE: return replaceCell(oldState, action.at, null) } -} - -export type HexMapReducer = typeof hexMapReducer; \ No newline at end of file +} \ No newline at end of file diff --git a/client/src/state/Coordinates.ts b/client/src/state/Coordinates.ts index 433098b..49e9784 100644 --- a/client/src/state/Coordinates.ts +++ b/client/src/state/Coordinates.ts @@ -1,23 +1,5 @@ -/** Cubic (3-dimensional) coordinates for algorithms. */ import {HexagonOrientation, HexMapRepresentation, LineParity} from "./HexMap"; -export interface CubicCoordinates { - /** The cubic x-coordinate. */ - readonly x: number - /** The cubic y-coordinate. */ - readonly y: number - /** The cubic z-coordinate. */ - readonly z: number -} - -/** Axial (2-dimensional cube variant) coordinates for display. */ -export interface AxialCoordinates { - /** The axial x-coordinate. */ - readonly q: number - /** The axial y-coordinate. */ - readonly r: number -} - /** Staggered (storage) coordinates for accessing cell storage. */ export interface StorageCoordinates { /** The index of the line within the map. */ diff --git a/client/src/ui/debug/ConsoleConnection.tsx b/client/src/ui/debug/ConsoleConnection.tsx deleted file mode 100644 index 81b85a5..0000000 --- a/client/src/ui/debug/ConsoleConnection.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import { - CLIENT_ACT, - ClientAction, - ClientActAction, - isClientActAction, - SendableAction, - SentAction, - SERVER_FAILED, - SERVER_GOODBYE, - SERVER_HELLO, - SERVER_OK, - SERVER_ACT, - SERVER_SOCKET_STARTUP, - ServerAction, - SocketState -} from "../../actions/NetworkAction"; -import {HexagonOrientation, HexMapRepresentation, initializeMap, LineParity} from "../../state/HexMap"; -import {ReactElement, useContext, useEffect, useRef, useState} from "react"; -import {DispatchContext} from "../context/DispatchContext"; -import {USER_ACTIVE_COLOR} from "../../actions/UserAction"; -import {CELL_COLOR} from "../../actions/CellAction"; - -export enum OrientationConstants { - ROWS = "ROWS", - COLUMNS = "COLUMNS", - EVEN_ROWS = "EVEN_ROWS", - EVEN_COLUMNS = "EVEN_COLUMNS" -} -export function orientationFromString(string: string): HexMapRepresentation { - const normalized = string.toUpperCase().trim() - switch (normalized) { - case OrientationConstants.ROWS: - return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.ODD } - case OrientationConstants.COLUMNS: - return { orientation: HexagonOrientation.FLAT_TOP, indentedLines: LineParity.ODD } - case OrientationConstants.EVEN_ROWS: - return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.EVEN } - case OrientationConstants.EVEN_COLUMNS: - return { orientation: HexagonOrientation.FLAT_TOP, indentedLines: LineParity.EVEN } - default: - return { orientation: HexagonOrientation.POINTY_TOP, indentedLines: LineParity.ODD } - } -} - -/** Fake "connection" to a "server" that actually just goes back and forth with the console. */ -export class ConsoleConnection { - public receivedMessages: ClientAction[] = [] - private readonly dispatch: (action: ServerAction) => void - - constructor(dispatch: (action: ServerAction) => void) { - this.dispatch = dispatch - } - - receive(action: ClientAction): void { - this.receivedMessages.push(action) - if (isClientActAction(action)) { - console.log(`Received Sent action containing: ${action.actions.map((value) => `${value.id}/${value.action.type}`).join(", ")}`) - } else { - console.log(`Received: ${action.type}`) - } - } - - public sendSocketConnecting(): void { - this.dispatch({ - type: SERVER_SOCKET_STARTUP, - state: SocketState.CONNECTING - }) - } - - public sendSocketConnected(): void { - this.dispatch({ - type: SERVER_SOCKET_STARTUP, - state: SocketState.OPEN - }) - } - - public sendHello({color = "#0000FF", displayMode = "ROWS", xid = "TotallyCoolXID", lines = 10, cells = 10}: { - color?: string, - displayMode?: string, - xid?: string, - lines?: number, - cells?: number - } = {}): void { - this.dispatch({ - type: SERVER_HELLO, - version: 1, - state: { - map: initializeMap({ - lines, - cellsPerLine: cells, - displayMode: orientationFromString(displayMode), - xid - }), - user: {activeColor: color} - } - }) - } - - public sendOK(ids: readonly number[]): void { - this.dispatch({ - type: SERVER_OK, - ids - }) - } - - public sendFailed(ids: readonly number[], error: string = "No thanks."): void { - this.dispatch({ - type: SERVER_FAILED, - ids, - error - }) - } - - public sendGoodbye({code = 1000, reason = "Okay, bye then!"}: { code?: number, reason?: string } = {}): void { - this.dispatch({ - type: SERVER_GOODBYE, - code, - reason, - currentTime: new Date() - }) - } - - public sendColorChange(color: string = "#FF0000FF"): void { - this.dispatch({ - type: SERVER_ACT, - actions: [{ - type: USER_ACTIVE_COLOR, - color - }] - }) - } - - public sendColorAtTile(color: string = "#FFFF00FF", line: number = 0, cell: number = 0): void { - this.dispatch({ - type: SERVER_ACT, - actions: [{ - type: CELL_COLOR, - at: { line, cell }, - color - }] - }) - } -} - - -export function ConsoleConnector({specialMessage, pendingMessages, nextID}: {specialMessage: ClientAction|null, pendingMessages: readonly SendableAction[], nextID: number}): ReactElement { - const dispatch = useContext(DispatchContext) - const connector = useRef(new ConsoleConnection(dispatch || (() => null))) - const [lastSpecialMessage, setLastSpecialMessage] = useState(null) - useEffect(() => { - // @ts-ignore - window.fakedServerConnection = connector.current - }, []); - useEffect(() => { - if (dispatch !== null) { - if (pendingMessages.length > 0) { - const sentMessages: SentAction[] = pendingMessages.map((action, index) => { - return { id: index + nextID, action } - }); - const sentMessage: ClientActAction = { - type: CLIENT_ACT, - actions: sentMessages - }; - connector.current.receive(sentMessage) - dispatch(sentMessage) - } - } - }, [nextID, dispatch, pendingMessages]) - useEffect(() => { - if (specialMessage !== null && specialMessage !== lastSpecialMessage) { - connector.current.receive(specialMessage); - setLastSpecialMessage(specialMessage); - } - }, [specialMessage, lastSpecialMessage, setLastSpecialMessage]) - return
Console connection active
-} \ No newline at end of file diff --git a/client/src/util/ArrayUtils.ts b/client/src/util/ArrayUtils.ts index 6d4a943..929f3b4 100644 --- a/client/src/util/ArrayUtils.ts +++ b/client/src/util/ArrayUtils.ts @@ -1,7 +1,3 @@ -export function arrayShallowEqual(left: readonly T[], right: readonly T[]): boolean { - return left.length === right.length && arrayShallowStartsWith(left, right) -} - export function arrayShallowStartsWith(target: readonly T[], prefix: readonly T[]): boolean { return target.length >= prefix.length && prefix.every((value, index) => target[index] === value) } \ No newline at end of file diff --git a/mage.sh b/mage.sh index 0895ffc..fb6b15e 100755 --- a/mage.sh +++ b/mage.sh @@ -1,12 +1,12 @@ #!/bin/bash -MAINPATH=$(readlink -e ${BASH_SOURCE%/*}) +SCRIPTPATH=$(readlink -e ${BASH_SOURCE}) +MAINPATH=${SCRIPTPATH%/*} BUILDTOOLSPATH=${MAINPATH}/buildtools MAGEPATH=${BUILDTOOLSPATH}/mage - if [[ ! -x "$MAGEPATH" ]]; then echo "go install-ing mage..." GOBIN="$BUILDTOOLSPATH" go install github.com/magefile/mage@latest fi -exec "$MAGEPATH" -d "$MAINPATH" -w "$MAINPATH" "$@" \ No newline at end of file +exec "$MAGEPATH" -d "$MAINPATH" -w "$MAINPATH" "$@" diff --git a/magefile.go b/magefile.go index 2bc9c95..6b51256 100644 --- a/magefile.go +++ b/magefile.go @@ -3,8 +3,30 @@ package main import ( + "context" + "git.reya.zone/reya/hexmap/proto" + // mage:import server - _ "git.reya.zone/reya/hexmap/server" + "git.reya.zone/reya/hexmap/server" + "github.com/magefile/mage/mg" + // mage:import client - _ "git.reya.zone/reya/hexmap/client" + "git.reya.zone/reya/hexmap/client" ) + +type Protobuf mg.Namespace + +func (Protobuf) InstallPlugins(ctx context.Context) error { + mg.CtxDeps(ctx, server.Protobuf.InstallPlugins, client.Protobuf.InstallPlugins) + return nil +} + +func (Protobuf) Build(ctx context.Context) error { + mg.SerialCtxDeps(ctx, Protobuf.Clean, Protobuf.InstallPlugins) + return proto.Compile(ctx, []proto.ProtocFlagsFunc{server.ProtocFlags, client.ProtocFlags}) +} + +func (Protobuf) Clean(ctx context.Context) error { + mg.CtxDeps(ctx, server.Protobuf.Clean, client.Protobuf.Clean) + return nil +} diff --git a/proto/action.proto b/proto/action.proto index 73b20a5..52d6aca 100644 --- a/proto/action.proto +++ b/proto/action.proto @@ -4,11 +4,24 @@ import "coords.proto"; option go_package = "git.reya.zone/reya/hexmap/server/action"; -message CellSetColor { +message CellSetColorPB { fixed32 color = 1; - StorageCoordinates at = 2; + StorageCoordinatesPB at = 2; } -message UserSetActiveColor { +message UserSetActiveColorPB { fixed32 color = 1; +} + +message ClientActionPB { + oneof action { + CellSetColorPB cell_set_color = 1; + UserSetActiveColorPB user_set_active_color = 2; + } +} + +message ServerActionPB { + oneof action { + ClientActionPB client = 1; + } } \ No newline at end of file diff --git a/proto/client.proto b/proto/client.proto index f51fcbe..f493448 100644 --- a/proto/client.proto +++ b/proto/client.proto @@ -4,28 +4,25 @@ import "action.proto"; option go_package = "git.reya.zone/reya/hexmap/server/websocket"; -message ClientHello { +message ClientHelloPB { uint32 version = 1; } -message ClientRefresh { +message ClientRefreshPB { } -message ClientAct { - message ClientAction { +message ClientActPB { + message IDed { uint32 id = 1; - oneof action { - CellSetColor cell_set_color = 2; - UserSetActiveColor user_set_active_color = 3; - } + ClientActionPB action = 2; } - repeated ClientAction actions = 1; + repeated IDed actions = 1; } -message ClientCommand { +message ClientCommandPB { oneof command { - ClientHello hello = 1; - ClientRefresh refresh = 2; - ClientAct act = 3; + ClientHelloPB hello = 1; + ClientRefreshPB refresh = 2; + ClientActPB act = 3; } } \ No newline at end of file diff --git a/proto/coords.proto b/proto/coords.proto index 8634b29..78d2410 100644 --- a/proto/coords.proto +++ b/proto/coords.proto @@ -2,7 +2,7 @@ syntax = "proto3"; option go_package = "git.reya.zone/reya/hexmap/server/state"; -message StorageCoordinates { +message StorageCoordinatesPB { uint32 line = 1; uint32 cell = 2; } \ No newline at end of file diff --git a/proto/magefile.go b/proto/magefile.go new file mode 100644 index 0000000..96de9f6 --- /dev/null +++ b/proto/magefile.go @@ -0,0 +1,56 @@ +package proto + +import ( + "context" + "github.com/magefile/mage/mg" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func BaseProtocFlags() ([]string, error) { + return []string{"-I=proto"}, nil +} + +type ProtocFlagsFunc func() ([]string, error) + +func Sources() ([]string, error) { + result := []string(nil) + err := filepath.WalkDir("proto", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".proto") { + result = append(result, path) + } + return nil + }) + return result, err +} + +func Compile(ctx context.Context, withPlugins []ProtocFlagsFunc) error { + flags, err := BaseProtocFlags() + if err != nil { + return err + } + for _, flagsFunc := range withPlugins { + pluginFlags, err := flagsFunc() + if err != nil { + return err + } + flags = append(flags, pluginFlags...) + } + protoFiles, err := Sources() + if err != nil { + return err + } + args := append(flags, protoFiles...) + cmd := exec.CommandContext(ctx, "protoc", args...) + cmd.Stderr = os.Stderr + if mg.Verbose() { + cmd.Stdout = os.Stdout + } + return cmd.Run() +} diff --git a/proto/map.proto b/proto/map.proto index 98ac842..0d89838 100644 --- a/proto/map.proto +++ b/proto/map.proto @@ -2,32 +2,36 @@ syntax = "proto3"; option go_package = "git.reya.zone/reya/hexmap/server/state"; -message HexCell { +message HexCellPB { fixed32 color = 1; } -message HexLine { - repeated HexCell cells = 1; +message HexLinePB { + repeated HexCellPB cells = 1; } -message HexMap { +message HexLayerPB { + repeated HexLinePB lines = 1; +} + +message HexMapPB { message Layout { enum Orientation { UNKNOWN_ORIENTATION = 0; - FLAT_TOP_ORIENTATION = 1; - POINTY_TOP_ORIENTATION = 2; + POINTY_TOP = 1; + FLAT_TOP = 2; } enum LineParity { - UNKNOWN_LINE_PARITY = 0; - EVEN_LINE_PARITY = 1; - ODD_LINE_PARITY = 2; + UNKNOWN_LINE = 0; + ODD = 1; + EVEN = 2; } Orientation orientation = 1; - LineParity line_parity = 2; + LineParity indented_lines = 2; } - string xid = 1; + bytes xid = 1; uint32 lines = 2; uint32 cells_per_line = 3; Layout layout = 4; - repeated HexLine line_cells = 5; + HexLayerPB layer = 5; } diff --git a/proto/server.proto b/proto/server.proto index 1a2f6f5..0cbb916 100644 --- a/proto/server.proto +++ b/proto/server.proto @@ -1,41 +1,38 @@ syntax = "proto3"; import "action.proto"; -import "map.proto"; -import "user.proto"; +import "state.proto"; option go_package = "git.reya.zone/reya/hexmap/server/websocket"; -message ServerState { - HexMap map = 1; - UserState user = 2; -} - -message ServerHello { +message ServerHelloPB { uint32 version = 1; - ServerState state = 2; + SyncableStatePB state = 2; } -message ServerRefresh { - ServerState state = 1; +message ServerRefreshPB { + SyncableStatePB state = 1; } -message ServerOK { +message ServerOKPB { repeated uint32 ids = 1; } -message ServerFailed { +message ServerFailedPB { repeated uint32 ids = 1; string error = 2; } -message ServerAction { - oneof action { - CellSetColor cell_set_color = 1; - UserSetActiveColor user_set_active_color = 2; - } +message ServerActPB { + repeated ServerActionPB actions = 1; } -message ServerAct { - repeated ServerAction actions = 1; +message ServerCommandPB { + oneof command { + ServerHelloPB hello = 1; + ServerRefreshPB refresh = 2; + ServerOKPB ok = 3; + ServerFailedPB failed = 4; + ServerActPB act = 5; + } } \ No newline at end of file diff --git a/proto/state.proto b/proto/state.proto new file mode 100644 index 0000000..f7320b7 --- /dev/null +++ b/proto/state.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +option go_package = "git.reya.zone/reya/hexmap/server/state"; + +import "map.proto"; +import "user.proto"; + +message SyncableStatePB { + HexMapPB map = 1; + UserStatePB user = 2; +} \ No newline at end of file diff --git a/proto/user.proto b/proto/user.proto index cc7e879..6939c98 100644 --- a/proto/user.proto +++ b/proto/user.proto @@ -2,6 +2,6 @@ syntax = "proto3"; option go_package = "git.reya.zone/reya/hexmap/server/state"; -message UserState { +message UserStatePB { fixed32 color = 1; } \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore index 9695e6e..8374028 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -1,2 +1,2 @@ *.pb.go -*.zap.go \ No newline at end of file +proto/ \ No newline at end of file diff --git a/server/action/action.go b/server/action/action.go index e16a1ae..5ae920c 100644 --- a/server/action/action.go +++ b/server/action/action.go @@ -14,30 +14,38 @@ var ( ErrNoTransparentColors = errors.New("transparent colors not allowed") ) -type Type string - // Action is the interface for actions that can be shared between clients, or between the server and a client. type Action interface { zapcore.ObjectMarshaler - // Type gives the Javascript type that is sent over the wire. - Type() Type // Apply causes the action's effects to be applied to s, mutating it in place. // All Actions must conform to the standard that if an action can't be correctly applied, or if it would // have no effect, it returns an error without changing s. // If an action can be correctly applied but would have no effect, it should return ErrNoOp. // If an action is correctly applied and has an effect, it should return nil. Apply(s *state.Synced) error - // fromJSONMap causes the action's state to be overwritten by data from the given map. - fromJSONMap(data map[string] interface{}) error } -type parseAction struct { - Type string `json:"type"` +type Client interface { + Server + // ToClientPB converts the action into a client action protocol buffer. + ToClientPB() *ClientActionPB +} + +type Server interface { + Action + // ToServerPB converts the action into a server action protocol buffer. + ToServerPB() *ServerActionPB +} + +func serverPBFromClient(c Client) *ServerActionPB { + return &ServerActionPB{ + Action: &ServerActionPB_Client{Client: c.ToClientPB()}, + } } -type Slice []Action +type ServerSlice []Server -func (s Slice) MarshalLogArray(encoder zapcore.ArrayEncoder) error { +func (s ServerSlice) MarshalLogArray(encoder zapcore.ArrayEncoder) error { var finalErr error = nil for _, a := range s { err := encoder.AppendObject(a) @@ -47,3 +55,58 @@ func (s Slice) MarshalLogArray(encoder zapcore.ArrayEncoder) error { } return finalErr } + +// CellColor is the action sent when a cell of the map has been colored a different color. +type CellColor struct { + // At is the location of the cell in storage coordinates. + At state.StorageCoordinates `json:"at"` + // Color is the color the cell has been changed to. + Color state.Color `json:"color"` +} + +func (c CellColor) MarshalLogObject(encoder zapcore.ObjectEncoder) error { + encoder.AddString("type", "CellColor") + err := encoder.AddObject("at", c.At) + encoder.AddString("color", c.Color.String()) + return err +} + +// Apply sets the target cell's color, or returns ErrNoOp if it can't. +func (c CellColor) Apply(s *state.Synced) error { + if c.Color.A < 0xF { + return ErrNoTransparentColors + } + cell, err := s.Map.Layer.GetCellAt(c.At) + if err != nil { + return err + } + if cell.Color == c.Color { + return ErrNoOp + } + cell.Color = c.Color + return nil +} + +// UserActiveColor is the action sent when the user's current color, the one being painted with, changes. +type UserActiveColor struct { + // Color is the color that is now active. + Color state.Color `json:"color"` +} + +func (c UserActiveColor) MarshalLogObject(encoder zapcore.ObjectEncoder) error { + encoder.AddString("type", "UserActiveColor") + encoder.AddString("color", c.Color.String()) + return nil +} + +// Apply sets the user's active color, or returns ErrNoOp if it can't. +func (c UserActiveColor) Apply(s *state.Synced) error { + if c.Color.A < 0xF { + return ErrNoTransparentColors + } + if s.User.ActiveColor == c.Color { + return ErrNoOp + } + s.User.ActiveColor = c.Color + return nil +} diff --git a/server/action/action.pbconv.go b/server/action/action.pbconv.go new file mode 100644 index 0000000..68e5afb --- /dev/null +++ b/server/action/action.pbconv.go @@ -0,0 +1,75 @@ +package action + +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 *ServerActionPB_Client: + return action.Client.ToGo() + default: + panic("A case was missed in ServerActionPB.ToGo!") + } +} + +func (x *ClientActionPB) ToGo() (Client, error) { + if x == nil { + return nil, nil + } + switch action := x.Action.(type) { + case *ClientActionPB_CellSetColor: + return action.CellSetColor.ToGo() + case *ClientActionPB_UserSetActiveColor: + return action.UserSetActiveColor.ToGo(), nil + default: + panic("A case was missed in ClientActionPB.ToGo!") + } +} + +func (x *CellSetColorPB) ToGo() (*CellColor, error) { + at, err := x.At.ToGo() + if err != nil { + return nil, err + } + return &CellColor{ + At: at, + Color: state.ColorFromInt(x.Color), + }, nil +} + +func (c CellColor) ToServerPB() *ServerActionPB { + return serverPBFromClient(c) +} + +func (c CellColor) ToClientPB() *ClientActionPB { + return &ClientActionPB{ + Action: &ClientActionPB_CellSetColor{ + CellSetColor: &CellSetColorPB{ + Color: c.Color.ToInt(), + At: c.At.ToPB(), + }, + }, + } +} + +func (x *UserSetActiveColorPB) ToGo() *UserActiveColor { + return &UserActiveColor{ + Color: state.ColorFromInt(x.Color), + } +} + +func (c UserActiveColor) ToServerPB() *ServerActionPB { + return serverPBFromClient(c) +} + +func (c UserActiveColor) ToClientPB() *ClientActionPB { + return &ClientActionPB{ + Action: &ClientActionPB_UserSetActiveColor{ + UserSetActiveColor: &UserSetActiveColorPB{ + Color: c.Color.ToInt(), + }, + }, + } +} diff --git a/server/action/map.go b/server/action/map.go deleted file mode 100644 index 76fed37..0000000 --- a/server/action/map.go +++ /dev/null @@ -1,45 +0,0 @@ -package action - -import ( - "git.reya.zone/reya/hexmap/server/state" - "go.uber.org/zap/zapcore" -) - -const ( - CellColorType Type = "CELL_COLOR" -) - -// CellColor is the action sent when a cell of the map has been colored a different color. -type CellColor struct { - // At is the location of the cell in storage coordinates. - At state.StorageCoordinates `json:"at"` - // Color is the color the cell has been changed to. - Color state.HexColor `json:"color"` -} - -func (c CellColor) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(CellColorType)) - err := encoder.AddObject("at", c.At) - encoder.AddString("color", c.Color.String()) - return err -} - -func (c CellColor) Type() Type { - return CellColorType -} - -// Apply sets the target cell's color, or returns ErrNoOp if it can't. -func (c CellColor) Apply(s *state.Synced) error { - if c.Color.A < 0xF { - return ErrNoTransparentColors - } - cell, err := s.Map.LineCells.GetCellAt(c.At) - if err != nil { - return err - } - if cell.Color == c.Color { - return ErrNoOp - } - cell.Color = c.Color - return nil -} diff --git a/server/action/user.go b/server/action/user.go deleted file mode 100644 index 2058b6f..0000000 --- a/server/action/user.go +++ /dev/null @@ -1,33 +0,0 @@ -package action - -import ( - "git.reya.zone/reya/hexmap/server/state" - "go.uber.org/zap/zapcore" -) - -const ( - UserActiveColorType = "USER_ACTIVE_COLOR" -) - -// UserActiveColor is the action sent when the user's current color, the one being painted with, changes. -type UserActiveColor struct { - // Color is the color that is now active. - Color state.HexColor `json:"color"` -} - -func (c UserActiveColor) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("color", c.Color.String()) - return nil -} - -// Apply sets the user's active color, or returns ErrNoOp if it can't. -func (c UserActiveColor) Apply(s *state.Synced) error { - if c.Color.A < 0xF { - return ErrNoTransparentColors - } - if s.User.ActiveColor == c.Color { - return ErrNoOp - } - s.User.ActiveColor = c.Color - return nil -} \ No newline at end of file diff --git a/server/magefile.go b/server/magefile.go index 15a365d..fcdcf89 100644 --- a/server/magefile.go +++ b/server/magefile.go @@ -4,11 +4,10 @@ import ( "context" "fmt" "git.reya.zone/reya/hexmap/build" + "git.reya.zone/reya/hexmap/proto" "github.com/magefile/mage/mg" "io/fs" "os" - "os/exec" - "path" "path/filepath" "strings" ) @@ -20,7 +19,7 @@ import ( type Protobuf mg.Namespace func (Protobuf) InstallGoPlugin(ctx context.Context) error { - alreadyDone, err := build.HasExecutableInBuildtools("protoc-gen-go") + alreadyDone, err := build.HasExecutableInTools("protoc-gen-go") if err != nil { return err } @@ -34,19 +33,17 @@ func (Protobuf) InstallPlugins(ctx context.Context) { mg.CtxDeps(ctx, Protobuf.InstallGoPlugin) } -func (Protobuf) Build(ctx context.Context) error { - mg.CtxDeps(ctx, Protobuf.Clean, Protobuf.InstallPlugins) - tooldir, err := build.GetBuildToolsDir() +func ProtocFlags() ([]string, error) { + buildPath, err := filepath.Abs(filepath.Join(build.ToolsDir, "protoc-gen-go")) if err != nil { - return err - } - goPluginPathFlag := fmt.Sprintf("--plugin=%s", path.Join(tooldir, "protoc-gen-go")) - cmd := exec.CommandContext(ctx, "protoc", goPluginPathFlag, "-I=proto", "--go_out=.", "--go_opt=module=git.reya.zone/reya/hexmap", "proto/action.proto", "proto/client.proto", "proto/coords.proto", "proto/map.proto", "proto/server.proto", "proto/user.proto") - cmd.Stderr = os.Stderr - if mg.Verbose() { - cmd.Stdout = os.Stdout + return nil, err } - return cmd.Run() + return []string{"--plugin=" + buildPath, "--go_out=.", "--go_opt=module=git.reya.zone/reya/hexmap"}, nil +} + +func (Protobuf) Build(ctx context.Context) error { + mg.SerialCtxDeps(ctx, Protobuf.Clean, Protobuf.InstallPlugins) + return proto.Compile(ctx, []proto.ProtocFlagsFunc{ProtocFlags}) } func (Protobuf) Clean(ctx context.Context) error { diff --git a/server/room/actor.go b/server/room/actor.go index 94d3057..7809484 100644 --- a/server/room/actor.go +++ b/server/room/actor.go @@ -24,7 +24,7 @@ func (r *room) act() { case RefreshRequest: r.sendRefresh(msg.id) case ApplyRequest: - msgLogger.Debug("Received action to apply from client", zap.Int("actionId", msg.action.ID)) + msgLogger.Debug("Received action to apply from client", zap.Uint32("actionId", msg.action.ID)) result := r.applyAction(msg.action.Action) if result != nil { r.broadcastAction(client, msg.action.ID, msg.action.Action) @@ -126,8 +126,8 @@ 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 int, action action.Action) { - logger := r.logger.With(zap.Stringer("originalClient", originalClientID), zap.Int("actionID", originalActionID), zap.Object("action", action)) +func (r *room) broadcastAction(originalClientID xid.ID, originalActionID uint32, action action.Action) { + logger := r.logger.With(zap.Stringer("originalClient", originalClientID), zap.Uint32("actionID", originalActionID), zap.Object("action", action)) broadcast := ActionBroadcast{ id: r.id, originalClientID: originalClientID, @@ -144,8 +144,8 @@ func (r *room) broadcastAction(originalClientID xid.ID, originalActionID int, ac } // acknowledgeAction sends a response to the original client which requested an action. -func (r *room) acknowledgeAction(id xid.ID, actionID int, error error) { - logger := r.logger.With(zap.Stringer("id", id), zap.Int("actionId", actionID), zap.Error(error)) +func (r *room) acknowledgeAction(id xid.ID, actionID uint32, error error) { + logger := r.logger.With(zap.Stringer("id", id), zap.Uint32("actionId", actionID), zap.Error(error)) logger.Debug("Responding to client with the status of its action") client, ok := r.clients[id] if !ok { diff --git a/server/room/client.go b/server/room/client.go index d226986..faf9af1 100644 --- a/server/room/client.go +++ b/server/room/client.go @@ -110,7 +110,7 @@ func (c *Client) NewClient(opts NewClientOptions) *Client { return newClientForRoom(c.roomId, c.outgoingChannel, opts) } -// The message created by Refresh 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 { if c.shuttingDown { panic("Already started shutting down; no new messages should be sent") @@ -120,7 +120,7 @@ func (c *Client) Refresh() RefreshRequest { } } -// The message created by Leave 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. // 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. @@ -135,7 +135,7 @@ func (c *Client) Leave() LeaveRequest { } } -// The message created by Stop causes the local client to signal that it is shutting down. +// Stop creates a message which causes the local client to signal that it is shutting down. // It is important to Stop when the room needs to be shut down. // After sending Stop, the client must confirm that it has been removed by waiting for a ShutdownRequest, which should // be handled normally. diff --git a/server/room/clientmessage.go b/server/room/clientmessage.go index 4254db6..062dae9 100644 --- a/server/room/clientmessage.go +++ b/server/room/clientmessage.go @@ -9,7 +9,7 @@ import ( // ClientMessage marks messages coming from clients to the room. type ClientMessage interface { zapcore.ObjectMarshaler - // SourceID is the id of the client sending the message. + // ClientID is the id of the client sending the message. ClientID() xid.ID } diff --git a/server/room/message.go b/server/room/message.go index 35e575b..9595d64 100644 --- a/server/room/message.go +++ b/server/room/message.go @@ -63,7 +63,7 @@ func (r RefreshResponse) CurrentState() *state.Synced { type ApplyResponse struct { id xid.ID // actionID is the ID of the action that completed or failed. - actionID int + actionID uint32 // result is nil if the action was completed, or an error if it failed. result error } @@ -71,7 +71,7 @@ type ApplyResponse struct { func (a ApplyResponse) MarshalLogObject(encoder zapcore.ObjectEncoder) error { encoder.AddString("type", "ApplyResponse") encoder.AddString("id", a.id.String()) - encoder.AddInt("actionId", a.actionID) + encoder.AddUint32("actionId", a.actionID) encoder.AddBool("success", a.result == nil) if a.result != nil { encoder.AddString("failure", a.result.Error()) @@ -99,7 +99,7 @@ type ActionBroadcast struct { // originalClientID is the client that sent the action in the first place. originalClientID xid.ID // originalActionID is the ID that the client that sent the action sent. - originalActionID int + originalActionID uint32 // action is the action that succeeded. action action.Action } @@ -108,7 +108,7 @@ func (a ActionBroadcast) MarshalLogObject(encoder zapcore.ObjectEncoder) error { encoder.AddString("type", "ActionBroadcast") encoder.AddString("id", a.id.String()) encoder.AddString("originalClientId", a.originalClientID.String()) - encoder.AddInt("originalActionId", a.originalActionID) + encoder.AddUint32("originalActionId", a.originalActionID) return encoder.AddObject("action", a.action) } diff --git a/server/state/color.go b/server/state/color.go new file mode 100644 index 0000000..fdab1cb --- /dev/null +++ b/server/state/color.go @@ -0,0 +1,46 @@ +package state + +import "fmt" + +// Color is an internal representation of a hexadecimal color string. +// It can take one of these formats: +// #RRGGBBAA +// #RRGGBB +// #RGBA +// #RGB +// When marshaling, it will always choose the most efficient format, but any format can be used when unmarshaling. +type Color struct { + // R is the red component of the color. + R uint8 + // G is the green component of the color. + G uint8 + // B is the blue component of the color. + B uint8 + // A is the alpha component of the color. + A uint8 +} + +// String prints the Color as an abbreviated notation. +// Specifically, short form is used when possible (i.e., when all components are evenly divisible by 0x11). +// The alpha component is left out if it's 0xFF. +func (c Color) String() string { + if c.R%0x11 == 0 && c.G%0x11 == 0 && c.B%0x11 == 0 && c.A%0x11 == 0 { + // Short form works. + if c.A == 0xFF { + // It's great when it's easy! + return fmt.Sprintf("#%01X%01X%01X", c.R/0x11, c.G/0x11, c.B/0x11) + } else { + // Just need to add the alpha. + return fmt.Sprintf("#%01X%01X%01X%01X", c.R/0x11, c.G/0x11, c.B/0x11, c.A/0x11) + } + } else { + // Gotta use long form. + if c.A == 0xFF { + // Can skip the alpha channel, though. + return fmt.Sprintf("#%02X%02X%02X", c.R, c.G, c.B) + } else { + // Doing things the hard way. + return fmt.Sprintf("#%02X%02X%02X%02X", c.R, c.G, c.B, c.A) + } + } +} diff --git a/server/state/color.pbconv.go b/server/state/color.pbconv.go new file mode 100644 index 0000000..4549633 --- /dev/null +++ b/server/state/color.pbconv.go @@ -0,0 +1,16 @@ +package state + +// ColorFromInt decodes a packed uint32 into a hex color. +func ColorFromInt(value uint32) Color { + return Color{ + R: uint8((value >> 24) & 0xFF), + G: uint8((value >> 16) & 0xFF), + B: uint8((value >> 8) & 0xFF), + A: uint8((value >> 0) & 0xFF), + } +} + +// ToInt packs a hex color into a uint32. +func (c Color) ToInt() uint32 { + return uint32(c.R)<<24 | uint32(c.G)<<16 | uint32(c.B)<<8 | uint32(c.A) +} diff --git a/server/state/coordinates.go b/server/state/coords.go similarity index 93% rename from server/state/coordinates.go rename to server/state/coords.go index 6ca05e1..a22a28b 100644 --- a/server/state/coordinates.go +++ b/server/state/coords.go @@ -1,6 +1,8 @@ package state -import "go.uber.org/zap/zapcore" +import ( + "go.uber.org/zap/zapcore" +) // StorageCoordinates gives the coordinates of a cell in a form optimized for storage. type StorageCoordinates struct { diff --git a/server/state/coords.pbconv.go b/server/state/coords.pbconv.go new file mode 100644 index 0000000..a8e8f47 --- /dev/null +++ b/server/state/coords.pbconv.go @@ -0,0 +1,25 @@ +package state + +import ( + "errors" + "math" +) + +var ErrOutOfBounds = errors.New("coordinate was out of bounds") + +func (x *StorageCoordinatesPB) ToGo() (StorageCoordinates, error) { + if x.Line > math.MaxUint8 || x.Cell > math.MaxUint8 { + return StorageCoordinates{}, ErrOutOfBounds + } + return StorageCoordinates{ + Line: uint8(x.Line), + Cell: uint8(x.Cell), + }, nil +} + +func (s StorageCoordinates) ToPB() *StorageCoordinatesPB { + return &StorageCoordinatesPB{ + Line: uint32(s.Line), + Cell: uint32(s.Cell), + } +} diff --git a/server/state/hexcolor.go b/server/state/hexcolor.go deleted file mode 100644 index 9f2a5c9..0000000 --- a/server/state/hexcolor.go +++ /dev/null @@ -1,104 +0,0 @@ -package state - -import "fmt" - -// HexColor is an internal representation of a hexadecimal color string. -// It can take one of these formats: -// #RRGGBBAA -// #RRGGBB -// #RGBA -// #RGB -// When marshaling, it will always choose the most efficient format, but any format can be used when unmarshaling. -type HexColor struct { - // R is the red component of the color. - R uint8 - // G is the green component of the color. - G uint8 - // B is the blue component of the color. - B uint8 - // A is the alpha component of the color. - A uint8 -} - -// HexColorFromString decodes a hexadecimal string into a hex color. -func HexColorFromString(text string) (HexColor, error) { - var hex HexColor - if err := (&hex).UnmarshalText([]byte(text)); err != nil { - return hex, err - } - return hex, nil -} - -func (h *HexColor) UnmarshalText(text []byte) error { - var count, expected int - var short bool - var scanErr error - switch len(text) { - case 9: - // Long form with alpha: #RRGGBBAA - expected = 4 - short = false - count, scanErr = fmt.Sscanf(string(text), "#%02X%02X%02X%02X", &h.R, &h.G, &h.B, &h.A) - case 7: - // Long form: #RRGGBB - expected = 3 - h.A = 0xFF - count, scanErr = fmt.Sscanf(string(text), "#%02X%02X%02X", &h.R, &h.G, &h.B) - case 5: - // Short form with alpha: #RGBA - expected = 4 - short = true - count, scanErr = fmt.Sscanf(string(text), "#%01X%01X%01X%01X", &h.R, &h.G, &h.B, &h.A) - case 4: - // Short form: #RGB - expected = 3 - h.A = 0xF - short = true - count, scanErr = fmt.Sscanf(string(text), "#%01X%01X%01X", &h.R, &h.G, &h.B) - default: - return fmt.Errorf("can't decode %s as HexColor: wrong length", text) - } - if scanErr != nil { - return scanErr - } - if count != expected { - return fmt.Errorf("can't decode %s as HexColor: missing components", text) - } - if short { - h.R *= 0x11 - h.G *= 0x11 - h.B *= 0x11 - h.A *= 0x11 - } - return nil -} - -// MarshalText marshals the HexColor into a small string. -func (h HexColor) MarshalText() (text []byte, err error) { - return []byte(h.String()), nil -} - -// String prints the HexColor as an abbreviated notation. -// Specifically, short form is used when possible (i.e., when all components are evenly divisible by 0x11). -// The alpha component is left out if it's 0xFF. -func (h HexColor) String() string { - if h.R%0x11 == 0 && h.G%0x11 == 0 && h.B%0x11 == 0 && h.A%0x11 == 0 { - // Short form works. - if h.A == 0xFF { - // It's great when it's easy! - return fmt.Sprintf("#%01X%01X%01X", h.R/0x11, h.G/0x11, h.B/0x11) - } else { - // Just need to add the alpha. - return fmt.Sprintf("#%01X%01X%01X%01X", h.R/0x11, h.G/0x11, h.B/0x11, h.A/0x11) - } - } else { - // Gotta use long form. - if h.A == 0xFF { - // Can skip the alpha channel, though. - return fmt.Sprintf("#%02X%02X%02X", h.R, h.G, h.B) - } else { - // Doing things the hard way. - return fmt.Sprintf("#%02X%02X%02X%02X", h.R, h.G, h.B, h.A) - } - } -} diff --git a/server/state/hexorientation.go b/server/state/hexorientation.go deleted file mode 100644 index 60c3449..0000000 --- a/server/state/hexorientation.go +++ /dev/null @@ -1,51 +0,0 @@ -package state - -import "fmt" - -// HexOrientation is the enum for the direction hexes are facing. -type HexOrientation uint8 - -const ( - // PointyTop indicates hexes that have a pair of sides on either side in the horizontal direction, - // and points on the top and bottom in the vertical direction. - PointyTop HexOrientation = 1 - // FlatTop indicates hexes that have a pair of points on either side in the horizontal direction, - // and sides on the top and bottom in the vertical direction. - FlatTop HexOrientation = 2 -) - -// UnmarshalText unmarshals from the equivalent Javascript constant name. -func (o *HexOrientation) UnmarshalText(text []byte) error { - switch string(text) { - case "POINTY_TOP": - *o = PointyTop - return nil - case "FLAT_TOP": - *o = FlatTop - return nil - default: - return fmt.Errorf("can't unmarshal unknown HexOrientation %s", text) - } -} - -// MarshalText marshals into the equivalent JavaScript constant name. -func (o HexOrientation) MarshalText() (text []byte, err error) { - switch o { - case PointyTop, FlatTop: - return []byte(o.String()), nil - default: - return nil, fmt.Errorf("can't marshal unknown HexOrientation %d", o) - } -} - -// String returns the equivalent JavaScript constant name. -func (o HexOrientation) String() string { - switch o { - case PointyTop: - return "POINTY_TOP" - case FlatTop: - return "FLAT_TOP" - default: - return fmt.Sprintf("[unknown HexOrientation %d]", o) - } -} diff --git a/server/state/lineparity.go b/server/state/lineparity.go deleted file mode 100644 index 0f08027..0000000 --- a/server/state/lineparity.go +++ /dev/null @@ -1,49 +0,0 @@ -package state - -import "fmt" - -// LineParity indicates whether odd or even lines are indented. -type LineParity uint8 - -const ( - // OddLines indicates that odd lines - 1, 3, 5... - are indented by 1/2 cell. - OddLines LineParity = 1 - // EvenLines indicates that even lines - 0, 2, 4... - are indented by 1/2 cell. - EvenLines LineParity = 2 -) - -// UnmarshalText unmarshals from the equivalent Javascript constant name. -func (o *LineParity) UnmarshalText(text []byte) error { - switch string(text) { - case "ODD": - *o = OddLines - return nil - case "EVEN": - *o = EvenLines - return nil - default: - return fmt.Errorf("can't unmarshal unknown LineParity %s", text) - } -} - -// MarshalText marshals into the equivalent JavaScript constant name. -func (o LineParity) MarshalText() (text []byte, err error) { - switch o { - case OddLines, EvenLines: - return []byte(o.String()), nil - default: - return nil, fmt.Errorf("can't marshal unknown LineParity %d", o) - } -} - -// String returns the equivalent JavaScript constant name. -func (o LineParity) String() string { - switch o { - case OddLines: - return "ODD_LINES" - case EvenLines: - return "EVEN_LINES" - default: - return fmt.Sprintf("[unknown LineParity %d]", o) - } -} diff --git a/server/state/hexmap.go b/server/state/map.go similarity index 58% rename from server/state/hexmap.go rename to server/state/map.go index 24e7291..fa1a2ba 100644 --- a/server/state/hexmap.go +++ b/server/state/map.go @@ -6,22 +6,72 @@ import ( "go.uber.org/zap/zapcore" ) -// HexMapRepresentation combines HexOrientation and LineParity to represent a map's display mode. -type HexMapRepresentation struct { +// HexOrientation is the enum for the direction hexes are facing. +type HexOrientation uint8 + +const ( + // UnknownOrientation indicates that an invalid orientation was specified. + UnknownOrientation HexOrientation = 0 + // PointyTop indicates hexes that have a pair of sides on either side in the horizontal direction, + // and points on the top and bottom in the vertical direction. + PointyTop HexOrientation = 1 + // FlatTop indicates hexes that have a pair of points on either side in the horizontal direction, + // and sides on the top and bottom in the vertical direction. + FlatTop HexOrientation = 2 +) + +// String returns the equivalent JavaScript constant name. +func (o HexOrientation) String() string { + switch o { + case PointyTop: + return "POINTY_TOP" + case FlatTop: + return "FLAT_TOP" + default: + return fmt.Sprintf("[unknown HexOrientation %d]", o) + } +} + +// LineParity indicates whether odd or even lines are indented. +type LineParity uint8 + +const ( + // UnknownParity indicates that parity was not specified or unknown. + UnknownParity LineParity = 0 + // OddLines indicates that odd lines - 1, 3, 5... - are indented by 1/2 cell. + OddLines LineParity = 1 + // EvenLines indicates that even lines - 0, 2, 4... - are indented by 1/2 cell. + EvenLines LineParity = 2 +) + +// String returns the equivalent JavaScript constant name. +func (o LineParity) String() string { + switch o { + case OddLines: + return "ODD_LINES" + case EvenLines: + return "EVEN_LINES" + default: + return fmt.Sprintf("[unknown LineParity %d]", o) + } +} + +// Layout combines HexOrientation and LineParity to represent a map's display mode. +type Layout struct { Orientation HexOrientation `json:"orientation"` IndentedLines LineParity `json:"indentedLines"` } -func (h HexMapRepresentation) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("orientation", h.Orientation.String()) - encoder.AddString("indentedLines", h.IndentedLines.String()) +func (l Layout) MarshalLogObject(encoder zapcore.ObjectEncoder) error { + encoder.AddString("orientation", l.Orientation.String()) + encoder.AddString("indentedLines", l.IndentedLines.String()) return nil } // HexCell contains data for a single cell of the map. type HexCell struct { // Color contains the color of the cell, in hex notation. - Color HexColor `json:"color"` + Color Color `json:"color"` } func (h HexCell) MarshalLogObject(encoder zapcore.ObjectEncoder) error { @@ -98,20 +148,20 @@ type HexMap struct { // CellsPerLine is the rough number of columns (in PointyTop orientation) or rows (in FlatTop orientation). // This is the number of cells joined together, flat-edge to flat-edge, in each line. CellsPerLine uint8 `json:"cellsPerLine"` - // DisplayMode is the orientation and line parity used to display the map. - DisplayMode HexMapRepresentation `json:"displayMode"` - // LineCells contains the actual map data. - // LineCells itself is a slice with Lines elements, each of which is a line; + // Layout is the orientation and line parity used to display the map. + Layout Layout `json:"displayMode"` + // 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. - LineCells HexLayer `json:"lineCells"` + Layer HexLayer `json:"lineCells"` } 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.DisplayMode) - lineCellsErr := encoder.AddArray("lineCells", m.LineCells) + displayModeErr := encoder.AddObject("displayMode", m.Layout) + lineCellsErr := encoder.AddArray("lineCells", m.Layer) if displayModeErr != nil { return displayModeErr } else { @@ -125,7 +175,7 @@ func (m HexMap) Copy() HexMap { XID: m.XID, Lines: m.Lines, CellsPerLine: m.CellsPerLine, - DisplayMode: m.DisplayMode, - LineCells: m.LineCells.Copy(), + Layout: m.Layout, + Layer: m.Layer.Copy(), } } diff --git a/server/state/map.pbconv.go b/server/state/map.pbconv.go new file mode 100644 index 0000000..2afdab2 --- /dev/null +++ b/server/state/map.pbconv.go @@ -0,0 +1,142 @@ +package state + +import ( + "github.com/rs/xid" + "math" +) + +func (x HexMapPB_Layout_Orientation) ToGo() HexOrientation { + switch x { + case HexMapPB_Layout_POINTY_TOP: + return PointyTop + case HexMapPB_Layout_FLAT_TOP: + return FlatTop + default: + return UnknownOrientation + } +} + +func (o HexOrientation) ToPB() HexMapPB_Layout_Orientation { + switch o { + case PointyTop: + return HexMapPB_Layout_POINTY_TOP + case FlatTop: + return HexMapPB_Layout_FLAT_TOP + default: + return HexMapPB_Layout_UNKNOWN_ORIENTATION + } +} + +func (x HexMapPB_Layout_LineParity) ToGo() LineParity { + switch x { + case HexMapPB_Layout_EVEN: + return EvenLines + case HexMapPB_Layout_ODD: + return OddLines + default: + return UnknownParity + } +} + +func (o LineParity) ToPB() HexMapPB_Layout_LineParity { + switch o { + case OddLines: + return HexMapPB_Layout_ODD + case EvenLines: + return HexMapPB_Layout_EVEN + default: + return HexMapPB_Layout_UNKNOWN_LINE + } +} + +func (x *HexMapPB_Layout) ToGo() Layout { + return Layout{ + Orientation: x.Orientation.ToGo(), + IndentedLines: x.IndentedLines.ToGo(), + } +} + +func (l Layout) ToPB() *HexMapPB_Layout { + return &HexMapPB_Layout{ + Orientation: l.Orientation.ToPB(), + IndentedLines: l.IndentedLines.ToPB(), + } +} + +func (x *HexCellPB) ToGo() HexCell { + return HexCell{ + Color: ColorFromInt(x.Color), + } +} + +func (h HexCell) ToPB() *HexCellPB { + return &HexCellPB{ + Color: h.Color.ToInt(), + } +} + +func (x *HexLinePB) ToGo() HexLine { + r := make(HexLine, len(x.Cells)) + for index, cell := range x.Cells { + r[index] = cell.ToGo() + } + return r +} + +func (l HexLine) ToPB() *HexLinePB { + cells := make([]*HexCellPB, len(l)) + for index, cell := range l { + cells[index] = cell.ToPB() + } + return &HexLinePB{ + Cells: cells, + } +} + +func (x *HexLayerPB) ToGo() HexLayer { + r := make(HexLayer, len(x.Lines)) + for index, line := range x.Lines { + r[index] = line.ToGo() + } + return r +} + +func (l HexLayer) ToPB() *HexLayerPB { + lines := make([]*HexLinePB, len(l)) + for index, line := range l { + lines[index] = line.ToPB() + } + return &HexLayerPB{ + Lines: lines, + } +} + +func (x *HexMapPB) ToGo() (HexMap, error) { + pbId, err := xid.FromBytes(x.Xid) + if err != nil { + return HexMap{}, err + } + if x.Lines > math.MaxUint8 { + return HexMap{}, ErrOutOfBounds + } + if x.CellsPerLine > math.MaxUint8 { + return HexMap{}, ErrOutOfBounds + } + return HexMap{ + XID: pbId, + Lines: uint8(x.Lines), + CellsPerLine: uint8(x.CellsPerLine), + Layout: x.Layout.ToGo(), + Layer: x.Layer.ToGo(), + }, nil +} + +func (m HexMap) ToPB() *HexMapPB { + return &HexMapPB{ + Xid: m.XID.Bytes(), + Lines: uint32(m.Lines), + CellsPerLine: uint32(m.CellsPerLine), + Layout: m.Layout.ToPB(), + Layer: m.Layer.ToPB(), + } +} diff --git a/server/state/synced.go b/server/state/state.go similarity index 90% rename from server/state/synced.go rename to server/state/state.go index 782c0a9..b28e69a 100644 --- a/server/state/synced.go +++ b/server/state/state.go @@ -6,8 +6,8 @@ import ( // Synced contains all state that is synced between the server and its clients. type Synced struct { - Map HexMap `json:"map"` - User UserData `json:"user"` + Map HexMap `json:"map"` + User UserState `json:"user"` } func (s *Synced) MarshalLogObject(encoder zapcore.ObjectEncoder) error { diff --git a/server/state/state.pbconv.go b/server/state/state.pbconv.go new file mode 100644 index 0000000..62c7f91 --- /dev/null +++ b/server/state/state.pbconv.go @@ -0,0 +1,20 @@ +package state + +func (x *SyncableStatePB) ToGo() (Synced, error) { + pbMap, err := x.Map.ToGo() + if err != nil { + return Synced{}, err + } + user := x.User.ToGo() + return Synced{ + Map: pbMap, + User: user, + }, nil +} + +func (s Synced) ToPB() *SyncableStatePB { + return &SyncableStatePB{ + Map: s.Map.ToPB(), + User: s.User.ToPB(), + } +} diff --git a/server/state/user.go b/server/state/user.go index 8724b6a..6a6d103 100644 --- a/server/state/user.go +++ b/server/state/user.go @@ -2,20 +2,20 @@ package state import "go.uber.org/zap/zapcore" -// UserData contains data about clients that is synced between client and server. -// Unlike the map, UserData is not persisted to disk, and all UserData is lost on shutdown. -type UserData struct { - ActiveColor HexColor `json:"activeColor"` +// UserState contains data about clients that is synced between client and server. +// Unlike the map, UserState is not persisted to disk, and all UserState is lost on shutdown. +type UserState struct { + ActiveColor Color `json:"activeColor"` } -func (u UserData) MarshalLogObject(encoder zapcore.ObjectEncoder) error { +func (u UserState) MarshalLogObject(encoder zapcore.ObjectEncoder) error { encoder.AddString("activeColor", u.ActiveColor.String()) return nil } -// Copy creates a deep copy of this UserData. -func (u UserData) Copy() UserData { - return UserData{ +// Copy creates a deep copy of this UserState. +func (u UserState) Copy() UserState { + return UserState{ ActiveColor: u.ActiveColor, } } diff --git a/server/state/user.pbconv.go b/server/state/user.pbconv.go new file mode 100644 index 0000000..be9a135 --- /dev/null +++ b/server/state/user.pbconv.go @@ -0,0 +1,13 @@ +package state + +func (x *UserStatePB) ToGo() UserState { + return UserState{ + ActiveColor: ColorFromInt(x.Color), + } +} + +func (u UserState) ToPB() *UserStatePB { + return &UserStatePB{ + Color: u.ActiveColor.ToInt(), + } +} diff --git a/server/websocket/client.go b/server/websocket/client.go index c66a6eb..653ce13 100644 --- a/server/websocket/client.go +++ b/server/websocket/client.go @@ -8,34 +8,22 @@ import ( // ClientCommandType is an enum type for the client's protocol messages. type ClientCommandType string -const ( - ClientHelloType ClientCommandType = "HELLO" - ClientRefreshType ClientCommandType = "REFRESH" - ClientActType ClientCommandType = "ACT" - ClientGoodbyeType ClientCommandType = GoodbyeType - ClientMalformedCommandType ClientCommandType = "(malformed command)" -) - // ClientCommand s are those sent by the client. type ClientCommand interface { zapcore.ObjectMarshaler - // ClientType gives the type constant that will be sent on or read from the wire. - ClientType() ClientCommandType + // ToClientPB converts the command to a client protocol buffer which will be sent on the wire. + ToClientPB() *ClientCommandPB } // ClientHello is the command sent by the client when it first establishes the connection. type ClientHello struct { // Version is the protocol version the client is running. - Version int `json:"version"` -} - -func (c ClientHello) ClientType() ClientCommandType { - return ClientHelloType + Version uint32 `json:"version"` } func (c ClientHello) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ClientHelloType)) - encoder.AddInt("version", c.Version) + encoder.AddString("type", "Hello") + encoder.AddUint32("version", c.Version) return nil } @@ -43,25 +31,21 @@ func (c ClientHello) MarshalLogObject(encoder zapcore.ObjectEncoder) error { type ClientRefresh struct { } -func (c ClientRefresh) ClientType() ClientCommandType { - return ClientRefreshType -} - func (c ClientRefresh) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ClientRefreshType)) + encoder.AddString("type", "Refresh") 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 int `json:"id"` + ID uint32 `json:"id"` // Action contains the action that was actually being sent. - Action action.Action `json:"action"` + Action action.Client `json:"action"` } func (i IDed) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddInt("id", i.ID) + encoder.AddUint32("id", i.ID) return encoder.AddObject("action", i.Action) } @@ -84,12 +68,8 @@ type ClientAct struct { Actions IDPairs `json:"actions"` } -func (c ClientAct) ClientType() ClientCommandType { - return ClientActType -} - func (c ClientAct) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ClientActType)) + encoder.AddString("type", "Act") return encoder.AddArray("actions", c.Actions) } @@ -101,11 +81,7 @@ type ClientMalformed struct { } func (c ClientMalformed) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ClientMalformedCommandType)) + encoder.AddString("type", "(malformed command)") encoder.AddString("error", c.Error.Error()) return nil } - -func (c ClientMalformed) ClientType() ClientCommandType { - return ClientMalformedCommandType -} diff --git a/server/websocket/client.pbconv.go b/server/websocket/client.pbconv.go new file mode 100644 index 0000000..29b1cd6 --- /dev/null +++ b/server/websocket/client.pbconv.go @@ -0,0 +1,96 @@ +package websocket + +func (x *ClientCommandPB) ToGo() (ClientCommand, error) { + switch msg := x.Command.(type) { + case *ClientCommandPB_Hello: + return msg.Hello.ToGo(), nil + case *ClientCommandPB_Refresh: + return msg.Refresh.ToGo(), nil + case *ClientCommandPB_Act: + return msg.Act.ToGo() + default: + panic("A case was missed in ClientCommandPB.ToGo!") + } +} + +func (x *ClientHelloPB) ToGo() ClientHello { + return ClientHello{ + Version: x.Version, + } +} + +func (c ClientHello) ToClientPB() *ClientCommandPB { + return &ClientCommandPB{ + Command: &ClientCommandPB_Hello{ + Hello: &ClientHelloPB{ + Version: c.Version, + }, + }, + } +} + +func (*ClientRefreshPB) ToGo() ClientRefresh { + return ClientRefresh{} +} + +func (c ClientRefresh) ToClientPB() *ClientCommandPB { + return &ClientCommandPB{ + Command: &ClientCommandPB_Refresh{ + Refresh: &ClientRefreshPB{}, + }, + } +} + +func (x *ClientActPB_IDed) ToGo() (IDed, error) { + action, err := x.Action.ToGo() + if err != nil { + return IDed{}, nil + } + return IDed{ + ID: x.Id, + Action: action, + }, nil +} + +func (i IDed) ToPB() *ClientActPB_IDed { + return &ClientActPB_IDed{ + Id: i.ID, + Action: i.Action.ToClientPB(), + } +} + +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 + } + actions[index] = action + } + return ClientAct{ + Actions: actions, + }, nil +} + +func (c ClientAct) ToClientPB() *ClientCommandPB { + actions := make([]*ClientActPB_IDed, len(c.Actions)) + for index, ided := range c.Actions { + actions[index] = ided.ToPB() + } + return &ClientCommandPB{ + Command: &ClientCommandPB_Act{ + Act: &ClientActPB{ + Actions: actions, + }, + }, + } +} + +func (ClientMalformed) ToClientPB() *ClientCommandPB { + return nil +} + +func (SocketClosed) ToClientPB() *ClientCommandPB { + return nil +} diff --git a/server/websocket/reader.go b/server/websocket/reader.go index a6d9a99..fc08ea5 100644 --- a/server/websocket/reader.go +++ b/server/websocket/reader.go @@ -1,19 +1,18 @@ package websocket import ( - "bytes" - "encoding/json" "fmt" "github.com/gorilla/websocket" "go.uber.org/zap" + "google.golang.org/protobuf/proto" "time" ) type reader struct { // conn is the connection to the client read from by the reader. - conn *websocket.Conn + conn *websocket.Conn // channel is the channel that the reader sends messages it has received on. - channel chan ClientCommand + channel chan ClientCommand // readNotifications is the channel that alerts are sent to the writer on, to let it know to readNotifications chan<- time.Time // logger is the logger used to record the state of the reader, primarily in Debug level. @@ -29,29 +28,6 @@ func (i InvalidMessageType) Error() string { return fmt.Sprintf("invalid message type %d", i.MessageType) } -// InvalidCommandType is placed in ClientMalformed when the type of the command was unknown. -type InvalidCommandType struct { - CommandType ClientCommandType -} - -func (i InvalidCommandType) Error() string { - return fmt.Sprintf("invalid command type %s", i.CommandType) -} - -// InvalidPayload is placed in ClientMalformed when the payload could not be parsed. -type InvalidPayload struct { - CommandType ClientCommandType - Cause error -} - -func (i InvalidPayload) Error() string { - return fmt.Sprintf("command type %s had invalid payload: %s", i.CommandType, i.Cause) -} - -func (i InvalidPayload) Unwrap() error { - return i.Cause -} - // act contains the main read loop of the reader. func (r *reader) act() { defer r.shutdown() @@ -72,14 +48,14 @@ func (r *reader) act() { 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 @@ -94,7 +70,7 @@ func (r *reader) act() { // parseCommand attempts to parse the incoming message func (r *reader) parseCommand(socketType int, data []byte) ClientCommand { - if socketType != websocket.TextMessage { + if socketType != websocket.BinaryMessage { err := &InvalidMessageType{ MessageType: socketType, } @@ -103,58 +79,19 @@ func (r *reader) parseCommand(socketType int, data []byte) ClientCommand { Error: err, } } - r.logger.Debug("Received command, parse") - parts := bytes.SplitN(data, []byte(" "), 2) - commandBytes := parts[0] - payloadJson := parts[1] - var command ClientCommandType - if len(payloadJson) == 0 { - // Since there's no payload, we expect the command to end with an exclamation point. - if bytes.HasSuffix(commandBytes, []byte("!")) { - command = ClientCommandType(bytes.TrimSuffix(commandBytes, []byte("!"))) - } else { - r.logger.Warn("Received command not fitting the protocol: has no payload but no ! after command type") - command = ClientCommandType(commandBytes) - } - } else { - command = ClientCommandType(commandBytes) - } - switch command { - case ClientHelloType: - hello := &ClientHello{} - err := json.Unmarshal(payloadJson, hello) - if err != nil { - return ClientMalformed{ - Error: &InvalidPayload{ - CommandType: command, - Cause: err, - }, - } - } - return hello - case ClientRefreshType: - if len(payloadJson) != 0 { - r.logger.Warn("Received command not fitting the protocol: has payload for payloadless Refresh command") - } - refresh := &ClientRefresh{} - return refresh - case ClientActType: - act := &ClientAct{} - err := json.Unmarshal(payloadJson, act) - if err != nil { - return ClientMalformed{ - Error: &InvalidPayload{ - CommandType: command, - Cause: err, - }, - } - } - return act - default: + r.logger.Debug("Received command, parsing") + var cmdPb ClientCommandPB + err := proto.Unmarshal(data, &cmdPb) + if err != nil { return ClientMalformed{ - Error: InvalidCommandType{CommandType: command}, + Error: err, } } + cmd, err := (&cmdPb).ToGo() + if err != nil { + return ClientMalformed{Error: err} + } + return cmd } // updateDeadlines extends the time limit for pongs, and instructs the writer to hold off on sending a ping for the next PingDelay. @@ -178,4 +115,4 @@ func (r *reader) shutdown() { close(r.readNotifications) r.readNotifications = nil r.conn = nil -} \ No newline at end of file +} diff --git a/server/websocket/server.go b/server/websocket/server.go index 499e2a2..1a34d38 100644 --- a/server/websocket/server.go +++ b/server/websocket/server.go @@ -9,40 +9,27 @@ import ( // ServerMessageType is an enum type for the server's messages. type ServerMessageType string -const ( - ServerHelloType ServerMessageType = "HELLO" - ServerRefreshType ServerMessageType = "REFRESH" - ServerOKType ServerMessageType = "OK" - ServerFailedType ServerMessageType = "FAILED" - ServerActType ServerMessageType = "ACT" - ServerGoodbyeType ServerMessageType = GoodbyeType -) - // ServerCommand s are sent by the server to the client. type ServerCommand interface { zapcore.ObjectMarshaler - // ServerType returns the type constant that will be sent on the wire. - ServerType() ServerMessageType + // ToServerPB converts the command to a server protocol buffer which will be sent on the wire. + ToServerPB() *ServerCommandPB } // ServerHello is the command sent to establish the current state of the server when a new client connects. type ServerHello struct { // Version is the protocol version the server is running. - Version int `json:"version"` + Version uint32 `json:"version"` // State is the complete state of the server as of when the client joined. State *state.Synced `json:"state"` } func (s ServerHello) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ServerHelloType)) - encoder.AddInt("version", s.Version) + encoder.AddString("type", "Hello") + encoder.AddUint32("version", s.Version) return encoder.AddObject("state", s.State) } -func (s ServerHello) ServerType() ServerMessageType { - return ServerHelloType -} - // ServerRefresh is the command sent to reestablish the current state of the server in response to ClientRefresh. type ServerRefresh struct { // State is the complete state of the server as of when the corresponding ClientRefresh was processed. @@ -50,19 +37,15 @@ type ServerRefresh struct { } func (s ServerRefresh) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ServerRefreshType)) + encoder.AddString("type", "Refresh") return encoder.AddObject("state", s.State) } -func (s ServerRefresh) ServerType() ServerMessageType { - return ServerRefreshType -} - -type IDSlice []int +type IDSlice []uint32 func (i IDSlice) MarshalLogArray(encoder zapcore.ArrayEncoder) error { for _, v := range i { - encoder.AppendInt(v) + encoder.AppendUint32(v) } return nil } @@ -76,14 +59,10 @@ type ServerOK struct { } func (s ServerOK) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ServerOKType)) + encoder.AddString("type", "OK") return encoder.AddArray("ids", s.IDs) } -func (s ServerOK) ServerType() ServerMessageType { - return ServerOKType -} - // ServerFailed is the command sent when one or more client actions have been rejected. type ServerFailed struct { // IDs contains the IDs of the actions which were rejected, in the order they were rejected. @@ -95,28 +74,20 @@ type ServerFailed struct { } func (s ServerFailed) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ServerFailedType)) + encoder.AddString("type", "Failed") err := encoder.AddArray("ids", s.IDs) encoder.AddString("error", s.Error) return err } -func (s ServerFailed) ServerType() ServerMessageType { - return ServerFailedType -} - // ServerAct is the command sent when one or more client actions from other clients have been accepted and applied. // The client's own actions will never be included in this command. type ServerAct struct { // Actions contains the actions that are now being applied. - Actions action.Slice `json:"actions"` + Actions action.ServerSlice `json:"actions"` } func (s ServerAct) MarshalLogObject(encoder zapcore.ObjectEncoder) error { - encoder.AddString("type", string(ServerActType)) + encoder.AddString("type", "Act") return encoder.AddArray("actions", s.Actions) } - -func (s ServerAct) ServerType() ServerMessageType { - return ServerActType -} diff --git a/server/websocket/server.pbconv.go b/server/websocket/server.pbconv.go new file mode 100644 index 0000000..0d7ee15 --- /dev/null +++ b/server/websocket/server.pbconv.go @@ -0,0 +1,136 @@ +package websocket + +import "git.reya.zone/reya/hexmap/server/action" + +func (x *ServerCommandPB) ToGo() (ServerCommand, error) { + switch msg := x.Command.(type) { + case *ServerCommandPB_Hello: + return msg.Hello.ToGo() + case *ServerCommandPB_Refresh: + return msg.Refresh.ToGo() + case *ServerCommandPB_Ok: + return msg.Ok.ToGo(), nil + case *ServerCommandPB_Failed: + return msg.Failed.ToGo(), nil + case *ServerCommandPB_Act: + return msg.Act.ToGo() + default: + panic("A case was missed in ServerCommandPB.ToGo!") + } +} + +func (x *ServerHelloPB) ToGo() (ServerHello, error) { + state, err := x.State.ToGo() + if err != nil { + return ServerHello{}, err + } + return ServerHello{ + Version: x.Version, + State: &state, + }, nil +} + +func (s ServerHello) ToServerPB() *ServerCommandPB { + return &ServerCommandPB{ + Command: &ServerCommandPB_Hello{ + Hello: &ServerHelloPB{ + Version: s.Version, + State: s.State.ToPB(), + }, + }, + } +} + +func (x *ServerRefreshPB) ToGo() (ServerRefresh, error) { + state, err := x.State.ToGo() + if err != nil { + return ServerRefresh{}, err + } + return ServerRefresh{ + State: &state, + }, nil +} + +func (s ServerRefresh) ToServerPB() *ServerCommandPB { + return &ServerCommandPB{ + Command: &ServerCommandPB_Refresh{ + Refresh: &ServerRefreshPB{ + State: s.State.ToPB(), + }, + }, + } +} + +func (x *ServerOKPB) ToGo() ServerOK { + ids := make(IDSlice, len(x.Ids)) + copy(ids, x.Ids) + return ServerOK{ + IDs: ids, + } +} + +func (s ServerOK) ToServerPB() *ServerCommandPB { + ids := make([]uint32, len(s.IDs)) + copy(ids, s.IDs) + return &ServerCommandPB{ + Command: &ServerCommandPB_Ok{ + Ok: &ServerOKPB{ + Ids: ids, + }, + }, + } +} + +func (x *ServerFailedPB) ToGo() ServerFailed { + ids := make(IDSlice, len(x.Ids)) + copy(ids, x.Ids) + return ServerFailed{ + IDs: ids, + Error: x.Error, + } +} + +func (s ServerFailed) ToServerPB() *ServerCommandPB { + ids := make([]uint32, len(s.IDs)) + copy(ids, s.IDs) + return &ServerCommandPB{ + Command: &ServerCommandPB_Failed{ + Failed: &ServerFailedPB{ + Ids: ids, + Error: s.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 + } + actions[index] = convertedAct + } + return ServerAct{ + Actions: actions, + }, nil +} + +func (s ServerAct) ToServerPB() *ServerCommandPB { + actions := make([]*action.ServerActionPB, len(s.Actions)) + for index, act := range s.Actions { + actions[index] = act.ToServerPB() + } + return &ServerCommandPB{ + Command: &ServerCommandPB_Act{ + Act: &ServerActPB{ + Actions: actions, + }, + }, + } +} + +func (SocketClosed) ToServerPB() *ServerCommandPB { + return nil +} diff --git a/server/websocket/shared.go b/server/websocket/shared.go index 6354d33..984935c 100644 --- a/server/websocket/shared.go +++ b/server/websocket/shared.go @@ -11,7 +11,7 @@ const ( GoodbyeType = "GOODBYE" ) -var StandardClientCloseTypes = []int { websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure } +var StandardClientCloseTypes = []int{websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure} // SocketClosed is synthesized when a client closes the WebSocket connection, or sent to the write process to write a // WebSocket close message. @@ -37,10 +37,6 @@ func (c SocketClosed) MarshalLogObject(encoder zapcore.ObjectEncoder) error { return nil } -func (c SocketClosed) ClientType() ClientCommandType { - return ClientGoodbyeType -} - func (c SocketClosed) ServerType() ServerMessageType { - return ServerGoodbyeType + return GoodbyeType } diff --git a/server/websocket/writer.go b/server/websocket/writer.go index 5b294ee..874893a 100644 --- a/server/websocket/writer.go +++ b/server/websocket/writer.go @@ -1,10 +1,9 @@ package websocket import ( - "encoding/json" - "fmt" "github.com/gorilla/websocket" "go.uber.org/zap" + "google.golang.org/protobuf/proto" "io" "time" ) @@ -94,9 +93,15 @@ func (w *writer) act() { // send actually transmits a ServerCommand to the client according to the protocol. func (w *writer) send(msg ServerCommand) { + w.logger.Debug("Marshaling command as protobuf", zap.Object("msg", msg)) + marshaled, err := proto.Marshal(msg.ToServerPB()) + if err != nil { + w.logger.Error("Error while marshaling to protobuf", zap.Error(err)) + return + } writeDeadline := time.Now().Add(WriteTimeLimit) w.logger.Debug("Setting deadline to write command", zap.Time("writeDeadline", writeDeadline)) - err := w.conn.SetWriteDeadline(writeDeadline) + err = w.conn.SetWriteDeadline(writeDeadline) if err != nil { w.logger.Error("Error while setting write deadline", zap.Time("writeDeadline", writeDeadline), zap.Object("msg", msg), zap.Error(err)) } @@ -114,27 +119,12 @@ func (w *writer) send(msg ServerCommand) { } w.logger.Debug("Command sent") }(writer) - w.logger.Debug("Marshaling command to JSON", zap.Object("msg", msg)) - payload, err := json.Marshal(msg) + _, err = writer.Write(marshaled) if err != nil { - w.logger.Error("Error while rendering command payload to JSON", zap.Object("msg", msg), zap.Error(err)) + w.logger.Error("Error while writing marshaled protobuf to connection", zap.Error(err)) return } - if len(payload) == 2 { - // This is an empty JSON message. We can leave it out. - w.logger.Debug("Empty payload, sending only command type", zap.String("type", string(msg.ServerType()))) - _, err = fmt.Fprintf(writer, "%s!", msg.ServerType()) - if err != nil { - w.logger.Error("Error while writing no-payload command", zap.Error(err), zap.Object("msg", msg)) - } - } else { - // Because we need to send this, we put in a space instead of an exclamation mark. - w.logger.Debug("Sending command with payload", zap.String("type", string(msg.ServerType())), zap.ByteString("payload", payload)) - _, err = fmt.Fprintf(writer, "%s %s", msg.ServerType(), payload) - if err != nil { - w.logger.Error("Error while writing command with payload", zap.Error(err), zap.Object("msg", msg)) - } - } + // Deferred close happens now } // sendClose sends a close message on the websocket connection, but does not actually close the connection. @@ -144,7 +134,7 @@ func (w *writer) sendClose(msg SocketClosed) { close(w.channel) w.channel = nil w.logger.Debug("Writing close message") - err := w.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(int(msg.Code), msg.Text), time.Now().Add(ControlTimeLimit)) + err := w.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(msg.Code, msg.Text), time.Now().Add(ControlTimeLimit)) if err != nil { w.logger.Warn("Error while sending close", zap.Error(err)) }