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
+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