phase 8: lobby UI + cross-stack lobby command catalog + TS FlatBuffers

- Extend pkg/model/lobby and pkg/schema/fbs/lobby.fbs with public-games
  list, my-applications/invites lists, game-create, application-submit,
  invite-redeem/decline. Mirror the matching transcoder pairs and Go
  fixture round-trip tests.
- Wire the seven new lobby message types through
  gateway/internal/backendclient/{routes,lobby_commands}.go with
  per-command REST helpers, JSON-tolerant decoding of backend wire
  shapes, and httptest-based unit coverage for success / 4xx / 5xx /
  503 across each command.
- Introduce TS-side FlatBuffers via the `flatbuffers` runtime dep, a
  `make fbs-ts` target driving flatc, and the generated bindings under
  ui/frontend/src/proto/galaxy/fbs. Phase 7's `user.account.get` decode
  now uses these bindings as well, closing the JSON.parse vs
  FlatBuffers gap that would have failed against a real local stack.
- Replace the placeholder lobby with five sections (my games, pending
  invitations, my applications, public games, create new game) and the
  /lobby/create form. Submit-application uses an inline race-name
  form on the public-game card; create-game keeps name / description /
  turn_schedule / enrollment_ends_at always visible and the rest under
  an Advanced toggle with TS-side defaults.
- Update lobby/+page.svelte to throw LobbyError on non-ok result codes;
  GalaxyClient.executeCommand now returns { resultCode, payloadBytes }.
- Vitest binding round-trips, lobby.ts wrapper unit tests, lobby-page
  + lobby-create component tests, Playwright lobby-flow.spec covering
  create / submit / accept across all four projects. Phase 7 e2e was
  migrated to the FlatBuffers fixtures and to click+fill against the
  Safari-autofill readonly inputs.
- Mark Phase 8 done in ui/PLAN.md, mirror the wire-format note into
  Phase 7, append the new lobby commands to gateway/README.md and
  docs/ARCHITECTURE.md, add ui/docs/lobby.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 18:05:08 +02:00
parent 5d2a3b79c5
commit f57a290432
90 changed files with 11862 additions and 112 deletions
+641 -8
View File
@@ -53,14 +53,7 @@ func MyGamesListResponseToPayload(response *lobbymodel.MyGamesListResponse) ([]b
itemOffsets[index] = encodeGameSummary(builder, response.Items[index])
}
var itemsVector flatbuffers.UOffsetT
if len(itemOffsets) > 0 {
lobbyfbs.MyGamesListResponseStartItemsVector(builder, len(itemOffsets))
for index := len(itemOffsets) - 1; index >= 0; index-- {
builder.PrependUOffsetT(itemOffsets[index])
}
itemsVector = builder.EndVector(len(itemOffsets))
}
itemsVector := finishOffsetVector(builder, itemOffsets)
lobbyfbs.MyGamesListResponseStart(builder)
if itemsVector != 0 {
@@ -96,6 +89,248 @@ func PayloadToMyGamesListResponse(data []byte) (result *lobbymodel.MyGamesListRe
return out, nil
}
// PublicGamesListRequestToPayload converts lobbymodel.PublicGamesListRequest to
// FlatBuffers bytes.
func PublicGamesListRequestToPayload(request *lobbymodel.PublicGamesListRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode public games list request payload: request is nil")
}
builder := flatbuffers.NewBuilder(32)
lobbyfbs.PublicGamesListRequestStart(builder)
lobbyfbs.PublicGamesListRequestAddPage(builder, int32(request.Page))
lobbyfbs.PublicGamesListRequestAddPageSize(builder, int32(request.PageSize))
offset := lobbyfbs.PublicGamesListRequestEnd(builder)
lobbyfbs.FinishPublicGamesListRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToPublicGamesListRequest converts FlatBuffers payload bytes into
// lobbymodel.PublicGamesListRequest.
func PayloadToPublicGamesListRequest(data []byte) (result *lobbymodel.PublicGamesListRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode public games list request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode public games list request payload", &result, &err)
request := lobbyfbs.GetRootAsPublicGamesListRequest(data, 0)
return &lobbymodel.PublicGamesListRequest{
Page: int(request.Page()),
PageSize: int(request.PageSize()),
}, nil
}
// PublicGamesListResponseToPayload converts lobbymodel.PublicGamesListResponse
// to FlatBuffers bytes.
func PublicGamesListResponseToPayload(response *lobbymodel.PublicGamesListResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode public games list response payload: response is nil")
}
builder := flatbuffers.NewBuilder(512)
itemOffsets := make([]flatbuffers.UOffsetT, len(response.Items))
for index := range response.Items {
itemOffsets[index] = encodeGameSummary(builder, response.Items[index])
}
itemsVector := finishOffsetVector(builder, itemOffsets)
lobbyfbs.PublicGamesListResponseStart(builder)
if itemsVector != 0 {
lobbyfbs.PublicGamesListResponseAddItems(builder, itemsVector)
}
lobbyfbs.PublicGamesListResponseAddPage(builder, int32(response.Page))
lobbyfbs.PublicGamesListResponseAddPageSize(builder, int32(response.PageSize))
lobbyfbs.PublicGamesListResponseAddTotal(builder, int32(response.Total))
offset := lobbyfbs.PublicGamesListResponseEnd(builder)
lobbyfbs.FinishPublicGamesListResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToPublicGamesListResponse converts FlatBuffers payload bytes into
// lobbymodel.PublicGamesListResponse.
func PayloadToPublicGamesListResponse(data []byte) (result *lobbymodel.PublicGamesListResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode public games list response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode public games list response payload", &result, &err)
response := lobbyfbs.GetRootAsPublicGamesListResponse(data, 0)
out := &lobbymodel.PublicGamesListResponse{
Items: make([]lobbymodel.GameSummary, 0, response.ItemsLength()),
Page: int(response.Page()),
PageSize: int(response.PageSize()),
Total: int(response.Total()),
}
summary := new(lobbyfbs.GameSummary)
for index := 0; index < response.ItemsLength(); index++ {
if !response.Items(summary, index) {
return nil, fmt.Errorf("decode public games list response payload: items[%d] is missing", index)
}
out.Items = append(out.Items, decodeGameSummary(summary))
}
return out, nil
}
// MyApplicationsListRequestToPayload converts lobbymodel.MyApplicationsListRequest
// to FlatBuffers bytes.
func MyApplicationsListRequestToPayload(request *lobbymodel.MyApplicationsListRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode my applications list request payload: request is nil")
}
builder := flatbuffers.NewBuilder(32)
lobbyfbs.MyApplicationsListRequestStart(builder)
offset := lobbyfbs.MyApplicationsListRequestEnd(builder)
lobbyfbs.FinishMyApplicationsListRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToMyApplicationsListRequest converts FlatBuffers payload bytes into
// lobbymodel.MyApplicationsListRequest.
func PayloadToMyApplicationsListRequest(data []byte) (result *lobbymodel.MyApplicationsListRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode my applications list request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode my applications list request payload", &result, &err)
_ = lobbyfbs.GetRootAsMyApplicationsListRequest(data, 0)
return &lobbymodel.MyApplicationsListRequest{}, nil
}
// MyApplicationsListResponseToPayload converts lobbymodel.MyApplicationsListResponse
// to FlatBuffers bytes.
func MyApplicationsListResponseToPayload(response *lobbymodel.MyApplicationsListResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode my applications list response payload: response is nil")
}
builder := flatbuffers.NewBuilder(512)
itemOffsets := make([]flatbuffers.UOffsetT, len(response.Items))
for index := range response.Items {
itemOffsets[index] = encodeApplicationSummary(builder, response.Items[index])
}
itemsVector := finishOffsetVector(builder, itemOffsets)
lobbyfbs.MyApplicationsListResponseStart(builder)
if itemsVector != 0 {
lobbyfbs.MyApplicationsListResponseAddItems(builder, itemsVector)
}
offset := lobbyfbs.MyApplicationsListResponseEnd(builder)
lobbyfbs.FinishMyApplicationsListResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToMyApplicationsListResponse converts FlatBuffers payload bytes into
// lobbymodel.MyApplicationsListResponse.
func PayloadToMyApplicationsListResponse(data []byte) (result *lobbymodel.MyApplicationsListResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode my applications list response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode my applications list response payload", &result, &err)
response := lobbyfbs.GetRootAsMyApplicationsListResponse(data, 0)
out := &lobbymodel.MyApplicationsListResponse{
Items: make([]lobbymodel.ApplicationSummary, 0, response.ItemsLength()),
}
app := new(lobbyfbs.ApplicationSummary)
for index := 0; index < response.ItemsLength(); index++ {
if !response.Items(app, index) {
return nil, fmt.Errorf("decode my applications list response payload: items[%d] is missing", index)
}
out.Items = append(out.Items, decodeApplicationSummary(app))
}
return out, nil
}
// MyInvitesListRequestToPayload converts lobbymodel.MyInvitesListRequest
// to FlatBuffers bytes.
func MyInvitesListRequestToPayload(request *lobbymodel.MyInvitesListRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode my invites list request payload: request is nil")
}
builder := flatbuffers.NewBuilder(32)
lobbyfbs.MyInvitesListRequestStart(builder)
offset := lobbyfbs.MyInvitesListRequestEnd(builder)
lobbyfbs.FinishMyInvitesListRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToMyInvitesListRequest converts FlatBuffers payload bytes into
// lobbymodel.MyInvitesListRequest.
func PayloadToMyInvitesListRequest(data []byte) (result *lobbymodel.MyInvitesListRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode my invites list request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode my invites list request payload", &result, &err)
_ = lobbyfbs.GetRootAsMyInvitesListRequest(data, 0)
return &lobbymodel.MyInvitesListRequest{}, nil
}
// MyInvitesListResponseToPayload converts lobbymodel.MyInvitesListResponse
// to FlatBuffers bytes.
func MyInvitesListResponseToPayload(response *lobbymodel.MyInvitesListResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode my invites list response payload: response is nil")
}
builder := flatbuffers.NewBuilder(512)
itemOffsets := make([]flatbuffers.UOffsetT, len(response.Items))
for index := range response.Items {
itemOffsets[index] = encodeInviteSummary(builder, response.Items[index])
}
itemsVector := finishOffsetVector(builder, itemOffsets)
lobbyfbs.MyInvitesListResponseStart(builder)
if itemsVector != 0 {
lobbyfbs.MyInvitesListResponseAddItems(builder, itemsVector)
}
offset := lobbyfbs.MyInvitesListResponseEnd(builder)
lobbyfbs.FinishMyInvitesListResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToMyInvitesListResponse converts FlatBuffers payload bytes into
// lobbymodel.MyInvitesListResponse.
func PayloadToMyInvitesListResponse(data []byte) (result *lobbymodel.MyInvitesListResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode my invites list response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode my invites list response payload", &result, &err)
response := lobbyfbs.GetRootAsMyInvitesListResponse(data, 0)
out := &lobbymodel.MyInvitesListResponse{
Items: make([]lobbymodel.InviteSummary, 0, response.ItemsLength()),
}
invite := new(lobbyfbs.InviteSummary)
for index := 0; index < response.ItemsLength(); index++ {
if !response.Items(invite, index) {
return nil, fmt.Errorf("decode my invites list response payload: items[%d] is missing", index)
}
out.Items = append(out.Items, decodeInviteSummary(invite))
}
return out, nil
}
// OpenEnrollmentRequestToPayload converts lobbymodel.OpenEnrollmentRequest to
// FlatBuffers bytes suitable for the authenticated gateway transport.
func OpenEnrollmentRequestToPayload(request *lobbymodel.OpenEnrollmentRequest) ([]byte, error) {
@@ -165,6 +400,292 @@ func PayloadToOpenEnrollmentResponse(data []byte) (result *lobbymodel.OpenEnroll
}, nil
}
// GameCreateRequestToPayload converts lobbymodel.GameCreateRequest to
// FlatBuffers bytes.
func GameCreateRequestToPayload(request *lobbymodel.GameCreateRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode game create request payload: request is nil")
}
builder := flatbuffers.NewBuilder(256)
gameName := builder.CreateString(request.GameName)
description := builder.CreateString(request.Description)
turnSchedule := builder.CreateString(request.TurnSchedule)
targetEngineVersion := builder.CreateString(request.TargetEngineVersion)
lobbyfbs.GameCreateRequestStart(builder)
lobbyfbs.GameCreateRequestAddGameName(builder, gameName)
lobbyfbs.GameCreateRequestAddDescription(builder, description)
lobbyfbs.GameCreateRequestAddMinPlayers(builder, int32(request.MinPlayers))
lobbyfbs.GameCreateRequestAddMaxPlayers(builder, int32(request.MaxPlayers))
lobbyfbs.GameCreateRequestAddStartGapHours(builder, int32(request.StartGapHours))
lobbyfbs.GameCreateRequestAddStartGapPlayers(builder, int32(request.StartGapPlayers))
lobbyfbs.GameCreateRequestAddEnrollmentEndsAtMs(builder, request.EnrollmentEndsAt.UTC().UnixMilli())
lobbyfbs.GameCreateRequestAddTurnSchedule(builder, turnSchedule)
lobbyfbs.GameCreateRequestAddTargetEngineVersion(builder, targetEngineVersion)
offset := lobbyfbs.GameCreateRequestEnd(builder)
lobbyfbs.FinishGameCreateRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToGameCreateRequest converts FlatBuffers payload bytes into
// lobbymodel.GameCreateRequest.
func PayloadToGameCreateRequest(data []byte) (result *lobbymodel.GameCreateRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode game create request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode game create request payload", &result, &err)
request := lobbyfbs.GetRootAsGameCreateRequest(data, 0)
return &lobbymodel.GameCreateRequest{
GameName: string(request.GameName()),
Description: string(request.Description()),
MinPlayers: int(request.MinPlayers()),
MaxPlayers: int(request.MaxPlayers()),
StartGapHours: int(request.StartGapHours()),
StartGapPlayers: int(request.StartGapPlayers()),
EnrollmentEndsAt: time.UnixMilli(request.EnrollmentEndsAtMs()).UTC(),
TurnSchedule: string(request.TurnSchedule()),
TargetEngineVersion: string(request.TargetEngineVersion()),
}, nil
}
// GameCreateResponseToPayload converts lobbymodel.GameCreateResponse to
// FlatBuffers bytes.
func GameCreateResponseToPayload(response *lobbymodel.GameCreateResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode game create response payload: response is nil")
}
builder := flatbuffers.NewBuilder(256)
gameOffset := encodeGameSummary(builder, response.Game)
lobbyfbs.GameCreateResponseStart(builder)
lobbyfbs.GameCreateResponseAddGame(builder, gameOffset)
offset := lobbyfbs.GameCreateResponseEnd(builder)
lobbyfbs.FinishGameCreateResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToGameCreateResponse converts FlatBuffers payload bytes into
// lobbymodel.GameCreateResponse.
func PayloadToGameCreateResponse(data []byte) (result *lobbymodel.GameCreateResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode game create response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode game create response payload", &result, &err)
response := lobbyfbs.GetRootAsGameCreateResponse(data, 0)
game := response.Game(nil)
if game == nil {
return nil, errors.New("decode game create response payload: game is missing")
}
return &lobbymodel.GameCreateResponse{
Game: decodeGameSummary(game),
}, nil
}
// ApplicationSubmitRequestToPayload converts lobbymodel.ApplicationSubmitRequest
// to FlatBuffers bytes.
func ApplicationSubmitRequestToPayload(request *lobbymodel.ApplicationSubmitRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode application submit request payload: request is nil")
}
builder := flatbuffers.NewBuilder(128)
gameID := builder.CreateString(request.GameID)
raceName := builder.CreateString(request.RaceName)
lobbyfbs.ApplicationSubmitRequestStart(builder)
lobbyfbs.ApplicationSubmitRequestAddGameId(builder, gameID)
lobbyfbs.ApplicationSubmitRequestAddRaceName(builder, raceName)
offset := lobbyfbs.ApplicationSubmitRequestEnd(builder)
lobbyfbs.FinishApplicationSubmitRequestBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToApplicationSubmitRequest converts FlatBuffers payload bytes into
// lobbymodel.ApplicationSubmitRequest.
func PayloadToApplicationSubmitRequest(data []byte) (result *lobbymodel.ApplicationSubmitRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode application submit request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode application submit request payload", &result, &err)
request := lobbyfbs.GetRootAsApplicationSubmitRequest(data, 0)
return &lobbymodel.ApplicationSubmitRequest{
GameID: string(request.GameId()),
RaceName: string(request.RaceName()),
}, nil
}
// ApplicationSubmitResponseToPayload converts lobbymodel.ApplicationSubmitResponse
// to FlatBuffers bytes.
func ApplicationSubmitResponseToPayload(response *lobbymodel.ApplicationSubmitResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode application submit response payload: response is nil")
}
builder := flatbuffers.NewBuilder(256)
appOffset := encodeApplicationSummary(builder, response.Application)
lobbyfbs.ApplicationSubmitResponseStart(builder)
lobbyfbs.ApplicationSubmitResponseAddApplication(builder, appOffset)
offset := lobbyfbs.ApplicationSubmitResponseEnd(builder)
lobbyfbs.FinishApplicationSubmitResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToApplicationSubmitResponse converts FlatBuffers payload bytes into
// lobbymodel.ApplicationSubmitResponse.
func PayloadToApplicationSubmitResponse(data []byte) (result *lobbymodel.ApplicationSubmitResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode application submit response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode application submit response payload", &result, &err)
response := lobbyfbs.GetRootAsApplicationSubmitResponse(data, 0)
app := response.Application(nil)
if app == nil {
return nil, errors.New("decode application submit response payload: application is missing")
}
return &lobbymodel.ApplicationSubmitResponse{
Application: decodeApplicationSummary(app),
}, nil
}
// InviteRedeemRequestToPayload converts lobbymodel.InviteRedeemRequest to
// FlatBuffers bytes.
func InviteRedeemRequestToPayload(request *lobbymodel.InviteRedeemRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode invite redeem request payload: request is nil")
}
return encodeInviteAction(request.GameID, request.InviteID, true)
}
// PayloadToInviteRedeemRequest converts FlatBuffers payload bytes into
// lobbymodel.InviteRedeemRequest.
func PayloadToInviteRedeemRequest(data []byte) (result *lobbymodel.InviteRedeemRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode invite redeem request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode invite redeem request payload", &result, &err)
request := lobbyfbs.GetRootAsInviteRedeemRequest(data, 0)
return &lobbymodel.InviteRedeemRequest{
GameID: string(request.GameId()),
InviteID: string(request.InviteId()),
}, nil
}
// InviteRedeemResponseToPayload converts lobbymodel.InviteRedeemResponse to
// FlatBuffers bytes.
func InviteRedeemResponseToPayload(response *lobbymodel.InviteRedeemResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode invite redeem response payload: response is nil")
}
builder := flatbuffers.NewBuilder(256)
inviteOffset := encodeInviteSummary(builder, response.Invite)
lobbyfbs.InviteRedeemResponseStart(builder)
lobbyfbs.InviteRedeemResponseAddInvite(builder, inviteOffset)
offset := lobbyfbs.InviteRedeemResponseEnd(builder)
lobbyfbs.FinishInviteRedeemResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToInviteRedeemResponse converts FlatBuffers payload bytes into
// lobbymodel.InviteRedeemResponse.
func PayloadToInviteRedeemResponse(data []byte) (result *lobbymodel.InviteRedeemResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode invite redeem response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode invite redeem response payload", &result, &err)
response := lobbyfbs.GetRootAsInviteRedeemResponse(data, 0)
invite := response.Invite(nil)
if invite == nil {
return nil, errors.New("decode invite redeem response payload: invite is missing")
}
return &lobbymodel.InviteRedeemResponse{
Invite: decodeInviteSummary(invite),
}, nil
}
// InviteDeclineRequestToPayload converts lobbymodel.InviteDeclineRequest to
// FlatBuffers bytes.
func InviteDeclineRequestToPayload(request *lobbymodel.InviteDeclineRequest) ([]byte, error) {
if request == nil {
return nil, errors.New("encode invite decline request payload: request is nil")
}
return encodeInviteAction(request.GameID, request.InviteID, false)
}
// PayloadToInviteDeclineRequest converts FlatBuffers payload bytes into
// lobbymodel.InviteDeclineRequest.
func PayloadToInviteDeclineRequest(data []byte) (result *lobbymodel.InviteDeclineRequest, err error) {
if len(data) == 0 {
return nil, errors.New("decode invite decline request payload: data is empty")
}
defer recoverLobbyDecodePanic("decode invite decline request payload", &result, &err)
request := lobbyfbs.GetRootAsInviteDeclineRequest(data, 0)
return &lobbymodel.InviteDeclineRequest{
GameID: string(request.GameId()),
InviteID: string(request.InviteId()),
}, nil
}
// InviteDeclineResponseToPayload converts lobbymodel.InviteDeclineResponse to
// FlatBuffers bytes.
func InviteDeclineResponseToPayload(response *lobbymodel.InviteDeclineResponse) ([]byte, error) {
if response == nil {
return nil, errors.New("encode invite decline response payload: response is nil")
}
builder := flatbuffers.NewBuilder(256)
inviteOffset := encodeInviteSummary(builder, response.Invite)
lobbyfbs.InviteDeclineResponseStart(builder)
lobbyfbs.InviteDeclineResponseAddInvite(builder, inviteOffset)
offset := lobbyfbs.InviteDeclineResponseEnd(builder)
lobbyfbs.FinishInviteDeclineResponseBuffer(builder, offset)
return builder.FinishedBytes(), nil
}
// PayloadToInviteDeclineResponse converts FlatBuffers payload bytes into
// lobbymodel.InviteDeclineResponse.
func PayloadToInviteDeclineResponse(data []byte) (result *lobbymodel.InviteDeclineResponse, err error) {
if len(data) == 0 {
return nil, errors.New("decode invite decline response payload: data is empty")
}
defer recoverLobbyDecodePanic("decode invite decline response payload", &result, &err)
response := lobbyfbs.GetRootAsInviteDeclineResponse(data, 0)
invite := response.Invite(nil)
if invite == nil {
return nil, errors.New("decode invite decline response payload: invite is missing")
}
return &lobbymodel.InviteDeclineResponse{
Invite: decodeInviteSummary(invite),
}, nil
}
// LobbyErrorResponseToPayload converts lobbymodel.ErrorResponse to FlatBuffers
// bytes suitable for the authenticated gateway transport.
func LobbyErrorResponseToPayload(response *lobbymodel.ErrorResponse) ([]byte, error) {
@@ -212,6 +733,38 @@ func PayloadToLobbyErrorResponse(data []byte) (result *lobbymodel.ErrorResponse,
}, nil
}
func encodeInviteAction(gameID, inviteID string, redeem bool) ([]byte, error) {
builder := flatbuffers.NewBuilder(128)
gameOffset := builder.CreateString(gameID)
inviteOffset := builder.CreateString(inviteID)
if redeem {
lobbyfbs.InviteRedeemRequestStart(builder)
lobbyfbs.InviteRedeemRequestAddGameId(builder, gameOffset)
lobbyfbs.InviteRedeemRequestAddInviteId(builder, inviteOffset)
offset := lobbyfbs.InviteRedeemRequestEnd(builder)
lobbyfbs.FinishInviteRedeemRequestBuffer(builder, offset)
} else {
lobbyfbs.InviteDeclineRequestStart(builder)
lobbyfbs.InviteDeclineRequestAddGameId(builder, gameOffset)
lobbyfbs.InviteDeclineRequestAddInviteId(builder, inviteOffset)
offset := lobbyfbs.InviteDeclineRequestEnd(builder)
lobbyfbs.FinishInviteDeclineRequestBuffer(builder, offset)
}
return builder.FinishedBytes(), nil
}
func finishOffsetVector(builder *flatbuffers.Builder, offsets []flatbuffers.UOffsetT) flatbuffers.UOffsetT {
if len(offsets) == 0 {
return 0
}
builder.StartVector(4, len(offsets), 4)
for index := len(offsets) - 1; index >= 0; index-- {
builder.PrependUOffsetT(offsets[index])
}
return builder.EndVector(len(offsets))
}
func encodeGameSummary(builder *flatbuffers.Builder, summary lobbymodel.GameSummary) flatbuffers.UOffsetT {
gameID := builder.CreateString(summary.GameID)
gameName := builder.CreateString(summary.GameName)
@@ -248,6 +801,86 @@ func decodeGameSummary(summary *lobbyfbs.GameSummary) lobbymodel.GameSummary {
}
}
func encodeApplicationSummary(builder *flatbuffers.Builder, app lobbymodel.ApplicationSummary) flatbuffers.UOffsetT {
applicationID := builder.CreateString(app.ApplicationID)
gameID := builder.CreateString(app.GameID)
applicantUserID := builder.CreateString(app.ApplicantUserID)
raceName := builder.CreateString(app.RaceName)
status := builder.CreateString(app.Status)
lobbyfbs.ApplicationSummaryStart(builder)
lobbyfbs.ApplicationSummaryAddApplicationId(builder, applicationID)
lobbyfbs.ApplicationSummaryAddGameId(builder, gameID)
lobbyfbs.ApplicationSummaryAddApplicantUserId(builder, applicantUserID)
lobbyfbs.ApplicationSummaryAddRaceName(builder, raceName)
lobbyfbs.ApplicationSummaryAddStatus(builder, status)
lobbyfbs.ApplicationSummaryAddCreatedAtMs(builder, app.CreatedAt.UTC().UnixMilli())
lobbyfbs.ApplicationSummaryAddDecidedAtMs(builder, unixMilliFromOptional(app.DecidedAt))
return lobbyfbs.ApplicationSummaryEnd(builder)
}
func decodeApplicationSummary(app *lobbyfbs.ApplicationSummary) lobbymodel.ApplicationSummary {
return lobbymodel.ApplicationSummary{
ApplicationID: string(app.ApplicationId()),
GameID: string(app.GameId()),
ApplicantUserID: string(app.ApplicantUserId()),
RaceName: string(app.RaceName()),
Status: string(app.Status()),
CreatedAt: time.UnixMilli(app.CreatedAtMs()).UTC(),
DecidedAt: optionalUnixMilli(app.DecidedAtMs()),
}
}
func encodeInviteSummary(builder *flatbuffers.Builder, invite lobbymodel.InviteSummary) flatbuffers.UOffsetT {
inviteID := builder.CreateString(invite.InviteID)
gameID := builder.CreateString(invite.GameID)
inviterUserID := builder.CreateString(invite.InviterUserID)
invitedUserID := builder.CreateString(invite.InvitedUserID)
code := builder.CreateString(invite.Code)
raceName := builder.CreateString(invite.RaceName)
status := builder.CreateString(invite.Status)
lobbyfbs.InviteSummaryStart(builder)
lobbyfbs.InviteSummaryAddInviteId(builder, inviteID)
lobbyfbs.InviteSummaryAddGameId(builder, gameID)
lobbyfbs.InviteSummaryAddInviterUserId(builder, inviterUserID)
lobbyfbs.InviteSummaryAddInvitedUserId(builder, invitedUserID)
lobbyfbs.InviteSummaryAddCode(builder, code)
lobbyfbs.InviteSummaryAddRaceName(builder, raceName)
lobbyfbs.InviteSummaryAddStatus(builder, status)
lobbyfbs.InviteSummaryAddCreatedAtMs(builder, invite.CreatedAt.UTC().UnixMilli())
lobbyfbs.InviteSummaryAddExpiresAtMs(builder, invite.ExpiresAt.UTC().UnixMilli())
lobbyfbs.InviteSummaryAddDecidedAtMs(builder, unixMilliFromOptional(invite.DecidedAt))
return lobbyfbs.InviteSummaryEnd(builder)
}
func decodeInviteSummary(invite *lobbyfbs.InviteSummary) lobbymodel.InviteSummary {
return lobbymodel.InviteSummary{
InviteID: string(invite.InviteId()),
GameID: string(invite.GameId()),
InviterUserID: string(invite.InviterUserId()),
InvitedUserID: string(invite.InvitedUserId()),
Code: string(invite.Code()),
RaceName: string(invite.RaceName()),
Status: string(invite.Status()),
CreatedAt: time.UnixMilli(invite.CreatedAtMs()).UTC(),
ExpiresAt: time.UnixMilli(invite.ExpiresAtMs()).UTC(),
DecidedAt: optionalUnixMilli(invite.DecidedAtMs()),
}
}
// unixMilliFromOptional returns 0 when the pointer is nil, else the
// millisecond UTC timestamp. The transcoder uses 0 as the "not set"
// sentinel because FlatBuffers tables do not carry per-field presence
// flags, and 0 is unreachable for any realistic decided/redeemed time.
// optionalUnixMilli (defined in user.go) is the decode counterpart.
func unixMilliFromOptional(t *time.Time) int64 {
if t == nil {
return 0
}
return t.UTC().UnixMilli()
}
func recoverLobbyDecodePanic[T any](message string, result **T, err *error) {
if recovered := recover(); recovered != nil {
*result = nil
+517
View File
@@ -0,0 +1,517 @@
package transcoder
import (
"reflect"
"testing"
"time"
lobbymodel "galaxy/model/lobby"
)
// fixedTimes returns deterministic UTC timestamps for round-trip
// fixtures. Millisecond precision matches the FlatBuffers `*Ms` ints,
// which is what the transcoder preserves.
func fixedTimes() (created, updated, ends time.Time) {
created = time.Date(2026, time.May, 7, 9, 30, 15, 123_000_000, time.UTC)
updated = created.Add(2 * time.Minute)
ends = created.Add(48 * time.Hour)
return
}
func TestLobbyMyGamesListRoundTrip(t *testing.T) {
t.Parallel()
created, updated, ends := fixedTimes()
source := &lobbymodel.MyGamesListResponse{
Items: []lobbymodel.GameSummary{
{
GameID: "game-private-7c8f",
GameName: "First Contact",
GameType: "private",
Status: "draft",
OwnerUserID: "user-9912",
MinPlayers: 2,
MaxPlayers: 8,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
},
{
GameID: "game-public-aabb",
GameName: "Open Lobby",
GameType: "public",
Status: "enrollment_open",
OwnerUserID: "",
MinPlayers: 4,
MaxPlayers: 12,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
},
},
}
payload, err := MyGamesListResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyGamesListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
requestBytes, err := MyGamesListRequestToPayload(&lobbymodel.MyGamesListRequest{})
if err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := PayloadToMyGamesListRequest(requestBytes); err != nil {
t.Fatalf("decode request: %v", err)
}
}
func TestLobbyMyGamesListEmpty(t *testing.T) {
t.Parallel()
payload, err := MyGamesListResponseToPayload(&lobbymodel.MyGamesListResponse{Items: nil})
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyGamesListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got := len(decoded.Items); got != 0 {
t.Fatalf("expected empty items, got %d", got)
}
}
func TestLobbyPublicGamesListRoundTrip(t *testing.T) {
t.Parallel()
created, updated, ends := fixedTimes()
requestSource := &lobbymodel.PublicGamesListRequest{Page: 3, PageSize: 25}
requestBytes, err := PublicGamesListRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToPublicGamesListRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
responseSource := &lobbymodel.PublicGamesListResponse{
Items: []lobbymodel.GameSummary{
{
GameID: "game-public-aabb",
GameName: "Open Lobby",
GameType: "public",
Status: "enrollment_open",
OwnerUserID: "",
MinPlayers: 4,
MaxPlayers: 12,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
},
},
Page: 3,
PageSize: 25,
Total: 51,
}
responseBytes, err := PublicGamesListResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToPublicGamesListResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyMyApplicationsListRoundTrip(t *testing.T) {
t.Parallel()
created, _, _ := fixedTimes()
decided := created.Add(2 * time.Hour)
requestBytes, err := MyApplicationsListRequestToPayload(&lobbymodel.MyApplicationsListRequest{})
if err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := PayloadToMyApplicationsListRequest(requestBytes); err != nil {
t.Fatalf("decode request: %v", err)
}
source := &lobbymodel.MyApplicationsListResponse{
Items: []lobbymodel.ApplicationSummary{
{
ApplicationID: "app-1",
GameID: "game-public-aabb",
ApplicantUserID: "user-9912",
RaceName: "Vegan Federation",
Status: "pending",
CreatedAt: created,
DecidedAt: nil,
},
{
ApplicationID: "app-2",
GameID: "game-public-ccdd",
ApplicantUserID: "user-9912",
RaceName: "Lithic Compact",
Status: "approved",
CreatedAt: created,
DecidedAt: &decided,
},
},
}
payload, err := MyApplicationsListResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyApplicationsListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestLobbyMyInvitesListRoundTrip(t *testing.T) {
t.Parallel()
created, _, _ := fixedTimes()
expires := created.Add(72 * time.Hour)
decided := created.Add(1 * time.Hour)
requestBytes, err := MyInvitesListRequestToPayload(&lobbymodel.MyInvitesListRequest{})
if err != nil {
t.Fatalf("encode request: %v", err)
}
if _, err := PayloadToMyInvitesListRequest(requestBytes); err != nil {
t.Fatalf("decode request: %v", err)
}
source := &lobbymodel.MyInvitesListResponse{
Items: []lobbymodel.InviteSummary{
{
InviteID: "invite-user-bound",
GameID: "game-private-7c8f",
InviterUserID: "user-1111",
InvitedUserID: "user-9912",
Code: "",
RaceName: "Vegan Federation",
Status: "pending",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: nil,
},
{
InviteID: "invite-code-based",
GameID: "game-private-3322",
InviterUserID: "user-1111",
InvitedUserID: "",
Code: "ABCDEF12",
RaceName: "Lithic Compact",
Status: "accepted",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: &decided,
},
},
}
payload, err := MyInvitesListResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToMyInvitesListResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestLobbyOpenEnrollmentRoundTrip(t *testing.T) {
t.Parallel()
requestSource := &lobbymodel.OpenEnrollmentRequest{GameID: "game-private-7c8f"}
requestBytes, err := OpenEnrollmentRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToOpenEnrollmentRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
responseSource := &lobbymodel.OpenEnrollmentResponse{GameID: "game-private-7c8f", Status: "enrollment_open"}
responseBytes, err := OpenEnrollmentResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToOpenEnrollmentResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyGameCreateRoundTrip(t *testing.T) {
t.Parallel()
_, _, ends := fixedTimes()
requestSource := &lobbymodel.GameCreateRequest{
GameName: "First Contact",
Description: "First Phase 8 sandbox game",
MinPlayers: 2,
MaxPlayers: 8,
StartGapHours: 24,
StartGapPlayers: 2,
EnrollmentEndsAt: ends,
TurnSchedule: "0 0 * * *",
TargetEngineVersion: "v1",
}
requestBytes, err := GameCreateRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToGameCreateRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
created, updated, _ := fixedTimes()
responseSource := &lobbymodel.GameCreateResponse{
Game: lobbymodel.GameSummary{
GameID: "game-private-newly-created",
GameName: "First Contact",
GameType: "private",
Status: "draft",
OwnerUserID: "user-9912",
MinPlayers: 2,
MaxPlayers: 8,
EnrollmentEndsAt: ends,
CreatedAt: created,
UpdatedAt: updated,
},
}
responseBytes, err := GameCreateResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToGameCreateResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyApplicationSubmitRoundTrip(t *testing.T) {
t.Parallel()
requestSource := &lobbymodel.ApplicationSubmitRequest{GameID: "game-public-aabb", RaceName: "Vegan Federation"}
requestBytes, err := ApplicationSubmitRequestToPayload(requestSource)
if err != nil {
t.Fatalf("encode request: %v", err)
}
requestDecoded, err := PayloadToApplicationSubmitRequest(requestBytes)
if err != nil {
t.Fatalf("decode request: %v", err)
}
if !reflect.DeepEqual(requestSource, requestDecoded) {
t.Fatalf("request round-trip mismatch\nsource: %#v\ndecoded:%#v", requestSource, requestDecoded)
}
created, _, _ := fixedTimes()
responseSource := &lobbymodel.ApplicationSubmitResponse{
Application: lobbymodel.ApplicationSummary{
ApplicationID: "app-3",
GameID: "game-public-aabb",
ApplicantUserID: "user-9912",
RaceName: "Vegan Federation",
Status: "pending",
CreatedAt: created,
DecidedAt: nil,
},
}
responseBytes, err := ApplicationSubmitResponseToPayload(responseSource)
if err != nil {
t.Fatalf("encode response: %v", err)
}
responseDecoded, err := PayloadToApplicationSubmitResponse(responseBytes)
if err != nil {
t.Fatalf("decode response: %v", err)
}
if !reflect.DeepEqual(responseSource, responseDecoded) {
t.Fatalf("response round-trip mismatch\nsource: %#v\ndecoded:%#v", responseSource, responseDecoded)
}
}
func TestLobbyInviteRedeemAndDeclineRoundTrip(t *testing.T) {
t.Parallel()
created, _, _ := fixedTimes()
expires := created.Add(72 * time.Hour)
decided := created.Add(1 * time.Hour)
redeemReq := &lobbymodel.InviteRedeemRequest{GameID: "game-private-7c8f", InviteID: "invite-user-bound"}
redeemBytes, err := InviteRedeemRequestToPayload(redeemReq)
if err != nil {
t.Fatalf("encode redeem request: %v", err)
}
redeemDecoded, err := PayloadToInviteRedeemRequest(redeemBytes)
if err != nil {
t.Fatalf("decode redeem request: %v", err)
}
if !reflect.DeepEqual(redeemReq, redeemDecoded) {
t.Fatalf("redeem request mismatch")
}
redeemResp := &lobbymodel.InviteRedeemResponse{
Invite: lobbymodel.InviteSummary{
InviteID: "invite-user-bound",
GameID: "game-private-7c8f",
InviterUserID: "user-1111",
InvitedUserID: "user-9912",
Code: "",
RaceName: "Vegan Federation",
Status: "accepted",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: &decided,
},
}
redeemRespBytes, err := InviteRedeemResponseToPayload(redeemResp)
if err != nil {
t.Fatalf("encode redeem response: %v", err)
}
redeemRespDecoded, err := PayloadToInviteRedeemResponse(redeemRespBytes)
if err != nil {
t.Fatalf("decode redeem response: %v", err)
}
if !reflect.DeepEqual(redeemResp, redeemRespDecoded) {
t.Fatalf("redeem response mismatch")
}
declineReq := &lobbymodel.InviteDeclineRequest{GameID: "game-private-7c8f", InviteID: "invite-user-bound"}
declineBytes, err := InviteDeclineRequestToPayload(declineReq)
if err != nil {
t.Fatalf("encode decline request: %v", err)
}
declineDecoded, err := PayloadToInviteDeclineRequest(declineBytes)
if err != nil {
t.Fatalf("decode decline request: %v", err)
}
if !reflect.DeepEqual(declineReq, declineDecoded) {
t.Fatalf("decline request mismatch")
}
declineResp := &lobbymodel.InviteDeclineResponse{
Invite: lobbymodel.InviteSummary{
InviteID: "invite-user-bound",
GameID: "game-private-7c8f",
InviterUserID: "user-1111",
InvitedUserID: "user-9912",
Code: "",
RaceName: "Vegan Federation",
Status: "declined",
CreatedAt: created,
ExpiresAt: expires,
DecidedAt: &decided,
},
}
declineRespBytes, err := InviteDeclineResponseToPayload(declineResp)
if err != nil {
t.Fatalf("encode decline response: %v", err)
}
declineRespDecoded, err := PayloadToInviteDeclineResponse(declineRespBytes)
if err != nil {
t.Fatalf("decode decline response: %v", err)
}
if !reflect.DeepEqual(declineResp, declineRespDecoded) {
t.Fatalf("decline response mismatch")
}
}
func TestLobbyErrorResponseRoundTrip(t *testing.T) {
t.Parallel()
source := &lobbymodel.ErrorResponse{
Error: lobbymodel.ErrorBody{
Code: "conflict",
Message: "request conflicts with current state",
},
}
payload, err := LobbyErrorResponseToPayload(source)
if err != nil {
t.Fatalf("encode: %v", err)
}
decoded, err := PayloadToLobbyErrorResponse(payload)
if err != nil {
t.Fatalf("decode: %v", err)
}
if !reflect.DeepEqual(source, decoded) {
t.Fatalf("round-trip mismatch\nsource: %#v\ndecoded:%#v", source, decoded)
}
}
func TestLobbyDecodersRecoverFromCorruption(t *testing.T) {
t.Parallel()
garbage := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
if _, err := PayloadToMyGamesListResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt my games payload")
}
if _, err := PayloadToPublicGamesListResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt public games payload")
}
if _, err := PayloadToApplicationSubmitResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt application submit response")
}
if _, err := PayloadToInviteRedeemResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt invite redeem response")
}
if _, err := PayloadToLobbyErrorResponse(garbage); err == nil {
t.Fatal("expected error decoding corrupt error payload")
}
}
func TestLobbyDecodersRejectEmptyPayload(t *testing.T) {
t.Parallel()
if _, err := PayloadToMyGamesListRequest(nil); err == nil {
t.Fatal("expected error decoding nil request")
}
if _, err := PayloadToPublicGamesListResponse(nil); err == nil {
t.Fatal("expected error decoding nil response")
}
if _, err := PayloadToInviteDeclineRequest(nil); err == nil {
t.Fatal("expected error decoding nil decline request")
}
}