diff --git a/.gitea/workflows/dev-deploy.yaml b/.gitea/workflows/dev-deploy.yaml index 0ebf15f..3eb6305 100644 --- a/.gitea/workflows/dev-deploy.yaml +++ b/.gitea/workflows/dev-deploy.yaml @@ -7,6 +7,12 @@ name: Deploy · Dev # `integration` as part of the PR that produced this push, so this # workflow does not re-run those tests — it focuses on packaging and # rollout. +# +# `workflow_dispatch` is also accepted so a developer can deploy any +# branch (typically a feature branch under active review) into the +# shared dev environment from the Gitea Actions UI without waiting for +# the PR to merge first. The deploy job picks up whatever the chosen +# ref is — same packaging + healthcheck steps as the merge path. on: push: @@ -23,6 +29,7 @@ on: - 'tools/dev-deploy/**' - '.gitea/workflows/dev-deploy.yaml' - '!**/*.md' + workflow_dispatch: {} jobs: deploy: @@ -62,6 +69,11 @@ jobs: working-directory: ui/frontend env: VITE_GATEWAY_BASE_URL: https://api.galaxy.lan + # Surface the synthetic-report loader and similar dev-only + # affordances in the long-lived dev bundle. The prod build + # path (`prod-build.yaml`) leaves this flag unset so the + # production bundle keeps the same affordances stripped. + VITE_GALAXY_DEV_AFFORDANCES: "true" run: | # The response-signing public key is committed in # `.env.development` alongside its private counterpart in diff --git a/backend/internal/auth/auth_e2e_test.go b/backend/internal/auth/auth_e2e_test.go index f88460e..d4f4937 100644 --- a/backend/internal/auth/auth_e2e_test.go +++ b/backend/internal/auth/auth_e2e_test.go @@ -513,6 +513,52 @@ func TestConfirmEmailCodeWrongCode(t *testing.T) { } } +// TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling proves the +// dev-mode override is a true escape hatch: a developer who already +// burned past ChallengeMaxAttempts on a long-lived dev challenge +// (typically because the throttle merged repeated send-email-code +// calls onto one challenge_id) can still recover by submitting the +// fixed code without first waiting out the challenge TTL. +func TestConfirmEmailCodeDevFixedCodeBypassesAttemptsCeiling(t *testing.T) { + db := startPostgres(t) + cfg := authConfig() + cfg.DevFixedCode = "999999" + svc := buildServiceWithConfig(t, db, cfg) + ctx := context.Background() + + id, err := svc.SendEmailCode(ctx, "dev-bypass-ceiling@example.test", "en", "", "") + if err != nil { + t.Fatalf("send: %v", err) + } + + // Burn through the attempts ceiling with deliberately wrong codes. + for i := range cfg.ChallengeMaxAttempts + 1 { + _, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{ + ChallengeID: id, + Code: "111111", + ClientPublicKey: randomKey(t), + TimeZone: "UTC", + }) + if err == nil { + t.Fatalf("attempt %d unexpectedly succeeded", i) + } + } + + // The dev-fixed code still goes through. + session, err := svc.ConfirmEmailCode(ctx, auth.ConfirmInputs{ + ChallengeID: id, + Code: "999999", + ClientPublicKey: randomKey(t), + TimeZone: "UTC", + }) + if err != nil { + t.Fatalf("dev-fixed-code after attempts exhausted: %v", err) + } + if session.DeviceSessionID == uuid.Nil { + t.Fatalf("dev-fixed-code did not produce a session") + } +} + func TestConfirmEmailCodeAttemptsCeiling(t *testing.T) { db := startPostgres(t) svc, mailer, _, _ := buildService(t, db) diff --git a/backend/internal/auth/challenge.go b/backend/internal/auth/challenge.go index 73cb364..08ef81e 100644 --- a/backend/internal/auth/challenge.go +++ b/backend/internal/auth/challenge.go @@ -163,15 +163,28 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi return Session{}, err } - if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts { - s.deps.Logger.Info("auth challenge attempts exhausted", + // The dev-mode fixed-code override is checked first so it bypasses + // both the bcrypt verify and the per-challenge attempts ceiling. + // Without this, a developer who already burned through + // `ChallengeMaxAttempts` on an existing un-consumed challenge — + // for example after the throttle merged repeated send-email-code + // calls onto one challenge_id — could not recover with the fixed + // code either, defeating the purpose of the override. Production + // deployments leave `DevFixedCode` empty, so this branch is + // inert and the regular attempts gate still applies. + if s.devFixedCodeMatches(in.Code) { + s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override", zap.String("challenge_id", in.ChallengeID.String()), zap.Int32("attempts", loaded.Attempts), ) - return Session{}, ErrTooManyAttempts - } - - if !s.devFixedCodeMatches(in.Code) { + } else { + if int(loaded.Attempts) > s.deps.Config.ChallengeMaxAttempts { + s.deps.Logger.Info("auth challenge attempts exhausted", + zap.String("challenge_id", in.ChallengeID.String()), + zap.Int32("attempts", loaded.Attempts), + ) + return Session{}, ErrTooManyAttempts + } if err := verifyCode(loaded.CodeHash, in.Code); err != nil { if errors.Is(err, ErrCodeMismatch) { s.deps.Logger.Info("auth challenge code mismatch", @@ -182,10 +195,6 @@ func (s *Service) ConfirmEmailCode(ctx context.Context, in ConfirmInputs) (Sessi } return Session{}, err } - } else { - s.deps.Logger.Warn("auth challenge accepted via dev-mode fixed code override", - zap.String("challenge_id", in.ChallengeID.String()), - ) } // Re-check permanent_block after verifying the code. SendEmailCode diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md index 581d673..4e86b01 100644 --- a/backend/internal/diplomail/README.md +++ b/backend/internal/diplomail/README.md @@ -26,7 +26,10 @@ Three Postgres tables in the `backend` schema: - `diplomail_messages` — one row per send (personal, admin, or system). Captures `game_name` and IP at insert time so audit - rendering survives renames and purges. + rendering survives renames and purges. The `sender_race_name` + column snapshots the sender's race in the game at send time when + the sender is a player with an active membership; the in-game UI + keys per-race thread grouping on this column. - `diplomail_recipients` — one row per (message, recipient). Holds per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at` state. Snapshot fields (`recipient_user_name`, @@ -72,6 +75,24 @@ mail to every active member; `Service.changeMembershipStatus` / `detector.LanguageDetector` (default: `whatlanggo`, body-only, ≥ 25 runes; shorter bodies stay `und`). +## Recipient selection + +`POST /messages` and `POST /admin` (when `target="user"`) accept the +recipient identifier in one of two shapes: + +- `recipient_user_id` (uuid) — explicit user lookup; the recipient + may be any active member of the game. +- `recipient_race_name` (string) — resolves to the active member + with this race name in the game. Race names are unique by lobby + invariant; lobby-removed and blocked members cannot be reached + through the race-name shortcut (they no longer appear in the + active scope). Exactly one of the two fields must be supplied; + supplying both, or neither, returns `invalid_request`. + +The race-name path lets the in-game UI compose mail directly off +the engine's `report.races[]` view without an extra membership +round-trip. + ## Translation Stage D adds a lazy translation cache. When a recipient reads a diff --git a/backend/internal/diplomail/admin_send.go b/backend/internal/diplomail/admin_send.go index 0b4c5c8..eb2eb9c 100644 --- a/backend/internal/diplomail/admin_send.go +++ b/backend/internal/diplomail/admin_send.go @@ -29,7 +29,11 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp return Message{}, Recipient{}, err } - recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID) + recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName) + if err != nil { + return Message{}, Recipient{}, err + } + recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID) if err != nil { if errors.Is(err, ErrNotFound) { return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden) @@ -37,7 +41,7 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err) } - msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, + msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername, recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle) if err != nil { return Message{}, Recipient{}, err @@ -84,7 +88,7 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI } gameName := members[0].GameName - msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, + msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername, in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast) if err != nil { return Message{}, nil, err @@ -147,6 +151,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas } username := sender.UserName + senderRace := sender.RaceName msgInsert := MessageInsert{ MessageID: uuid.New(), GameID: in.GameID, @@ -155,6 +160,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas SenderKind: SenderKindPlayer, SenderUserID: &callerID, SenderUsername: &username, + SenderRaceName: &senderRace, SenderIP: in.SenderIP, Subject: subject, Body: body, @@ -217,7 +223,7 @@ func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiG zap.String("scope", scope)) continue } - msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername, + msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername, game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast) if err != nil { return nil, 0, err @@ -356,7 +362,7 @@ func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) e gameName := members[0].GameName subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason) - msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", + msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "", ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast) if err != nil { return err @@ -385,7 +391,7 @@ func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEv } subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason) - msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", + msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "", ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle) if err != nil { return err @@ -417,10 +423,12 @@ func (s *Service) prepareContent(subject, body string) (string, string, error) { // for every admin-kind send. The CHECK constraint maps sender // shapes: // -// sender_kind='player' → CallerKind owner; sender_user_id set +// sender_kind='player' → CallerKind owner; sender_user_id set, +// sender_race_name resolved from +// Memberships.GetActiveMembership // sender_kind='admin' → CallerKind admin; sender_user_id nil // sender_kind='system' → CallerKind system; sender_username nil -func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string, +func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string, gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) { out := MessageInsert{ MessageID: uuid.New(), @@ -443,6 +451,17 @@ func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid. out.SenderKind = SenderKindPlayer out.SenderUserID = &uid out.SenderUsername = &uname + // Owner race snapshot is best-effort: a private-game owner who + // has an active membership in their own game contributes a + // race name; an owner who is not a current member (or whose + // membership is removed/blocked) leaves the field nil. The + // CHECK constraint accepts both shapes for sender_kind='player'. + if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil { + race := ownerMember.RaceName + out.SenderRaceName = &race + } else if !errors.Is(err, ErrNotFound) { + return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err) + } case CallerKindAdmin: uname := callerUsername out.SenderKind = SenderKindAdmin diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go index 3f85830..71511b8 100644 --- a/backend/internal/diplomail/diplomail_e2e_test.go +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -369,6 +369,114 @@ func TestDiplomailPersonalFlow(t *testing.T) { } } +// TestDiplomailPersonalByRaceName exercises the Phase 28 contract: the +// UI passes a recipient race name (read out of the game report); the +// service resolves it to the active member with that race name and +// snapshots the sender's race onto the message row. Error cases cover +// the validation rules baked into the wire schema. +func TestDiplomailPersonalByRaceName(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + kicked := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedAccount(t, db, kicked) + seedGame(t, db, gameID, "Race-Name Resolution Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Race-Name Resolution Game", + UserName: "sender", RaceName: "Senders", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Race-Name Resolution Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{ + {gameID, kicked}: { + UserID: kicked, GameID: gameID, GameName: "Race-Name Resolution Game", + UserName: "kicked", RaceName: "Departed", Status: "removed", + }, + }, + } + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: &recordingPublisher{}, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // Happy path: race name resolves and sender_race_name is snapshotted. + msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientRaceName: "Receivers", + Subject: "Trade", + Body: "Care to talk?", + SenderIP: "203.0.113.4", + }) + if err != nil { + t.Fatalf("send by race name: %v", err) + } + if rcpt.UserID != recipient { + t.Fatalf("recipient = %s, want %s", rcpt.UserID, recipient) + } + if msg.SenderRaceName == nil || *msg.SenderRaceName != "Senders" { + t.Fatalf("sender_race_name = %v, want \"Senders\"", msg.SenderRaceName) + } + + // Both identifiers supplied → invalid_request. + if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + RecipientRaceName: "Receivers", + Body: "x", + }); !errors.Is(err, diplomail.ErrInvalidInput) { + t.Fatalf("dual identifier: %v, want ErrInvalidInput", err) + } + + // Neither identifier supplied → invalid_request. + if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + Body: "x", + }); !errors.Is(err, diplomail.ErrInvalidInput) { + t.Fatalf("no identifier: %v, want ErrInvalidInput", err) + } + + // Race name with no matching active member → invalid_request. + if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientRaceName: "Strangers", + Body: "x", + }); !errors.Is(err, diplomail.ErrInvalidInput) { + t.Fatalf("unknown race: %v, want ErrInvalidInput", err) + } + + // Race name of a lobby-removed member → invalid_request (the + // active-only scope filters them out; the lookup never returns + // them). + if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientRaceName: "Departed", + Body: "x", + }); !errors.Is(err, diplomail.ErrInvalidInput) { + t.Fatalf("kicked race: %v, want ErrInvalidInput", err) + } +} + func TestDiplomailRejectsNonActiveSender(t *testing.T) { db := startPostgres(t) ctx := context.Background() diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go index b6365a7..a79374a 100644 --- a/backend/internal/diplomail/service.go +++ b/backend/internal/diplomail/service.go @@ -32,15 +32,20 @@ const previewMaxRunes = 120 // ErrForbidden; the inserted Message is never persisted in those // cases. func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) { - if in.SenderUserID == in.RecipientUserID { - return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput) - } subject := strings.TrimRight(in.Subject, " \t") body := strings.TrimRight(in.Body, " \t\n") if err := s.validateContent(subject, body); err != nil { return Message{}, Recipient{}, err } + recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName) + if err != nil { + return Message{}, Recipient{}, err + } + if in.SenderUserID == recipientID { + return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput) + } + sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID) if err != nil { if errors.Is(err, ErrNotFound) { @@ -48,7 +53,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa } return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err) } - recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID) + recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID) if err != nil { if errors.Is(err, ErrNotFound) { return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden) @@ -57,14 +62,17 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa } username := sender.UserName + senderRace := sender.RaceName + senderUserID := in.SenderUserID msgInsert := MessageInsert{ MessageID: uuid.New(), GameID: in.GameID, GameName: sender.GameName, Kind: KindPersonal, SenderKind: SenderKindPlayer, - SenderUserID: &in.SenderUserID, + SenderUserID: &senderUserID, SenderUsername: &username, + SenderRaceName: &senderRace, SenderIP: in.SenderIP, Subject: subject, Body: body, @@ -75,7 +83,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa rcptInsert := buildRecipientInsert( msgInsert.MessageID, MemberSnapshot{ - UserID: in.RecipientUserID, + UserID: recipientID, GameID: in.GameID, GameName: recipient.GameName, UserName: recipient.UserName, @@ -101,6 +109,47 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa return msg, recipients[0], nil } +// resolveActiveRecipient turns a (user_id, race_name) pair into the +// canonical user id of an active member of gameID. Exactly one of the +// two inputs must be set; both-set or both-empty returns +// ErrInvalidInput. Race-name resolution is restricted to the active +// scope so lobby-removed and blocked members cannot be reached +// through the race-name shortcut. ErrInvalidInput is also returned +// when the race name matches zero members; ErrForbidden when the +// race name matches more than one active row (defence in depth — race +// names are unique within a game by lobby invariant). +func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) { + byRaceName = strings.TrimSpace(byRaceName) + hasUser := byUserID != uuid.Nil + hasRace := byRaceName != "" + switch { + case hasUser && hasRace: + return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput) + case !hasUser && !hasRace: + return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput) + case hasUser: + return byUserID, nil + } + members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive) + if err != nil { + return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err) + } + var found []MemberSnapshot + for _, m := range members { + if m.RaceName == byRaceName { + found = append(found, m) + } + } + switch len(found) { + case 0: + return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName) + case 1: + return found[0].UserID, nil + default: + return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName) + } +} + // GetMessage returns the InboxEntry for messageID addressed to // userID. ErrNotFound is returned when the caller is not a recipient // of the message — handlers translate that to 404 so the existence @@ -267,10 +316,13 @@ func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (m return map[string]bool{KindAdmin: true}, nil } -// ListSent returns personal messages authored by senderUserID in -// gameID, newest first. Admin/system rows have no `sender_user_id` -// and are therefore excluded; the user surface does not need them. -func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { +// ListSent returns the sender-side view of personal messages +// authored by senderUserID in gameID, newest first. Each entry pairs +// the message with one of its recipient rows; single sends contribute +// one entry per message, broadcasts contribute one entry per +// addressee. Admin and system rows have no `sender_user_id` and are +// therefore excluded; the user surface does not need them. +func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) { return s.deps.Store.ListSent(ctx, gameID, senderUserID) } diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go index e6364cf..d2394fc 100644 --- a/backend/internal/diplomail/store.go +++ b/backend/internal/diplomail/store.go @@ -31,7 +31,7 @@ func messageColumns() postgres.ColumnList { m := table.DiplomailMessages return postgres.ColumnList{ m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, - m.SenderUserID, m.SenderUsername, m.SenderIP, + m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP, m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt, } } @@ -59,6 +59,7 @@ type MessageInsert struct { SenderKind string SenderUserID *uuid.UUID SenderUsername *string + SenderRaceName *string SenderIP string Subject string Body string @@ -101,7 +102,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse m := table.DiplomailMessages msgStmt := m.INSERT( m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, - m.SenderUserID, m.SenderUsername, m.SenderIP, + m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP, m.Subject, m.Body, m.BodyLang, m.BroadcastScope, ).VALUES( msg.MessageID, @@ -111,6 +112,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse msg.SenderKind, uuidPtrArg(msg.SenderUserID), stringPtrArg(msg.SenderUsername), + stringPtrArg(msg.SenderRaceName), msg.SenderIP, msg.Subject, msg.Body, @@ -241,25 +243,38 @@ func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]Inbo return out, nil } -// ListSent returns messages authored by senderUserID in gameID, -// newest first. Personal messages only — admin/system rows have -// `sender_user_id IS NULL` and are filtered out by the WHERE clause. -func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { +// ListSent returns the sender-side view of personal messages +// authored by senderUserID in gameID, newest first. Each +// `InboxEntry` carries the message together with one of its +// recipient rows — single sends produce one entry per message; +// game broadcasts produce one entry per addressee (the in-game +// mail UI collapses broadcast entries into a single stand-alone +// item by `message_id`). Admin / system rows have +// `sender_user_id IS NULL` and are excluded by the WHERE clause. +func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]InboxEntry, error) { m := table.DiplomailMessages - stmt := postgres.SELECT(messageColumns()). - FROM(m). + r := table.DiplomailRecipients + cols := append(messageColumns(), recipientColumns()...) + stmt := postgres.SELECT(cols). + FROM(m.INNER_JOIN(r, r.MessageID.EQ(m.MessageID))). WHERE( m.GameID.EQ(postgres.UUID(gameID)). AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))), ). - ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) - var rows []model.DiplomailMessages - if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC(), r.RecipientID.ASC()) + var dest []struct { + model.DiplomailMessages + Recipient model.DiplomailRecipients `alias:"diplomail_recipients"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err) } - out := make([]Message, 0, len(rows)) - for _, row := range rows { - out = append(out, messageFromModel(row)) + out := make([]InboxEntry, 0, len(dest)) + for _, row := range dest { + out = append(out, InboxEntry{ + Message: messageFromModel(row.DiplomailMessages), + Recipient: recipientFromModel(row.Recipient), + }) } return out, nil } @@ -737,6 +752,10 @@ func messageFromModel(row model.DiplomailMessages) Message { name := *row.SenderUsername out.SenderUsername = &name } + if row.SenderRaceName != nil { + name := *row.SenderRaceName + out.SenderRaceName = &name + } return out } diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go index f1b29f7..52bfb45 100644 --- a/backend/internal/diplomail/types.go +++ b/backend/internal/diplomail/types.go @@ -23,6 +23,11 @@ type Message struct { SenderKind string SenderUserID *uuid.UUID SenderUsername *string + // SenderRaceName carries the snapshot of the sender's race in the + // game at send time. Non-nil for sender_kind='player' rows, nil + // for admin and system. The in-game mail UI groups personal + // threads by this name (Phase 28). + SenderRaceName *string SenderIP string Subject string Body string @@ -92,16 +97,19 @@ type Translation struct { } // SendPersonalInput is the request payload for SendPersonal: the -// caller sending a single-recipient personal message. Validation -// (active membership, body length, etc.) is performed inside the -// service. +// caller sending a single-recipient personal message. Exactly one of +// RecipientUserID and RecipientRaceName must be non-zero; the +// service resolves a non-empty RecipientRaceName to the active +// member with that race in the game. Other validation (active +// membership, body length, etc.) is performed inside the service. type SendPersonalInput struct { - GameID uuid.UUID - SenderUserID uuid.UUID - RecipientUserID uuid.UUID - Subject string - Body string - SenderIP string + GameID uuid.UUID + SenderUserID uuid.UUID + RecipientUserID uuid.UUID + RecipientRaceName string + Subject string + Body string + SenderIP string } // CallerKind enumerates the privileged sender roles for admin-kind @@ -116,17 +124,21 @@ const ( // SendAdminPersonalInput is the request payload for an owner / // admin / system sending an admin-kind message to a single -// recipient. Authorization (owner-vs-admin distinction) is enforced -// by the HTTP layer; the service trusts the caller designation. +// recipient. Exactly one of RecipientUserID and RecipientRaceName +// must be non-zero; the service resolves a non-empty +// RecipientRaceName to the active member with that race in the +// game. Authorization (owner-vs-admin distinction) is enforced by +// the HTTP layer; the service trusts the caller designation. type SendAdminPersonalInput struct { - GameID uuid.UUID - CallerKind string - CallerUserID *uuid.UUID - CallerUsername string - RecipientUserID uuid.UUID - Subject string - Body string - SenderIP string + GameID uuid.UUID + CallerKind string + CallerUserID *uuid.UUID + CallerUsername string + RecipientUserID uuid.UUID + RecipientRaceName string + Subject string + Body string + SenderIP string } // SendAdminBroadcastInput is the request payload for an owner / diff --git a/backend/internal/postgres/jet/backend/model/diplomail_messages.go b/backend/internal/postgres/jet/backend/model/diplomail_messages.go index c2939cc..8b82732 100644 --- a/backend/internal/postgres/jet/backend/model/diplomail_messages.go +++ b/backend/internal/postgres/jet/backend/model/diplomail_messages.go @@ -20,6 +20,7 @@ type DiplomailMessages struct { SenderKind string SenderUserID *uuid.UUID SenderUsername *string + SenderRaceName *string SenderIP string Subject string Body string diff --git a/backend/internal/postgres/jet/backend/table/diplomail_messages.go b/backend/internal/postgres/jet/backend/table/diplomail_messages.go index 902e05c..afd403f 100644 --- a/backend/internal/postgres/jet/backend/table/diplomail_messages.go +++ b/backend/internal/postgres/jet/backend/table/diplomail_messages.go @@ -24,6 +24,7 @@ type diplomailMessagesTable struct { SenderKind postgres.ColumnString SenderUserID postgres.ColumnString SenderUsername postgres.ColumnString + SenderRaceName postgres.ColumnString SenderIP postgres.ColumnString Subject postgres.ColumnString Body postgres.ColumnString @@ -78,14 +79,15 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai SenderKindColumn = postgres.StringColumn("sender_kind") SenderUserIDColumn = postgres.StringColumn("sender_user_id") SenderUsernameColumn = postgres.StringColumn("sender_username") + SenderRaceNameColumn = postgres.StringColumn("sender_race_name") SenderIPColumn = postgres.StringColumn("sender_ip") SubjectColumn = postgres.StringColumn("subject") BodyColumn = postgres.StringColumn("body") BodyLangColumn = postgres.StringColumn("body_lang") BroadcastScopeColumn = postgres.StringColumn("broadcast_scope") CreatedAtColumn = postgres.TimestampzColumn("created_at") - allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} - mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} ) @@ -100,6 +102,7 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai SenderKind: SenderKindColumn, SenderUserID: SenderUserIDColumn, SenderUsername: SenderUsernameColumn, + SenderRaceName: SenderRaceNameColumn, SenderIP: SenderIPColumn, Subject: SubjectColumn, Body: BodyColumn, diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index c749749..afbb939 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -683,6 +683,11 @@ CREATE TABLE diplomail_messages ( sender_kind text NOT NULL, sender_user_id uuid, sender_username text, + -- sender_race_name is the immutable snapshot of the sender's race + -- in this game, captured at insert time when sender_kind='player'. + -- Admin and system messages carry NULL. The Phase 28 mail UI keys + -- per-race threading on this column. + sender_race_name text, sender_ip text NOT NULL DEFAULT '', subject text NOT NULL DEFAULT '', body text NOT NULL, @@ -698,6 +703,13 @@ CREATE TABLE diplomail_messages ( (sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR (sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL) ), + -- sender_race_name is only meaningful for player senders. Admin + -- and system rows never carry a race; player rows carry one when + -- the sender has an active membership at send time (a non-playing + -- private-game owner may legitimately have none). + CONSTRAINT diplomail_messages_sender_race_chk CHECK ( + sender_kind = 'player' OR sender_race_name IS NULL + ), CONSTRAINT diplomail_messages_kind_sender_chk CHECK ( (kind = 'personal' AND sender_kind = 'player') OR (kind = 'admin' AND sender_kind IN ('player', 'admin', 'system')) diff --git a/backend/internal/server/handlers_admin_diplomail.go b/backend/internal/server/handlers_admin_diplomail.go index 0e1dd0a..328b902 100644 --- a/backend/internal/server/handlers_admin_diplomail.go +++ b/backend/internal/server/handlers_admin_diplomail.go @@ -60,19 +60,24 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc { ctx := c.Request.Context() switch req.Target { case "", "user": - recipientID, parseErr := uuid.Parse(req.RecipientUserID) - if parseErr != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") - return + var recipientID uuid.UUID + if req.RecipientUserID != "" { + parsed, parseErr := uuid.Parse(req.RecipientUserID) + if parseErr != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + recipientID = parsed } msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ - GameID: gameID, - CallerKind: diplomail.CallerKindAdmin, - CallerUsername: username, - RecipientUserID: recipientID, - Subject: req.Subject, - Body: req.Body, - SenderIP: clientip.ExtractSourceIP(c), + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: username, + RecipientUserID: recipientID, + RecipientRaceName: req.RecipientRaceName, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), }) if sendErr != nil { respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr) diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go index 53960b6..7ab053a 100644 --- a/backend/internal/server/handlers_user_mail.go +++ b/backend/internal/server/handlers_user_mail.go @@ -87,19 +87,24 @@ func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc { httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") return } - recipientID, err := uuid.Parse(req.RecipientUserID) - if err != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") - return + var recipientID uuid.UUID + if req.RecipientUserID != "" { + parsed, perr := uuid.Parse(req.RecipientUserID) + if perr != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + recipientID = parsed } ctx := c.Request.Context() msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{ - GameID: gameID, - SenderUserID: userID, - RecipientUserID: recipientID, - Subject: req.Subject, - Body: req.Body, - SenderIP: clientip.ExtractSourceIP(c), + GameID: gameID, + SenderUserID: userID, + RecipientUserID: recipientID, + RecipientRaceName: req.RecipientRaceName, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), }) if err != nil { respondDiplomailError(c, h.logger, "user mail send personal", ctx, err) @@ -189,9 +194,9 @@ func (h *UserMailHandlers) Sent() gin.HandlerFunc { respondDiplomailError(c, h.logger, "user mail sent", ctx, err) return } - out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))} - for _, m := range items { - out.Items = append(out.Items, mailMessageSummaryToWire(m)) + out := userMailSentListWire{Items: make([]userMailMessageDetailWire, 0, len(items))} + for _, entry := range items { + out.Items = append(out.Items, mailMessageDetailToWire(entry, false)) } c.JSON(http.StatusOK, out) } @@ -341,21 +346,26 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc { switch req.Target { case "", "user": - recipientID, parseErr := uuid.Parse(req.RecipientUserID) - if parseErr != nil { - httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") - return + var recipientID uuid.UUID + if req.RecipientUserID != "" { + parsed, parseErr := uuid.Parse(req.RecipientUserID) + if parseErr != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + recipientID = parsed } callerUserID := userID msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ - GameID: gameID, - CallerKind: diplomail.CallerKindOwner, - CallerUserID: &callerUserID, - CallerUsername: account.UserName, - RecipientUserID: recipientID, - Subject: req.Subject, - Body: req.Body, - SenderIP: clientip.ExtractSourceIP(c), + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &callerUserID, + CallerUsername: account.UserName, + RecipientUserID: recipientID, + RecipientRaceName: req.RecipientRaceName, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), }) if sendErr != nil { respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr) @@ -449,10 +459,13 @@ func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) { } // userMailSendRequestWire mirrors the request body for SendPersonal. +// Exactly one of `recipient_user_id` and `recipient_race_name` must +// be supplied; the service rejects ambiguous and empty inputs. type userMailSendRequestWire struct { - RecipientUserID string `json:"recipient_user_id"` - Subject string `json:"subject,omitempty"` - Body string `json:"body"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` } // userMailSendBroadcastRequestWire mirrors the request body for the @@ -464,15 +477,16 @@ type userMailSendBroadcastRequestWire struct { } // userMailSendAdminRequestWire mirrors the request body for the -// owner-only admin send. `target="user"` requires -// `recipient_user_id`; `target="all"` accepts the optional -// `recipients` scope (default `active`). +// owner-only admin send. `target="user"` requires exactly one of +// `recipient_user_id` and `recipient_race_name`; `target="all"` +// accepts the optional `recipients` scope (default `active`). type userMailSendAdminRequestWire struct { - Target string `json:"target"` - RecipientUserID string `json:"recipient_user_id,omitempty"` - Recipients string `json:"recipients,omitempty"` - Subject string `json:"subject,omitempty"` - Body string `json:"body"` + Target string `json:"target"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Recipients string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` } // userMailBroadcastReceiptWire is the response shape returned after a @@ -524,6 +538,7 @@ type userMailMessageDetailWire struct { SenderKind string `json:"sender_kind"` SenderUserID *string `json:"sender_user_id,omitempty"` SenderUsername *string `json:"sender_username,omitempty"` + SenderRaceName *string `json:"sender_race_name,omitempty"` Subject string `json:"subject,omitempty"` Body string `json:"body"` BodyLang string `json:"body_lang"` @@ -540,27 +555,18 @@ type userMailMessageDetailWire struct { Translator *string `json:"translator,omitempty"` } -// userMailSentSummaryWire mirrors the response shape for the -// sender-side listing. Recipient state is intentionally omitted (one -// author may have N recipients per broadcast in later stages). -type userMailSentSummaryWire struct { - MessageID string `json:"message_id"` - GameID string `json:"game_id"` - GameName string `json:"game_name,omitempty"` - Kind string `json:"kind"` - Subject string `json:"subject,omitempty"` - Body string `json:"body"` - BodyLang string `json:"body_lang"` - BroadcastScope string `json:"broadcast_scope"` - CreatedAt string `json:"created_at"` -} - type userMailInboxListWire struct { Items []userMailMessageDetailWire `json:"items"` } +// userMailSentListWire mirrors the response shape for the +// sender-side listing. Phase 28's in-game UI threads sent messages +// by the recipient's race name, so the wire carries the full +// message detail (including the recipient snapshot) — single sends +// contribute one row per message, broadcasts contribute one row per +// addressee and the UI collapses them by `message_id`. type userMailSentListWire struct { - Items []userMailSentSummaryWire `json:"items"` + Items []userMailMessageDetailWire `json:"items"` } type userMailUnreadCountWire struct { @@ -597,6 +603,10 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM s := *entry.SenderUsername out.SenderUsername = &s } + if entry.SenderRaceName != nil { + s := *entry.SenderRaceName + out.SenderRaceName = &s + } if entry.Recipient.RecipientRaceName != nil { s := *entry.Recipient.RecipientRaceName out.RecipientRaceName = &s @@ -624,20 +634,6 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM return out } -func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire { - return userMailSentSummaryWire{ - MessageID: m.MessageID.String(), - GameID: m.GameID.String(), - GameName: m.GameName, - Kind: m.Kind, - Subject: m.Subject, - Body: m.Body, - BodyLang: m.BodyLang, - BroadcastScope: m.BroadcastScope, - CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), - } -} - // mailRecipientStateToWire renders the recipient row after a // mark-read or soft-delete call. The caller only needs the per-user // state, not the full message body again. diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 251fb6d..4477cb2 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -4068,11 +4068,22 @@ components: UserMailSendRequest: type: object additionalProperties: false - required: [recipient_user_id, body] + required: [body] properties: recipient_user_id: type: string format: uuid + description: | + Either `recipient_user_id` or `recipient_race_name` must + be supplied; supplying both is rejected as + `invalid_request`. + recipient_race_name: + type: string + description: | + Resolves to the active member with this race name in the + game. Mutually exclusive with `recipient_user_id`. The + server returns `forbidden` when the matching member is no + longer active (lobby-removed / blocked). subject: type: string description: | @@ -4093,10 +4104,18 @@ components: type: string format: uuid description: | - Required when `target="user"`. Identifies the recipient - of the personal admin message; the recipient may be in - any membership status (admin notifications can reach - kicked players). + One of `recipient_user_id` and `recipient_race_name` is + required when `target="user"`. Identifies the recipient + of the personal admin message by uuid; the recipient may + be in any membership status (admin notifications can + reach kicked players when addressed by user_id). + recipient_race_name: + type: string + description: | + Optional alternative to `recipient_user_id` when + `target="user"`. Resolves to the active member with this + race name in the game; lobby-removed and blocked members + cannot be reached through the race-name shortcut. recipients: type: string enum: [active, active_and_removed, all_members] @@ -4323,6 +4342,17 @@ components: sender_username: type: string nullable: true + sender_race_name: + type: string + nullable: true + description: | + Snapshot of the sender's race name in this game at send + time. Populated when `sender_kind="player"` and the + sender had an active membership at send time; nil for + admin and system messages, and for player messages sent + by a private-game owner who was not an active member at + send time. The in-game UI keys per-race threading on this + field. subject: type: string body: @@ -4370,41 +4400,6 @@ components: translator: type: string description: Identifier of the translation engine that produced the cached row. - UserMailSentSummary: - type: object - additionalProperties: false - required: - - message_id - - game_id - - kind - - body - - body_lang - - broadcast_scope - - created_at - properties: - message_id: - type: string - format: uuid - game_id: - type: string - format: uuid - game_name: - type: string - kind: - type: string - enum: [personal, admin] - subject: - type: string - body: - type: string - body_lang: - type: string - broadcast_scope: - type: string - enum: [single, game_broadcast, multi_game_broadcast] - created_at: - type: string - format: date-time UserMailInboxList: type: object additionalProperties: false @@ -4415,6 +4410,13 @@ components: items: $ref: "#/components/schemas/UserMailMessageDetail" UserMailSentList: + description: | + Sender-side listing of personal messages authored by the + caller. Each item carries the same shape as inbox entries + (including the recipient snapshot); single sends contribute + one row per message, broadcasts contribute one row per + addressee so the in-game UI can collapse them by + `message_id` into a single stand-alone item. type: object additionalProperties: false required: [items] @@ -4422,7 +4424,7 @@ components: items: type: array items: - $ref: "#/components/schemas/UserMailSentSummary" + $ref: "#/components/schemas/UserMailMessageDetail" UserMailUnreadCount: type: object additionalProperties: false diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 054317b..15c4271 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -1270,6 +1270,20 @@ The message detail response includes both the original body and, when available, the cached translation; the client UI defaults to the translated text and offers a "show original" toggle. +The in-game UI groups personal mail into per-race threads — every +personal message exchanged between the local player and another +race lands in one thread keyed on the other party's race. System +mail, admin notifications, and the player's own paid-tier +broadcasts render as stand-alone entries in the same list pane and +are never threaded. `read_at` and `deleted_at` drive the local +unread counter and the soft-delete affordance but are not surfaced +to the user — diplomatic mail does not promise read receipts. The +compose form picks the recipient by race name (resolved +server-side from `Memberships.ListMembers(game_id, "active")`); no +client-side memberships listing is fetched. See +[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md) for the +detailed UI breakdown. + ### 11.5 Lifecycle hooks Three lobby transitions land as system mail in the affected diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 77460dd..a3b2d60 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -1309,6 +1309,20 @@ bulk-purge всей почты соответствующей партии. кэш) перевод; UI по умолчанию показывает перевод и предлагает переключение «показать оригинал». +Внутриигровой UI группирует личную почту по веткам по расам — +каждая личная переписка между локальным игроком и другой расой +оказывается в одной ветке, ключевая по расе собеседника. +Системные сообщения, административные уведомления и собственные +рассылки игрока (платный тариф) показываются отдельными +автономными записями в том же списке и никогда не группируются. +`read_at` и `deleted_at` поддерживают локальный счётчик +непрочитанного и кнопку удаления, но не показываются игроку — +дипломатическая почта не обещает уведомления о прочтении. Форма +compose выбирает получателя по имени расы (сервер резолвит через +`Memberships.ListMembers(game_id, "active")`); клиент не тянет +отдельный список членов. Подробнее — в +[`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md). + ### 11.5 Хуки жизненного цикла Три транзитных перехода в лобби порождают system mail в inbox diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 8d41527..2c1095b 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -186,7 +186,8 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo userRoutes := backendclient.UserRoutes(backend.REST()) lobbyRoutes := backendclient.LobbyRoutes(backend.REST()) gameRoutes := backendclient.GameRoutes(backend.REST()) - allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes)) + mailRoutes := backendclient.MailRoutes(backend.REST()) + allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes)+len(mailRoutes)) for k, v := range userRoutes { allRoutes[k] = v } @@ -196,6 +197,9 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo for k, v := range gameRoutes { allRoutes[k] = v } + for k, v := range mailRoutes { + allRoutes[k] = v + } cleanup := func() error { return closeRedisClient() diff --git a/gateway/internal/backendclient/games_commands.go b/gateway/internal/backendclient/games_commands.go index 799c538..2688464 100644 --- a/gateway/internal/backendclient/games_commands.go +++ b/gateway/internal/backendclient/games_commands.go @@ -63,6 +63,12 @@ func (c *RESTClient) ExecuteGameCommand(ctx context.Context, command downstream. return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err) } return c.executeUserGamesReport(ctx, command.UserID, req) + case reportmodel.MessageTypeUserGamesBattle: + req, err := transcoder.PayloadToGameBattleRequest(command.PayloadBytes) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command %q: %w", command.MessageType, err) + } + return c.executeUserGamesBattle(ctx, command.UserID, req) default: return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute game command: unsupported message type %q", command.MessageType) } @@ -127,6 +133,26 @@ func (c *RESTClient) executeUserGamesReport(ctx context.Context, userID string, return projectUserGamesReportResponse(status, respBody) } +func (c *RESTClient) executeUserGamesBattle(ctx context.Context, userID string, req *reportmodel.GameBattleRequest) (downstream.UnaryResult, error) { + if req.GameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.battle: game_id must not be empty") + } + if req.BattleID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.battle: battle_id must not be empty") + } + target := fmt.Sprintf("%s/api/v1/user/games/%s/battles/%d/%s", + c.baseURL, + url.PathEscape(req.GameID.String()), + req.Turn, + url.PathEscape(req.BattleID.String()), + ) + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.battle: %w", err) + } + return projectUserGamesBattleResponse(status, respBody) +} + // buildEngineCommandBody serialises a slice of typed commands into the // JSON shape expected by backend's command/order handlers (a // `gamerest.Command` with the actor field left empty — backend rebinds @@ -262,3 +288,32 @@ func projectUserGamesReportResponse(statusCode int, payload []byte) (downstream. return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode) } } + +// projectUserGamesBattleResponse decodes the engine's BattleReport JSON +// payload (forwarded by backend's user.games.battle proxy) and +// re-encodes it as a FlatBuffers BattleReport for the signed-gRPC +// client. 404 from backend surfaces as the canonical `not_found` +// gateway error so the UI can render its "battle not found" state. +func projectUserGamesBattleResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) { + switch { + case statusCode == http.StatusOK: + var report reportmodel.BattleReport + if err := json.Unmarshal(payload, &report); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode engine battle report: %w", err) + } + encoded, err := transcoder.BattleReportToPayload(&report) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("encode battle report payload: %w", err) + } + return downstream.UnaryResult{ + ResultCode: userCommandResultCodeOK, + PayloadBytes: encoded, + }, nil + case statusCode == http.StatusServiceUnavailable: + return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable + case statusCode >= 400 && statusCode <= 599: + return projectUserBackendError(statusCode, payload) + default: + return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode) + } +} diff --git a/gateway/internal/backendclient/games_commands_test.go b/gateway/internal/backendclient/games_commands_test.go index 05ca497..4382de9 100644 --- a/gateway/internal/backendclient/games_commands_test.go +++ b/gateway/internal/backendclient/games_commands_test.go @@ -11,6 +11,7 @@ import ( "galaxy/gateway/internal/backendclient" "galaxy/gateway/internal/downstream" ordermodel "galaxy/model/order" + reportmodel "galaxy/model/report" "galaxy/transcoder" "github.com/google/uuid" @@ -170,6 +171,78 @@ func TestExecuteUserGamesOrderGetRejectsNegativeTurn(t *testing.T) { assert.Contains(t, err.Error(), "user.games.order.get") } +func TestExecuteUserGamesBattleForwardsAndDecodesResponse(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("66666666-7777-8888-9999-aaaaaaaaaaaa") + battleID := uuid.MustParse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, + "/api/v1/user/games/"+gameID.String()+"/battles/7/"+battleID.String(), + r.URL.Path, + ) + require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID)) + raceID := uuid.MustParse("11111111-2222-3333-4444-555555555555") + writeJSON(t, w, http.StatusOK, map[string]any{ + "id": battleID.String(), + "planet": uint(42), + "planetName": "Tau Ceti II", + "races": map[string]string{"1": raceID.String()}, + "ships": map[string]map[string]any{}, + "protocol": []any{}, + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload, err := transcoder.GameBattleRequestToPayload(&reportmodel.GameBattleRequest{ + GameID: gameID, + Turn: 7, + BattleID: battleID, + }) + require.NoError(t, err) + result, err := client.ExecuteGameCommand(context.Background(), newAuthCommand(t, reportmodel.MessageTypeUserGamesBattle, payload)) + require.NoError(t, err) + assert.Equal(t, "ok", result.ResultCode) + + decoded, err := transcoder.PayloadToBattleReport(result.PayloadBytes) + require.NoError(t, err) + require.NotNil(t, decoded) + assert.Equal(t, battleID, decoded.ID) + assert.Equal(t, uint(42), decoded.Planet) + assert.Equal(t, "Tau Ceti II", decoded.PlanetName) +} + +func TestExecuteUserGamesBattleMapsNotFound(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("77777777-8888-9999-aaaa-bbbbbbbbbbbb") + battleID := uuid.MustParse("99999999-aaaa-bbbb-cccc-dddddddddddd") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(t, w, http.StatusNotFound, map[string]any{ + "error": map[string]any{ + "code": "not_found", + "message": "battle not found", + }, + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload, err := transcoder.GameBattleRequestToPayload(&reportmodel.GameBattleRequest{ + GameID: gameID, + Turn: 2, + BattleID: battleID, + }) + require.NoError(t, err) + result, err := client.ExecuteGameCommand(context.Background(), newAuthCommand(t, reportmodel.MessageTypeUserGamesBattle, payload)) + require.NoError(t, err) + assert.Equal(t, "not_found", result.ResultCode) +} + // writeJSON copy below mirrors the helper used by other test files // in this package; keeping it adjacent to its callers avoids // reaching across files in a fresh test. diff --git a/gateway/internal/backendclient/mail_commands.go b/gateway/internal/backendclient/mail_commands.go new file mode 100644 index 0000000..a63e80a --- /dev/null +++ b/gateway/internal/backendclient/mail_commands.go @@ -0,0 +1,567 @@ +package backendclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "galaxy/gateway/internal/downstream" + diplomailmodel "galaxy/model/diplomail" + commonfbs "galaxy/schema/fbs/common" + fbs "galaxy/schema/fbs/diplomail" + + flatbuffers "github.com/google/flatbuffers/go" + "github.com/google/uuid" +) + +// ExecuteMailCommand routes one authenticated `user.games.mail.*` +// command into the matching `/api/v1/user/games/{game_id}/mail/...` +// backend REST endpoint. Each command decodes a FlatBuffers request +// payload, issues the REST call, decodes the JSON response, and +// re-encodes the result as a typed FlatBuffers envelope. +func (c *RESTClient) ExecuteMailCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { + if c == nil || c.httpClient == nil { + return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil client") + } + if ctx == nil { + return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil context") + } + if err := ctx.Err(); err != nil { + return downstream.UnaryResult{}, err + } + if strings.TrimSpace(command.UserID) == "" { + return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: user_id must not be empty") + } + + switch command.MessageType { + case diplomailmodel.MessageTypeUserGamesMailInbox: + return c.executeMailInbox(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailSent: + return c.executeMailSent(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailMessageGet: + return c.executeMailMessageGet(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailSend: + return c.executeMailSend(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailBroadcast: + return c.executeMailBroadcast(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailAdmin: + return c.executeMailAdmin(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailRead: + return c.executeMailRead(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailDelete: + return c.executeMailDelete(ctx, command.UserID, command.PayloadBytes) + default: + return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute mail command: unsupported message type %q", command.MessageType) + } +} + +// mailMessageJSON mirrors the backend's `UserMailMessageDetail` wire +// shape from `backend/openapi.yaml`. Pointer fields are nullable in +// the OpenAPI spec; the encoder treats empty strings as "absent". +type mailMessageJSON struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + SenderUserID *string `json:"sender_user_id,omitempty"` + SenderUsername *string `json:"sender_username,omitempty"` + SenderRaceName *string `json:"sender_race_name,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` + RecipientUserID string `json:"recipient_user_id"` + RecipientUserName string `json:"recipient_user_name,omitempty"` + RecipientRaceName *string `json:"recipient_race_name,omitempty"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` + TranslatedSubject *string `json:"translated_subject,omitempty"` + TranslatedBody *string `json:"translated_body,omitempty"` + TranslationLang *string `json:"translation_lang,omitempty"` + Translator *string `json:"translator,omitempty"` +} + +// mailRecipientStateJSON mirrors `UserMailRecipientState`. +type mailRecipientStateJSON struct { + MessageID string `json:"message_id"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +// mailBroadcastReceiptJSON mirrors `UserMailBroadcastReceipt`. +type mailBroadcastReceiptJSON struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` + RecipientCount int `json:"recipient_count"` +} + +type mailInboxJSON struct { + Items []mailMessageJSON `json:"items"` +} + +func (c *RESTClient) executeMailInbox(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: payload is empty") + } + flat := fbs.GetRootAsInboxRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: game_id is missing") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/inbox" + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.inbox: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var resp mailInboxJSON + if err := json.Unmarshal(respBody, &resp); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail inbox response: %w", err) + } + out := encodeMailMessageList(resp.Items, fbs.InboxResponseStart, fbs.InboxResponseAddItems, fbs.InboxResponseEnd, fbs.FinishInboxResponseBuffer) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil +} + +func (c *RESTClient) executeMailSent(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: payload is empty") + } + flat := fbs.GetRootAsSentRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: game_id is missing") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/sent" + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.sent: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var resp mailInboxJSON + if err := json.Unmarshal(respBody, &resp); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail sent response: %w", err) + } + out := encodeMailMessageList(resp.Items, fbs.SentResponseStart, fbs.SentResponseAddItems, fbs.SentResponseEnd, fbs.FinishSentResponseBuffer) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil +} + +func (c *RESTClient) executeMailMessageGet(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: payload is empty") + } + flat := fbs.GetRootAsMessageGetRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + messageID := readUUID(flat.MessageId(nil)) + if gameID == uuid.Nil || messageID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: game_id and message_id are required") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.message.get: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var msg mailMessageJSON + if err := json.Unmarshal(respBody, &msg); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail message response: %w", err) + } + builder := flatbuffers.NewBuilder(512) + msgOff := encodeMailMessage(builder, &msg) + fbs.MessageGetResponseStart(builder) + fbs.MessageGetResponseAddMessage(builder, msgOff) + root := fbs.MessageGetResponseEnd(builder) + fbs.FinishMessageGetResponseBuffer(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailSend(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: payload is empty") + } + flat := fbs.GetRootAsSendRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: game_id is missing") + } + body := struct { + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + }{ + RecipientUserID: string(flat.RecipientUserId()), + RecipientRaceName: string(flat.RecipientRaceName()), + Subject: string(flat.Subject()), + Body: string(flat.Body()), + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages" + respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.send: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var msg mailMessageJSON + if err := json.Unmarshal(respBody, &msg); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail send response: %w", err) + } + builder := flatbuffers.NewBuilder(512) + msgOff := encodeMailMessage(builder, &msg) + fbs.SendResponseStart(builder) + fbs.SendResponseAddMessage(builder, msgOff) + root := fbs.SendResponseEnd(builder) + fbs.FinishSendResponseBuffer(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailBroadcast(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: payload is empty") + } + flat := fbs.GetRootAsBroadcastRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: game_id is missing") + } + body := struct { + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + }{ + Subject: string(flat.Subject()), + Body: string(flat.Body()), + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/broadcast" + respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.broadcast: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var receipt mailBroadcastReceiptJSON + if err := json.Unmarshal(respBody, &receipt); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail broadcast response: %w", err) + } + builder := flatbuffers.NewBuilder(256) + recOff := encodeMailBroadcastReceipt(builder, &receipt) + fbs.BroadcastResponseStart(builder) + fbs.BroadcastResponseAddReceipt(builder, recOff) + root := fbs.BroadcastResponseEnd(builder) + fbs.FinishBroadcastResponseBuffer(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailAdmin(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: payload is empty") + } + flat := fbs.GetRootAsAdminRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: game_id is missing") + } + target := string(flat.Target()) + body := struct { + Target string `json:"target"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Recipients string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + }{ + Target: target, + RecipientUserID: string(flat.RecipientUserId()), + RecipientRaceName: string(flat.RecipientRaceName()), + Recipients: string(flat.Recipients()), + Subject: string(flat.Subject()), + Body: string(flat.Body()), + } + url := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/admin" + respBody, status, err := c.do(ctx, http.MethodPost, url, userID, body) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.admin: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + builder := flatbuffers.NewBuilder(512) + if target == "all" { + var receipt mailBroadcastReceiptJSON + if err := json.Unmarshal(respBody, &receipt); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail admin broadcast response: %w", err) + } + recOff := encodeMailBroadcastReceipt(builder, &receipt) + fbs.AdminResponseStart(builder) + fbs.AdminResponseAddReceipt(builder, recOff) + root := fbs.AdminResponseEnd(builder) + fbs.FinishAdminResponseBuffer(builder, root) + } else { + var msg mailMessageJSON + if err := json.Unmarshal(respBody, &msg); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail admin send response: %w", err) + } + msgOff := encodeMailMessage(builder, &msg) + fbs.AdminResponseStart(builder) + fbs.AdminResponseAddMessage(builder, msgOff) + root := fbs.AdminResponseEnd(builder) + fbs.FinishAdminResponseBuffer(builder, root) + } + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailRead(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: payload is empty") + } + flat := fbs.GetRootAsReadRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + messageID := readUUID(flat.MessageId(nil)) + if gameID == uuid.Nil || messageID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: game_id and message_id are required") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + "/read" + respBody, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{}) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.read: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + return encodeRecipientStateResponse(respBody, fbs.ReadResponseStart, fbs.ReadResponseAddState, fbs.ReadResponseEnd, fbs.FinishReadResponseBuffer) +} + +func (c *RESTClient) executeMailDelete(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: payload is empty") + } + flat := fbs.GetRootAsDeleteRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + messageID := readUUID(flat.MessageId(nil)) + if gameID == uuid.Nil || messageID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: game_id and message_id are required") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + respBody, status, err := c.do(ctx, http.MethodDelete, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.delete: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + return encodeRecipientStateResponse(respBody, fbs.DeleteResponseStart, fbs.DeleteResponseAddState, fbs.DeleteResponseEnd, fbs.FinishDeleteResponseBuffer) +} + +// encodeRecipientStateResponse decodes the JSON recipient-state body +// and emits the corresponding FlatBuffers Read/Delete envelope. The +// caller supplies the trio of envelope start / add-state / end / finish +// functions so this helper covers both endpoints with the same shape. +func encodeRecipientStateResponse(respBody []byte, + startFn func(*flatbuffers.Builder), + addStateFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), + endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT, + finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), +) (downstream.UnaryResult, error) { + var state mailRecipientStateJSON + if err := json.Unmarshal(respBody, &state); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail recipient state: %w", err) + } + builder := flatbuffers.NewBuilder(128) + stateOff := encodeMailRecipientState(builder, &state) + startFn(builder) + addStateFn(builder, stateOff) + root := endFn(builder) + finishFn(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +// encodeMailMessageList is a shared helper that encodes a slice of +// mailMessageJSON items into either an InboxResponse or a +// SentResponse FlatBuffers envelope. The two envelopes have the same +// shape (just a `items` vector of MailMessage) so the trio of +// constructor functions parameterises the helper. +func encodeMailMessageList(items []mailMessageJSON, + startFn func(*flatbuffers.Builder), + addItemsFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), + endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT, + finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), +) []byte { + builder := flatbuffers.NewBuilder(1024) + offsets := make([]flatbuffers.UOffsetT, 0, len(items)) + for i := range items { + offsets = append(offsets, encodeMailMessage(builder, &items[i])) + } + // FlatBuffers vectors are built in reverse: prepend each offset. + builder.StartVector(4, len(offsets), 4) + for i := len(offsets) - 1; i >= 0; i-- { + builder.PrependUOffsetT(offsets[i]) + } + itemsVec := builder.EndVector(len(offsets)) + startFn(builder) + addItemsFn(builder, itemsVec) + root := endFn(builder) + finishFn(builder, root) + return builder.FinishedBytes() +} + +// encodeMailMessage builds a MailMessage table inside builder. Returns +// the offset of the finished table. Strings are interned through the +// builder; missing JSON fields (nil pointers, empty strings) yield +// empty FB strings which the readers treat as absent. +func encodeMailMessage(builder *flatbuffers.Builder, m *mailMessageJSON) flatbuffers.UOffsetT { + messageIDOff := builder.CreateString(m.MessageID) + gameIDOff := builder.CreateString(m.GameID) + gameNameOff := builder.CreateString(m.GameName) + kindOff := builder.CreateString(m.Kind) + senderKindOff := builder.CreateString(m.SenderKind) + senderUserIDOff := builder.CreateString(stringPtrValue(m.SenderUserID)) + senderUsernameOff := builder.CreateString(stringPtrValue(m.SenderUsername)) + senderRaceNameOff := builder.CreateString(stringPtrValue(m.SenderRaceName)) + subjectOff := builder.CreateString(m.Subject) + bodyOff := builder.CreateString(m.Body) + bodyLangOff := builder.CreateString(m.BodyLang) + broadcastScopeOff := builder.CreateString(m.BroadcastScope) + recipientUserIDOff := builder.CreateString(m.RecipientUserID) + recipientUserNameOff := builder.CreateString(m.RecipientUserName) + recipientRaceNameOff := builder.CreateString(stringPtrValue(m.RecipientRaceName)) + translatedSubjectOff := builder.CreateString(stringPtrValue(m.TranslatedSubject)) + translatedBodyOff := builder.CreateString(stringPtrValue(m.TranslatedBody)) + translationLangOff := builder.CreateString(stringPtrValue(m.TranslationLang)) + translatorOff := builder.CreateString(stringPtrValue(m.Translator)) + + fbs.MailMessageStart(builder) + fbs.MailMessageAddMessageId(builder, messageIDOff) + fbs.MailMessageAddGameId(builder, gameIDOff) + fbs.MailMessageAddGameName(builder, gameNameOff) + fbs.MailMessageAddKind(builder, kindOff) + fbs.MailMessageAddSenderKind(builder, senderKindOff) + fbs.MailMessageAddSenderUserId(builder, senderUserIDOff) + fbs.MailMessageAddSenderUsername(builder, senderUsernameOff) + fbs.MailMessageAddSenderRaceName(builder, senderRaceNameOff) + fbs.MailMessageAddSubject(builder, subjectOff) + fbs.MailMessageAddBody(builder, bodyOff) + fbs.MailMessageAddBodyLang(builder, bodyLangOff) + fbs.MailMessageAddBroadcastScope(builder, broadcastScopeOff) + fbs.MailMessageAddCreatedAtMs(builder, parseRFC3339Millis(m.CreatedAt)) + fbs.MailMessageAddRecipientUserId(builder, recipientUserIDOff) + fbs.MailMessageAddRecipientUserName(builder, recipientUserNameOff) + fbs.MailMessageAddRecipientRaceName(builder, recipientRaceNameOff) + fbs.MailMessageAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(m.ReadAt))) + fbs.MailMessageAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(m.DeletedAt))) + fbs.MailMessageAddTranslatedSubject(builder, translatedSubjectOff) + fbs.MailMessageAddTranslatedBody(builder, translatedBodyOff) + fbs.MailMessageAddTranslationLang(builder, translationLangOff) + fbs.MailMessageAddTranslator(builder, translatorOff) + return fbs.MailMessageEnd(builder) +} + +// encodeMailRecipientState builds a MailRecipientState table. +func encodeMailRecipientState(builder *flatbuffers.Builder, s *mailRecipientStateJSON) flatbuffers.UOffsetT { + messageIDOff := builder.CreateString(s.MessageID) + fbs.MailRecipientStateStart(builder) + fbs.MailRecipientStateAddMessageId(builder, messageIDOff) + fbs.MailRecipientStateAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(s.ReadAt))) + fbs.MailRecipientStateAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(s.DeletedAt))) + return fbs.MailRecipientStateEnd(builder) +} + +// encodeMailBroadcastReceipt builds a MailBroadcastReceipt table. +func encodeMailBroadcastReceipt(builder *flatbuffers.Builder, r *mailBroadcastReceiptJSON) flatbuffers.UOffsetT { + messageIDOff := builder.CreateString(r.MessageID) + gameIDOff := builder.CreateString(r.GameID) + gameNameOff := builder.CreateString(r.GameName) + kindOff := builder.CreateString(r.Kind) + senderKindOff := builder.CreateString(r.SenderKind) + subjectOff := builder.CreateString(r.Subject) + bodyOff := builder.CreateString(r.Body) + bodyLangOff := builder.CreateString(r.BodyLang) + broadcastScopeOff := builder.CreateString(r.BroadcastScope) + fbs.MailBroadcastReceiptStart(builder) + fbs.MailBroadcastReceiptAddMessageId(builder, messageIDOff) + fbs.MailBroadcastReceiptAddGameId(builder, gameIDOff) + fbs.MailBroadcastReceiptAddGameName(builder, gameNameOff) + fbs.MailBroadcastReceiptAddKind(builder, kindOff) + fbs.MailBroadcastReceiptAddSenderKind(builder, senderKindOff) + fbs.MailBroadcastReceiptAddSubject(builder, subjectOff) + fbs.MailBroadcastReceiptAddBody(builder, bodyOff) + fbs.MailBroadcastReceiptAddBodyLang(builder, bodyLangOff) + fbs.MailBroadcastReceiptAddBroadcastScope(builder, broadcastScopeOff) + fbs.MailBroadcastReceiptAddCreatedAtMs(builder, parseRFC3339Millis(r.CreatedAt)) + fbs.MailBroadcastReceiptAddRecipientCount(builder, int32(r.RecipientCount)) + return fbs.MailBroadcastReceiptEnd(builder) +} + +// projectMailErrorResponse maps a non-2xx response into a UnaryResult +// carrying the backend error envelope, reusing the shared user-mail +// error-projection. 503 is bubbled as ErrDownstreamUnavailable. +func projectMailErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) { + if statusCode == http.StatusServiceUnavailable { + return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable + } + if statusCode >= 400 && statusCode <= 599 { + return projectUserBackendError(statusCode, payload) + } + return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode) +} + +// readUUID converts the common.UUID struct (or its absence) into a +// google/uuid.UUID. Returns uuid.Nil when the input is nil. +func readUUID(u *commonfbs.UUID) uuid.UUID { + if u == nil { + return uuid.Nil + } + var out uuid.UUID + hi := u.Hi() + lo := u.Lo() + for i := 0; i < 8; i++ { + out[i] = byte(hi >> (56 - 8*i)) + out[i+8] = byte(lo >> (56 - 8*i)) + } + return out +} + +// stringPtrValue returns "" for nil and the dereferenced value +// otherwise. Used to flatten nullable JSON strings into the +// always-present FlatBuffers string slot. +func stringPtrValue(p *string) string { + if p == nil { + return "" + } + return *p +} + +// parseRFC3339Millis parses an RFC 3339 timestamp string (the format +// the backend mail handler emits) into Unix milliseconds. Returns 0 +// when the input is empty or unparseable, matching the "absent" +// convention for the *_at_ms wire fields. +func parseRFC3339Millis(s string) int64 { + if s == "" { + return 0 + } + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return 0 + } + return t.UnixMilli() +} diff --git a/gateway/internal/backendclient/mail_commands_test.go b/gateway/internal/backendclient/mail_commands_test.go new file mode 100644 index 0000000..7adfeb5 --- /dev/null +++ b/gateway/internal/backendclient/mail_commands_test.go @@ -0,0 +1,209 @@ +package backendclient_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "galaxy/gateway/internal/backendclient" + diplomailmodel "galaxy/model/diplomail" + commonfbs "galaxy/schema/fbs/common" + fbs "galaxy/schema/fbs/diplomail" + + flatbuffers "github.com/google/flatbuffers/go" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteMailInboxDecodesItems(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("11111111-2222-3333-4444-555555555555") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/inbox", r.URL.Path) + require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID)) + writeJSON(t, w, http.StatusOK, map[string]any{ + "items": []map[string]any{ + { + "message_id": "00000000-0000-0000-0000-000000000001", + "game_id": gameID.String(), + "kind": "personal", + "sender_kind": "player", + "sender_user_id": "00000000-0000-0000-0000-000000000010", + "sender_username": "alice", + "sender_race_name": "AliceRace", + "subject": "hi", + "body": "hello there", + "body_lang": "en", + "broadcast_scope": "single", + "created_at": "2026-05-15T12:00:00Z", + "recipient_user_id": "00000000-0000-0000-0000-000000000020", + "recipient_user_name": "bob", + "recipient_race_name": "BobRace", + }, + }, + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload := buildInboxRequest(gameID) + cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailInbox, payload) + result, err := client.ExecuteMailCommand(context.Background(), cmd) + require.NoError(t, err) + assert.Equal(t, "ok", result.ResultCode) + + resp := fbs.GetRootAsInboxResponse(result.PayloadBytes, 0) + require.Equal(t, 1, resp.ItemsLength()) + var item fbs.MailMessage + require.True(t, resp.Items(&item, 0)) + assert.Equal(t, "00000000-0000-0000-0000-000000000001", string(item.MessageId())) + assert.Equal(t, "AliceRace", string(item.SenderRaceName())) + assert.Equal(t, "BobRace", string(item.RecipientRaceName())) +} + +func TestExecuteMailSendForwardsRaceName(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("22222222-3333-4444-5555-666666666666") + var captured struct { + Body string + RecipientUserID string + RecipientRaceName string + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages", r.URL.Path) + var req map[string]any + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &req)) + if v, ok := req["body"].(string); ok { + captured.Body = v + } + if v, ok := req["recipient_user_id"].(string); ok { + captured.RecipientUserID = v + } + if v, ok := req["recipient_race_name"].(string); ok { + captured.RecipientRaceName = v + } + writeJSON(t, w, http.StatusCreated, map[string]any{ + "message_id": "00000000-0000-0000-0000-000000000099", + "game_id": gameID.String(), + "kind": "personal", + "sender_kind": "player", + "sender_user_id": "00000000-0000-0000-0000-000000000010", + "sender_race_name": "Senders", + "body": captured.Body, + "body_lang": "en", + "broadcast_scope": "single", + "created_at": "2026-05-15T12:00:00Z", + "recipient_user_id": "00000000-0000-0000-0000-000000000020", + "recipient_race_name": "Receivers", + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload := buildSendRequestByRaceName(gameID, "Receivers", "let us talk") + cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailSend, payload) + result, err := client.ExecuteMailCommand(context.Background(), cmd) + require.NoError(t, err) + assert.Equal(t, "ok", result.ResultCode) + + resp := fbs.GetRootAsSendResponse(result.PayloadBytes, 0) + require.NotNil(t, resp.Message(nil)) + msg := resp.Message(nil) + assert.Equal(t, "let us talk", string(msg.Body())) + assert.Equal(t, "Senders", string(msg.SenderRaceName())) + assert.Equal(t, "Receivers", string(msg.RecipientRaceName())) + + assert.Empty(t, captured.RecipientUserID) + assert.Equal(t, "Receivers", captured.RecipientRaceName) + assert.Equal(t, "let us talk", captured.Body) +} + +func TestExecuteMailReadReturnsState(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("33333333-4444-5555-6666-777777777777") + messageID := uuid.MustParse("00000000-0000-0000-0000-0000000000aa") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages/"+messageID.String()+"/read", r.URL.Path) + writeJSON(t, w, http.StatusOK, map[string]any{ + "message_id": messageID.String(), + "read_at": "2026-05-15T12:34:56Z", + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload := buildReadRequest(gameID, messageID) + cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailRead, payload) + result, err := client.ExecuteMailCommand(context.Background(), cmd) + require.NoError(t, err) + + resp := fbs.GetRootAsReadResponse(result.PayloadBytes, 0) + state := resp.State(nil) + require.NotNil(t, state) + assert.Equal(t, messageID.String(), string(state.MessageId())) + assert.NotZero(t, state.ReadAtMs()) +} + +// buildInboxRequest emits a FlatBuffers InboxRequest envelope with +// the supplied game_id. +func buildInboxRequest(gameID uuid.UUID) []byte { + builder := flatbuffers.NewBuilder(64) + hi, lo := uuidToHiLo(gameID) + fbs.InboxRequestStart(builder) + fbs.InboxRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo)) + root := fbs.InboxRequestEnd(builder) + fbs.FinishInboxRequestBuffer(builder, root) + return builder.FinishedBytes() +} + +// buildSendRequestByRaceName emits a FlatBuffers SendRequest that +// addresses the recipient by race name rather than user_id. +func buildSendRequestByRaceName(gameID uuid.UUID, raceName, body string) []byte { + builder := flatbuffers.NewBuilder(128) + raceOff := builder.CreateString(raceName) + bodyOff := builder.CreateString(body) + hi, lo := uuidToHiLo(gameID) + fbs.SendRequestStart(builder) + fbs.SendRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo)) + fbs.SendRequestAddRecipientRaceName(builder, raceOff) + fbs.SendRequestAddBody(builder, bodyOff) + root := fbs.SendRequestEnd(builder) + fbs.FinishSendRequestBuffer(builder, root) + return builder.FinishedBytes() +} + +// buildReadRequest emits a FlatBuffers ReadRequest envelope. +func buildReadRequest(gameID, messageID uuid.UUID) []byte { + builder := flatbuffers.NewBuilder(64) + gameHi, gameLo := uuidToHiLo(gameID) + msgHi, msgLo := uuidToHiLo(messageID) + fbs.ReadRequestStart(builder) + fbs.ReadRequestAddGameId(builder, commonfbs.CreateUUID(builder, gameHi, gameLo)) + fbs.ReadRequestAddMessageId(builder, commonfbs.CreateUUID(builder, msgHi, msgLo)) + root := fbs.ReadRequestEnd(builder) + fbs.FinishReadRequestBuffer(builder, root) + return builder.FinishedBytes() +} + +// uuidToHiLo splits a 16-byte UUID into the two big-endian uint64 +// halves the common.UUID struct uses. +func uuidToHiLo(u uuid.UUID) (uint64, uint64) { + var hi, lo uint64 + for i := 0; i < 8; i++ { + hi = (hi << 8) | uint64(u[i]) + lo = (lo << 8) | uint64(u[i+8]) + } + return hi, lo +} diff --git a/gateway/internal/backendclient/routes.go b/gateway/internal/backendclient/routes.go index 7b4e37a..86d6ae5 100644 --- a/gateway/internal/backendclient/routes.go +++ b/gateway/internal/backendclient/routes.go @@ -4,6 +4,7 @@ import ( "context" "galaxy/gateway/internal/downstream" + diplomailmodel "galaxy/model/diplomail" lobbymodel "galaxy/model/lobby" ordermodel "galaxy/model/order" reportmodel "galaxy/model/report" @@ -64,6 +65,28 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client { ordermodel.MessageTypeUserGamesOrder: target, ordermodel.MessageTypeUserGamesOrderGet: target, reportmodel.MessageTypeUserGamesReport: target, + reportmodel.MessageTypeUserGamesBattle: target, + } +} + +// MailRoutes returns the authenticated `user.games.mail.*` downstream +// routes served by backend's diplomail subsystem. When client is nil +// every route resolves to a dependency-unavailable client so the +// static router still recognises the message types. +func MailRoutes(client *RESTClient) map[string]downstream.Client { + target := downstream.Client(unavailableClient{}) + if client != nil { + target = mailCommandClient{rest: client} + } + return map[string]downstream.Client{ + diplomailmodel.MessageTypeUserGamesMailInbox: target, + diplomailmodel.MessageTypeUserGamesMailSent: target, + diplomailmodel.MessageTypeUserGamesMailMessageGet: target, + diplomailmodel.MessageTypeUserGamesMailSend: target, + diplomailmodel.MessageTypeUserGamesMailBroadcast: target, + diplomailmodel.MessageTypeUserGamesMailAdmin: target, + diplomailmodel.MessageTypeUserGamesMailRead: target, + diplomailmodel.MessageTypeUserGamesMailDelete: target, } } @@ -97,9 +120,18 @@ func (c gameCommandClient) ExecuteCommand(ctx context.Context, command downstrea return c.rest.ExecuteGameCommand(ctx, command) } +type mailCommandClient struct { + rest *RESTClient +} + +func (c mailCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { + return c.rest.ExecuteMailCommand(ctx, command) +} + var ( _ downstream.Client = unavailableClient{} _ downstream.Client = userCommandClient{} _ downstream.Client = lobbyCommandClient{} _ downstream.Client = gameCommandClient{} + _ downstream.Client = mailCommandClient{} ) diff --git a/gateway/internal/backendclient/routes_test.go b/gateway/internal/backendclient/routes_test.go index 2c4df89..aba6d75 100644 --- a/gateway/internal/backendclient/routes_test.go +++ b/gateway/internal/backendclient/routes_test.go @@ -60,6 +60,7 @@ func TestRoutesCoverAllAuthenticatedMessageTypes(t *testing.T) { ordermodel.MessageTypeUserGamesOrder, ordermodel.MessageTypeUserGamesOrderGet, reportmodel.MessageTypeUserGamesReport, + reportmodel.MessageTypeUserGamesBattle, }, actual: backendclient.GameRoutes(nil), }, diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 7d0f70a..128e400 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -101,6 +101,16 @@ const ( // the authenticated gRPC listener address. authenticatedGRPCAddrEnvVar = "GATEWAY_AUTHENTICATED_GRPC_ADDR" + // authenticatedGRPCCORSAllowedOriginsEnvVar names the environment + // variable that configures the comma-separated list of browser + // origins permitted to call the authenticated Connect-Web surface. + // An empty value disables CORS entirely; the listener then refuses + // to send Access-Control-* headers and browsers block cross-origin + // fetches. Set this in any deployment that fronts the gateway + // behind a different hostname than the SvelteKit bundle (e.g. + // `https://www.galaxy.lan` calling `https://api.galaxy.lan`). + authenticatedGRPCCORSAllowedOriginsEnvVar = "GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS" + // authenticatedGRPCConnectionTimeoutEnvVar names the environment variable // that configures the inbound connection handshake timeout for the // authenticated gRPC listener. @@ -542,6 +552,13 @@ type AuthenticatedGRPCConfig struct { // AntiAbuse configures the authenticated gRPC rate limits enforced after // the request passes the transport authenticity checks. AntiAbuse AuthenticatedGRPCAntiAbuseConfig + + // CORSAllowedOrigins is the exact-match list of browser origins + // permitted to call the authenticated Connect-Web surface. Empty + // disables CORS — requests without an Access-Control-Allow-Origin + // response will be blocked by the browser, which is the production + // posture when the UI and the gateway share a single hostname. + CORSAllowedOrigins []string } // SessionCacheConfig describes the bounds of the gateway's in-memory @@ -836,6 +853,16 @@ func LoadFromEnv() (Config, error) { cfg.PublicHTTP.CORSAllowedOrigins = origins } + if v, ok := os.LookupEnv(authenticatedGRPCCORSAllowedOriginsEnvVar); ok { + origins := make([]string, 0) + for part := range strings.SplitSeq(v, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + origins = append(origins, trimmed) + } + } + cfg.AuthenticatedGRPC.CORSAllowedOrigins = origins + } + if v, ok := os.LookupEnv(backendHTTPURLEnvVar); ok { cfg.Backend.HTTPBaseURL = v } diff --git a/gateway/internal/grpcapi/cors.go b/gateway/internal/grpcapi/cors.go new file mode 100644 index 0000000..ea23557 --- /dev/null +++ b/gateway/internal/grpcapi/cors.go @@ -0,0 +1,64 @@ +package grpcapi + +import ( + "net/http" +) + +// withCORS wraps next so that CORS preflight (OPTIONS) requests with an +// allow-listed Origin receive 204 plus the `Access-Control-Allow-*` +// headers Connect-Web needs, and actual requests get the matching +// `Access-Control-Allow-Origin` header echoed back. Origins are +// compared exactly: scheme, host, and port must match. An empty +// allow-list passes through untouched — the production posture when +// the UI and the gateway share one hostname. +// +// The wrapper mirrors `restapi.withCORS` but speaks plain `net/http` +// because the Connect handler is mounted on a `http.ServeMux`, not a +// gin engine. Connect-Web POSTs use `Content-Type: application/connect+json` +// which triggers a browser preflight; without these headers the +// browser surfaces "Load failed" before the Connect handler even sees +// the request. +func withCORS(allowedOrigins []string, next http.Handler) http.Handler { + allowed := make(map[string]struct{}, len(allowedOrigins)) + for _, origin := range allowedOrigins { + allowed[origin] = struct{}{} + } + if len(allowed) == 0 { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + next.ServeHTTP(w, r) + return + } + if _, ok := allowed[origin]; !ok { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Add("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Credentials", "true") + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" { + w.Header().Set("Access-Control-Allow-Headers", reqHeaders) + } else { + // Defaults cover the Connect-Web preflight set: protocol + // version, content type, timeout, and the signed-request + // metadata the gateway interceptor expects. + w.Header().Set("Access-Control-Allow-Headers", + "Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Authorization") + } + // Expose the response headers Connect-Web needs to read on + // the client (e.g. trailers folded into headers for unary). + w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message") + w.Header().Set("Access-Control-Max-Age", "3600") + w.WriteHeader(http.StatusNoContent) + return + } + // Expose the same response headers on the actual call. + w.Header().Set("Access-Control-Expose-Headers", "Connect-Protocol-Version, Grpc-Status, Grpc-Message") + next.ServeHTTP(w, r) + }) +} diff --git a/gateway/internal/grpcapi/server.go b/gateway/internal/grpcapi/server.go index e101b58..93cf13e 100644 --- a/gateway/internal/grpcapi/server.go +++ b/gateway/internal/grpcapi/server.go @@ -169,7 +169,10 @@ func (s *Server) Run(ctx context.Context) error { ) mux.Handle(path, handler) - tracedHandler := otelhttp.NewHandler(mux, "authenticated_edge") + // CORS runs OUTSIDE the otelhttp wrapper so preflight OPTIONS calls + // answer with 204 immediately and never enter the trace path. + corsMux := withCORS(s.cfg.CORSAllowedOrigins, mux) + tracedHandler := otelhttp.NewHandler(corsMux, "authenticated_edge") http2Server := &http2.Server{IdleTimeout: s.cfg.ConnectionTimeout} httpServer := &http.Server{ Handler: h2c.NewHandler(tracedHandler, http2Server), diff --git a/pkg/model/diplomail/diplomail.go b/pkg/model/diplomail/diplomail.go new file mode 100644 index 0000000..b8f963d --- /dev/null +++ b/pkg/model/diplomail/diplomail.go @@ -0,0 +1,56 @@ +// Package diplomail defines the public typed command identifiers +// exposed at the authenticated Gateway -> Diplomatic Mail boundary. +// +// The gateway routes each `user.games.mail.*` ExecuteCommand into the +// matching `/api/v1/user/games/{game_id}/mail/*` REST endpoint on the +// backend; the wire envelopes and payload tables live in +// `pkg/schema/fbs/diplomail.fbs`. +package diplomail + +const ( + // MessageTypeUserGamesMailInbox is the authenticated gateway + // message type used to read the caller's diplomatic-mail inbox + // for one game. Backend filters out rows whose `available_at` is + // still nil (translation in flight). + MessageTypeUserGamesMailInbox = "user.games.mail.inbox" + + // MessageTypeUserGamesMailSent is the authenticated gateway + // message type used to read the caller's outgoing personal + // messages for one game. Admin and system rows are not included. + MessageTypeUserGamesMailSent = "user.games.mail.sent" + + // MessageTypeUserGamesMailMessageGet is the authenticated + // gateway message type used to read a single message detail + // addressed to the caller. The response carries the translation + // rendering when one is cached for the caller's preferred + // language. + MessageTypeUserGamesMailMessageGet = "user.games.mail.message.get" + + // MessageTypeUserGamesMailSend is the authenticated gateway + // message type used to send a single-recipient personal message. + // Exactly one of `recipient_user_id` and `recipient_race_name` + // must be supplied; the backend resolves the race-name shortcut + // through `Memberships.ListMembers(gameID, "active")`. + MessageTypeUserGamesMailSend = "user.games.mail.send" + + // MessageTypeUserGamesMailBroadcast is the authenticated gateway + // message type used by paid-tier callers to broadcast a personal + // message to every other active member of the game. + MessageTypeUserGamesMailBroadcast = "user.games.mail.broadcast" + + // MessageTypeUserGamesMailAdmin is the authenticated gateway + // message type used by the game owner to compose an admin-kind + // notification. The wire shape is target-discriminated: `user` + // addresses a single recipient (by id or race name); `all` + // broadcasts to every member matching the requested scope. + MessageTypeUserGamesMailAdmin = "user.games.mail.admin" + + // MessageTypeUserGamesMailRead is the authenticated gateway + // message type used to mark a single message as read. Idempotent. + MessageTypeUserGamesMailRead = "user.games.mail.read" + + // MessageTypeUserGamesMailDelete is the authenticated gateway + // message type used to soft-delete a single message. The + // recipient row must already be marked read. + MessageTypeUserGamesMailDelete = "user.games.mail.delete" +) diff --git a/pkg/model/report/messages.go b/pkg/model/report/messages.go index f3668b9..f8a8eee 100644 --- a/pkg/model/report/messages.go +++ b/pkg/model/report/messages.go @@ -9,6 +9,13 @@ import "github.com/google/uuid" // `Report`. const MessageTypeUserGamesReport = "user.games.report" +// MessageTypeUserGamesBattle is the authenticated gateway message type +// used to fetch one battle report through +// `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}`. The +// signed payload is a FlatBuffers `GameBattleRequest`; the response is +// a FlatBuffers `BattleReport`. +const MessageTypeUserGamesBattle = "user.games.battle" + // GameReportRequest is the typed payload of MessageTypeUserGamesReport. // `GameID` selects the target game (the message_type alone is not // enough; this scope is per-game) and `Turn` selects the requested @@ -20,3 +27,19 @@ type GameReportRequest struct { // Turn is the zero-based turn number whose report is requested. Turn uint `json:"turn"` } + +// GameBattleRequest is the typed payload of MessageTypeUserGamesBattle. +// `GameID` selects the target game; `Turn` is the turn the battle +// happened at (the engine partitions battles by turn for cheap lookup); +// `BattleID` is the in-game identifier returned in the report's +// battle-summary list. All three fields are required. +type GameBattleRequest struct { + // GameID identifies the game the battle belongs to. + GameID uuid.UUID `json:"game_id"` + + // Turn is the turn number the battle happened at. + Turn uint `json:"turn"` + + // BattleID is the engine-assigned id of the battle to fetch. + BattleID uuid.UUID `json:"battle_id"` +} diff --git a/pkg/schema/fbs/battle.fbs b/pkg/schema/fbs/battle.fbs index 0f57b83..254019a 100644 --- a/pkg/schema/fbs/battle.fbs +++ b/pkg/schema/fbs/battle.fbs @@ -49,4 +49,14 @@ table BattleReport { protocol:[BattleActionReport]; } +// GameBattleRequest is the signed-gRPC request payload for +// `MessageTypeUserGamesBattle`. Gateway forwards this into the +// backend's `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` +// endpoint after resolving the caller's runtime player mapping. +table GameBattleRequest { + game_id:UUID (required); + turn:uint32; + battle_id:UUID (required); +} + root_type BattleReport; diff --git a/pkg/schema/fbs/battle/GameBattleRequest.go b/pkg/schema/fbs/battle/GameBattleRequest.go new file mode 100644 index 0000000..0651b7f --- /dev/null +++ b/pkg/schema/fbs/battle/GameBattleRequest.go @@ -0,0 +1,96 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package battle + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type GameBattleRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsGameBattleRequest(buf []byte, offset flatbuffers.UOffsetT) *GameBattleRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &GameBattleRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishGameBattleRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsGameBattleRequest(buf []byte, offset flatbuffers.UOffsetT) *GameBattleRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &GameBattleRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedGameBattleRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *GameBattleRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *GameBattleRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *GameBattleRequest) GameId(obj *UUID) *UUID { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := o + rcv._tab.Pos + if obj == nil { + obj = new(UUID) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *GameBattleRequest) Turn() uint32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetUint32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameBattleRequest) MutateTurn(n uint32) bool { + return rcv._tab.MutateUint32Slot(6, n) +} + +func (rcv *GameBattleRequest) BattleId(obj *UUID) *UUID { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := o + rcv._tab.Pos + if obj == nil { + obj = new(UUID) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func GameBattleRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func GameBattleRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func GameBattleRequestAddTurn(builder *flatbuffers.Builder, turn uint32) { + builder.PrependUint32Slot(1, turn, 0) +} +func GameBattleRequestAddBattleId(builder *flatbuffers.Builder, battleId flatbuffers.UOffsetT) { + builder.PrependStructSlot(2, flatbuffers.UOffsetT(battleId), 0) +} +func GameBattleRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail.fbs b/pkg/schema/fbs/diplomail.fbs new file mode 100644 index 0000000..3049612 --- /dev/null +++ b/pkg/schema/fbs/diplomail.fbs @@ -0,0 +1,196 @@ +// diplomail contains FlatBuffers payloads used by the authenticated +// gateway boundary for the in-game diplomatic-mail subsystem. The +// wire shapes here mirror the trusted internal +// `/api/v1/user/games/{game_id}/mail/*` REST surface; gateway derives +// the calling `user_id` from the verified session and forwards it as +// `X-User-Id` to backend. + +include "common.fbs"; + +namespace diplomail; + +// MailMessage stores one inbox / sent-list / message-detail row. The +// fields mirror `UserMailMessageDetail` in `backend/openapi.yaml` +// with the following encoding rules: +// +// - `*_user_id` fields are RFC 4122 string UUIDs ("" means absent +// for nullable fields such as `sender_user_id`). +// - `*_at_ms` fields carry Unix milliseconds; `0` means the +// timestamp is absent (e.g. an unread message has +// `read_at_ms == 0`). +// - `translated_*`, `translation_lang`, and `translator` are set +// when the backend served a cached rendering into the caller's +// preferred language; empty otherwise. +// - `sender_race_name` is the snapshot of the sender's race name +// in this game at send time. Present for `sender_kind="player"` +// messages when the sender had an active membership; absent for +// admin and system messages. The in-game UI keys per-race +// threading on this field. +table MailMessage { + message_id:string; + game_id:string; + game_name:string; + kind:string; + sender_kind:string; + sender_user_id:string; + sender_username:string; + sender_race_name:string; + subject:string; + body:string; + body_lang:string; + broadcast_scope:string; + created_at_ms:int64; + recipient_user_id:string; + recipient_user_name:string; + recipient_race_name:string; + read_at_ms:int64; + deleted_at_ms:int64; + translated_subject:string; + translated_body:string; + translation_lang:string; + translator:string; +} + +// MailRecipientState mirrors the `UserMailRecipientState` payload +// returned from mark-read and soft-delete endpoints. Same timestamp +// conventions as `MailMessage`. +table MailRecipientState { + message_id:string; + read_at_ms:int64; + deleted_at_ms:int64; +} + +// MailBroadcastReceipt mirrors `UserMailBroadcastReceipt`. Returned +// from broadcast sends (paid-tier and admin); `recipient_count` is +// the number of recipient rows the server materialised. +table MailBroadcastReceipt { + message_id:string; + game_id:string; + game_name:string; + kind:string; + sender_kind:string; + subject:string; + body:string; + body_lang:string; + broadcast_scope:string; + created_at_ms:int64; + recipient_count:int32; +} + +// InboxRequest stores the read-side request for the caller's inbox +// in `game_id`. Backend filters to messages with `available_at` set +// (translation completed when the recipient's preferred language +// differs from the body language). +table InboxRequest { + game_id:common.UUID (required); +} + +// InboxResponse stores the resulting inbox list, newest first. +// `items` is empty when the caller has no available messages in +// this game. +table InboxResponse { + items:[MailMessage]; +} + +// SentRequest stores the read-side request for the caller's sent +// personal messages in `game_id`. Admin / system rows are not +// included. +table SentRequest { + game_id:common.UUID (required); +} + +// SentResponse stores the caller's outgoing personal-message list. +// Each `MailMessage` carries the original recipient snapshot. +table SentResponse { + items:[MailMessage]; +} + +// MessageGetRequest stores the read-side request for a single +// message detail. The caller must be a recipient of the message. +table MessageGetRequest { + game_id:common.UUID (required); + message_id:common.UUID (required); +} + +// MessageGetResponse stores the fully decorated message detail +// including any cached translation into the caller's preferred +// language. +table MessageGetResponse { + message:MailMessage; +} + +// SendRequest stores the write-side request for a single-recipient +// personal send. Exactly one of `recipient_user_id` / +// `recipient_race_name` must be supplied; the empty string means +// "use the other field". +table SendRequest { + game_id:common.UUID (required); + recipient_user_id:string; + recipient_race_name:string; + subject:string; + body:string; +} + +// SendResponse echoes the freshly inserted message detail. +table SendResponse { + message:MailMessage; +} + +// BroadcastRequest stores the paid-tier player broadcast. The +// recipient set is always "every other active member of the game". +table BroadcastRequest { + game_id:common.UUID (required); + subject:string; + body:string; +} + +// BroadcastResponse stores the receipt returned by the server. +table BroadcastResponse { + receipt:MailBroadcastReceipt; +} + +// AdminRequest stores the owner-only admin send. `target="user"` +// requires exactly one of `recipient_user_id` / `recipient_race_name`; +// `target="all"` accepts the optional `recipients` scope (default +// `active`). +table AdminRequest { + game_id:common.UUID (required); + target:string; + recipient_user_id:string; + recipient_race_name:string; + recipients:string; + subject:string; + body:string; +} + +// AdminResponse carries the result of an admin send. When the +// request had `target="user"`, `message` is set; when `target="all"`, +// `receipt` is set. Callers branch on which field is present. +table AdminResponse { + message:MailMessage; + receipt:MailBroadcastReceipt; +} + +// ReadRequest stores the mark-read intent for a single message. The +// caller must be a recipient. Idempotent. +table ReadRequest { + game_id:common.UUID (required); + message_id:common.UUID (required); +} + +// ReadResponse echoes the recipient state after the operation. +table ReadResponse { + state:MailRecipientState; +} + +// DeleteRequest stores the soft-delete intent for a single message. +// The message must already be marked read (HTTP 409 otherwise). +table DeleteRequest { + game_id:common.UUID (required); + message_id:common.UUID (required); +} + +// DeleteResponse echoes the recipient state after the operation. +table DeleteResponse { + state:MailRecipientState; +} diff --git a/pkg/schema/fbs/diplomail/AdminRequest.go b/pkg/schema/fbs/diplomail/AdminRequest.go new file mode 100644 index 0000000..b8aa774 --- /dev/null +++ b/pkg/schema/fbs/diplomail/AdminRequest.go @@ -0,0 +1,133 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type AdminRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsAdminRequest(buf []byte, offset flatbuffers.UOffsetT) *AdminRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AdminRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishAdminRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAdminRequest(buf []byte, offset flatbuffers.UOffsetT) *AdminRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AdminRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAdminRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AdminRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AdminRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AdminRequest) 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 *AdminRequest) Target() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AdminRequest) RecipientUserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AdminRequest) RecipientRaceName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AdminRequest) Recipients() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AdminRequest) Subject() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AdminRequest) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AdminRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(7) +} +func AdminRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func AdminRequestAddTarget(builder *flatbuffers.Builder, target flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(target), 0) +} +func AdminRequestAddRecipientUserId(builder *flatbuffers.Builder, recipientUserId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(recipientUserId), 0) +} +func AdminRequestAddRecipientRaceName(builder *flatbuffers.Builder, recipientRaceName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(recipientRaceName), 0) +} +func AdminRequestAddRecipients(builder *flatbuffers.Builder, recipients flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(recipients), 0) +} +func AdminRequestAddSubject(builder *flatbuffers.Builder, subject flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(subject), 0) +} +func AdminRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(body), 0) +} +func AdminRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/AdminResponse.go b/pkg/schema/fbs/diplomail/AdminResponse.go new file mode 100644 index 0000000..497b708 --- /dev/null +++ b/pkg/schema/fbs/diplomail/AdminResponse.go @@ -0,0 +1,81 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AdminResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsAdminResponse(buf []byte, offset flatbuffers.UOffsetT) *AdminResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AdminResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishAdminResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAdminResponse(buf []byte, offset flatbuffers.UOffsetT) *AdminResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AdminResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAdminResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AdminResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AdminResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AdminResponse) Message(obj *MailMessage) *MailMessage { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailMessage) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *AdminResponse) Receipt(obj *MailBroadcastReceipt) *MailBroadcastReceipt { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailBroadcastReceipt) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func AdminResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func AdminResponseAddMessage(builder *flatbuffers.Builder, message flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(message), 0) +} +func AdminResponseAddReceipt(builder *flatbuffers.Builder, receipt flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(receipt), 0) +} +func AdminResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/BroadcastRequest.go b/pkg/schema/fbs/diplomail/BroadcastRequest.go new file mode 100644 index 0000000..4869364 --- /dev/null +++ b/pkg/schema/fbs/diplomail/BroadcastRequest.go @@ -0,0 +1,89 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type BroadcastRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsBroadcastRequest(buf []byte, offset flatbuffers.UOffsetT) *BroadcastRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BroadcastRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishBroadcastRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBroadcastRequest(buf []byte, offset flatbuffers.UOffsetT) *BroadcastRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BroadcastRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBroadcastRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BroadcastRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BroadcastRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BroadcastRequest) 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 *BroadcastRequest) Subject() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *BroadcastRequest) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func BroadcastRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func BroadcastRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func BroadcastRequestAddSubject(builder *flatbuffers.Builder, subject flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(subject), 0) +} +func BroadcastRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(body), 0) +} +func BroadcastRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/BroadcastResponse.go b/pkg/schema/fbs/diplomail/BroadcastResponse.go new file mode 100644 index 0000000..9145c15 --- /dev/null +++ b/pkg/schema/fbs/diplomail/BroadcastResponse.go @@ -0,0 +1,65 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BroadcastResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsBroadcastResponse(buf []byte, offset flatbuffers.UOffsetT) *BroadcastResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BroadcastResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishBroadcastResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBroadcastResponse(buf []byte, offset flatbuffers.UOffsetT) *BroadcastResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BroadcastResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBroadcastResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BroadcastResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BroadcastResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BroadcastResponse) Receipt(obj *MailBroadcastReceipt) *MailBroadcastReceipt { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailBroadcastReceipt) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func BroadcastResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func BroadcastResponseAddReceipt(builder *flatbuffers.Builder, receipt flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(receipt), 0) +} +func BroadcastResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/DeleteRequest.go b/pkg/schema/fbs/diplomail/DeleteRequest.go new file mode 100644 index 0000000..8fc1d85 --- /dev/null +++ b/pkg/schema/fbs/diplomail/DeleteRequest.go @@ -0,0 +1,83 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type DeleteRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsDeleteRequest(buf []byte, offset flatbuffers.UOffsetT) *DeleteRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &DeleteRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishDeleteRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsDeleteRequest(buf []byte, offset flatbuffers.UOffsetT) *DeleteRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &DeleteRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedDeleteRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *DeleteRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *DeleteRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *DeleteRequest) 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 *DeleteRequest) MessageId(obj *common.UUID) *common.UUID { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + 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 DeleteRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func DeleteRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func DeleteRequestAddMessageId(builder *flatbuffers.Builder, messageId flatbuffers.UOffsetT) { + builder.PrependStructSlot(1, flatbuffers.UOffsetT(messageId), 0) +} +func DeleteRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/DeleteResponse.go b/pkg/schema/fbs/diplomail/DeleteResponse.go new file mode 100644 index 0000000..ac9fcae --- /dev/null +++ b/pkg/schema/fbs/diplomail/DeleteResponse.go @@ -0,0 +1,65 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type DeleteResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsDeleteResponse(buf []byte, offset flatbuffers.UOffsetT) *DeleteResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &DeleteResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishDeleteResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsDeleteResponse(buf []byte, offset flatbuffers.UOffsetT) *DeleteResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &DeleteResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedDeleteResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *DeleteResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *DeleteResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *DeleteResponse) State(obj *MailRecipientState) *MailRecipientState { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailRecipientState) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func DeleteResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func DeleteResponseAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(state), 0) +} +func DeleteResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/InboxRequest.go b/pkg/schema/fbs/diplomail/InboxRequest.go new file mode 100644 index 0000000..89ddbf6 --- /dev/null +++ b/pkg/schema/fbs/diplomail/InboxRequest.go @@ -0,0 +1,67 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type InboxRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsInboxRequest(buf []byte, offset flatbuffers.UOffsetT) *InboxRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &InboxRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishInboxRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsInboxRequest(buf []byte, offset flatbuffers.UOffsetT) *InboxRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &InboxRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedInboxRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *InboxRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *InboxRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *InboxRequest) 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 InboxRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func InboxRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func InboxRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/InboxResponse.go b/pkg/schema/fbs/diplomail/InboxResponse.go new file mode 100644 index 0000000..be0305c --- /dev/null +++ b/pkg/schema/fbs/diplomail/InboxResponse.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type InboxResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsInboxResponse(buf []byte, offset flatbuffers.UOffsetT) *InboxResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &InboxResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishInboxResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsInboxResponse(buf []byte, offset flatbuffers.UOffsetT) *InboxResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &InboxResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedInboxResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *InboxResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *InboxResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *InboxResponse) Items(obj *MailMessage, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + 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 *InboxResponse) ItemsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func InboxResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func InboxResponseAddItems(builder *flatbuffers.Builder, items flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(items), 0) +} +func InboxResponseStartItemsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func InboxResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/MailBroadcastReceipt.go b/pkg/schema/fbs/diplomail/MailBroadcastReceipt.go new file mode 100644 index 0000000..651ca5d --- /dev/null +++ b/pkg/schema/fbs/diplomail/MailBroadcastReceipt.go @@ -0,0 +1,178 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MailBroadcastReceipt struct { + _tab flatbuffers.Table +} + +func GetRootAsMailBroadcastReceipt(buf []byte, offset flatbuffers.UOffsetT) *MailBroadcastReceipt { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MailBroadcastReceipt{} + x.Init(buf, n+offset) + return x +} + +func FinishMailBroadcastReceiptBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMailBroadcastReceipt(buf []byte, offset flatbuffers.UOffsetT) *MailBroadcastReceipt { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MailBroadcastReceipt{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMailBroadcastReceiptBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MailBroadcastReceipt) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MailBroadcastReceipt) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MailBroadcastReceipt) MessageId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) GameName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) SenderKind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) Subject() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) BodyLang() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) BroadcastScope() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailBroadcastReceipt) CreatedAtMs() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailBroadcastReceipt) MutateCreatedAtMs(n int64) bool { + return rcv._tab.MutateInt64Slot(22, n) +} + +func (rcv *MailBroadcastReceipt) RecipientCount() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailBroadcastReceipt) MutateRecipientCount(n int32) bool { + return rcv._tab.MutateInt32Slot(24, n) +} + +func MailBroadcastReceiptStart(builder *flatbuffers.Builder) { + builder.StartObject(11) +} +func MailBroadcastReceiptAddMessageId(builder *flatbuffers.Builder, messageId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(messageId), 0) +} +func MailBroadcastReceiptAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(gameId), 0) +} +func MailBroadcastReceiptAddGameName(builder *flatbuffers.Builder, gameName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(gameName), 0) +} +func MailBroadcastReceiptAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(kind), 0) +} +func MailBroadcastReceiptAddSenderKind(builder *flatbuffers.Builder, senderKind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(senderKind), 0) +} +func MailBroadcastReceiptAddSubject(builder *flatbuffers.Builder, subject flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(subject), 0) +} +func MailBroadcastReceiptAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(body), 0) +} +func MailBroadcastReceiptAddBodyLang(builder *flatbuffers.Builder, bodyLang flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(bodyLang), 0) +} +func MailBroadcastReceiptAddBroadcastScope(builder *flatbuffers.Builder, broadcastScope flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(broadcastScope), 0) +} +func MailBroadcastReceiptAddCreatedAtMs(builder *flatbuffers.Builder, createdAtMs int64) { + builder.PrependInt64Slot(9, createdAtMs, 0) +} +func MailBroadcastReceiptAddRecipientCount(builder *flatbuffers.Builder, recipientCount int32) { + builder.PrependInt32Slot(10, recipientCount, 0) +} +func MailBroadcastReceiptEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/MailMessage.go b/pkg/schema/fbs/diplomail/MailMessage.go new file mode 100644 index 0000000..1d278b4 --- /dev/null +++ b/pkg/schema/fbs/diplomail/MailMessage.go @@ -0,0 +1,303 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MailMessage struct { + _tab flatbuffers.Table +} + +func GetRootAsMailMessage(buf []byte, offset flatbuffers.UOffsetT) *MailMessage { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MailMessage{} + x.Init(buf, n+offset) + return x +} + +func FinishMailMessageBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMailMessage(buf []byte, offset flatbuffers.UOffsetT) *MailMessage { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MailMessage{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMailMessageBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MailMessage) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MailMessage) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MailMessage) MessageId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) GameName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) SenderKind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) SenderUserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) SenderUsername() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) SenderRaceName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) Subject() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) BodyLang() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) BroadcastScope() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(26)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) CreatedAtMs() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(28)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailMessage) MutateCreatedAtMs(n int64) bool { + return rcv._tab.MutateInt64Slot(28, n) +} + +func (rcv *MailMessage) RecipientUserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(30)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) RecipientUserName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(32)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) RecipientRaceName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(34)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) ReadAtMs() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(36)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailMessage) MutateReadAtMs(n int64) bool { + return rcv._tab.MutateInt64Slot(36, n) +} + +func (rcv *MailMessage) DeletedAtMs() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(38)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailMessage) MutateDeletedAtMs(n int64) bool { + return rcv._tab.MutateInt64Slot(38, n) +} + +func (rcv *MailMessage) TranslatedSubject() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(40)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) TranslatedBody() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(42)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) TranslationLang() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(44)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailMessage) Translator() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(46)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func MailMessageStart(builder *flatbuffers.Builder) { + builder.StartObject(22) +} +func MailMessageAddMessageId(builder *flatbuffers.Builder, messageId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(messageId), 0) +} +func MailMessageAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(gameId), 0) +} +func MailMessageAddGameName(builder *flatbuffers.Builder, gameName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(gameName), 0) +} +func MailMessageAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(kind), 0) +} +func MailMessageAddSenderKind(builder *flatbuffers.Builder, senderKind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(senderKind), 0) +} +func MailMessageAddSenderUserId(builder *flatbuffers.Builder, senderUserId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(senderUserId), 0) +} +func MailMessageAddSenderUsername(builder *flatbuffers.Builder, senderUsername flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(senderUsername), 0) +} +func MailMessageAddSenderRaceName(builder *flatbuffers.Builder, senderRaceName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(senderRaceName), 0) +} +func MailMessageAddSubject(builder *flatbuffers.Builder, subject flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(subject), 0) +} +func MailMessageAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(body), 0) +} +func MailMessageAddBodyLang(builder *flatbuffers.Builder, bodyLang flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(10, flatbuffers.UOffsetT(bodyLang), 0) +} +func MailMessageAddBroadcastScope(builder *flatbuffers.Builder, broadcastScope flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(11, flatbuffers.UOffsetT(broadcastScope), 0) +} +func MailMessageAddCreatedAtMs(builder *flatbuffers.Builder, createdAtMs int64) { + builder.PrependInt64Slot(12, createdAtMs, 0) +} +func MailMessageAddRecipientUserId(builder *flatbuffers.Builder, recipientUserId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(13, flatbuffers.UOffsetT(recipientUserId), 0) +} +func MailMessageAddRecipientUserName(builder *flatbuffers.Builder, recipientUserName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(14, flatbuffers.UOffsetT(recipientUserName), 0) +} +func MailMessageAddRecipientRaceName(builder *flatbuffers.Builder, recipientRaceName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(15, flatbuffers.UOffsetT(recipientRaceName), 0) +} +func MailMessageAddReadAtMs(builder *flatbuffers.Builder, readAtMs int64) { + builder.PrependInt64Slot(16, readAtMs, 0) +} +func MailMessageAddDeletedAtMs(builder *flatbuffers.Builder, deletedAtMs int64) { + builder.PrependInt64Slot(17, deletedAtMs, 0) +} +func MailMessageAddTranslatedSubject(builder *flatbuffers.Builder, translatedSubject flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(18, flatbuffers.UOffsetT(translatedSubject), 0) +} +func MailMessageAddTranslatedBody(builder *flatbuffers.Builder, translatedBody flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(19, flatbuffers.UOffsetT(translatedBody), 0) +} +func MailMessageAddTranslationLang(builder *flatbuffers.Builder, translationLang flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(20, flatbuffers.UOffsetT(translationLang), 0) +} +func MailMessageAddTranslator(builder *flatbuffers.Builder, translator flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(21, flatbuffers.UOffsetT(translator), 0) +} +func MailMessageEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/MailRecipientState.go b/pkg/schema/fbs/diplomail/MailRecipientState.go new file mode 100644 index 0000000..fa697d2 --- /dev/null +++ b/pkg/schema/fbs/diplomail/MailRecipientState.go @@ -0,0 +1,90 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MailRecipientState struct { + _tab flatbuffers.Table +} + +func GetRootAsMailRecipientState(buf []byte, offset flatbuffers.UOffsetT) *MailRecipientState { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MailRecipientState{} + x.Init(buf, n+offset) + return x +} + +func FinishMailRecipientStateBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMailRecipientState(buf []byte, offset flatbuffers.UOffsetT) *MailRecipientState { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MailRecipientState{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMailRecipientStateBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MailRecipientState) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MailRecipientState) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MailRecipientState) MessageId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *MailRecipientState) ReadAtMs() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailRecipientState) MutateReadAtMs(n int64) bool { + return rcv._tab.MutateInt64Slot(6, n) +} + +func (rcv *MailRecipientState) DeletedAtMs() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *MailRecipientState) MutateDeletedAtMs(n int64) bool { + return rcv._tab.MutateInt64Slot(8, n) +} + +func MailRecipientStateStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func MailRecipientStateAddMessageId(builder *flatbuffers.Builder, messageId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(messageId), 0) +} +func MailRecipientStateAddReadAtMs(builder *flatbuffers.Builder, readAtMs int64) { + builder.PrependInt64Slot(1, readAtMs, 0) +} +func MailRecipientStateAddDeletedAtMs(builder *flatbuffers.Builder, deletedAtMs int64) { + builder.PrependInt64Slot(2, deletedAtMs, 0) +} +func MailRecipientStateEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/MessageGetRequest.go b/pkg/schema/fbs/diplomail/MessageGetRequest.go new file mode 100644 index 0000000..b254b05 --- /dev/null +++ b/pkg/schema/fbs/diplomail/MessageGetRequest.go @@ -0,0 +1,83 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type MessageGetRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsMessageGetRequest(buf []byte, offset flatbuffers.UOffsetT) *MessageGetRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MessageGetRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishMessageGetRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMessageGetRequest(buf []byte, offset flatbuffers.UOffsetT) *MessageGetRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MessageGetRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMessageGetRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MessageGetRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MessageGetRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MessageGetRequest) 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 *MessageGetRequest) MessageId(obj *common.UUID) *common.UUID { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + 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 MessageGetRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func MessageGetRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func MessageGetRequestAddMessageId(builder *flatbuffers.Builder, messageId flatbuffers.UOffsetT) { + builder.PrependStructSlot(1, flatbuffers.UOffsetT(messageId), 0) +} +func MessageGetRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/MessageGetResponse.go b/pkg/schema/fbs/diplomail/MessageGetResponse.go new file mode 100644 index 0000000..985aeff --- /dev/null +++ b/pkg/schema/fbs/diplomail/MessageGetResponse.go @@ -0,0 +1,65 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type MessageGetResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsMessageGetResponse(buf []byte, offset flatbuffers.UOffsetT) *MessageGetResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &MessageGetResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishMessageGetResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsMessageGetResponse(buf []byte, offset flatbuffers.UOffsetT) *MessageGetResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &MessageGetResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedMessageGetResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *MessageGetResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *MessageGetResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *MessageGetResponse) Message(obj *MailMessage) *MailMessage { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailMessage) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func MessageGetResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func MessageGetResponseAddMessage(builder *flatbuffers.Builder, message flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(message), 0) +} +func MessageGetResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/ReadRequest.go b/pkg/schema/fbs/diplomail/ReadRequest.go new file mode 100644 index 0000000..c681a40 --- /dev/null +++ b/pkg/schema/fbs/diplomail/ReadRequest.go @@ -0,0 +1,83 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type ReadRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsReadRequest(buf []byte, offset flatbuffers.UOffsetT) *ReadRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ReadRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishReadRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsReadRequest(buf []byte, offset flatbuffers.UOffsetT) *ReadRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ReadRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedReadRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ReadRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ReadRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ReadRequest) 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 *ReadRequest) MessageId(obj *common.UUID) *common.UUID { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + 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 ReadRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func ReadRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func ReadRequestAddMessageId(builder *flatbuffers.Builder, messageId flatbuffers.UOffsetT) { + builder.PrependStructSlot(1, flatbuffers.UOffsetT(messageId), 0) +} +func ReadRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/ReadResponse.go b/pkg/schema/fbs/diplomail/ReadResponse.go new file mode 100644 index 0000000..f6abec3 --- /dev/null +++ b/pkg/schema/fbs/diplomail/ReadResponse.go @@ -0,0 +1,65 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type ReadResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsReadResponse(buf []byte, offset flatbuffers.UOffsetT) *ReadResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &ReadResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishReadResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsReadResponse(buf []byte, offset flatbuffers.UOffsetT) *ReadResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &ReadResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedReadResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *ReadResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *ReadResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *ReadResponse) State(obj *MailRecipientState) *MailRecipientState { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailRecipientState) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func ReadResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func ReadResponseAddState(builder *flatbuffers.Builder, state flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(state), 0) +} +func ReadResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/SendRequest.go b/pkg/schema/fbs/diplomail/SendRequest.go new file mode 100644 index 0000000..b4038cc --- /dev/null +++ b/pkg/schema/fbs/diplomail/SendRequest.go @@ -0,0 +1,111 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type SendRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsSendRequest(buf []byte, offset flatbuffers.UOffsetT) *SendRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &SendRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishSendRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSendRequest(buf []byte, offset flatbuffers.UOffsetT) *SendRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &SendRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSendRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *SendRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *SendRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *SendRequest) 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 *SendRequest) RecipientUserId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *SendRequest) RecipientRaceName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *SendRequest) Subject() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *SendRequest) Body() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func SendRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func SendRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func SendRequestAddRecipientUserId(builder *flatbuffers.Builder, recipientUserId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(recipientUserId), 0) +} +func SendRequestAddRecipientRaceName(builder *flatbuffers.Builder, recipientRaceName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(recipientRaceName), 0) +} +func SendRequestAddSubject(builder *flatbuffers.Builder, subject flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(subject), 0) +} +func SendRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(body), 0) +} +func SendRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/SendResponse.go b/pkg/schema/fbs/diplomail/SendResponse.go new file mode 100644 index 0000000..7a94850 --- /dev/null +++ b/pkg/schema/fbs/diplomail/SendResponse.go @@ -0,0 +1,65 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type SendResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsSendResponse(buf []byte, offset flatbuffers.UOffsetT) *SendResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &SendResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishSendResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSendResponse(buf []byte, offset flatbuffers.UOffsetT) *SendResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &SendResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSendResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *SendResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *SendResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *SendResponse) Message(obj *MailMessage) *MailMessage { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(MailMessage) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func SendResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func SendResponseAddMessage(builder *flatbuffers.Builder, message flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(message), 0) +} +func SendResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/SentRequest.go b/pkg/schema/fbs/diplomail/SentRequest.go new file mode 100644 index 0000000..551ed66 --- /dev/null +++ b/pkg/schema/fbs/diplomail/SentRequest.go @@ -0,0 +1,67 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + common "galaxy/schema/fbs/common" +) + +type SentRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsSentRequest(buf []byte, offset flatbuffers.UOffsetT) *SentRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &SentRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishSentRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSentRequest(buf []byte, offset flatbuffers.UOffsetT) *SentRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &SentRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSentRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *SentRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *SentRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *SentRequest) 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 SentRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func SentRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependStructSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func SentRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/schema/fbs/diplomail/SentResponse.go b/pkg/schema/fbs/diplomail/SentResponse.go new file mode 100644 index 0000000..1dc4354 --- /dev/null +++ b/pkg/schema/fbs/diplomail/SentResponse.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package diplomail + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type SentResponse struct { + _tab flatbuffers.Table +} + +func GetRootAsSentResponse(buf []byte, offset flatbuffers.UOffsetT) *SentResponse { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &SentResponse{} + x.Init(buf, n+offset) + return x +} + +func FinishSentResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsSentResponse(buf []byte, offset flatbuffers.UOffsetT) *SentResponse { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &SentResponse{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedSentResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *SentResponse) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *SentResponse) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *SentResponse) Items(obj *MailMessage, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + 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 *SentResponse) ItemsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func SentResponseStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func SentResponseAddItems(builder *flatbuffers.Builder, items flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(items), 0) +} +func SentResponseStartItemsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func SentResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/transcoder/battle.go b/pkg/transcoder/battle.go index 5190227..cd154de 100644 --- a/pkg/transcoder/battle.go +++ b/pkg/transcoder/battle.go @@ -382,3 +382,55 @@ func encodeBattleOffsetVector( } return builder.EndVector(length) } + +// GameBattleRequestToPayload converts model.GameBattleRequest to +// FlatBuffers bytes suitable for the authenticated gateway transport. +func GameBattleRequestToPayload(req *model.GameBattleRequest) ([]byte, error) { + if req == nil { + return nil, errors.New("encode game battle request payload: request is nil") + } + + builder := flatbuffers.NewBuilder(64) + + gameHi, gameLo := uuidToHiLo(req.GameID) + battleHi, battleLo := uuidToHiLo(req.BattleID) + + fbs.GameBattleRequestStart(builder) + fbs.GameBattleRequestAddGameId(builder, fbs.CreateUUID(builder, gameHi, gameLo)) + fbs.GameBattleRequestAddTurn(builder, uint32(req.Turn)) + fbs.GameBattleRequestAddBattleId(builder, fbs.CreateUUID(builder, battleHi, battleLo)) + offset := fbs.GameBattleRequestEnd(builder) + fbs.FinishGameBattleRequestBuffer(builder, offset) + + return builder.FinishedBytes(), nil +} + +// PayloadToGameBattleRequest converts FlatBuffers payload bytes into +// model.GameBattleRequest. +func PayloadToGameBattleRequest(data []byte) (result *model.GameBattleRequest, err error) { + if len(data) == 0 { + return nil, errors.New("decode game battle request payload: data is empty") + } + + defer func() { + if recovered := recover(); recovered != nil { + result = nil + err = fmt.Errorf("decode game battle request payload: panic recovered: %v", recovered) + } + }() + + req := fbs.GetRootAsGameBattleRequest(data, 0) + gameID := req.GameId(nil) + if gameID == nil { + return nil, errors.New("decode game battle request payload: game_id is missing") + } + battleID := req.BattleId(nil) + if battleID == nil { + return nil, errors.New("decode game battle request payload: battle_id is missing") + } + return &model.GameBattleRequest{ + GameID: uuidFromHiLo(gameID.Hi(), gameID.Lo()), + Turn: uint(req.Turn()), + BattleID: uuidFromHiLo(battleID.Hi(), battleID.Lo()), + }, nil +} diff --git a/tools/dev-deploy/.env.example b/tools/dev-deploy/.env.example index 12c3a19..73e932e 100644 --- a/tools/dev-deploy/.env.example +++ b/tools/dev-deploy/.env.example @@ -14,7 +14,9 @@ BACKEND_DEV_SANDBOX_ENGINE_VERSION=0.1.0 BACKEND_DEV_SANDBOX_PLAYER_COUNT=20 # `123456` short-circuits the email-code path for the dev account. -# Leave empty in environments where real Mailpit codes must be used. +# This is also the docker-compose default — set the variable to an +# empty string here when the environment must rely on real Mailpit +# codes (e.g. mail-flow QA). BACKEND_AUTH_DEV_FIXED_CODE=123456 # Name of the external Docker bridge the host Caddy is attached to. diff --git a/tools/dev-deploy/Caddyfile.dev b/tools/dev-deploy/Caddyfile.dev index 64758b7..eb949a1 100644 --- a/tools/dev-deploy/Caddyfile.dev +++ b/tools/dev-deploy/Caddyfile.dev @@ -20,6 +20,15 @@ @api host api.galaxy.lan handle @api { + # Connect-Web (authenticated) lives on a separate listener + # (`GATEWAY_AUTHENTICATED_GRPC_ADDR=:9090`). Anything else — + # public auth, healthz — is the public REST listener on + # `:8080`. The split mirrors the Vite dev-server proxy in + # `ui/frontend/vite.config.ts`. + @connect path /galaxy.gateway.v1.EdgeGateway/* + handle @connect { + reverse_proxy galaxy-api:9090 + } reverse_proxy galaxy-api:8080 } } diff --git a/tools/dev-deploy/Makefile b/tools/dev-deploy/Makefile index 403054b..4e154b8 100644 --- a/tools/dev-deploy/Makefile +++ b/tools/dev-deploy/Makefile @@ -62,6 +62,7 @@ seed-ui: @echo "building UI (vite build)…" (cd $(REPO_ROOT)/ui/frontend && \ VITE_GATEWAY_BASE_URL=https://api.galaxy.lan \ + VITE_GALAXY_DEV_AFFORDANCES=true \ VITE_GATEWAY_RESPONSE_PUBLIC_KEY=$$(cat $(REPO_ROOT)/ui/frontend/.env.development \ | sed -n 's/^VITE_GATEWAY_RESPONSE_PUBLIC_KEY=//p') \ pnpm build) diff --git a/tools/dev-deploy/README.md b/tools/dev-deploy/README.md index 0728d1b..1a04485 100644 --- a/tools/dev-deploy/README.md +++ b/tools/dev-deploy/README.md @@ -91,14 +91,16 @@ calls `make clean-data`. ## Logging in -The same dev-mode email-code override as `tools/local-dev/` applies: +The same dev-mode email-code override as `tools/local-dev/` applies, +and the dev-deploy compose ships with it enabled by default: 1. Enter `dev@galaxy.lan` (or whatever `BACKEND_DEV_SANDBOX_EMAIL` resolves to) in the login form. -2. Submit `123456` as the code if `BACKEND_AUTH_DEV_FIXED_CODE` is - non-empty. Otherwise open Mailpit at - `http://galaxy-mailpit:8025/` from inside the network or proxy it - through the host Caddy when needed. +2. Submit `123456` as the code — the docker-compose default for + `BACKEND_AUTH_DEV_FIXED_CODE` is `123456`, so the bcrypt-hashed + email code stays a fallback. To force real Mailpit codes (e.g. for + mail-flow QA), set `BACKEND_AUTH_DEV_FIXED_CODE=` (empty) in a + local `.env` and `make rebuild`. The fixed-code override is rejected by production env loaders, so it cannot leak into the prod environment. diff --git a/tools/dev-deploy/docker-compose.yml b/tools/dev-deploy/docker-compose.yml index 7944157..0962b3e 100644 --- a/tools/dev-deploy/docker-compose.yml +++ b/tools/dev-deploy/docker-compose.yml @@ -101,8 +101,18 @@ services: BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms BACKEND_OTEL_TRACES_EXPORTER: none BACKEND_OTEL_METRICS_EXPORTER: none - BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-} - BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-} + # Long-lived dev environment always opts into the fixed-code + # override so a returning developer can sign in with `123456` + # even after the matching browser session was cleared (the real + # bcrypt-hashed code is single-use). Set the var to an empty + # string in `.env` to disable. + BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-123456} + # Long-lived dev environment always bootstraps the "Dev Sandbox" + # game owned by this email so a freshly redeployed stack already + # has one ready-to-play game in the lobby. Set the variable to an + # empty string in `.env` to disable the bootstrap (e.g. for a + # cold-start QA pass). + BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-dev@galaxy.lan} BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-galaxy-engine:dev} BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-0.1.0} BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-20} @@ -161,6 +171,7 @@ services: # https://api.galaxy.lan. Browsers therefore issue cross-origin # requests to the gateway and need an explicit allow-list. GATEWAY_PUBLIC_HTTP_CORS_ALLOWED_ORIGINS: "https://www.galaxy.lan" + GATEWAY_AUTHENTICATED_GRPC_CORS_ALLOWED_ORIGINS: "https://www.galaxy.lan" # Anti-abuse defaults are looser than production: the dev # environment is shared by a handful of trusted testers who # frequently hammer the same identity to reproduce flows. diff --git a/ui/Makefile b/ui/Makefile index 425ad12..db05e4f 100644 --- a/ui/Makefile +++ b/ui/Makefile @@ -6,7 +6,7 @@ WASM_OUT := frontend/static/core.wasm WASM_EXEC := frontend/static/wasm_exec.js TINYGO_ROOT := $(shell tinygo env TINYGOROOT 2>/dev/null) FBS_OUT := frontend/src/proto/galaxy/fbs -FBS_INPUTS := ../pkg/schema/fbs/common.fbs ../pkg/schema/fbs/lobby.fbs ../pkg/schema/fbs/user.fbs ../pkg/schema/fbs/report.fbs ../pkg/schema/fbs/order.fbs +FBS_INPUTS := ../pkg/schema/fbs/common.fbs ../pkg/schema/fbs/lobby.fbs ../pkg/schema/fbs/user.fbs ../pkg/schema/fbs/report.fbs ../pkg/schema/fbs/order.fbs ../pkg/schema/fbs/diplomail.fbs ../pkg/schema/fbs/battle.fbs help: @echo "ui targets:" diff --git a/ui/PLAN.md b/ui/PLAN.md index 78572bf..d9ffb60 100644 --- a/ui/PLAN.md +++ b/ui/PLAN.md @@ -3070,9 +3070,73 @@ bottom): - animated transitions when survivors re-distribute after an elimination (currently hard-jumps). -## Phase 28. Diplomatic Mail View +## ~~Phase 28. Diplomatic Mail View~~ -Status: pending. +Status: done (CI gate passed on run 136 — go-unit / ui-test / integration all green at commit 6d0272b). + +Decisions baked in during implementation: + +1. **Transport: ConnectRPC `user.games.mail.*`.** Eight new + authenticated commands (inbox / sent / message.get / send / + broadcast / admin / read / delete) plumbed end-to-end through + the existing gateway → backend REST surface. Schemas in + `pkg/schema/fbs/diplomail.fbs`; constants in + `pkg/model/diplomail/diplomail.go`; gateway translation in + `gateway/internal/backendclient/mail_commands.go`. +2. **Recipient by race name.** The send / admin endpoints accept + an alternative `recipient_race_name` field; backend resolves it + via `Memberships.ListMembers(gameID, "active")`. The UI feeds + the picker straight off `report.races[].name` — no client-side + memberships RPC. +3. **`sender_race_name` snapshot.** New nullable column on + `diplomail_messages`, populated for `sender_kind='player'` + senders that have an active membership at send time. Drives the + per-race threading on the client. +4. **/sent returns full message detail.** Backend's bulk sent + listing now returns the same `UserMailMessageDetail` shape as + `/inbox`, one row per (message, recipient). The UI collapses + broadcasts by `message_id` into a single stand-alone item. +5. **Threading + stand-alones.** `MailStore.entries` groups + personal messages by the other party's race name. System, + admin, and outgoing broadcasts render as stand-alone items in + the same list pane. +6. **No read receipts.** `read_at` and `deleted_at` drive the + badge counter and soft-delete affordance but are never shown + to the user. +7. **Header badge.** Inline pill on the view-menu "diplomatic + mail" row, fed by `mailStore.unreadCount`. No always-visible + chrome added. +8. **Push event reuse.** A new + `eventStream.on("diplomail.message.received", …)` handler in + `routes/games/[id]/+layout.svelte` parses the verified payload, + refreshes the inbox, and raises a `toast.show` with a "view" + deep-link. +9. **Translation toggle.** Per-message Show original / Show + translation toggle inside both `thread-pane.svelte` and + `system-item-pane.svelte`; the body defaults to the cached + translation when present. + +Artifacts (delivered): + +- backend: `internal/postgres/migrations/00001_init.sql`, + `internal/diplomail/{types.go,store.go,service.go,admin_send.go,diplomail_e2e_test.go,README.md}`, + `internal/server/{handlers_user_mail.go,handlers_admin_diplomail.go}`, + `openapi.yaml`; +- wire: `pkg/schema/fbs/diplomail.fbs` + generated Go and TS + bindings; `pkg/model/diplomail/diplomail.go`; +- gateway: `gateway/internal/backendclient/{mail_commands.go,routes.go,mail_commands_test.go}`, + `gateway/cmd/gateway/main.go`; +- ui: `ui/frontend/src/api/diplomail.ts`, + `ui/frontend/src/lib/mail-store.svelte.ts`, + `ui/frontend/src/lib/active-view/mail.svelte` (+ subdir + `mail/{thread-list,thread-pane,system-item-pane,compose,system-titles}.svelte|.ts`), + `ui/frontend/src/lib/header/view-menu.svelte`, + `ui/frontend/src/routes/games/[id]/+layout.svelte`, + `ui/frontend/src/lib/i18n/locales/{en,ru}.ts`; +- docs: `ui/docs/diplomail-ui.md`, `docs/FUNCTIONAL.md` §11.4 + + mirror in `docs/FUNCTIONAL_ru.md`. + +Original phase brief follows. Goal: implement a mail inbox and compose flow as a dedicated view that replaces the map. diff --git a/ui/docs/diplomail-ui.md b/ui/docs/diplomail-ui.md new file mode 100644 index 0000000..1f71e83 --- /dev/null +++ b/ui/docs/diplomail-ui.md @@ -0,0 +1,97 @@ +# In-game diplomatic mail UI + +Phase 28 wires the in-game mail view that consumes the `diplomail` +subsystem in the backend. The route lives at `/games/:id/mail` +(registered in Phase 10) and replaces the active view when the user +opens the "diplomatic mail" entry in the header menu. + +## Wire surface + +Eight ConnectRPC commands sit between UI and backend, all under the +`user.games.mail.*` namespace: + +| Command | Backend REST endpoint | +|---|---| +| `user.games.mail.inbox` | `GET /api/v1/user/games/{id}/mail/inbox` | +| `user.games.mail.sent` | `GET …/mail/sent` | +| `user.games.mail.message.get` | `GET …/mail/messages/{message_id}` | +| `user.games.mail.send` | `POST …/mail/messages` | +| `user.games.mail.broadcast` | `POST …/mail/broadcast` | +| `user.games.mail.admin` | `POST …/mail/admin` | +| `user.games.mail.read` | `POST …/mail/messages/{id}/read` | +| `user.games.mail.delete` | `DELETE …/mail/messages/{id}` | + +The FlatBuffers schemas live under +[`pkg/schema/fbs/diplomail.fbs`](../../pkg/schema/fbs/diplomail.fbs); +the gateway translation lives in +[`gateway/internal/backendclient/mail_commands.go`](../../gateway/internal/backendclient/mail_commands.go). + +## Recipient by race name + +The compose flow does **not** consult a memberships listing. The +recipient picker reads `gameState.report.races[].name` (the Phase 22 +projection of `report.player[]`), and the send request carries the +chosen race name as `recipient_race_name`. The backend resolves it +against `Memberships.ListMembers(gameID, "active")` and rejects with +`forbidden` if the matching member is no longer active. This keeps +the UI off the lobby surface for the common case. + +## Threading model + +`MailStore.entries` is the derived rune the active view consumes. It +projects the union of inbox and sent into: + +- **Per-race threads** — every personal message keyed by another + race contributes to a thread keyed on that race name. Incoming is + keyed on `sender_race_name`; outgoing is keyed on + `recipient_race_name`. Thread messages are sorted oldest → newest + for chat-style rendering; the unread badge counts incoming + `read_at === null` rows only. +- **Stand-alone items** — system mail (`sender_kind=system`), admin + notifications (`sender_kind=admin`), and the caller's own + paid-tier broadcasts (`broadcast_scope=game_broadcast`). Backend + returns one row per recipient for paid-tier broadcasts; the UI + collapses them by `message_id` into a single stand-alone item. + +`read_at` and `deleted_at` are not surfaced to the user in any pane +— they only drive the badge counter and the optimistic mark-read +state. This is intentional (per Phase 28 decisions): the user-facing +spec for diplomatic mail does not promise read receipts. + +## Translation toggle + +When a message detail carries `translated_body`, the body and (if +non-empty) subject default to the translated rendering. Each message +pane exposes a "Show original" / "Show translation" button that +flips the per-message state. Messages without a cached translation +render the original directly with no toggle. + +## Push events + +`diplomail.message.received` push frames are dispatched from +`api/events.svelte.ts` via the singleton SubscribeEvents stream. The +in-game layout (`routes/games/[id]/+layout.svelte`) parses the +verified payload, calls `mailStore.applyPushEvent(gameId)` (which +re-fetches the inbox — the payload only carries a preview), and +raises a toast through `lib/toast.svelte.ts` with a "view" +deep-link to `/games/:id/mail`. + +The header view-menu's mail entry shows `mailStore.unreadCount` as +an inline pill — the only chrome the badge needs. + +## Layout + +Desktop (≥ 768 px) renders a two-pane CSS grid: list on the left, +detail on the right. Mobile flips to a single-pane stack; tapping a +list row hides the list and shows the detail with a back button. + +## Accessibility + +- Bodies render through Svelte's default text-content path (no HTML + parsing) per the backend rule of treating message text as plain + UTF-8. +- The compose dialog uses native form controls; the recipient + picker is a ` + + + {#if error} +

{error}

+ {/if} + + + + + + diff --git a/ui/frontend/src/lib/active-view/mail/system-item-pane.svelte b/ui/frontend/src/lib/active-view/mail/system-item-pane.svelte new file mode 100644 index 0000000..d573bbb --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/system-item-pane.svelte @@ -0,0 +1,106 @@ + + + +
+

{i18n.t(headerKey)}

+ {#if displaySubject} +
{displaySubject}
+ {/if} +

{displayBody}

+ {#if entry.message.translatedBody} + + {/if} + {#if incoming} + + {/if} +
+ + diff --git a/ui/frontend/src/lib/active-view/mail/system-titles.ts b/ui/frontend/src/lib/active-view/mail/system-titles.ts new file mode 100644 index 0000000..d2954f8 --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/system-titles.ts @@ -0,0 +1,30 @@ +// Maps a system-mail message (lifecycle hook) to its i18n title key. +// Kept as a typed helper so the thread-list and detail panes pick the +// same title even when the body templates evolve. + +import type { TranslationKey } from "$lib/i18n/index.svelte"; +import type { MailMessage } from "../../../api/diplomail"; + +const KEYWORDS: Array<{ test: RegExp; key: TranslationKey }> = [ + { test: /game[._ ]paused/i, key: "game.mail.system.game_paused.title" }, + { test: /game[._ ]cancelled|cancelled/i, key: "game.mail.system.game_cancelled.title" }, + { test: /membership[._ ]removed|kicked/i, key: "game.mail.system.membership_removed.title" }, + { test: /membership[._ ]blocked|blocked/i, key: "game.mail.system.membership_blocked.title" }, +]; + +/** + * systemTitleKey returns the localised title key for a system mail + * row. The lobby renders these messages through templated subjects; + * the UI matches on the subject to pick a canonical title regardless + * of language. Falls back to a generic system-mail title when no + * pattern matches. + */ +export function systemTitleKey(message: MailMessage): TranslationKey { + const subject = message.subject ?? ""; + for (const { test, key } of KEYWORDS) { + if (test.test(subject)) { + return key; + } + } + return "game.mail.system.generic.title"; +} diff --git a/ui/frontend/src/lib/active-view/mail/thread-list.svelte b/ui/frontend/src/lib/active-view/mail/thread-list.svelte new file mode 100644 index 0000000..bc933e9 --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/thread-list.svelte @@ -0,0 +1,130 @@ + + + + + + diff --git a/ui/frontend/src/lib/active-view/mail/thread-pane.svelte b/ui/frontend/src/lib/active-view/mail/thread-pane.svelte new file mode 100644 index 0000000..8e3977e --- /dev/null +++ b/ui/frontend/src/lib/active-view/mail/thread-pane.svelte @@ -0,0 +1,255 @@ + + + +
+

{thread.raceName}

+
    + {#each thread.messages as m (m.messageId)} +
  1. +
    + + {#if isOutgoing(m)} + {i18n.t("game.mail.outgoing_label")} + {:else} + {thread.raceName} + {/if} + + +
    + {#if displaySubject(m)} +
    {displaySubject(m)}
    + {/if} +

    {displayBody(m)}

    + {#if m.translatedBody} + + {/if} + {#if !isOutgoing(m)} + + {/if} +
  2. + {/each} +
+ +
+ + + {#if replyError} +

{replyError}

+ {/if} + +
+
+ + diff --git a/ui/frontend/src/lib/header/view-menu.svelte b/ui/frontend/src/lib/header/view-menu.svelte index cbe5bd4..30f6c99 100644 --- a/ui/frontend/src/lib/header/view-menu.svelte +++ b/ui/frontend/src/lib/header/view-menu.svelte @@ -15,10 +15,13 @@ polishes microcopy. import { onMount } from "svelte"; import { goto } from "$app/navigation"; import { i18n, type TranslationKey } from "$lib/i18n/index.svelte"; + import { mailStore } from "$lib/mail-store.svelte"; type Props = { gameId: string }; let { gameId }: Props = $props(); + const mailUnread = $derived(mailStore.unreadCount); + let open = $state(false); let rootEl: HTMLDivElement | null = $state(null); @@ -122,9 +125,15 @@ polishes microcopy. type="button" role="menuitem" data-testid="view-menu-item-mail" + class="with-badge" onclick={() => go(`/games/${gameId}/mail`)} > - {i18n.t("game.view.mail")} + {i18n.t("game.view.mail")} + {#if mailUnread > 0} + + {i18n.t("game.view.mail.badge", { count: String(mailUnread) })} + + {/if}