ui/phase-14: rename planet end-to-end + order read-back

Wires the first end-to-end command through the full pipeline:
inspector rename action → local order draft → user.games.order
submit → optimistic overlay on map / inspector → server hydration
on cache miss via the new user.games.order.get message type.

Backend: GET /api/v1/user/games/{id}/orders forwards to engine
GET /api/v1/order. Gateway parses the engine PUT response into the
extended UserGamesOrderResponse FBS envelope and adds
executeUserGamesOrderGet for the read-back path. Frontend ports
ValidateTypeName to TS, lands the inline rename editor + Submit
button, and exposes a renderedReport context so consumers see the
overlay-applied snapshot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-09 11:50:09 +02:00
parent 381e41b325
commit f80c623a74
86 changed files with 7505 additions and 138 deletions
+21
View File
@@ -18,6 +18,12 @@ const MessageTypeUserGamesCommand = "user.games.command"
// FlatBuffers `order.UserGamesOrder`.
const MessageTypeUserGamesOrder = "user.games.order"
// MessageTypeUserGamesOrderGet is the authenticated gateway message
// type used to read back the player's stored order for a given turn
// through `GET /api/v1/user/games/{game_id}/orders?turn=N`. The
// signed payload is a FlatBuffers `order.UserGamesOrderGet`.
const MessageTypeUserGamesOrderGet = "user.games.order.get"
// UserGamesCommand is the typed payload of MessageTypeUserGamesCommand.
// `GameID` selects the running engine container; `Commands` is the
// player command batch executed atomically by the engine. The `Actor`
@@ -54,6 +60,21 @@ func (o *UserGamesOrder) UnmarshalBinary(data []byte) error {
return json.Unmarshal(data, o)
}
// UserGamesOrderGet is the typed payload of
// MessageTypeUserGamesOrderGet. `Turn` is mandatory and must be
// non-negative; the caller pulls it from the lobby record at game
// boot. Backend rebinds the player from the runtime player mapping
// before forwarding to the engine.
type UserGamesOrderGet struct {
// GameID identifies the running game whose order is being
// read back.
GameID uuid.UUID `json:"game_id"`
// Turn selects the turn the stored order belongs to. Negative
// values are invalid.
Turn int `json:"turn"`
}
func AsCommand[E DecodableCommand](c DecodableCommand) (result E, ok bool) {
if v, ok := c.(E); ok {
return v, true
+9 -2
View File
@@ -2,8 +2,15 @@
## Generating sources
Given a `.fbs` file, source code can be generated using `flatc` command:
Given a `.fbs` file, source code can be generated using `flatc` from
this directory:
```shell
flatc --go {file}.fbs
flatc --go --go-module-name galaxy/schema/fbs {file}.fbs
```
The `--go-module-name` flag rewrites cross-namespace imports to the
fully-qualified module path (e.g. `common "galaxy/schema/fbs/common"`)
so the generated code links inside this Go module without local
replace directives. Omitting the flag yields imports such as
`common "common"` which fail to resolve.
+28 -3
View File
@@ -220,6 +220,31 @@ table UserGamesOrder {
// — kept as a typed envelope for future extension.
table UserGamesCommandResponse {}
// UserGamesOrderResponse is the success acknowledgement returned for
// `MessageTypeUserGamesOrder`. Mirrors `UserGamesCommandResponse`.
table UserGamesOrderResponse {}
// UserGamesOrderResponse mirrors the engine's `PUT /api/v1/order`
// success body: it echoes the stored order back to the caller with
// the engine-assigned `updated_at` timestamp and per-command
// `cmd_applied` / `cmd_error_code` populated on every entry.
table UserGamesOrderResponse {
game_id: common.UUID;
updated_at: int64;
commands: [CommandItem];
}
// UserGamesOrderGet is the signed-gRPC request payload for
// `MessageTypeUserGamesOrderGet`. Fetches the player's stored order
// for the given turn — the caller always knows the current turn from
// the lobby record so `turn` is required and must be non-negative.
table UserGamesOrderGet {
game_id: common.UUID (required);
turn: int64;
}
// UserGamesOrderGetResponse carries the result of
// `MessageTypeUserGamesOrderGet`. `found = false` is how the FBS
// envelope conveys the engine's `204 No Content` (no order stored
// for this player on this turn). When `found = true`, `order` is
// the engine's stored order for the turn.
table UserGamesOrderGetResponse {
found: bool;
order: UserGamesOrder;
}
+82
View File
@@ -0,0 +1,82 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package order
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type UserGamesOrderGet struct {
_tab flatbuffers.Table
}
func GetRootAsUserGamesOrderGet(buf []byte, offset flatbuffers.UOffsetT) *UserGamesOrderGet {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UserGamesOrderGet{}
x.Init(buf, n+offset)
return x
}
func FinishUserGamesOrderGetBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUserGamesOrderGet(buf []byte, offset flatbuffers.UOffsetT) *UserGamesOrderGet {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UserGamesOrderGet{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUserGamesOrderGetBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UserGamesOrderGet) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UserGamesOrderGet) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UserGamesOrderGet) GameId(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *UserGamesOrderGet) Turn() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *UserGamesOrderGet) MutateTurn(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func UserGamesOrderGetStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func UserGamesOrderGetAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func UserGamesOrderGetAddTurn(builder *flatbuffers.Builder, turn int64) {
builder.PrependInt64Slot(1, turn, 0)
}
func UserGamesOrderGetEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
@@ -0,0 +1,80 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package order
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type UserGamesOrderGetResponse struct {
_tab flatbuffers.Table
}
func GetRootAsUserGamesOrderGetResponse(buf []byte, offset flatbuffers.UOffsetT) *UserGamesOrderGetResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &UserGamesOrderGetResponse{}
x.Init(buf, n+offset)
return x
}
func FinishUserGamesOrderGetResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsUserGamesOrderGetResponse(buf []byte, offset flatbuffers.UOffsetT) *UserGamesOrderGetResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &UserGamesOrderGetResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedUserGamesOrderGetResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *UserGamesOrderGetResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *UserGamesOrderGetResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UserGamesOrderGetResponse) Found() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *UserGamesOrderGetResponse) MutateFound(n bool) bool {
return rcv._tab.MutateBoolSlot(4, n)
}
func (rcv *UserGamesOrderGetResponse) Order(obj *UserGamesOrder) *UserGamesOrder {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(UserGamesOrder)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func UserGamesOrderGetResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func UserGamesOrderGetResponseAddFound(builder *flatbuffers.Builder, found bool) {
builder.PrependBoolSlot(0, found, false)
}
func UserGamesOrderGetResponseAddOrder(builder *flatbuffers.Builder, order flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(order), 0)
}
func UserGamesOrderGetResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60 -1
View File
@@ -4,6 +4,8 @@ package order
import (
flatbuffers "github.com/google/flatbuffers/go"
common "galaxy/schema/fbs/common"
)
type UserGamesOrderResponse struct {
@@ -41,8 +43,65 @@ func (rcv *UserGamesOrderResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *UserGamesOrderResponse) GameId(obj *common.UUID) *common.UUID {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := o + rcv._tab.Pos
if obj == nil {
obj = new(common.UUID)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *UserGamesOrderResponse) UpdatedAt() int64 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt64(o + rcv._tab.Pos)
}
return 0
}
func (rcv *UserGamesOrderResponse) MutateUpdatedAt(n int64) bool {
return rcv._tab.MutateInt64Slot(6, n)
}
func (rcv *UserGamesOrderResponse) Commands(obj *CommandItem, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *UserGamesOrderResponse) CommandsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func UserGamesOrderResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(0)
builder.StartObject(3)
}
func UserGamesOrderResponseAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func UserGamesOrderResponseAddUpdatedAt(builder *flatbuffers.Builder, updatedAt int64) {
builder.PrependInt64Slot(1, updatedAt, 0)
}
func UserGamesOrderResponseAddCommands(builder *flatbuffers.Builder, commands flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(commands), 0)
}
func UserGamesOrderResponseStartCommandsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func UserGamesOrderResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
+321 -5
View File
@@ -1,6 +1,7 @@
package transcoder
import (
"encoding/json"
"errors"
"fmt"
@@ -9,8 +10,117 @@ import (
fbs "galaxy/schema/fbs/order"
flatbuffers "github.com/google/flatbuffers/go"
"github.com/google/uuid"
)
// JSONToUserGamesOrder decodes the engine's JSON response body for
// `PUT /api/v1/order` and `GET /api/v1/order` into the typed
// `*model.UserGamesOrder`. The model's `Commands` field is an
// interface (`order.DecodableCommand`), so plain `json.Unmarshal`
// can't reach it — this helper performs the same per-`@type`
// dispatch as `game/internal/repo.ParseOrder`, but stays inside the
// shared transcoder so non-engine callers (the gateway, tests) can
// reuse it without crossing module boundaries.
func JSONToUserGamesOrder(payload []byte) (*model.UserGamesOrder, error) {
if len(payload) == 0 {
return nil, errors.New("decode user games order json: payload is empty")
}
var raw struct {
GameID string `json:"game_id"`
UpdatedAt int64 `json:"updatedAt"`
Commands []json.RawMessage `json:"cmd"`
}
if err := json.Unmarshal(payload, &raw); err != nil {
return nil, fmt.Errorf("decode user games order json: %w", err)
}
out := &model.UserGamesOrder{
UpdatedAt: raw.UpdatedAt,
}
if raw.GameID != "" {
gameID, err := uuid.Parse(raw.GameID)
if err != nil {
return nil, fmt.Errorf("decode user games order json: invalid game_id %q: %w", raw.GameID, err)
}
out.GameID = gameID
}
if len(raw.Commands) == 0 {
return out, nil
}
out.Commands = make([]model.DecodableCommand, len(raw.Commands))
for i, rawCmd := range raw.Commands {
cmd, err := decodeJSONCommand(rawCmd)
if err != nil {
return nil, fmt.Errorf("decode user games order json command %d: %w", i, err)
}
out.Commands[i] = cmd
}
return out, nil
}
func decodeJSONCommand(raw json.RawMessage) (model.DecodableCommand, error) {
meta := new(model.CommandMeta)
if err := json.Unmarshal(raw, meta); err != nil {
return nil, err
}
switch meta.CmdType {
case model.CommandTypeRaceQuit:
return unmarshalJSONCommand(raw, new(model.CommandRaceQuit))
case model.CommandTypeRaceVote:
return unmarshalJSONCommand(raw, new(model.CommandRaceVote))
case model.CommandTypeRaceRelation:
return unmarshalJSONCommand(raw, new(model.CommandRaceRelation))
case model.CommandTypeShipClassCreate:
return unmarshalJSONCommand(raw, new(model.CommandShipClassCreate))
case model.CommandTypeShipClassMerge:
return unmarshalJSONCommand(raw, new(model.CommandShipClassMerge))
case model.CommandTypeShipClassRemove:
return unmarshalJSONCommand(raw, new(model.CommandShipClassRemove))
case model.CommandTypeShipGroupBreak:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupBreak))
case model.CommandTypeShipGroupLoad:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupLoad))
case model.CommandTypeShipGroupUnload:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupUnload))
case model.CommandTypeShipGroupSend:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupSend))
case model.CommandTypeShipGroupUpgrade:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupUpgrade))
case model.CommandTypeShipGroupMerge:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupMerge))
case model.CommandTypeShipGroupDismantle:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupDismantle))
case model.CommandTypeShipGroupTransfer:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupTransfer))
case model.CommandTypeShipGroupJoinFleet:
return unmarshalJSONCommand(raw, new(model.CommandShipGroupJoinFleet))
case model.CommandTypeFleetMerge:
return unmarshalJSONCommand(raw, new(model.CommandFleetMerge))
case model.CommandTypeFleetSend:
return unmarshalJSONCommand(raw, new(model.CommandFleetSend))
case model.CommandTypeScienceCreate:
return unmarshalJSONCommand(raw, new(model.CommandScienceCreate))
case model.CommandTypeScienceRemove:
return unmarshalJSONCommand(raw, new(model.CommandScienceRemove))
case model.CommandTypePlanetRename:
return unmarshalJSONCommand(raw, new(model.CommandPlanetRename))
case model.CommandTypePlanetProduce:
return unmarshalJSONCommand(raw, new(model.CommandPlanetProduce))
case model.CommandTypePlanetRouteSet:
return unmarshalJSONCommand(raw, new(model.CommandPlanetRouteSet))
case model.CommandTypePlanetRouteRemove:
return unmarshalJSONCommand(raw, new(model.CommandPlanetRouteRemove))
default:
return nil, fmt.Errorf("unknown command type: %s", meta.CmdType)
}
}
func unmarshalJSONCommand[T model.DecodableCommand](raw json.RawMessage, v T) (model.DecodableCommand, error) {
if err := json.Unmarshal(raw, v); err != nil {
return nil, err
}
return v, nil
}
type encodedCommand struct {
cmdID string
cmdApplied *bool
@@ -955,14 +1065,220 @@ func EmptyUserGamesCommandResponsePayload() []byte {
return builder.FinishedBytes()
}
// EmptyUserGamesOrderResponsePayload mirrors
// EmptyUserGamesCommandResponsePayload for `MessageTypeUserGamesOrder`.
func EmptyUserGamesOrderResponsePayload() []byte {
builder := flatbuffers.NewBuilder(16)
// UserGamesOrderResponseToPayload encodes the engine's response body
// for `PUT /api/v1/order` into the wire FlatBuffers envelope expected
// for `MessageTypeUserGamesOrder`. The engine populates per-command
// `cmdApplied` / `cmdErrorCode` fields, and they round-trip into the
// FBS `CommandItem` entries unchanged. A nil response is encoded as
// an empty envelope so the gateway can fall back to a batch-level
// "ok" answer when the engine body is unavailable.
func UserGamesOrderResponseToPayload(req *model.UserGamesOrder) ([]byte, error) {
builder := flatbuffers.NewBuilder(1024)
if req == nil {
fbs.UserGamesOrderResponseStart(builder)
offset := fbs.UserGamesOrderResponseEnd(builder)
fbs.FinishUserGamesOrderResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
commandsVec, err := encodeCommandItemVector(builder, req.Commands, "user games order response")
if err != nil {
return nil, err
}
fbs.UserGamesOrderResponseStart(builder)
hi, lo := uuidToHiLo(req.GameID)
fbs.UserGamesOrderResponseAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
fbs.UserGamesOrderResponseAddUpdatedAt(builder, req.UpdatedAt)
if commandsVec != 0 {
fbs.UserGamesOrderResponseAddCommands(builder, commandsVec)
}
offset := fbs.UserGamesOrderResponseEnd(builder)
fbs.FinishUserGamesOrderResponseBuffer(builder, offset)
return builder.FinishedBytes()
return builder.FinishedBytes(), nil
}
// PayloadToUserGamesOrderGet decodes the FlatBuffers payload of
// `MessageTypeUserGamesOrderGet` into the typed model. `Turn` is
// validated to be non-negative; the gateway and backend reject
// negative values before forwarding to the engine.
func PayloadToUserGamesOrderGet(data []byte) (result *model.UserGamesOrderGet, err error) {
if len(data) == 0 {
return nil, errors.New("decode user games order get payload: data is empty")
}
defer func() {
if recovered := recover(); recovered != nil {
result = nil
err = fmt.Errorf("decode user games order get payload: panic recovered: %v", recovered)
}
}()
flat := fbs.GetRootAsUserGamesOrderGet(data, 0)
gameID := flat.GameId(nil)
if gameID == nil {
return nil, errors.New("decode user games order get payload: game_id is missing")
}
turn := flat.Turn()
if turn < 0 {
return nil, fmt.Errorf("decode user games order get payload: turn must be non-negative, got %d", turn)
}
return &model.UserGamesOrderGet{
GameID: uuidFromHiLo(gameID.Hi(), gameID.Lo()),
Turn: int(turn),
}, nil
}
// UserGamesOrderGetToPayload encodes a `model.UserGamesOrderGet`
// request into FlatBuffers bytes suitable for the authenticated
// gateway transport.
func UserGamesOrderGetToPayload(req *model.UserGamesOrderGet) ([]byte, error) {
if req == nil {
return nil, errors.New("encode user games order get payload: request is nil")
}
if req.Turn < 0 {
return nil, fmt.Errorf("encode user games order get payload: turn must be non-negative, got %d", req.Turn)
}
builder := flatbuffers.NewBuilder(64)
fbs.UserGamesOrderGetStart(builder)
hi, lo := uuidToHiLo(req.GameID)
fbs.UserGamesOrderGetAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
fbs.UserGamesOrderGetAddTurn(builder, int64(req.Turn))
offset := fbs.UserGamesOrderGetEnd(builder)
fbs.FinishUserGamesOrderGetBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// UserGamesOrderGetResponseToPayload encodes the typed response of
// `MessageTypeUserGamesOrderGet`. `found = false` corresponds to the
// engine's `204 No Content` answer; `order` is omitted in that case.
func UserGamesOrderGetResponseToPayload(order *model.UserGamesOrder, found bool) ([]byte, error) {
builder := flatbuffers.NewBuilder(1024)
var orderOffset flatbuffers.UOffsetT
if found && order != nil {
commandsVec, err := encodeCommandItemVector(builder, order.Commands, "user games order get response")
if err != nil {
return nil, err
}
fbs.UserGamesOrderStart(builder)
hi, lo := uuidToHiLo(order.GameID)
fbs.UserGamesOrderAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo))
fbs.UserGamesOrderAddUpdatedAt(builder, order.UpdatedAt)
if commandsVec != 0 {
fbs.UserGamesOrderAddCommands(builder, commandsVec)
}
orderOffset = fbs.UserGamesOrderEnd(builder)
}
fbs.UserGamesOrderGetResponseStart(builder)
fbs.UserGamesOrderGetResponseAddFound(builder, found)
if orderOffset != 0 {
fbs.UserGamesOrderGetResponseAddOrder(builder, orderOffset)
}
offset := fbs.UserGamesOrderGetResponseEnd(builder)
fbs.FinishUserGamesOrderGetResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToUserGamesOrderResponse decodes the engine's PUT response
// envelope into a typed `*UserGamesOrder`. Empty payloads decode to
// nil so callers can fall back to batch-level handling without a
// dedicated marker.
func PayloadToUserGamesOrderResponse(data []byte) (result *model.UserGamesOrder, err error) {
if len(data) == 0 {
return nil, nil
}
defer func() {
if recovered := recover(); recovered != nil {
result = nil
err = fmt.Errorf("decode user games order response payload: panic recovered: %v", recovered)
}
}()
flat := fbs.GetRootAsUserGamesOrderResponse(data, 0)
gameID := flat.GameId(nil)
if gameID == nil {
// Empty envelope (gateway fallback). The caller treats this
// as "no per-command detail" and synthesises a batch-level
// answer.
if flat.CommandsLength() == 0 {
return nil, nil
}
return nil, errors.New("decode user games order response payload: game_id is missing")
}
out := &model.UserGamesOrder{
GameID: uuidFromHiLo(gameID.Hi(), gameID.Lo()),
UpdatedAt: flat.UpdatedAt(),
}
count := flat.CommandsLength()
if count > 0 {
out.Commands = make([]model.DecodableCommand, count)
flatCommand := new(fbs.CommandItem)
for i := 0; i < count; i++ {
if !flat.Commands(flatCommand, i) {
return nil, fmt.Errorf("decode user games order response %d: command item is missing", i)
}
cmd, decodeErr := decodeOrderCommand(flatCommand, i)
if decodeErr != nil {
return nil, decodeErr
}
out.Commands[i] = cmd
}
}
return out, nil
}
// PayloadToUserGamesOrderGetResponse decodes the FlatBuffers response
// of `MessageTypeUserGamesOrderGet`. When `found = false`, returns
// `(nil, false, nil)` matching the engine's `204 No Content`
// semantics.
func PayloadToUserGamesOrderGetResponse(data []byte) (order *model.UserGamesOrder, found bool, err error) {
if len(data) == 0 {
return nil, false, errors.New("decode user games order get response payload: data is empty")
}
defer func() {
if recovered := recover(); recovered != nil {
order = nil
found = false
err = fmt.Errorf("decode user games order get response payload: panic recovered: %v", recovered)
}
}()
flat := fbs.GetRootAsUserGamesOrderGetResponse(data, 0)
if !flat.Found() {
return nil, false, nil
}
inner := flat.Order(nil)
if inner == nil {
return nil, true, errors.New("decode user games order get response payload: order is missing while found=true")
}
gameID := inner.GameId(nil)
if gameID == nil {
return nil, true, errors.New("decode user games order get response payload: order.game_id is missing")
}
out := &model.UserGamesOrder{
GameID: uuidFromHiLo(gameID.Hi(), gameID.Lo()),
UpdatedAt: inner.UpdatedAt(),
}
count := inner.CommandsLength()
if count > 0 {
out.Commands = make([]model.DecodableCommand, count)
flatCommand := new(fbs.CommandItem)
for i := 0; i < count; i++ {
if !inner.Commands(flatCommand, i) {
return nil, true, fmt.Errorf("decode user games order get response %d: command item is missing", i)
}
cmd, decodeErr := decodeOrderCommand(flatCommand, i)
if decodeErr != nil {
return nil, true, decodeErr
}
out.Commands[i] = cmd
}
}
return out, true, nil
}
// encodeCommandItemVector serialises a slice of DecodableCommand into a
+154
View File
@@ -77,6 +77,160 @@ func TestUserGamesCommandRejectsNilAndEmpty(t *testing.T) {
if _, err := PayloadToUserGamesOrder(nil); err == nil {
t.Fatalf("expected error decoding empty user games order")
}
if _, err := UserGamesOrderGetToPayload(nil); err == nil {
t.Fatalf("expected error encoding nil user games order get")
}
if _, err := PayloadToUserGamesOrderGet(nil); err == nil {
t.Fatalf("expected error decoding empty user games order get")
}
if _, _, err := PayloadToUserGamesOrderGetResponse(nil); err == nil {
t.Fatalf("expected error decoding empty user games order get response")
}
}
func TestUserGamesOrderResponsePayloadRoundTrip(t *testing.T) {
t.Parallel()
applied := true
rejected := false
errCode := 7
source := &model.UserGamesOrder{
GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
UpdatedAt: 99,
Commands: []model.DecodableCommand{
&model.CommandPlanetRename{
CommandMeta: commandMeta("cmd-1", model.CommandTypePlanetRename, &applied, nil),
Number: 5,
Name: "alpha",
},
&model.CommandPlanetRename{
CommandMeta: commandMeta("cmd-2", model.CommandTypePlanetRename, &rejected, &errCode),
Number: 6,
Name: "beta",
},
},
}
payload, err := UserGamesOrderResponseToPayload(source)
if err != nil {
t.Fatalf("encode user games order response: %v", err)
}
decoded, err := PayloadToUserGamesOrderResponse(payload)
if err != nil {
t.Fatalf("decode user games order response: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestUserGamesOrderResponseEmptyPayload(t *testing.T) {
t.Parallel()
payload, err := UserGamesOrderResponseToPayload(nil)
if err != nil {
t.Fatalf("encode empty user games order response: %v", err)
}
if len(payload) == 0 {
t.Fatal("empty envelope payload must be non-zero length")
}
decoded, err := PayloadToUserGamesOrderResponse(payload)
if err != nil {
t.Fatalf("decode empty user games order response: %v", err)
}
if decoded != nil {
t.Fatalf("empty envelope must decode to nil, got %#v", decoded)
}
}
func TestUserGamesOrderGetPayloadRoundTrip(t *testing.T) {
t.Parallel()
source := &model.UserGamesOrderGet{
GameID: uuid.MustParse("11111111-2222-3333-4444-555555555555"),
Turn: 7,
}
payload, err := UserGamesOrderGetToPayload(source)
if err != nil {
t.Fatalf("encode user games order get: %v", err)
}
decoded, err := PayloadToUserGamesOrderGet(payload)
if err != nil {
t.Fatalf("decode user games order get: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestUserGamesOrderGetRejectsNegativeTurn(t *testing.T) {
t.Parallel()
if _, err := UserGamesOrderGetToPayload(&model.UserGamesOrderGet{
GameID: uuid.MustParse("11111111-2222-3333-4444-555555555555"),
Turn: -1,
}); err == nil {
t.Fatalf("expected error encoding negative turn")
}
}
func TestUserGamesOrderGetResponseRoundTrip(t *testing.T) {
t.Parallel()
applied := true
stored := &model.UserGamesOrder{
GameID: uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"),
UpdatedAt: 1234,
Commands: []model.DecodableCommand{
&model.CommandPlanetRename{
CommandMeta: commandMeta("cmd-1", model.CommandTypePlanetRename, &applied, nil),
Number: 5,
Name: "stored",
},
},
}
payload, err := UserGamesOrderGetResponseToPayload(stored, true)
if err != nil {
t.Fatalf("encode user games order get response: %v", err)
}
decoded, found, err := PayloadToUserGamesOrderGetResponse(payload)
if err != nil {
t.Fatalf("decode user games order get response: %v", err)
}
if !found {
t.Fatal("expected found=true round-trip")
}
if !reflect.DeepEqual(stored, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", stored, decoded)
}
}
func TestUserGamesOrderGetResponseNotFound(t *testing.T) {
t.Parallel()
payload, err := UserGamesOrderGetResponseToPayload(nil, false)
if err != nil {
t.Fatalf("encode not-found response: %v", err)
}
decoded, found, err := PayloadToUserGamesOrderGetResponse(payload)
if err != nil {
t.Fatalf("decode not-found response: %v", err)
}
if found {
t.Fatal("expected found=false")
}
if decoded != nil {
t.Fatalf("expected nil order, got %#v", decoded)
}
}
func TestInt64ToInt(t *testing.T) {