diff --git a/server/actions/client/client.go b/server/actions/client/client.go new file mode 100644 index 0000000..b391d6e --- /dev/null +++ b/server/actions/client/client.go @@ -0,0 +1,19 @@ +package client + +import "hexmap-server/actions/syncable" + +type Hello struct { + Version int `json:"version"` +} + +type Refresh struct { +} + +type SentAction struct { + Id int `json:"id"` + Action syncable.Action `json:"action"` +} + +type SentActions struct { + Nested []SentAction `json:"nested"` +} diff --git a/server/actions/server/server.go b/server/actions/server/server.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/server/actions/server/server.go @@ -0,0 +1 @@ +package server diff --git a/server/actions/syncable/action.go b/server/actions/syncable/action.go new file mode 100644 index 0000000..4b2f701 --- /dev/null +++ b/server/actions/syncable/action.go @@ -0,0 +1,18 @@ +package syncable + +import ( + "errors" + "hexmap-server/state" +) + +var ErrorNoOp error = errors.New("action's effects were already applied, or it's an empty action") + +// Action is the interface for actions that can be shared. +type Action interface { + // Apply causes the action's effects to be applied to s, mutating it in place. + // All syncable.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 ErrorNoOp. + // If an action is correctly applied and has an effect, it should return nil. + Apply(s *state.Synced) error +} diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..bdf7d9b --- /dev/null +++ b/server/go.mod @@ -0,0 +1,5 @@ +module hexmap-server + +go 1.16 + +require github.com/rs/xid v1.3.0 // indirect diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..47ea790 --- /dev/null +++ b/server/go.sum @@ -0,0 +1,2 @@ +github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= +github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= diff --git a/server/persistence/persistence.go b/server/persistence/persistence.go new file mode 100644 index 0000000..dc7cf83 --- /dev/null +++ b/server/persistence/persistence.go @@ -0,0 +1 @@ +package persistence diff --git a/server/state/coordinates.go b/server/state/coordinates.go new file mode 100644 index 0000000..4608586 --- /dev/null +++ b/server/state/coordinates.go @@ -0,0 +1,9 @@ +package state + +// StorageCoordinates gives the coordinates of a cell in a form optimized for storage. +type StorageCoordinates struct { + // Line is the index from 0 to Lines - 1 of the HexLine in the HexLayer. + Line uint8 `json:"line"` + // Cell is the index from 0 to CellsPerLine - 1 of the HexCell in the HexLine. + Cell uint8 `json:"cell"` +} diff --git a/server/state/hexcolor.go b/server/state/hexcolor.go new file mode 100644 index 0000000..9f2a5c9 --- /dev/null +++ b/server/state/hexcolor.go @@ -0,0 +1,104 @@ +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/hexmap.go b/server/state/hexmap.go new file mode 100644 index 0000000..b9282cb --- /dev/null +++ b/server/state/hexmap.go @@ -0,0 +1,83 @@ +package state + +import ( + "fmt" + "github.com/rs/xid" +) + +// HexMapRepresentation combines HexOrientation and LineParity to represent a map's display mode. +type HexMapRepresentation struct { + Orientation HexOrientation `json:"orientation"` + IndentedLines LineParity `json:"indentedLines"` +} + +// 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"` +} + +// HexLine is a line of cells which are adjacent by flat sides in a vertical or horizontal direction. +type HexLine []HexCell + +// Copy creates a deep copy of this HexLine. +func (l HexLine) Copy() HexLine { + duplicate := make(HexLine, len(l)) + for index, value := range l { + duplicate[index] = value + } + return duplicate +} + +// HexLayer is a two-dimensional plane of cells which are arranged into lines. +type HexLayer []HexLine + +// GetCellAt returns a reference to the cell at the given coordinates. +func (l HexLayer) GetCellAt(c StorageCoordinates) (*HexCell, error) { + 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) { + return nil, fmt.Errorf("cell %d out of bounds (%d)", c.Cell, len(line)) + } + return &(line[c.Cell]), nil +} + +// Copy creates a deep copy of this HexLayer. +func (l HexLayer) Copy() HexLayer { + duplicate := make(HexLayer, len(l)) + for index, value := range l { + duplicate[index] = value.Copy() + } + return duplicate +} + +// HexMap contains the data for a map instance. +type HexMap struct { + // Xid is the unique ID of the HexMap, used to encourage clients not to blindly interact with a different map. + Xid xid.ID `json:"xid"` + // Lines is the rough number of rows (in PointyTop orientation) or columns (in FlatTop orientation) in the map. + // Because different lines will be staggered, it's somewhat hard to see. + Lines uint8 `json:"lines"` + // 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; + // each of those lines is a slice of CellsPerLine cells. + LineCells HexLayer `json:"lineCells"` +} + +// Copy creates a deep copy of this HexMap. +func (m HexMap) Copy() HexMap { + return HexMap{ + Xid: m.Xid, + Lines: m.Lines, + CellsPerLine: m.CellsPerLine, + DisplayMode: m.DisplayMode, + LineCells: m.LineCells.Copy(), + } +} diff --git a/server/state/hexorientation.go b/server/state/hexorientation.go new file mode 100644 index 0000000..60c3449 --- /dev/null +++ b/server/state/hexorientation.go @@ -0,0 +1,51 @@ +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 new file mode 100644 index 0000000..0f08027 --- /dev/null +++ b/server/state/lineparity.go @@ -0,0 +1,49 @@ +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/synced.go b/server/state/synced.go new file mode 100644 index 0000000..5a98c02 --- /dev/null +++ b/server/state/synced.go @@ -0,0 +1,15 @@ +package state + +// Synced contains all state that is synced between the server and its clients. +type Synced struct { + Map HexMap `json:"map"` + User UserData `json:"user"` +} + +// Copy creates a deep copy of this Synced instance. +func (s Synced) Copy() Synced { + return Synced{ + Map: s.Map.Copy(), + User: s.User.Copy(), + } +} diff --git a/server/state/user.go b/server/state/user.go new file mode 100644 index 0000000..9d585c4 --- /dev/null +++ b/server/state/user.go @@ -0,0 +1,14 @@ +package state + +// 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:"active_color"` +} + +// Copy creates a deep copy of this UserData. +func (u UserData) Copy() UserData { + return UserData{ + ActiveColor: u.ActiveColor, + } +}