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
@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"galaxy/gateway/internal/downstream"
lobbymodel "galaxy/model/lobby"
@@ -55,12 +56,52 @@ func (c *RESTClient) ExecuteLobbyCommand(ctx context.Context, command downstream
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyMyGames(ctx, command.UserID)
case lobbymodel.MessageTypePublicGamesList:
req, err := transcoder.PayloadToPublicGamesListRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyPublicGames(ctx, command.UserID, req)
case lobbymodel.MessageTypeMyApplicationsList:
if _, err := transcoder.PayloadToMyApplicationsListRequest(command.PayloadBytes); err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyMyApplications(ctx, command.UserID)
case lobbymodel.MessageTypeMyInvitesList:
if _, err := transcoder.PayloadToMyInvitesListRequest(command.PayloadBytes); err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyMyInvites(ctx, command.UserID)
case lobbymodel.MessageTypeOpenEnrollment:
req, err := transcoder.PayloadToOpenEnrollmentRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyOpenEnrollment(ctx, command.UserID, req)
case lobbymodel.MessageTypeGameCreate:
req, err := transcoder.PayloadToGameCreateRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyGameCreate(ctx, command.UserID, req)
case lobbymodel.MessageTypeApplicationSubmit:
req, err := transcoder.PayloadToApplicationSubmitRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyApplicationSubmit(ctx, command.UserID, req)
case lobbymodel.MessageTypeInviteRedeem:
req, err := transcoder.PayloadToInviteRedeemRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyInviteRedeem(ctx, command.UserID, req)
case lobbymodel.MessageTypeInviteDecline:
req, err := transcoder.PayloadToInviteDeclineRequest(command.PayloadBytes)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command %q: %w", command.MessageType, err)
}
return c.executeLobbyInviteDecline(ctx, command.UserID, req)
default:
return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute lobby command: unsupported message type %q", command.MessageType)
}
@@ -88,6 +129,81 @@ func (c *RESTClient) executeLobbyMyGames(ctx context.Context, userID string) (do
return projectLobbyErrorResponse(status, body)
}
func (c *RESTClient) executeLobbyPublicGames(ctx context.Context, userID string, req *lobbymodel.PublicGamesListRequest) (downstream.UnaryResult, error) {
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 50
}
target := fmt.Sprintf("%s/api/v1/user/lobby/games?page=%d&page_size=%d", c.baseURL, page, pageSize)
body, status, err := c.do(ctx, http.MethodGet, target, userID, nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.public.games.list: %w", err)
}
if status == http.StatusOK {
page, err := decodePublicGamesPage(body)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.PublicGamesListResponseToPayload(page)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, body)
}
func (c *RESTClient) executeLobbyMyApplications(ctx context.Context, userID string) (downstream.UnaryResult, error) {
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/applications", userID, nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.applications.list: %w", err)
}
if status == http.StatusOK {
response, err := decodeApplicationsList(body)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.MyApplicationsListResponseToPayload(response)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, body)
}
func (c *RESTClient) executeLobbyMyInvites(ctx context.Context, userID string) (downstream.UnaryResult, error) {
body, status, err := c.do(ctx, http.MethodGet, c.baseURL+"/api/v1/user/lobby/my/invites", userID, nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.my.invites.list: %w", err)
}
if status == http.StatusOK {
response, err := decodeInvitesList(body)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.MyInvitesListResponseToPayload(response)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, body)
}
func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID string, req *lobbymodel.OpenEnrollmentRequest) (downstream.UnaryResult, error) {
if req == nil || strings.TrimSpace(req.GameID) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.game.open-enrollment: game_id must not be empty")
@@ -122,6 +238,338 @@ func (c *RESTClient) executeLobbyOpenEnrollment(ctx context.Context, userID stri
return projectLobbyErrorResponse(status, body)
}
func (c *RESTClient) executeLobbyGameCreate(ctx context.Context, userID string, req *lobbymodel.GameCreateRequest) (downstream.UnaryResult, error) {
if req == nil || strings.TrimSpace(req.GameName) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: game_name must not be empty")
}
if strings.TrimSpace(req.TurnSchedule) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: turn_schedule must not be empty")
}
if strings.TrimSpace(req.TargetEngineVersion) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: target_engine_version must not be empty")
}
if req.MinPlayers <= 0 || req.MaxPlayers <= 0 {
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: min_players and max_players must be positive")
}
if req.MinPlayers > req.MaxPlayers {
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: min_players must not exceed max_players")
}
if req.EnrollmentEndsAt.IsZero() {
return downstream.UnaryResult{}, errors.New("execute lobby.game.create: enrollment_ends_at must be set")
}
body := map[string]any{
"game_name": req.GameName,
"visibility": "private",
"description": req.Description,
"min_players": int32(req.MinPlayers),
"max_players": int32(req.MaxPlayers),
"start_gap_hours": int32(req.StartGapHours),
"start_gap_players": int32(req.StartGapPlayers),
"enrollment_ends_at": req.EnrollmentEndsAt.UTC().Format(time.RFC3339Nano),
"turn_schedule": req.TurnSchedule,
"target_engine_version": req.TargetEngineVersion,
}
payload, status, err := c.do(ctx, http.MethodPost, c.baseURL+"/api/v1/user/lobby/games", userID, body)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.game.create: %w", err)
}
if status == http.StatusOK || status == http.StatusCreated {
summary, err := decodeGameSummaryFromGameDetail(payload)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.GameCreateResponseToPayload(&lobbymodel.GameCreateResponse{Game: summary})
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, payload)
}
func (c *RESTClient) executeLobbyApplicationSubmit(ctx context.Context, userID string, req *lobbymodel.ApplicationSubmitRequest) (downstream.UnaryResult, error) {
if req == nil || strings.TrimSpace(req.GameID) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.application.submit: game_id must not be empty")
}
if strings.TrimSpace(req.RaceName) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.application.submit: race_name must not be empty")
}
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/applications"
body := map[string]any{"race_name": req.RaceName}
payload, status, err := c.do(ctx, http.MethodPost, target, userID, body)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.application.submit: %w", err)
}
if status == http.StatusOK || status == http.StatusCreated {
app, err := decodeApplicationDetail(payload)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.ApplicationSubmitResponseToPayload(&lobbymodel.ApplicationSubmitResponse{Application: app})
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, payload)
}
func (c *RESTClient) executeLobbyInviteRedeem(ctx context.Context, userID string, req *lobbymodel.InviteRedeemRequest) (downstream.UnaryResult, error) {
if req == nil || strings.TrimSpace(req.GameID) == "" || strings.TrimSpace(req.InviteID) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.invite.redeem: game_id and invite_id must not be empty")
}
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/invites/" + url.PathEscape(req.InviteID) + "/redeem"
payload, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.invite.redeem: %w", err)
}
if status == http.StatusOK {
invite, err := decodeInviteDetail(payload)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.InviteRedeemResponseToPayload(&lobbymodel.InviteRedeemResponse{Invite: invite})
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, payload)
}
func (c *RESTClient) executeLobbyInviteDecline(ctx context.Context, userID string, req *lobbymodel.InviteDeclineRequest) (downstream.UnaryResult, error) {
if req == nil || strings.TrimSpace(req.GameID) == "" || strings.TrimSpace(req.InviteID) == "" {
return downstream.UnaryResult{}, errors.New("execute lobby.invite.decline: game_id and invite_id must not be empty")
}
target := c.baseURL + "/api/v1/user/lobby/games/" + url.PathEscape(req.GameID) + "/invites/" + url.PathEscape(req.InviteID) + "/decline"
payload, status, err := c.do(ctx, http.MethodPost, target, userID, nil)
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("execute lobby.invite.decline: %w", err)
}
if status == http.StatusOK {
invite, err := decodeInviteDetail(payload)
if err != nil {
return downstream.UnaryResult{}, err
}
payloadBytes, err := transcoder.InviteDeclineResponseToPayload(&lobbymodel.InviteDeclineResponse{Invite: invite})
if err != nil {
return downstream.UnaryResult{}, fmt.Errorf("encode success response payload: %w", err)
}
return downstream.UnaryResult{
ResultCode: lobbyResultCodeOK,
PayloadBytes: payloadBytes,
}, nil
}
return projectLobbyErrorResponse(status, payload)
}
// decodeGameSummaryFromGameDetail accepts the backend's full
// LobbyGameDetail wire shape and projects it onto the gateway's
// GameSummary contract. It uses non-strict JSON decoding so the
// gateway tolerates the runtime/engine fields it does not forward to
// the UI.
func decodeGameSummaryFromGameDetail(payload []byte) (lobbymodel.GameSummary, error) {
var wire struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
GameType string `json:"game_type"`
Status string `json:"status"`
OwnerUserID *string `json:"owner_user_id"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
EnrollmentEndsAt time.Time `json:"enrollment_ends_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
return lobbymodel.GameSummary{}, fmt.Errorf("decode success response: %w", err)
}
owner := ""
if wire.OwnerUserID != nil {
owner = *wire.OwnerUserID
}
return lobbymodel.GameSummary{
GameID: wire.GameID,
GameName: wire.GameName,
GameType: wire.GameType,
Status: wire.Status,
OwnerUserID: owner,
MinPlayers: wire.MinPlayers,
MaxPlayers: wire.MaxPlayers,
EnrollmentEndsAt: wire.EnrollmentEndsAt.UTC(),
CreatedAt: wire.CreatedAt.UTC(),
UpdatedAt: wire.UpdatedAt.UTC(),
}, nil
}
func decodePublicGamesPage(payload []byte) (*lobbymodel.PublicGamesListResponse, error) {
var wire struct {
Items []struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
GameType string `json:"game_type"`
Status string `json:"status"`
OwnerUserID *string `json:"owner_user_id"`
MinPlayers int `json:"min_players"`
MaxPlayers int `json:"max_players"`
EnrollmentEndsAt time.Time `json:"enrollment_ends_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
} `json:"items"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
return nil, fmt.Errorf("decode success response: %w", err)
}
out := &lobbymodel.PublicGamesListResponse{
Items: make([]lobbymodel.GameSummary, 0, len(wire.Items)),
Page: wire.Page,
PageSize: wire.PageSize,
Total: wire.Total,
}
for _, w := range wire.Items {
owner := ""
if w.OwnerUserID != nil {
owner = *w.OwnerUserID
}
out.Items = append(out.Items, lobbymodel.GameSummary{
GameID: w.GameID,
GameName: w.GameName,
GameType: w.GameType,
Status: w.Status,
OwnerUserID: owner,
MinPlayers: w.MinPlayers,
MaxPlayers: w.MaxPlayers,
EnrollmentEndsAt: w.EnrollmentEndsAt.UTC(),
CreatedAt: w.CreatedAt.UTC(),
UpdatedAt: w.UpdatedAt.UTC(),
})
}
return out, nil
}
func decodeApplicationsList(payload []byte) (*lobbymodel.MyApplicationsListResponse, error) {
var wire struct {
Items []applicationDetailWire `json:"items"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
return nil, fmt.Errorf("decode success response: %w", err)
}
out := &lobbymodel.MyApplicationsListResponse{
Items: make([]lobbymodel.ApplicationSummary, 0, len(wire.Items)),
}
for _, w := range wire.Items {
out.Items = append(out.Items, w.toModel())
}
return out, nil
}
func decodeApplicationDetail(payload []byte) (lobbymodel.ApplicationSummary, error) {
var wire applicationDetailWire
if err := json.Unmarshal(payload, &wire); err != nil {
return lobbymodel.ApplicationSummary{}, fmt.Errorf("decode success response: %w", err)
}
return wire.toModel(), nil
}
func decodeInvitesList(payload []byte) (*lobbymodel.MyInvitesListResponse, error) {
var wire struct {
Items []inviteDetailWire `json:"items"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
return nil, fmt.Errorf("decode success response: %w", err)
}
out := &lobbymodel.MyInvitesListResponse{
Items: make([]lobbymodel.InviteSummary, 0, len(wire.Items)),
}
for _, w := range wire.Items {
out.Items = append(out.Items, w.toModel())
}
return out, nil
}
func decodeInviteDetail(payload []byte) (lobbymodel.InviteSummary, error) {
var wire inviteDetailWire
if err := json.Unmarshal(payload, &wire); err != nil {
return lobbymodel.InviteSummary{}, fmt.Errorf("decode success response: %w", err)
}
return wire.toModel(), nil
}
type applicationDetailWire struct {
ApplicationID string `json:"application_id"`
GameID string `json:"game_id"`
ApplicantUserID string `json:"applicant_user_id"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
}
func (w applicationDetailWire) toModel() lobbymodel.ApplicationSummary {
out := lobbymodel.ApplicationSummary{
ApplicationID: w.ApplicationID,
GameID: w.GameID,
ApplicantUserID: w.ApplicantUserID,
RaceName: w.RaceName,
Status: w.Status,
CreatedAt: w.CreatedAt.UTC(),
}
if w.DecidedAt != nil {
t := w.DecidedAt.UTC()
out.DecidedAt = &t
}
return out
}
type inviteDetailWire struct {
InviteID string `json:"invite_id"`
GameID string `json:"game_id"`
InviterUserID string `json:"inviter_user_id"`
InvitedUserID *string `json:"invited_user_id,omitempty"`
Code *string `json:"code,omitempty"`
RaceName string `json:"race_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
}
func (w inviteDetailWire) toModel() lobbymodel.InviteSummary {
out := lobbymodel.InviteSummary{
InviteID: w.InviteID,
GameID: w.GameID,
InviterUserID: w.InviterUserID,
RaceName: w.RaceName,
Status: w.Status,
CreatedAt: w.CreatedAt.UTC(),
ExpiresAt: w.ExpiresAt.UTC(),
}
if w.InvitedUserID != nil {
out.InvitedUserID = *w.InvitedUserID
}
if w.Code != nil {
out.Code = *w.Code
}
if w.DecidedAt != nil {
t := w.DecidedAt.UTC()
out.DecidedAt = &t
}
return out
}
func projectLobbyErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) {
switch {
case statusCode == http.StatusServiceUnavailable: