From d733ce311956cfa0a262e53efd3e57274d821ee2 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 19:47:40 +0200 Subject: [PATCH] Stage 8: UI social/account/history surfaces Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode -> backend REST -> existing domain services): friends (incl. one-time friend codes), per-user blocks, friend-game invitations, profile editing + email binding, the statistics screen, and the in-game history + GCG export. Friends gain two add paths (interview decision, a deliberate plan change): one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited redeem); and play-gated requests (shared game required) where an explicit decline is permanent, an ignored request lapses after 30 days, and a code bypasses a decline. Migration 00006 widens friendships_status_chk and adds friend_codes. Lobby notification badge is poll + push: a new generic `notify` event drives it live; the client polls on open/focus. Language stays a single Settings control that writes through to the durable account's preferred_language. GCG export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file. Tests: backend unit + inttest (friend gate/decline/code, ListInvitations, GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN (Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN, TESTING, module READMEs. --- PLAN.md | 53 ++- backend/README.md | 11 +- backend/cmd/backend/main.go | 1 + backend/internal/account/stats.go | 50 +++ backend/internal/game/service.go | 17 +- backend/internal/game/store.go | 18 ++ backend/internal/game/types.go | 3 + backend/internal/inttest/account_test.go | 15 + backend/internal/inttest/game_test.go | 10 + backend/internal/inttest/lobby_test.go | 28 ++ backend/internal/inttest/social_test.go | 114 ++++++- backend/internal/lobby/invitations.go | 98 +++++- backend/internal/notify/events.go | 13 + backend/internal/notify/notify.go | 12 + backend/internal/notify/notify_test.go | 12 + .../jet/backend/model/friend_codes.go | 22 ++ .../jet/backend/table/friend_codes.go | 93 ++++++ .../jet/backend/table/table_use_schema.go | 1 + .../migrations/00006_friend_codes.sql | 45 +++ backend/internal/server/dto.go | 10 +- backend/internal/server/dto_test.go | 18 ++ backend/internal/server/handlers.go | 61 ++++ backend/internal/server/handlers_account.go | 157 +++++++++ backend/internal/server/handlers_blocks.go | 76 +++++ backend/internal/server/handlers_friends.go | 254 +++++++++++++++ backend/internal/server/handlers_game.go | 26 ++ .../internal/server/handlers_invitations.go | 205 ++++++++++++ backend/internal/social/friendcodes.go | 209 ++++++++++++ backend/internal/social/friends.go | 146 +++++++-- backend/internal/social/social.go | 20 +- docs/ARCHITECTURE.md | 41 ++- docs/FUNCTIONAL.md | 36 ++- docs/FUNCTIONAL_ru.md | 36 ++- docs/TESTING.md | 17 +- docs/UI_DESIGN.md | 28 +- gateway/README.md | 8 +- gateway/internal/backendclient/api.go | 2 + gateway/internal/backendclient/api_social.go | 256 +++++++++++++++ gateway/internal/transcode/encode.go | 4 + gateway/internal/transcode/encode_social.go | 179 +++++++++++ gateway/internal/transcode/transcode.go | 1 + .../internal/transcode/transcode_social.go | 302 ++++++++++++++++++ .../transcode/transcode_social_test.go | 238 ++++++++++++++ pkg/fbs/scrabble.fbs | 149 ++++++++- pkg/fbs/scrabblefb/AccountRef.go | 71 ++++ pkg/fbs/scrabblefb/BlockList.go | 75 +++++ pkg/fbs/scrabblefb/CreateInvitationRequest.go | 139 ++++++++ pkg/fbs/scrabblefb/EmailBindRequest.go | 60 ++++ pkg/fbs/scrabblefb/EmailConfirmRequest.go | 71 ++++ pkg/fbs/scrabblefb/FriendCode.go | 75 +++++ pkg/fbs/scrabblefb/FriendList.go | 75 +++++ pkg/fbs/scrabblefb/FriendRespondRequest.go | 75 +++++ pkg/fbs/scrabblefb/GcgExport.go | 82 +++++ pkg/fbs/scrabblefb/IncomingRequestList.go | 75 +++++ pkg/fbs/scrabblefb/Invitation.go | 206 ++++++++++++ pkg/fbs/scrabblefb/InvitationActionRequest.go | 60 ++++ pkg/fbs/scrabblefb/InvitationInvitee.go | 97 ++++++ pkg/fbs/scrabblefb/InvitationList.go | 75 +++++ pkg/fbs/scrabblefb/NotificationEvent.go | 60 ++++ pkg/fbs/scrabblefb/Profile.go | 24 +- pkg/fbs/scrabblefb/RedeemCodeRequest.go | 60 ++++ pkg/fbs/scrabblefb/RedeemResult.go | 65 ++++ pkg/fbs/scrabblefb/StatsView.go | 124 +++++++ pkg/fbs/scrabblefb/TargetRequest.go | 60 ++++ pkg/fbs/scrabblefb/UpdateProfileRequest.go | 134 ++++++++ ui/README.md | 14 +- ui/e2e/game.spec.ts | 2 +- ui/e2e/social.spec.ts | 75 +++++ ui/scripts/bundle-size.mjs | 3 +- ui/src/App.svelte | 6 + ui/src/components/Menu.svelte | 49 ++- ui/src/game/Game.svelte | 33 +- ui/src/gen/fbs/scrabblefb.ts | 20 ++ ui/src/gen/fbs/scrabblefb/account-ref.ts | 60 ++++ ui/src/gen/fbs/scrabblefb/block-list.ts | 66 ++++ .../scrabblefb/create-invitation-request.ts | 119 +++++++ .../gen/fbs/scrabblefb/email-bind-request.ts | 48 +++ .../fbs/scrabblefb/email-confirm-request.ts | 60 ++++ ui/src/gen/fbs/scrabblefb/friend-code.ts | 58 ++++ ui/src/gen/fbs/scrabblefb/friend-list.ts | 66 ++++ .../fbs/scrabblefb/friend-respond-request.ts | 58 ++++ ui/src/gen/fbs/scrabblefb/gcg-export.ts | 72 +++++ .../fbs/scrabblefb/incoming-request-list.ts | 66 ++++ .../scrabblefb/invitation-action-request.ts | 48 +++ .../gen/fbs/scrabblefb/invitation-invitee.ts | 82 +++++ ui/src/gen/fbs/scrabblefb/invitation-list.ts | 66 ++++ ui/src/gen/fbs/scrabblefb/invitation.ts | 162 ++++++++++ .../gen/fbs/scrabblefb/notification-event.ts | 48 +++ ui/src/gen/fbs/scrabblefb/profile.ts | 28 +- .../gen/fbs/scrabblefb/redeem-code-request.ts | 48 +++ ui/src/gen/fbs/scrabblefb/redeem-result.ts | 49 +++ ui/src/gen/fbs/scrabblefb/stats-view.ts | 86 +++++ ui/src/gen/fbs/scrabblefb/target-request.ts | 48 +++ .../fbs/scrabblefb/update-profile-request.ts | 116 +++++++ ui/src/lib/app.svelte.ts | 59 ++++ ui/src/lib/client.ts | 36 +++ ui/src/lib/codec.test.ts | 94 +++++- ui/src/lib/codec.ts | 207 ++++++++++++ ui/src/lib/i18n/en.ts | 93 +++++- ui/src/lib/i18n/ru.ts | 93 +++++- ui/src/lib/mock/client.ts | 132 +++++++- ui/src/lib/mock/data.ts | 45 ++- ui/src/lib/model.ts | 73 +++++ ui/src/lib/router.svelte.ts | 6 + ui/src/lib/share.test.ts | 22 ++ ui/src/lib/share.ts | 48 +++ ui/src/lib/stats.test.ts | 26 ++ ui/src/lib/stats.ts | 14 + ui/src/lib/transport.ts | 67 ++++ ui/src/screens/Friends.svelte | 243 ++++++++++++++ ui/src/screens/Lobby.svelte | 99 +++++- ui/src/screens/NewGame.svelte | 172 +++++++++- ui/src/screens/Profile.svelte | 234 +++++++++++++- ui/src/screens/Stats.svelte | 82 +++++ 114 files changed, 8210 insertions(+), 149 deletions(-) create mode 100644 backend/internal/account/stats.go create mode 100644 backend/internal/postgres/jet/backend/model/friend_codes.go create mode 100644 backend/internal/postgres/jet/backend/table/friend_codes.go create mode 100644 backend/internal/postgres/migrations/00006_friend_codes.sql create mode 100644 backend/internal/server/handlers_account.go create mode 100644 backend/internal/server/handlers_blocks.go create mode 100644 backend/internal/server/handlers_friends.go create mode 100644 backend/internal/server/handlers_invitations.go create mode 100644 backend/internal/social/friendcodes.go create mode 100644 gateway/internal/backendclient/api_social.go create mode 100644 gateway/internal/transcode/encode_social.go create mode 100644 gateway/internal/transcode/transcode_social.go create mode 100644 gateway/internal/transcode/transcode_social_test.go create mode 100644 pkg/fbs/scrabblefb/AccountRef.go create mode 100644 pkg/fbs/scrabblefb/BlockList.go create mode 100644 pkg/fbs/scrabblefb/CreateInvitationRequest.go create mode 100644 pkg/fbs/scrabblefb/EmailBindRequest.go create mode 100644 pkg/fbs/scrabblefb/EmailConfirmRequest.go create mode 100644 pkg/fbs/scrabblefb/FriendCode.go create mode 100644 pkg/fbs/scrabblefb/FriendList.go create mode 100644 pkg/fbs/scrabblefb/FriendRespondRequest.go create mode 100644 pkg/fbs/scrabblefb/GcgExport.go create mode 100644 pkg/fbs/scrabblefb/IncomingRequestList.go create mode 100644 pkg/fbs/scrabblefb/Invitation.go create mode 100644 pkg/fbs/scrabblefb/InvitationActionRequest.go create mode 100644 pkg/fbs/scrabblefb/InvitationInvitee.go create mode 100644 pkg/fbs/scrabblefb/InvitationList.go create mode 100644 pkg/fbs/scrabblefb/NotificationEvent.go create mode 100644 pkg/fbs/scrabblefb/RedeemCodeRequest.go create mode 100644 pkg/fbs/scrabblefb/RedeemResult.go create mode 100644 pkg/fbs/scrabblefb/StatsView.go create mode 100644 pkg/fbs/scrabblefb/TargetRequest.go create mode 100644 pkg/fbs/scrabblefb/UpdateProfileRequest.go create mode 100644 ui/e2e/social.spec.ts create mode 100644 ui/src/gen/fbs/scrabblefb/account-ref.ts create mode 100644 ui/src/gen/fbs/scrabblefb/block-list.ts create mode 100644 ui/src/gen/fbs/scrabblefb/create-invitation-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/email-bind-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/email-confirm-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/friend-code.ts create mode 100644 ui/src/gen/fbs/scrabblefb/friend-list.ts create mode 100644 ui/src/gen/fbs/scrabblefb/friend-respond-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/gcg-export.ts create mode 100644 ui/src/gen/fbs/scrabblefb/incoming-request-list.ts create mode 100644 ui/src/gen/fbs/scrabblefb/invitation-action-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/invitation-invitee.ts create mode 100644 ui/src/gen/fbs/scrabblefb/invitation-list.ts create mode 100644 ui/src/gen/fbs/scrabblefb/invitation.ts create mode 100644 ui/src/gen/fbs/scrabblefb/notification-event.ts create mode 100644 ui/src/gen/fbs/scrabblefb/redeem-code-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/redeem-result.ts create mode 100644 ui/src/gen/fbs/scrabblefb/stats-view.ts create mode 100644 ui/src/gen/fbs/scrabblefb/target-request.ts create mode 100644 ui/src/gen/fbs/scrabblefb/update-profile-request.ts create mode 100644 ui/src/lib/share.test.ts create mode 100644 ui/src/lib/share.ts create mode 100644 ui/src/lib/stats.test.ts create mode 100644 ui/src/lib/stats.ts create mode 100644 ui/src/screens/Friends.svelte create mode 100644 ui/src/screens/Stats.svelte diff --git a/PLAN.md b/PLAN.md index 75d4766..0474fbf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -41,7 +41,7 @@ independent (see ARCHITECTURE §9.1). | 5 | Robot opponent | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | -| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo | +| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | | 9 | Telegram integration (bot side-service, deep-link, push) | todo | | 10 | Admin & dictionary ops (complaint review, version reload) | todo | | 11 | Account linking & merge | todo | @@ -538,6 +538,52 @@ Open details: deployment target/host; dashboards; load expectations. (not a modal); word-check is alphabet/length-limited, cached and throttled. Design details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md). +- **Stage 8** (interview + implementation): + - **Scope = vertical slice continued**: the social/account/history operations were + opened end-to-end (UI → gateway transcode → backend REST → existing domain + services). The only new backend logic is `lobby.ListInvitations`, + `account.Store.GetStats`, a `game.SharedGame` seam (self-join on `game_players`), + the friend-code mechanism, and the friendships `declined`-status change. + - **Friends — two add paths** (interview, a deliberate plan change): **one-time + friend codes** (the player to be added issues a **6-digit numeric** code, 12 h TTL, + SHA-256-hashed like email codes, single active per issuer, single-use, redeem + rate-limited) and a **play-gated request** (`SendFriendRequest` now requires a + shared game — active or finished). An explicit **decline is permanent** (blocks + re-send), an **ignored request lazily expires after 30 days** and may be re-sent, + and a **code from the same person bypasses a prior decline**. This **supersedes + Stage 4's** "declining/cancelling deletes the row" (cancel by the requester still + deletes; decline now sets `status='declined'`). Migration **00006** widens + `friendships_status_chk` and adds **`friend_codes`** (jetgen regen). No public ID + or name search — discovery is codes + befriend-an-opponent. + - **Badges = poll + push** (interview): a new generic **`notify`** push event + (`notify.KindNotification`, sub-kinds friend_request/friend_added/invitation/ + game_started) drives the lobby hamburger + "Friends" badge; emitted on friend- + request and invitation create and on the invitation's game start. The client polls + incoming requests + open invitations on lobby open and on focus (a missed push + while hidden), and re-polls on the `notify` event. Cursor-resume stays deferred + (single-instance MVP, §10). + - **Language single-control** (interview): the Settings language control writes + through to the durable account's `preferred_language` (`profile.update`); guests + keep only the client preference. Seeding the language from the platform/client on + first provider login is a **Stage 9** forward-note. + - **Guests = durable-only** (interview): friends/blocks/invitations/statistics and + history management are durable-account-only; a guest sees a sign-in prompt. + Binding an email to an existing guest (account linking) stays **Stage 11**. + - **GCG = finished-only + share** (interview): `game.ExportGCG` refuses an active + game (`game.ErrGameActive`) to avoid leaking the live journal mid-play; the client + exports via the **Web Share API** where available, else a **Blob download** + (`game-.gcg`). Capacitor-native file save lands with the native wrapper. + - **IA = as the mockup** (interview): Friends (friends + blocks) is its own screen + from the lobby menu; Invitations is a lobby section + a "play with friends" mode in + New game; Stats is a lobby tab-bar button; profile editing is on Profile; history + + GCG stay in the game. + - **Wire/codegen**: new fbs tables (friends/blocks/invitations/profile-update/email- + bind/stats/gcg + `NotificationEvent`; `Profile` gained trailing away fields) in + `pkg/fbs`, regenerated to committed Go + TS; ~21 new gateway transcode ops; new + REST handlers under `/api/v1/user/{friends,blocks,invitations,profile,email,stats}` + and `…/games/:id/gcg`. UI grows to ~82 KB gzip JS (budget 100 KB). No CI workflow + change (the Go and UI workflows already cover the new code). + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, @@ -570,3 +616,8 @@ Open details: deployment target/host; dashboards; load expectations. value)` table so the UI stops duplicating it, and optionally moving tile exchange to letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift. +- **TODO-5 — QR / deep-link friend codes (owner's idea, Stage 8).** The one-time + friend code is entered by hand today. Once the Telegram/native deep-link scheme + exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can + add you by scanning rather than typing. The code semantics (12 h TTL, single use, + one active per issuer) stay as-is; only the delivery changes. diff --git a/backend/README.md b/backend/README.md index 4dd05ca..909c304 100644 --- a/backend/README.md +++ b/backend/README.md @@ -60,9 +60,14 @@ Stage 6 opens the backend to the edge. The route groups gain their first handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under `/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a slice of authenticated `/api/v1/user` operations (profile, submit play, game -state, lobby enqueue/poll, chat). A new `internal/notify` hub feeds a second -listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming -live events (your-turn, opponent-moved, chat, nudge, match-found) to the gateway. +state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history +operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend, +list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` +(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`, +`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a +second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming +live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the +gateway. Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row with no identity, excluded from statistics. The shared wire contracts live in the sibling [`../pkg`](../pkg) module. diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 05dd45a..e7008d2 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -141,6 +141,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { matchmaker.SetNotifier(hub) go matchmaker.RunReaper(ctx, cfg.Lobby.ReaperInterval) invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc) + invitations.SetNotifier(hub) logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait)) srv := server.New(cfg.HTTPAddr, server.Deps{ diff --git a/backend/internal/account/stats.go b/backend/internal/account/stats.go new file mode 100644 index 0000000..707361f --- /dev/null +++ b/backend/internal/account/stats.go @@ -0,0 +1,50 @@ +package account + +import ( + "context" + "errors" + "fmt" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +// Stats is a durable account's lifetime record, written by the game domain on each +// finish and read for the player's statistics screen. MaxGamePoints is the best +// single game's total; MaxWordPoints is the best single move's score (which already +// includes every word it formed plus the all-tiles bonus). +type Stats struct { + Wins int + Losses int + Draws int + MaxGamePoints int + MaxWordPoints int +} + +// GetStats returns the lifetime statistics for id. An account with no account_stats +// row yet — a guest, or a player who has not finished a game — yields the zero +// Stats (all counters zero) rather than an error. +func (s *Store) GetStats(ctx context.Context, id uuid.UUID) (Stats, error) { + stmt := postgres.SELECT(table.AccountStats.AllColumns). + FROM(table.AccountStats). + WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(id))). + LIMIT(1) + var row model.AccountStats + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Stats{}, nil + } + return Stats{}, fmt.Errorf("account: get stats %s: %w", id, err) + } + return Stats{ + Wins: int(row.Wins), + Losses: int(row.Losses), + Draws: int(row.Draws), + MaxGamePoints: int(row.MaxGamePoints), + MaxWordPoints: int(row.MaxWordPoints), + }, nil +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 9d0793d..33ae347 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -566,6 +566,16 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid. return seats, g.ToMove, g.Status, nil } +// SharedGame reports whether accounts a and b are seated together in any game +// (active or finished). It backs the social package's "befriend an opponent" +// request gate without exposing the games tables; a self-pair is never shared. +func (svc *Service) SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) { + if a == b { + return false, nil + } + return svc.store.SharedGameExists(ctx, a, b) +} + // ListForAccount returns every game the account is seated in, newest first, for the // lobby's active/finished lists. The live position is not loaded — the summaries come // straight from the durable rows. @@ -586,12 +596,17 @@ func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, return HistoryView{Game: g, Moves: moves}, nil } -// ExportGCG renders a game as GCG text from the journal alone (no dictionary). +// ExportGCG renders a game as GCG text from the journal alone (no dictionary). It +// is allowed only on a finished game: exporting an in-progress game would leak the +// full move journal mid-play, so an active game yields ErrGameActive. func (svc *Service) ExportGCG(ctx context.Context, gameID uuid.UUID) (string, error) { g, err := svc.store.GetGame(ctx, gameID) if err != nil { return "", err } + if g.Status != StatusFinished { + return "", ErrGameActive + } moves, err := svc.store.GetJournal(ctx, gameID) if err != nil { return "", err diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 7969f2e..0e4875f 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { return projectGame(grow, srows) } +// SharedGameExists reports whether accounts a and b are both seated in at least +// one game (active or finished). It backs the social package's "befriend an +// opponent" gate via a self-join on game_players. +func (s *Store) SharedGameExists(ctx context.Context, a, b uuid.UUID) (bool, error) { + other := table.GamePlayers.AS("other") + stmt := postgres.SELECT(table.GamePlayers.GameID). + FROM(table.GamePlayers.INNER_JOIN(other, other.GameID.EQ(table.GamePlayers.GameID))). + WHERE( + table.GamePlayers.AccountID.EQ(postgres.UUID(a)). + AND(other.AccountID.EQ(postgres.UUID(b))), + ).LIMIT(1) + var rows []model.GamePlayers + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return false, fmt.Errorf("game: shared game exists: %w", err) + } + return len(rows) > 0, nil +} + // ListGamesForAccount loads every game the account is seated in (active and // finished), newest first, each joined with its ordered seats. It backs the lobby's // "my games" lists. diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 4642e1a..6f46541 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -29,6 +29,9 @@ var ( ErrNotYourTurn = errors.New("game: not the player's turn") // ErrFinished is returned when a transition is attempted on a finished game. ErrFinished = errors.New("game: game is finished") + // ErrGameActive is returned when an operation allowed only on a finished game + // (such as a GCG export) is attempted while the game is still active. + ErrGameActive = errors.New("game: game is still active") // ErrNotAPlayer is returned when an account is not seated in the game. ErrNotAPlayer = errors.New("game: account is not a player in this game") // ErrInvalidConfig is returned when CreateParams are not acceptable. diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 695e423..314f2c4 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -76,6 +76,21 @@ func TestAccountProvisionByIdentity(t *testing.T) { } } +// TestGetStatsZeroForFreshAccount checks that an account with no finished games +// reads back the zero statistics rather than an error (the Stage 8 stats screen). +func TestGetStatsZeroForFreshAccount(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + id := provisionAccount(t) + st, err := store.GetStats(ctx, id) + if err != nil { + t.Fatalf("get stats: %v", err) + } + if (st != account.Stats{}) { + t.Fatalf("fresh stats = %+v, want zero", st) + } +} + // identityConfirmed reads the confirmed flag for one identity directly. func identityConfirmed(t *testing.T, kind, externalID string) bool { t.Helper() diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 00ba2af..dc9c5c9 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -555,3 +555,13 @@ func equalStrings(a, b []string) bool { } return true } + +// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export +// is allowed only once the game is over, so an active game leaks nothing mid-play. +func TestExportGCGRefusesActiveGame(t *testing.T) { + ctx := context.Background() + gameID, _ := newGameWithSeats(t, 2) + if _, err := newGameService().ExportGCG(ctx, gameID); !errors.Is(err, game.ErrGameActive) { + t.Fatalf("export of active game = %v, want ErrGameActive", err) + } +} diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go index 65ead11..cbef58d 100644 --- a/backend/internal/inttest/lobby_test.go +++ b/backend/internal/inttest/lobby_test.go @@ -165,3 +165,31 @@ func TestInvitationCancelByInviter(t *testing.T) { t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err) } } + +func TestListInvitations(t *testing.T) { + ctx := context.Background() + svc := newInvitationService() + inviter := provisionAccount(t) + invitee := provisionAccount(t) + inv, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()) + if err != nil { + t.Fatalf("create: %v", err) + } + // An open invitation appears for both the inviter and the invitee. + for _, who := range []uuid.UUID{inviter, invitee} { + list, err := svc.ListInvitations(ctx, who) + if err != nil { + t.Fatalf("list for %s: %v", who, err) + } + if len(list) != 1 || list[0].ID != inv.ID { + t.Fatalf("invitations for %s = %+v, want [%s]", who, list, inv.ID) + } + } + // Once accepted (the game starts), it is no longer an open invitation. + if _, err := svc.RespondInvitation(ctx, inv.ID, invitee, true); err != nil { + t.Fatalf("accept: %v", err) + } + if list, _ := svc.ListInvitations(ctx, inviter); len(list) != 0 { + t.Fatalf("started invitation still listed: %+v", list) + } +} diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 2feb792..42f5900 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -43,7 +43,9 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) { func TestFriendRequestLifecycle(t *testing.T) { ctx := context.Background() svc := newSocialService() - a, b := provisionAccount(t), provisionAccount(t) + // A request is only allowed between players who share a game. + _, seats := newGameWithSeats(t, 2) + a, b := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) @@ -102,7 +104,8 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) { func TestBlockSeversFriendship(t *testing.T) { ctx := context.Background() svc := newSocialService() - a, b := provisionAccount(t), provisionAccount(t) + _, seats := newGameWithSeats(t, 2) + a, b := seats[0], seats[1] if err := svc.SendFriendRequest(ctx, a, b); err != nil { t.Fatalf("send: %v", err) } @@ -117,6 +120,113 @@ func TestBlockSeversFriendship(t *testing.T) { } } +func TestFriendRequestRequiresSharedGame(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + a, b := provisionAccount(t), provisionAccount(t) // never played together + if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrNoSharedGame) { + t.Fatalf("send without shared game = %v, want ErrNoSharedGame", err) + } +} + +func TestFriendDeclineIsPermanentUntilCode(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + _, seats := newGameWithSeats(t, 2) + a, b := seats[0], seats[1] + + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("send: %v", err) + } + if err := svc.RespondFriendRequest(ctx, b, a, false); err != nil { // b declines a + t.Fatalf("decline: %v", err) + } + // An explicit decline is remembered: a cannot re-send. + if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestDeclined) { + t.Fatalf("resend after decline = %v, want ErrRequestDeclined", err) + } + // But a one-time code from b bypasses the decline. + code, err := svc.IssueFriendCode(ctx, b) + if err != nil { + t.Fatalf("issue code: %v", err) + } + issuer, err := svc.RedeemFriendCode(ctx, a, code.Code) + if err != nil { + t.Fatalf("redeem: %v", err) + } + if issuer != b { + t.Fatalf("redeem issuer = %s, want b", issuer) + } + if friends, _ := svc.ListFriends(ctx, a); len(friends) != 1 || friends[0] != b { + t.Fatalf("friends of a after code = %v, want [b]", friends) + } +} + +func TestFriendRequestResendAfterExpiry(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + _, seats := newGameWithSeats(t, 2) + a, b := seats[0], seats[1] + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("send: %v", err) + } + // A request older than the 30-day window lazily expires: it leaves the incoming + // list and may be re-sent. + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, a, b); err != nil { + t.Fatalf("backdate: %v", err) + } + if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 0 { + t.Fatalf("expired request still incoming: %v", got) + } + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("resend after expiry: %v", err) + } + if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a { + t.Fatalf("re-sent request not incoming: %v", got) + } +} + +func TestFriendCodeSelfAndSingleUse(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + a := provisionAccount(t) + code, err := svc.IssueFriendCode(ctx, a) + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := svc.RedeemFriendCode(ctx, a, code.Code); !errors.Is(err, social.ErrSelfRelation) { + t.Fatalf("self redeem = %v, want ErrSelfRelation", err) + } + b := provisionAccount(t) + if _, err := svc.RedeemFriendCode(ctx, b, code.Code); err != nil { + t.Fatalf("redeem: %v", err) + } + // Single-use: redeeming the same code again fails. + if _, err := svc.RedeemFriendCode(ctx, provisionAccount(t), code.Code); !errors.Is(err, social.ErrFriendCodeInvalid) { + t.Fatalf("reused code = %v, want ErrFriendCodeInvalid", err) + } + if friends, _ := svc.ListFriends(ctx, b); len(friends) != 1 || friends[0] != a { + t.Fatalf("friends of b = %v, want [a]", friends) + } +} + +func TestListBlocks(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + a, b := provisionAccount(t), provisionAccount(t) + if err := svc.Block(ctx, a, b); err != nil { + t.Fatalf("block: %v", err) + } + blocked, err := svc.ListBlocks(ctx, a) + if err != nil { + t.Fatalf("list blocks: %v", err) + } + if len(blocked) != 1 || blocked[0] != b { + t.Fatalf("blocks = %v, want [b]", blocked) + } +} + func TestChatPostListAndBlocks(t *testing.T) { ctx := context.Background() svc := newSocialService() diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go index 92db720..92043dc 100644 --- a/backend/internal/lobby/invitations.go +++ b/backend/internal/lobby/invitations.go @@ -15,6 +15,7 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) @@ -76,6 +77,7 @@ type InvitationService struct { games GameCreator accounts *account.Store blocker Blocker + pub notify.Publisher now func() time.Time } @@ -88,10 +90,33 @@ func NewInvitationService(store *Store, games GameCreator, accounts *account.Sto games: games, accounts: accounts, blocker: blocker, + pub: notify.Nop{}, now: func() time.Time { return time.Now().UTC() }, } } +// SetNotifier installs the live-event publisher used to nudge invitees' lobby +// badges when an invitation arrives and to tell all seats when the game starts. It +// must be called during startup wiring; the default is notify.Nop (no live events, +// invitees still see the invitation on the next lobby poll). +func (svc *InvitationService) SetNotifier(p notify.Publisher) { + if p != nil { + svc.pub = p + } +} + +// notify publishes a re-poll Notification of the given sub-kind to each user. +func (svc *InvitationService) notify(kind string, userIDs ...uuid.UUID) { + if len(userIDs) == 0 { + return + } + intents := make([]notify.Intent, 0, len(userIDs)) + for _, id := range userIDs { + intents = append(intents, notify.Notification(id, kind)) + } + svc.pub.Publish(intents...) +} + // CreateInvitation records a pending invitation from inviterID to inviteeIDs (in // seat order, 1..N) with the given settings. The total seat count must be 2-4, // invitees distinct and not the inviter, every invitee an existing account with no @@ -147,7 +172,12 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil { return Invitation{}, err } - return svc.store.loadInvitation(ctx, id) + inv, err := svc.store.loadInvitation(ctx, id) + if err != nil { + return Invitation{}, err + } + svc.notify(notify.NotifyInvitation, inviteeIDs...) + return inv, nil } // RespondInvitation records accountID's accept or decline of an invitation. A @@ -194,6 +224,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil { return err } + svc.notify(notify.NotifyGameStarted, seats...) return nil } @@ -207,6 +238,26 @@ func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uu return svc.store.loadInvitation(ctx, invitationID) } +// ListInvitations returns the open (pending, not yet expired) invitations that +// touch accountID, whether as the inviter or an invitee, newest first. Expired +// invitations are hidden here (lazy expiry); the row's transition to 'expired' +// happens on the next response or cancel. +func (svc *InvitationService) ListInvitations(ctx context.Context, accountID uuid.UUID) ([]Invitation, error) { + ids, err := svc.store.listInvitationIDs(ctx, accountID, svc.now()) + if err != nil { + return nil, err + } + out := make([]Invitation, 0, len(ids)) + for _, id := range ids { + inv, err := svc.store.loadInvitation(ctx, id) + if err != nil { + return nil, err + } + out = append(out, inv) + } + return out, nil +} + // invitationInsert carries the immutable fields of a new invitation. type invitationInsert struct { id uuid.UUID @@ -297,6 +348,51 @@ func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, e return inv, nil } +// listInvitationIDs returns the ids of every pending, still-live invitation that +// accountID is part of (as inviter or invitee), newest first. It runs two queries +// (one per role) and merges them, avoiding a correlated subquery. +func (s *Store) listInvitationIDs(ctx context.Context, accountID uuid.UUID, now time.Time) ([]uuid.UUID, error) { + live := table.GameInvitations.Status.EQ(postgres.String(invitationPending)). + AND(table.GameInvitations.ExpiresAt.GT(postgres.TimestampzT(now))) + + var asInviter []model.GameInvitations + q1 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt). + FROM(table.GameInvitations). + WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(accountID)).AND(live)) + if err := q1.QueryContext(ctx, s.db, &asInviter); err != nil { + return nil, fmt.Errorf("lobby: list invitations as inviter: %w", err) + } + + var asInvitee []model.GameInvitations + q2 := postgres.SELECT(table.GameInvitations.InvitationID, table.GameInvitations.CreatedAt). + FROM(table.GameInvitations.INNER_JOIN( + table.GameInvitationInvitees, + table.GameInvitationInvitees.InvitationID.EQ(table.GameInvitations.InvitationID), + )). + WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID)).AND(live)) + if err := q2.QueryContext(ctx, s.db, &asInvitee); err != nil { + return nil, fmt.Errorf("lobby: list invitations as invitee: %w", err) + } + + seen := make(map[uuid.UUID]bool, len(asInviter)+len(asInvitee)) + merged := make([]model.GameInvitations, 0, len(asInviter)+len(asInvitee)) + for _, r := range append(asInviter, asInvitee...) { + if seen[r.InvitationID] { + continue + } + seen[r.InvitationID] = true + merged = append(merged, r) + } + slices.SortFunc(merged, func(a, b model.GameInvitations) int { + return b.CreatedAt.Compare(a.CreatedAt) + }) + out := make([]uuid.UUID, len(merged)) + for i, r := range merged { + out[i] = r.InvitationID + } + return out, nil +} + // respondTx applies an invitee's response inside a row-locked transaction so // concurrent responses serialise and exactly one accept can complete the set. func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) { diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 8c381ec..27de53e 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -83,6 +83,19 @@ func MatchFound(userID, gameID uuid.UUID) Intent { return Intent{UserID: userID, Kind: KindMatchFound, Payload: b.FinishedBytes(), EventID: eventID()} } +// Notification is a lightweight "re-poll" signal to userID that a friend request or +// invitation changed. kind is a sub-discriminator (NotifyFriendRequest, +// NotifyFriendAdded, NotifyInvitation, NotifyGameStarted) the client may use to +// scope its refresh. +func Notification(userID uuid.UUID, kind string) Intent { + b := flatbuffers.NewBuilder(32) + k := b.CreateString(kind) + fb.NotificationEventStart(b) + fb.NotificationEventAddKind(b, k) + b.Finish(fb.NotificationEventEnd(b)) + return Intent{UserID: userID, Kind: KindNotification, Payload: b.FinishedBytes(), EventID: eventID()} +} + // eventID returns a best-effort correlation id for one emitted event. func eventID() string { if id, err := uuid.NewV7(); err == nil { diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index 2f6fd20..fd1fb91 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -24,6 +24,18 @@ const ( KindChatMessage = "chat_message" KindNudge = "nudge" KindMatchFound = "match_found" + // KindNotification is a lightweight "re-poll your lobby counters" signal + // (incoming friend requests, invitations) that drives the lobby badge. + KindNotification = "notify" +) + +// Notification sub-kinds carried in a KindNotification event payload; the client +// re-fetches its lobby counters on any of them. +const ( + NotifyFriendRequest = "friend_request" + NotifyFriendAdded = "friend_added" + NotifyInvitation = "invitation" + NotifyGameStarted = "game_started" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/notify/notify_test.go b/backend/internal/notify/notify_test.go index c0635b7..e94ce44 100644 --- a/backend/internal/notify/notify_test.go +++ b/backend/internal/notify/notify_test.go @@ -98,3 +98,15 @@ func TestChatMessagePayloadRoundTrips(t *testing.T) { t.Fatalf("decoded wrong chat message: %+v", ev) } } + +func TestNotificationPayloadRoundTrips(t *testing.T) { + uid := uuid.New() + in := notify.Notification(uid, notify.NotifyFriendRequest) + if in.UserID != uid || in.Kind != notify.KindNotification || in.EventID == "" { + t.Fatalf("intent metadata wrong: %+v", in) + } + ev := fb.GetRootAsNotificationEvent(in.Payload, 0) + if got := string(ev.Kind()); got != notify.NotifyFriendRequest { + t.Fatalf("notification sub-kind = %q, want %q", got, notify.NotifyFriendRequest) + } +} diff --git a/backend/internal/postgres/jet/backend/model/friend_codes.go b/backend/internal/postgres/jet/backend/model/friend_codes.go new file mode 100644 index 0000000..9d859c4 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/friend_codes.go @@ -0,0 +1,22 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type FriendCodes struct { + CodeID uuid.UUID `sql:"primary_key"` + AccountID uuid.UUID + CodeHash string + ExpiresAt time.Time + ConsumedAt *time.Time + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/friend_codes.go b/backend/internal/postgres/jet/backend/table/friend_codes.go new file mode 100644 index 0000000..c6aa950 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/friend_codes.go @@ -0,0 +1,93 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var FriendCodes = newFriendCodesTable("backend", "friend_codes", "") + +type friendCodesTable struct { + postgres.Table + + // Columns + CodeID postgres.ColumnString + AccountID postgres.ColumnString + CodeHash postgres.ColumnString + ExpiresAt postgres.ColumnTimestampz + ConsumedAt postgres.ColumnTimestampz + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type FriendCodesTable struct { + friendCodesTable + + EXCLUDED friendCodesTable +} + +// AS creates new FriendCodesTable with assigned alias +func (a FriendCodesTable) AS(alias string) *FriendCodesTable { + return newFriendCodesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new FriendCodesTable with assigned schema name +func (a FriendCodesTable) FromSchema(schemaName string) *FriendCodesTable { + return newFriendCodesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new FriendCodesTable with assigned table prefix +func (a FriendCodesTable) WithPrefix(prefix string) *FriendCodesTable { + return newFriendCodesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new FriendCodesTable with assigned table suffix +func (a FriendCodesTable) WithSuffix(suffix string) *FriendCodesTable { + return newFriendCodesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newFriendCodesTable(schemaName, tableName, alias string) *FriendCodesTable { + return &FriendCodesTable{ + friendCodesTable: newFriendCodesTableImpl(schemaName, tableName, alias), + EXCLUDED: newFriendCodesTableImpl("", "excluded", ""), + } +} + +func newFriendCodesTableImpl(schemaName, tableName, alias string) friendCodesTable { + var ( + CodeIDColumn = postgres.StringColumn("code_id") + AccountIDColumn = postgres.StringColumn("account_id") + CodeHashColumn = postgres.StringColumn("code_hash") + ExpiresAtColumn = postgres.TimestampzColumn("expires_at") + ConsumedAtColumn = postgres.TimestampzColumn("consumed_at") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{CodeIDColumn, AccountIDColumn, CodeHashColumn, ExpiresAtColumn, ConsumedAtColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{AccountIDColumn, CodeHashColumn, ExpiresAtColumn, ConsumedAtColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{CreatedAtColumn} + ) + + return friendCodesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + CodeID: CodeIDColumn, + AccountID: AccountIDColumn, + CodeHash: CodeHashColumn, + ExpiresAt: ExpiresAtColumn, + ConsumedAt: ConsumedAtColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index ebac108..edc7307 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -16,6 +16,7 @@ func UseSchema(schema string) { ChatMessages = ChatMessages.FromSchema(schema) Complaints = Complaints.FromSchema(schema) EmailConfirmations = EmailConfirmations.FromSchema(schema) + FriendCodes = FriendCodes.FromSchema(schema) Friendships = Friendships.FromSchema(schema) GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema) GameInvitations = GameInvitations.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00006_friend_codes.sql b/backend/internal/postgres/migrations/00006_friend_codes.sql new file mode 100644 index 0000000..f9b5f93 --- /dev/null +++ b/backend/internal/postgres/migrations/00006_friend_codes.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- Stage 8 social UI: two changes to the friend graph. +-- +-- 1. A declined friend request is now remembered permanently (status 'declined') +-- instead of deleting the row, so a recipient's explicit "no" blocks the same +-- requester from re-sending (anti-spam). An ignored request still lazily +-- expires (30 days, computed from created_at in Go) and can then be re-sent; a +-- one-time friend code from the same person bypasses a prior decline. This +-- widens friendships_status_chk; the Stage 4 "declining deletes the row" rule +-- is superseded (cancelling by the requester still deletes). +-- +-- 2. friend_codes backs the code-redeem add-a-friend path: the player who wants to +-- be added issues a one-time 6-digit numeric code; whoever enters it becomes +-- their friend immediately. Only the hex-encoded SHA-256 of the code is stored +-- (the plaintext is never persisted, matching the session and email-code +-- models); expires_at bounds the 12h TTL and consumed_at marks single use. At +-- most one live code exists per issuer (issuing a new one clears the prior +-- unconsumed code, enforced in Go). This adds a table, so the generated jet code +-- is regenerated (cmd/jetgen). +SET search_path = backend, pg_catalog; + +ALTER TABLE friendships + DROP CONSTRAINT friendships_status_chk, + ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted', 'declined')); + +CREATE TABLE friend_codes ( + code_id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + code_hash text NOT NULL, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +-- Backs "clear the issuer's prior live code" on issue. +CREATE INDEX friend_codes_account_idx ON friend_codes (account_id); +-- Backs the redeem lookup by code hash. +CREATE INDEX friend_codes_code_hash_idx ON friend_codes (code_hash); + +-- +goose Down +SET search_path = backend, pg_catalog; + +DROP TABLE friend_codes; +ALTER TABLE friendships + DROP CONSTRAINT friendships_status_chk, + ADD CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')); diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 7e39b4f..e997847 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -32,12 +32,15 @@ type resolveResponse struct { UserID string `json:"user_id"` } -// profileResponse is the authenticated account's own profile. +// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd +// are the daily away window's "HH:MM" local-time bounds (in TimeZone). type profileResponse struct { UserID string `json:"user_id"` DisplayName string `json:"display_name"` PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` + AwayStart string `json:"away_start"` + AwayEnd string `json:"away_end"` HintBalance int `json:"hint_balance"` BlockChat bool `json:"block_chat"` BlockFriendRequests bool `json:"block_friend_requests"` @@ -149,6 +152,8 @@ func profileResponseFor(acc account.Account) profileResponse { DisplayName: acc.DisplayName, PreferredLanguage: acc.PreferredLanguage, TimeZone: acc.TimeZone, + AwayStart: acc.AwayStart.Format(awayTimeLayout), + AwayEnd: acc.AwayEnd.Format(awayTimeLayout), HintBalance: acc.HintBalance, BlockChat: acc.BlockChat, BlockFriendRequests: acc.BlockFriendRequests, @@ -156,6 +161,9 @@ func profileResponseFor(acc account.Account) profileResponse { } } +// awayTimeLayout is the "HH:MM" wire form of the daily away-window bounds. +const awayTimeLayout = "15:04" + // gameDTOFromGame projects a game.Game into its DTO. func gameDTOFromGame(g game.Game) gameDTO { seats := make([]seatDTO, 0, len(g.Seats)) diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index fea97e0..7f0ae7a 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -96,6 +96,24 @@ func TestGameDTOFromGame(t *testing.T) { } } +func TestProfileResponseForAwayWindow(t *testing.T) { + acc := account.Account{ + ID: uuid.New(), + DisplayName: "Kaya", + PreferredLanguage: "ru", + TimeZone: "Europe/Moscow", + AwayStart: time.Date(0, 1, 1, 0, 0, 0, 0, time.UTC), + AwayEnd: time.Date(0, 1, 1, 7, 30, 0, 0, time.UTC), + } + dto := profileResponseFor(acc) + if dto.AwayStart != "00:00" || dto.AwayEnd != "07:30" { + t.Fatalf("away window = (%q, %q), want (00:00, 07:30)", dto.AwayStart, dto.AwayEnd) + } + if dto.PreferredLanguage != "ru" || dto.TimeZone != "Europe/Moscow" { + t.Fatalf("profile dto mismatch: %+v", dto) + } +} + func TestMoveRecordDTOFrom(t *testing.T) { rec := engine.MoveRecord{ Player: 1, diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index de8411a..9c8b0c2 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -35,6 +35,12 @@ func (s *Server) registerRoutes() { u := s.user if s.accounts != nil { u.GET("/profile", s.handleProfile) + u.PUT("/profile", s.handleUpdateProfile) + u.GET("/stats", s.handleStats) + } + if s.emails != nil { + u.POST("/email/request", s.handleEmailBindRequest) + u.POST("/email/confirm", s.handleEmailBindConfirm) } if s.games != nil { u.GET("/games", s.handleListGames) @@ -48,15 +54,34 @@ func (s *Server) registerRoutes() { u.GET("/games/:id/check_word", s.handleCheckWord) u.POST("/games/:id/complaint", s.handleComplaint) u.GET("/games/:id/history", s.handleHistory) + u.GET("/games/:id/gcg", s.handleExportGCG) } if s.matchmaker != nil { u.POST("/lobby/enqueue", s.handleEnqueue) u.GET("/lobby/poll", s.handlePoll) } + if s.invitations != nil { + u.GET("/invitations", s.handleListInvitations) + u.POST("/invitations", s.handleCreateInvitation) + u.POST("/invitations/:id/accept", s.handleAcceptInvitation) + u.POST("/invitations/:id/decline", s.handleDeclineInvitation) + u.DELETE("/invitations/:id", s.handleCancelInvitation) + } if s.social != nil { u.POST("/games/:id/chat", s.handleChatPost) u.GET("/games/:id/chat", s.handleChatList) u.POST("/games/:id/nudge", s.handleNudge) + u.GET("/friends", s.handleListFriends) + u.GET("/friends/incoming", s.handleIncomingRequests) + u.POST("/friends/request", s.handleFriendRequest) + u.POST("/friends/respond", s.handleFriendRespond) + u.POST("/friends/cancel", s.handleFriendCancel) + u.DELETE("/friends/:id", s.handleUnfriend) + u.POST("/friends/code", s.handleIssueFriendCode) + u.POST("/friends/code/redeem", s.handleRedeemFriendCode) + u.GET("/blocks", s.handleListBlocks) + u.POST("/blocks", s.handleBlock) + u.DELETE("/blocks/:id", s.handleUnblock) } s.admin.GET("/ping", s.handleAdminPing) } @@ -117,8 +142,30 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "not_your_turn" case errors.Is(err, game.ErrFinished), errors.Is(err, social.ErrGameNotActive): return http.StatusConflict, "game_finished" + case errors.Is(err, game.ErrGameActive): + return http.StatusConflict, "game_active" + case errors.Is(err, account.ErrInvalidProfile): + return http.StatusBadRequest, "invalid_profile" + case errors.Is(err, account.ErrAlreadyConfirmed): + return http.StatusConflict, "already_confirmed" case errors.Is(err, lobby.ErrAlreadyQueued): return http.StatusConflict, "already_queued" + case errors.Is(err, lobby.ErrInvalidInvitation): + return http.StatusBadRequest, "invalid_invitation" + case errors.Is(err, lobby.ErrInvitationBlocked): + return http.StatusForbidden, "invitation_blocked" + case errors.Is(err, lobby.ErrInvitationNotFound): + return http.StatusNotFound, "invitation_not_found" + case errors.Is(err, lobby.ErrInvitationNotPending): + return http.StatusConflict, "invitation_not_pending" + case errors.Is(err, lobby.ErrInvitationExpired): + return http.StatusConflict, "invitation_expired" + case errors.Is(err, lobby.ErrNotInvited): + return http.StatusForbidden, "not_invited" + case errors.Is(err, lobby.ErrAlreadyResponded): + return http.StatusConflict, "already_responded" + case errors.Is(err, lobby.ErrNotInviter): + return http.StatusForbidden, "not_inviter" case errors.Is(err, game.ErrInvalidConfig): return http.StatusBadRequest, "invalid_config" case errors.Is(err, game.ErrNoHintAvailable): @@ -142,6 +189,20 @@ func statusForError(err error) (int, string) { errors.Is(err, social.ErrEmptyMessage), errors.Is(err, social.ErrForbiddenContent), errors.Is(err, social.ErrNudgeTooSoon): return http.StatusUnprocessableEntity, "chat_rejected" + case errors.Is(err, social.ErrSelfRelation): + return http.StatusBadRequest, "self_relation" + case errors.Is(err, social.ErrRequestExists): + return http.StatusConflict, "request_exists" + case errors.Is(err, social.ErrRequestBlocked): + return http.StatusForbidden, "request_blocked" + case errors.Is(err, social.ErrRequestNotFound): + return http.StatusNotFound, "request_not_found" + case errors.Is(err, social.ErrNoSharedGame): + return http.StatusForbidden, "no_shared_game" + case errors.Is(err, social.ErrRequestDeclined): + return http.StatusConflict, "request_declined" + case errors.Is(err, social.ErrFriendCodeInvalid): + return http.StatusUnprocessableEntity, "friend_code_invalid" default: return http.StatusInternalServerError, "internal" } diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go new file mode 100644 index 0000000..051edd5 --- /dev/null +++ b/backend/internal/server/handlers_account.go @@ -0,0 +1,157 @@ +package server + +import ( + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "scrabble/backend/internal/account" +) + +// The /api/v1/user account handlers wire profile editing, email binding and the +// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a +// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the +// client sends the complete desired profile. + +// updateProfileRequest is the full editable profile. away_start/away_end are +// "HH:MM" local-time bounds of the daily away window. +type updateProfileRequest struct { + DisplayName string `json:"display_name"` + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + AwayStart string `json:"away_start"` + AwayEnd string `json:"away_end"` + BlockChat bool `json:"block_chat"` + BlockFriendRequests bool `json:"block_friend_requests"` +} + +// statsDTO is a durable account's lifetime statistics (the derived games-played and +// win-rate are computed client-side). +type statsDTO struct { + Wins int `json:"wins"` + Losses int `json:"losses"` + Draws int `json:"draws"` + MaxGamePoints int `json:"max_game_points"` + MaxWordPoints int `json:"max_word_points"` +} + +// emailBindRequestBody starts binding an email to the caller's account. +type emailBindRequestBody struct { + Email string `json:"email"` +} + +// emailBindConfirmBody completes binding an email with its confirm code. +type emailBindConfirmBody struct { + Email string `json:"email"` + Code string `json:"code"` +} + +// parseAwayTime parses an "HH:MM" away-window bound. +func parseAwayTime(s string) (time.Time, bool) { + t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s)) + if err != nil { + return time.Time{}, false + } + return t, true +} + +// handleUpdateProfile overwrites the caller's editable profile fields. +func (s *Server) handleUpdateProfile(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req updateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + awayStart, ok := parseAwayTime(req.AwayStart) + if !ok { + abortBadRequest(c, "away_start must be HH:MM") + return + } + awayEnd, ok := parseAwayTime(req.AwayEnd) + if !ok { + abortBadRequest(c, "away_end must be HH:MM") + return + } + acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{ + DisplayName: req.DisplayName, + PreferredLanguage: req.PreferredLanguage, + TimeZone: req.TimeZone, + AwayStart: awayStart, + AwayEnd: awayEnd, + BlockChat: req.BlockChat, + BlockFriendRequests: req.BlockFriendRequests, + }) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, profileResponseFor(acc)) +} + +// handleStats returns the caller's lifetime statistics. +func (s *Server) handleStats(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + st, err := s.accounts.GetStats(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, statsDTO{ + Wins: st.Wins, + Losses: st.Losses, + Draws: st.Draws, + MaxGamePoints: st.MaxGamePoints, + MaxWordPoints: st.MaxWordPoints, + }) +} + +// handleEmailBindRequest issues a confirm code to bind an email to the caller. +func (s *Server) handleEmailBindRequest(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req emailBindRequestBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleEmailBindConfirm verifies the code and binds the email, returning the +// updated profile. +func (s *Server) handleEmailBindConfirm(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req emailBindConfirmBody + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, profileResponseFor(acc)) +} diff --git a/backend/internal/server/handlers_blocks.go b/backend/internal/server/handlers_blocks.go new file mode 100644 index 0000000..6791d19 --- /dev/null +++ b/backend/internal/server/handlers_blocks.go @@ -0,0 +1,76 @@ +package server + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block +// is mutual in effect (the social checks apply it both ways) and severs any +// friendship between the pair. They reuse the friend handlers' targetIDRequest and +// account-ref resolution. + +// blockListDTO is the accounts the caller has blocked. +type blockListDTO struct { + Blocked []accountRefDTO `json:"blocked"` +} + +// handleBlock blocks the body-supplied account. +func (s *Server) handleBlock(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req targetIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + target, ok := parseUUIDField(req.AccountID) + if !ok { + abortBadRequest(c, "invalid account id") + return + } + if err := s.social.Block(c.Request.Context(), uid, target); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleUnblock removes the caller's block on the :id account. +func (s *Server) handleUnblock(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + target, err := uuid.Parse(c.Param("id")) + if err != nil { + abortBadRequest(c, "invalid account id") + return + } + if err := s.social.Unblock(c.Request.Context(), uid, target); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleListBlocks returns the accounts the caller has blocked. +func (s *Server) handleListBlocks(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ids, err := s.social.ListBlocks(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, blockListDTO{Blocked: s.accountRefs(c.Request.Context(), ids)}) +} diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go new file mode 100644 index 0000000..83e0f3f --- /dev/null +++ b/backend/internal/server/handlers_friends.go @@ -0,0 +1,254 @@ +package server + +import ( + "context" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the +// befriend-an-opponent request flow, the one-time friend-code path, and the +// friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain +// call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved +// from the account store, mirroring fillSeatNames. + +// accountRefDTO is a referenced account with its display name resolved for the UI. +type accountRefDTO struct { + AccountID string `json:"account_id"` + DisplayName string `json:"display_name"` +} + +// friendListDTO is the caller's accepted friends. +type friendListDTO struct { + Friends []accountRefDTO `json:"friends"` +} + +// incomingListDTO is the friend requests awaiting the caller's response. +type incomingListDTO struct { + Requests []accountRefDTO `json:"requests"` +} + +// friendCodeDTO is a freshly issued one-time friend code (returned once). +type friendCodeDTO struct { + Code string `json:"code"` + ExpiresAtUnix int64 `json:"expires_at_unix"` +} + +// redeemResultDTO reports the new friend gained by redeeming a code. +type redeemResultDTO struct { + Friend accountRefDTO `json:"friend"` +} + +// targetIDRequest carries a single counterpart account id. +type targetIDRequest struct { + AccountID string `json:"account_id"` +} + +// friendRespondRequest accepts or declines a pending request from a requester. +type friendRespondRequest struct { + RequesterID string `json:"requester_id"` + Accept bool `json:"accept"` +} + +// redeemCodeRequest carries a friend code to redeem. +type redeemCodeRequest struct { + Code string `json:"code"` +} + +// namedRef resolves a single account id into its display-name ref, caching the +// lookup in memo so a caller can share it across many refs in one response. +func (s *Server) namedRef(ctx context.Context, id uuid.UUID, memo map[string]string) accountRefDTO { + key := id.String() + name, ok := memo[key] + if !ok { + if acc, err := s.accounts.GetByID(ctx, id); err == nil { + name = acc.DisplayName + } + memo[key] = name + } + return accountRefDTO{AccountID: key, DisplayName: name} +} + +// accountRefs resolves a list of account ids into display-name refs, memoising +// lookups within the call. +func (s *Server) accountRefs(ctx context.Context, ids []uuid.UUID) []accountRefDTO { + memo := map[string]string{} + out := make([]accountRefDTO, 0, len(ids)) + for _, id := range ids { + out = append(out, s.namedRef(ctx, id, memo)) + } + return out +} + +// accountRef resolves a single account id into its display-name ref. +func (s *Server) accountRef(ctx context.Context, id uuid.UUID) accountRefDTO { + return s.namedRef(ctx, id, map[string]string{}) +} + +// parseUUIDField parses a body-supplied account id, trimming whitespace. +func parseUUIDField(s string) (uuid.UUID, bool) { + id, err := uuid.Parse(strings.TrimSpace(s)) + if err != nil { + return uuid.UUID{}, false + } + return id, true +} + +// handleFriendRequest sends a friend request to an opponent the caller has played. +func (s *Server) handleFriendRequest(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req targetIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + target, ok := parseUUIDField(req.AccountID) + if !ok { + abortBadRequest(c, "invalid account id") + return + } + if err := s.social.SendFriendRequest(c.Request.Context(), uid, target); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleFriendRespond accepts or declines a pending incoming request. +func (s *Server) handleFriendRespond(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req friendRespondRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + requester, ok := parseUUIDField(req.RequesterID) + if !ok { + abortBadRequest(c, "invalid requester id") + return + } + if err := s.social.RespondFriendRequest(c.Request.Context(), uid, requester, req.Accept); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleFriendCancel withdraws the caller's own pending request. +func (s *Server) handleFriendCancel(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req targetIDRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + target, ok := parseUUIDField(req.AccountID) + if !ok { + abortBadRequest(c, "invalid account id") + return + } + if err := s.social.CancelFriendRequest(c.Request.Context(), uid, target); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleUnfriend removes a friendship with the :id account. +func (s *Server) handleUnfriend(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + other, err := uuid.Parse(c.Param("id")) + if err != nil { + abortBadRequest(c, "invalid account id") + return + } + if err := s.social.Unfriend(c.Request.Context(), uid, other); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleListFriends returns the caller's accepted friends. +func (s *Server) handleListFriends(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ids, err := s.social.ListFriends(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, friendListDTO{Friends: s.accountRefs(c.Request.Context(), ids)}) +} + +// handleIncomingRequests returns the friend requests awaiting the caller. +func (s *Server) handleIncomingRequests(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ids, err := s.social.ListIncomingRequests(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) +} + +// handleIssueFriendCode issues a one-time add-a-friend code for the caller. +func (s *Server) handleIssueFriendCode(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + code, err := s.social.IssueFriendCode(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, friendCodeDTO{Code: code.Code, ExpiresAtUnix: code.ExpiresAt.Unix()}) +} + +// handleRedeemFriendCode redeems a friend code, befriending its issuer. +func (s *Server) handleRedeemFriendCode(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req redeemCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + issuer, err := s.social.RedeemFriendCode(c.Request.Context(), uid, strings.TrimSpace(req.Code)) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, redeemResultDTO{Friend: s.accountRef(c.Request.Context(), issuer)}) +} diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go index 4a5ad11..b1a8087 100644 --- a/backend/internal/server/handlers_game.go +++ b/backend/internal/server/handlers_game.go @@ -243,6 +243,32 @@ func (s *Server) handleHistory(c *gin.Context) { c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves}) } +// gcgDTO is a game's GCG export: a suggested filename plus the GCG text. +type gcgDTO struct { + GameID string `json:"game_id"` + Filename string `json:"filename"` + Content string `json:"content"` +} + +// handleExportGCG returns a finished game's GCG transcript for download/share. The +// service refuses an active game (ErrGameActive) to avoid leaking the live journal. +func (s *Server) handleExportGCG(c *gin.Context) { + _, gameID, ok := s.userGame(c) + if !ok { + return + } + gcg, err := s.games.ExportGCG(c.Request.Context(), gameID) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, gcgDTO{ + GameID: gameID.String(), + Filename: "game-" + gameID.String() + ".gcg", + Content: gcg, + }) +} + // handleListGames returns the caller's active and finished games for the lobby. func (s *Server) handleListGames(c *gin.Context) { uid, ok := userID(c) diff --git a/backend/internal/server/handlers_invitations.go b/backend/internal/server/handlers_invitations.go new file mode 100644 index 0000000..6f41f0e --- /dev/null +++ b/backend/internal/server/handlers_invitations.go @@ -0,0 +1,205 @@ +package server + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "scrabble/backend/internal/engine" + "scrabble/backend/internal/lobby" +) + +// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8): +// create a 2-4 player invitation, accept/decline as an invitee, cancel as the +// inviter, and list the open invitations touching the caller. Display names for the +// inviter and invitees are resolved from the account store. + +// invitationInviteeDTO is one invitee's seat and response with their name resolved. +type invitationInviteeDTO struct { + AccountID string `json:"account_id"` + DisplayName string `json:"display_name"` + Seat int `json:"seat"` + Response string `json:"response"` +} + +// invitationDTO is a friend-game invitation with its settings and invitees. +type invitationDTO struct { + ID string `json:"id"` + Inviter accountRefDTO `json:"inviter"` + Invitees []invitationInviteeDTO `json:"invitees"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` + Status string `json:"status"` + GameID string `json:"game_id,omitempty"` + ExpiresAtUnix int64 `json:"expires_at_unix"` +} + +// invitationListDTO is the caller's open invitations. +type invitationListDTO struct { + Invitations []invitationDTO `json:"invitations"` +} + +// createInvitationRequest proposes a friend game to the named invitees. +type createInvitationRequest struct { + InviteeIDs []string `json:"invitee_ids"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` +} + +// invitationDTOFrom projects a lobby invitation, resolving names through memo. +func (s *Server) invitationDTOFrom(ctx context.Context, inv lobby.Invitation, memo map[string]string) invitationDTO { + dto := invitationDTO{ + ID: inv.ID.String(), + Inviter: s.namedRef(ctx, inv.InviterID, memo), + Invitees: make([]invitationInviteeDTO, 0, len(inv.Invitees)), + Variant: inv.Settings.Variant.String(), + TurnTimeoutSecs: int(inv.Settings.TurnTimeout.Seconds()), + HintsAllowed: inv.Settings.HintsAllowed, + HintsPerPlayer: inv.Settings.HintsPerPlayer, + DropoutTiles: inv.Settings.DropoutTiles.String(), + Status: inv.Status, + ExpiresAtUnix: inv.ExpiresAt.Unix(), + } + if inv.GameID != nil { + dto.GameID = inv.GameID.String() + } + for _, iv := range inv.Invitees { + ref := s.namedRef(ctx, iv.AccountID, memo) + dto.Invitees = append(dto.Invitees, invitationInviteeDTO{ + AccountID: ref.AccountID, + DisplayName: ref.DisplayName, + Seat: iv.Seat, + Response: iv.Response, + }) + } + return dto +} + +// handleCreateInvitation records a new friend-game invitation from the caller. +func (s *Server) handleCreateInvitation(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + var req createInvitationRequest + if err := c.ShouldBindJSON(&req); err != nil { + abortBadRequest(c, "invalid request body") + return + } + variant, err := engine.ParseVariant(req.Variant) + if err != nil { + abortBadRequest(c, "unknown variant") + return + } + settings := lobby.InvitationSettings{ + Variant: variant, + HintsAllowed: req.HintsAllowed, + HintsPerPlayer: req.HintsPerPlayer, + } + if req.TurnTimeoutSecs > 0 { + settings.TurnTimeout = time.Duration(req.TurnTimeoutSecs) * time.Second + } + if req.DropoutTiles != "" { + dropout, err := engine.ParseDropoutTiles(req.DropoutTiles) + if err != nil { + abortBadRequest(c, "unknown dropout_tiles") + return + } + settings.DropoutTiles = dropout + } + inviteeIDs := make([]uuid.UUID, 0, len(req.InviteeIDs)) + for _, raw := range req.InviteeIDs { + id, ok := parseUUIDField(raw) + if !ok { + abortBadRequest(c, "invalid invitee id") + return + } + inviteeIDs = append(inviteeIDs, id) + } + inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{})) +} + +// handleAcceptInvitation records the caller's acceptance, starting the game when it +// completes the set. +func (s *Server) handleAcceptInvitation(c *gin.Context) { + s.respondInvitation(c, true) +} + +// handleDeclineInvitation records the caller's decline, cancelling the invitation. +func (s *Server) handleDeclineInvitation(c *gin.Context) { + s.respondInvitation(c, false) +} + +// respondInvitation applies the caller's accept/decline to the :id invitation. +func (s *Server) respondInvitation(c *gin.Context, accept bool) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + abortBadRequest(c, "invalid invitation id") + return + } + inv, err := s.invitations.RespondInvitation(c.Request.Context(), id, uid, accept) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, s.invitationDTOFrom(c.Request.Context(), inv, map[string]string{})) +} + +// handleCancelInvitation withdraws the caller's own pending invitation. +func (s *Server) handleCancelInvitation(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + abortBadRequest(c, "invalid invitation id") + return + } + if err := s.invitations.CancelInvitation(c.Request.Context(), id, uid); err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, okResponse{OK: true}) +} + +// handleListInvitations returns the open invitations touching the caller. +func (s *Server) handleListInvitations(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + invs, err := s.invitations.ListInvitations(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + memo := map[string]string{} + out := make([]invitationDTO, 0, len(invs)) + for _, inv := range invs { + out = append(out, s.invitationDTOFrom(c.Request.Context(), inv, memo)) + } + c.JSON(http.StatusOK, invitationListDTO{Invitations: out}) +} diff --git a/backend/internal/social/friendcodes.go b/backend/internal/social/friendcodes.go new file mode 100644 index 0000000..56fd4b4 --- /dev/null +++ b/backend/internal/social/friendcodes.go @@ -0,0 +1,209 @@ +package social + +import ( + "context" + crand "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "math/big" + "time" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" + + "scrabble/backend/internal/notify" + "scrabble/backend/internal/postgres/jet/backend/model" + "scrabble/backend/internal/postgres/jet/backend/table" +) + +const ( + // friendCodeTTL bounds how long an issued friend code stays redeemable. + friendCodeTTL = 12 * time.Hour + // friendCodeIssueRetries caps regeneration attempts when a freshly generated + // code collides (by hash) with another account's still-live code. + friendCodeIssueRetries = 5 +) + +// FriendCode is a freshly issued one-time add-a-friend code. The plaintext Code is +// returned exactly once (only its hash is persisted); the issuer shares it out of +// band and whoever redeems it becomes their friend immediately. +type FriendCode struct { + Code string + ExpiresAt time.Time +} + +// IssueFriendCode issues a fresh one-time friend code for accountID, replacing the +// account's prior live code (at most one is redeemable per issuer at a time). Only +// the hash is stored; the returned plaintext is the only copy. A collision with +// another account's live code triggers a regeneration so the redeem lookup stays +// unambiguous. +func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (FriendCode, error) { + expiresAt := svc.now().Add(friendCodeTTL) + for range friendCodeIssueRetries { + code, hash, err := generateFriendCode() + if err != nil { + return FriendCode{}, err + } + inserted, err := svc.store.replaceFriendCode(ctx, accountID, hash, expiresAt, svc.now()) + if err != nil { + return FriendCode{}, err + } + if inserted { + return FriendCode{Code: code, ExpiresAt: expiresAt}, nil + } + } + return FriendCode{}, fmt.Errorf("social: could not issue a unique friend code after %d tries", friendCodeIssueRetries) +} + +// RedeemFriendCode makes redeemerID a friend of the account that issued code, +// consuming the code. It returns the issuer's account id on success, or +// ErrFriendCodeInvalid (unknown/used/expired), ErrSelfRelation (own code), or +// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior +// decline between the two: it clears the old row and writes a fresh friendship. +func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) { + issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now()) + if err != nil { + return uuid.UUID{}, err + } + if issuerID == redeemerID { + return uuid.UUID{}, ErrSelfRelation + } + blocked, err := svc.store.isBlocked(ctx, redeemerID, issuerID) + if err != nil { + return uuid.UUID{}, err + } + if blocked { + return uuid.UUID{}, ErrRequestBlocked + } + if err := svc.store.redeemFriendCode(ctx, codeID, issuerID, redeemerID, svc.now()); err != nil { + return uuid.UUID{}, err + } + svc.pub.Publish(notify.Notification(issuerID, notify.NotifyFriendAdded)) + return issuerID, nil +} + +// replaceFriendCode clears accountID's prior live code and inserts a fresh one, +// inside one transaction. It reports false (without inserting) when codeHash +// collides with another still-live code, so the caller regenerates. +func (s *Store) replaceFriendCode(ctx context.Context, accountID uuid.UUID, codeHash string, expiresAt, now time.Time) (bool, error) { + id, err := uuid.NewV7() + if err != nil { + return false, fmt.Errorf("social: new friend code id: %w", err) + } + inserted := false + err = withTx(ctx, s.db, func(tx *sql.Tx) error { + del := table.FriendCodes.DELETE().WHERE( + table.FriendCodes.AccountID.EQ(postgres.UUID(accountID)). + AND(table.FriendCodes.ConsumedAt.IS_NULL()), + ) + if _, err := del.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("clear prior friend codes: %w", err) + } + var live []model.FriendCodes + sel := postgres.SELECT(table.FriendCodes.CodeID). + FROM(table.FriendCodes). + WHERE( + table.FriendCodes.CodeHash.EQ(postgres.String(codeHash)). + AND(table.FriendCodes.ConsumedAt.IS_NULL()). + AND(table.FriendCodes.ExpiresAt.GT(postgres.TimestampzT(now))), + ).LIMIT(1) + if err := sel.QueryContext(ctx, tx, &live); err != nil { + return fmt.Errorf("check friend code collision: %w", err) + } + if len(live) > 0 { + return nil // collision: leave inserted false so the caller retries + } + ins := table.FriendCodes.INSERT( + table.FriendCodes.CodeID, table.FriendCodes.AccountID, table.FriendCodes.CodeHash, table.FriendCodes.ExpiresAt, + ).VALUES(id, accountID, codeHash, expiresAt) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("insert friend code: %w", err) + } + inserted = true + return nil + }) + if err != nil { + return false, err + } + return inserted, nil +} + +// liveFriendCodeByHash returns the issuer and code id of the live (unconsumed, +// unexpired) code with codeHash, or ErrFriendCodeInvalid when none matches. +func (s *Store) liveFriendCodeByHash(ctx context.Context, codeHash string, now time.Time) (issuerID, codeID uuid.UUID, err error) { + stmt := postgres.SELECT(table.FriendCodes.CodeID, table.FriendCodes.AccountID). + FROM(table.FriendCodes). + WHERE( + table.FriendCodes.CodeHash.EQ(postgres.String(codeHash)). + AND(table.FriendCodes.ConsumedAt.IS_NULL()). + AND(table.FriendCodes.ExpiresAt.GT(postgres.TimestampzT(now))), + ).LIMIT(1) + var row model.FriendCodes + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return uuid.UUID{}, uuid.UUID{}, ErrFriendCodeInvalid + } + return uuid.UUID{}, uuid.UUID{}, fmt.Errorf("social: load friend code: %w", err) + } + return row.AccountID, row.CodeID, nil +} + +// redeemFriendCode consumes the code and writes an accepted friendship between +// issuer and redeemer, inside one transaction. It clears any prior pending/declined +// row between the pair first, so a code overrides an earlier decline. A code already +// consumed by a concurrent redeem yields ErrFriendCodeInvalid (rolling back). +func (s *Store) redeemFriendCode(ctx context.Context, codeID, issuer, redeemer uuid.UUID, now time.Time) error { + return withTx(ctx, s.db, func(tx *sql.Tx) error { + upd := table.FriendCodes. + UPDATE(table.FriendCodes.ConsumedAt). + SET(postgres.TimestampzT(now)). + WHERE( + table.FriendCodes.CodeID.EQ(postgres.UUID(codeID)). + AND(table.FriendCodes.ConsumedAt.IS_NULL()), + ) + res, err := upd.ExecContext(ctx, tx) + if err != nil { + return fmt.Errorf("consume friend code: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("consume friend code rows: %w", err) + } + if n == 0 { + return ErrFriendCodeInvalid + } + del := table.Friendships.DELETE().WHERE(edgeEither(issuer, redeemer)) + if _, err := del.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("clear friendship before code accept: %w", err) + } + ins := table.Friendships.INSERT( + table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, + table.Friendships.CreatedAt, table.Friendships.RespondedAt, + ).VALUES(issuer, redeemer, friendAccepted, now, now) + if _, err := ins.ExecContext(ctx, tx); err != nil { + return fmt.Errorf("insert friendship from code: %w", err) + } + return nil + }) +} + +// generateFriendCode returns a random 6-digit numeric code and its hex SHA-256 hash. +func generateFriendCode() (code, hash string, err error) { + n, err := crand.Int(crand.Reader, big.NewInt(1_000_000)) + if err != nil { + return "", "", fmt.Errorf("social: generate friend code: %w", err) + } + code = fmt.Sprintf("%06d", n.Int64()) + return code, hashFriendCode(code), nil +} + +// hashFriendCode returns the hex-encoded SHA-256 of a friend code; the plaintext is +// never persisted, matching the session and email-code models. +func hashFriendCode(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index 9cdc9c5..d879535 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "scrabble/backend/internal/account" + "scrabble/backend/internal/notify" "scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/table" ) @@ -19,12 +20,22 @@ import ( const ( friendPending = "pending" friendAccepted = "accepted" + friendDeclined = "declined" ) +// friendRequestTTL is how long an unanswered (ignored) friend request stays +// pending before it lazily expires and may be re-sent. An explicit decline is +// remembered permanently (status 'declined') instead and is not subject to this +// window; a one-time friend code from the addressee bypasses a decline. +const friendRequestTTL = 30 * 24 * time.Hour + // SendFriendRequest records a pending friend request from requesterID to -// addresseeID. It refuses a self-request, a request blocked by either a per-user -// block or the addressee's block_friend_requests toggle, and a duplicate of an -// existing request or friendship in either direction. +// addresseeID — the "befriend an opponent" path. It requires the two to share a +// game (active or finished) and refuses a self-request, a request across a block or +// the addressee's block_friend_requests toggle, a duplicate of a live request or an +// existing friendship, and a re-send after an explicit decline (ErrRequestDeclined). +// An ignored request that has lazily expired (friendRequestTTL) may be re-sent and +// reopens the existing row with a fresh clock. func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error { if requesterID == addresseeID { return ErrSelfRelation @@ -43,32 +54,69 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse if blocked || addressee.BlockFriendRequests { return ErrRequestBlocked } - exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID) + shared, err := svc.games.SharedGame(ctx, requesterID, addresseeID) if err != nil { return err } - if exists { - return ErrRequestExists + if !shared { + return ErrNoSharedGame } - if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil { + edges, err := svc.store.loadEdges(ctx, requesterID, addresseeID) + if err != nil { + return err + } + cutoff := svc.now().Add(-friendRequestTTL) + for _, e := range edges { + // Already friends, or the addressee already has a live request awaiting the + // requester — in both cases there is nothing to (re-)send. + if e.Status == friendAccepted { + return ErrRequestExists + } + if e.RequesterID == addresseeID && e.Status == friendPending && e.CreatedAt.After(cutoff) { + return ErrRequestExists + } + } + for _, e := range edges { + if e.RequesterID != requesterID { + continue + } + switch e.Status { + case friendDeclined: + return ErrRequestDeclined + case friendPending: + if e.CreatedAt.After(cutoff) { + return ErrRequestExists + } + // An ignored request that has expired — reopen it with a fresh clock. + if err := svc.store.refreshFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil { + return err + } + svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest)) + return nil + } + } + if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID, svc.now()); err != nil { if isUniqueViolation(err) { return ErrRequestExists } return err } + svc.pub.Publish(notify.Notification(addresseeID, notify.NotifyFriendRequest)) return nil } // RespondFriendRequest lets addresseeID accept or decline the pending request -// from requesterID. Accepting flips it to a friendship; declining deletes it. -// Either way ErrRequestNotFound is returned when no pending request matches. +// from requesterID. Accepting flips it to a friendship; declining records a +// permanent 'declined' status (so the same requester cannot re-send), rather than +// deleting the row. Either way ErrRequestNotFound is returned when no pending +// request matches. func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error { var ok bool var err error if accept { ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now()) } else { - ok, err = svc.store.deletePendingRequest(ctx, requesterID, addresseeID) + ok, err = svc.store.declineFriendRequest(ctx, requesterID, addresseeID, svc.now()) } if err != nil { return err @@ -102,34 +150,31 @@ func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uui return svc.store.listFriends(ctx, accountID) } -// ListIncomingRequests returns the account IDs that have a pending friend request -// awaiting accountID's response. +// ListIncomingRequests returns the account IDs that have a live (not yet expired) +// pending friend request awaiting accountID's response. func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { - return svc.store.listIncomingRequests(ctx, accountID) + return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) } -// friendshipExists reports whether any friendship row (pending or accepted) exists -// between a and b in either direction. -func (s *Store) friendshipExists(ctx context.Context, a, b uuid.UUID) (bool, error) { - stmt := postgres.SELECT(table.Friendships.Status). +// loadEdges returns every friendship row between a and b in either direction (at +// most one per direction). It feeds SendFriendRequest's re-send classification. +func (s *Store) loadEdges(ctx context.Context, a, b uuid.UUID) ([]model.Friendships, error) { + stmt := postgres.SELECT(table.Friendships.AllColumns). FROM(table.Friendships). - WHERE(edgeEither(a, b)). - LIMIT(1) - var row model.Friendships - if err := stmt.QueryContext(ctx, s.db, &row); err != nil { - if errors.Is(err, qrm.ErrNoRows) { - return false, nil - } - return false, fmt.Errorf("social: friendship exists: %w", err) + WHERE(edgeEither(a, b)) + var rows []model.Friendships + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("social: load friendship edges: %w", err) } - return true, nil + return rows, nil } -// insertFriendRequest inserts a pending request from requester to addressee. -func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error { +// insertFriendRequest inserts a pending request from requester to addressee, +// stamping created_at so the lazy-expiry clock is deterministic under a fake now. +func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error { stmt := table.Friendships.INSERT( - table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, - ).VALUES(requester, addressee, friendPending) + table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status, table.Friendships.CreatedAt, + ).VALUES(requester, addressee, friendPending, now) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("social: insert friend request: %w", err) } @@ -151,6 +196,7 @@ func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uu } // deletePendingRequest removes a pending request and reports whether a row matched. +// It backs the requester's own cancel (which leaves no trace, unlike a decline). func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) { stmt := table.Friendships.DELETE().WHERE( table.Friendships.RequesterID.EQ(postgres.UUID(requester)). @@ -160,6 +206,38 @@ func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee u return execAffected(ctx, s.db, stmt, "social: delete friend request") } +// declineFriendRequest marks a pending request from requester to addressee as +// permanently declined (so the requester cannot re-send) and reports whether a row +// matched. +func (s *Store) declineFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) { + stmt := table.Friendships. + UPDATE(table.Friendships.Status, table.Friendships.RespondedAt). + SET(postgres.String(friendDeclined), postgres.TimestampzT(now)). + WHERE( + table.Friendships.RequesterID.EQ(postgres.UUID(requester)). + AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))). + AND(table.Friendships.Status.EQ(postgres.String(friendPending))), + ) + return execAffected(ctx, s.db, stmt, "social: decline friend request") +} + +// refreshFriendRequest resets an expired pending request's created_at so it counts +// as freshly sent again. +func (s *Store) refreshFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) error { + stmt := table.Friendships. + UPDATE(table.Friendships.CreatedAt). + SET(postgres.TimestampzT(now)). + WHERE( + table.Friendships.RequesterID.EQ(postgres.UUID(requester)). + AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))). + AND(table.Friendships.Status.EQ(postgres.String(friendPending))), + ) + if _, err := stmt.ExecContext(ctx, s.db); err != nil { + return fmt.Errorf("social: refresh friend request: %w", err) + } + return nil +} + // deleteFriendship removes an accepted friendship in either direction. func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error { stmt := table.Friendships.DELETE().WHERE( @@ -195,13 +273,15 @@ func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UU return out, nil } -// listIncomingRequests returns the requesters of every pending request to accountID. -func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { +// listIncomingRequests returns the requesters of every live (created after cutoff) +// pending request to accountID; lazily expired requests are hidden. +func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) { stmt := postgres.SELECT(table.Friendships.RequesterID). FROM(table.Friendships). WHERE( table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)). - AND(table.Friendships.Status.EQ(postgres.String(friendPending))), + AND(table.Friendships.Status.EQ(postgres.String(friendPending))). + AND(table.Friendships.CreatedAt.GT(postgres.TimestampzT(cutoff))), ) var rows []model.Friendships if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go index 9c70006..7f97f1f 100644 --- a/backend/internal/social/social.go +++ b/backend/internal/social/social.go @@ -19,11 +19,15 @@ import ( ) // GameReader is the slice of the game domain the social package needs: the seated -// accounts in seat order, the seat index whose turn it is, and the game status. -// game.Service satisfies it, so chat and nudge gate on game state without a -// dependency on the engine or the game's private state. +// accounts in seat order, the seat index whose turn it is, and the game status, plus +// a shared-game test. game.Service satisfies it, so chat, nudge and the +// befriend-an-opponent gate work without a dependency on the engine or the game's +// private state. type GameReader interface { Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error) + // SharedGame reports whether two accounts are seated together in any game + // (active or finished); it gates the "befriend an opponent" request path. + SharedGame(ctx context.Context, a, b uuid.UUID) (bool, error) } // Sentinel errors returned by the service. @@ -38,6 +42,16 @@ var ( ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests") // ErrRequestNotFound is returned when no pending friend request matches. ErrRequestNotFound = errors.New("social: no pending friend request") + // ErrNoSharedGame is returned when a friend request targets someone the + // requester has never shared a game with (the befriend-an-opponent gate). + ErrNoSharedGame = errors.New("social: you can only request someone you have played with") + // ErrRequestDeclined is returned when the addressee has previously declined a + // request from this requester; a re-send is refused (a one-time friend code + // from the addressee bypasses this). + ErrRequestDeclined = errors.New("social: this person has declined your friend request") + // ErrFriendCodeInvalid is returned when a redeemed friend code is unknown, + // already used, or expired. + ErrFriendCodeInvalid = errors.New("social: friend code is invalid or expired") // ErrNotParticipant is returned when an account is not seated in the game. ErrNotParticipant = errors.New("social: account is not a player in this game") // ErrChatBlocked is returned when the sender has disabled chat for themselves. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bc9ca4e..e9998c5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -269,10 +269,17 @@ requires (there is no DM surface; chat is per-game). robot (§7) and starts the game. On a pairing or substitution the matchmaker emits a **match-found** notification (§10), delivered over the live stream; `Poll` remains as a fallback for a client that is not currently streaming. -- **Friends**: a **request → accept** graph (one `friendships` table) — add by - friend list or internal ID now, by platform deep-link with Stage 9. Declining or - cancelling removes the pending request; blocking someone severs an existing - friendship. +- **Friends** (Stage 8): two add paths over one `friendships` table. A **one-time + code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, + SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem + rate-limited) is redeemed by the other player to become friends immediately. + Alternatively a **request → accept** is sent to someone you **share a game with** + (active or finished); the recipient may accept, ignore (the pending row lazily + expires after **30 days** and may be re-sent), or **decline** — a decline is + remembered (`status='declined'`) and blocks further requests from that sender, + unless they hand them a code, which overrides it. The requester's own cancel still + deletes the row; blocking someone severs an existing friendship. (Discovery by + friend list or platform deep-link arrives with Stage 9 / TODO-5.) - **Block**: two independent **global** account toggles (`block_chat`, `block_friend_requests`) **plus** a **per-user block list**. A per-user block is applied mutually: it hides the pair's chat from each other and refuses friend @@ -316,8 +323,9 @@ requires (there is no DM surface; chat is per-game). Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks` (per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations` (pending confirm-codes) and `game_invitations` / `game_invitation_invitees` - (friend-game invitations). The matchmaking pool is **in-memory** and persists - nothing. + (friend-game invitations). Stage 8's migration `00006` widened the `friendships` + status to admit `declined` and added `friend_codes` (one-time add-a-friend codes). + The matchmaking pool is **in-memory** and persists nothing. - **Active games are event-sourced.** A game is a `games` row (pinned `variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised turn cursor) plus an append-only, decoded move journal (`game_moves`); the live @@ -352,7 +360,9 @@ the same rows and is likewise self-contained — we ship our own writer (the sol exposes none): the standard Poslfit dialect (UTF-8, `#player`/`#lexicon` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` exchanges), plus `#note` lines for resignations and timeouts, which the standard -does not cover. +does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive` +otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client +shares the `.gcg` file via the Web Share API where available, else downloads it. ## 10. Notifications @@ -365,12 +375,17 @@ services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`, `user_id` to each client's Connect `Subscribe` stream while the app is open. The catalog is **your-turn** and **opponent-moved** (emitted from the game commit, so robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge** -(from the social service), and **match-found** (from the matchmaker, §8). Event -payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client -that is not currently streaming falls back to the matchmaker's `Poll` for -match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 9; -session-revocation events and cursor-based stream resume are deferred -(single-instance MVP). +(from the social service), **match-found** (from the matchmaker, §8), and **notify** +(Stage 8 — a lightweight "re-poll" signal carrying a sub-kind: friend-request, +friend-added, invitation or game-started; emitted on a friend-request and invitation +create and on an invitation's game start). Event payloads are FlatBuffers-encoded by +the backend and forwarded verbatim. A client that is not currently streaming falls +back to the matchmaker's `Poll` for match-found and, for the lobby **notification +badge** (incoming friend requests + open invitations), the client polls on lobby +open and on focus as well as re-polling on the `notify` event — covering a push +missed while the app was hidden. Out-of-app platform push (your-turn, nudge) is +wired in Stage 9; session-revocation events and cursor-based stream resume are +deferred (single-instance MVP). A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md). It is a client-side **mock** rotation today; a server-driven source (operational notices, diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1dc59ed..88a2b79 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -15,10 +15,10 @@ The web/app client (Svelte + Vite) realizes these stories. The **playable slice* auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign), the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge, real-time in-app updates, switching interface language (en/ru) and theme, and a -read-only profile. Managing friends and blocks, creating friend games (invitations), -editing the profile, the statistics screen and the history/GCG viewer arrive in -Stage 8. Settings also pick the board's bonus-label style (beginner / classic / -none). A hint **lays the suggested tiles on the board** for the player to confirm and +read-only profile. **Stage 8** adds managing friends (including one-time friend +codes) and blocks, friend-game invitations, editing the profile and binding an +email, the statistics screen, and the in-game history viewer with GCG export. +Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. @@ -42,10 +42,10 @@ account (stats summed, games/friends transferred). Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a per-variant pool and is paired with the next waiting human; after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are -formed by inviting players from the friend list or by internal ID (deep-link -invites arrive with the platform integration): the inviter chooses the settings -and the game starts once every invitee has accepted — any decline cancels it, and -an unanswered invitation expires after seven days. +formed by inviting players from the friend list (deep-link invites arrive with the +platform integration): the inviter chooses the settings and the game starts once +every invitee has accepted — any decline cancels it, and an unanswered invitation +expires after seven days. ### Playing a game *(Stage 3)* Place tiles, pass, exchange, or resign. A play is validated against the game's @@ -74,9 +74,13 @@ one, and a night-time pause that tracks the player's own day. It answers a nudge within a few minutes and nudges back when the player has been away a long time. It carries a human-like name and neither chats nor accepts friend requests. -### Social: friends, block, chat, nudge *(Stage 4)* -Send a friend request and have it accepted (decline or cancel withdraws it, -unfriending removes the friendship). Block globally — switch off incoming chat +### Social: friends, block, chat, nudge *(Stage 4 / 8)* +Become friends in two ways: redeem a **one-time code** the other player issues (six +digits, valid for twelve hours), or send a **request to someone you have played +with** — they accept, ignore it (a request lapses after thirty days and can then be +re-sent), or decline (a decline blocks further requests from you until they hand you +a code). Cancelling your own pending request withdraws it; unfriending removes the +friendship. Block globally — switch off incoming chat and/or friend requests — and block individual players (a per-user block hides that person's chat and stops requests and game invitations both ways; it also ends any existing friendship). Per-game chat is for quick reactions: messages are short @@ -91,11 +95,13 @@ once entered, attaches the email to the account (an email already confirmed by another account cannot be taken — that is a merge, a later stage). Linked platform accounts and merge arrive in Stage 11. -### History & statistics *(Stage 3)* +### History & statistics *(Stage 3 / 8)* Finished games are archived in a dictionary-independent form and exportable to -GCG. Statistics (durable accounts only): wins, losses, draws, max points in a -game, and max points for a single move (the best play, which already includes -every word it formed plus the all-tiles bonus). +GCG; the export is offered **only once a game is finished** (exporting a live game +would leak the move journal), and the client shares the `.gcg` file where the +platform supports it, otherwise downloads it. Statistics (durable accounts only): +wins, losses, draws, max points in a game, and max points for a single move (the +best play, which already includes every word it formed plus the all-tiles bonus). ### Administration *(Stage 10)* Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ec4efd1..d5598b2 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -14,9 +14,10 @@ игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача), top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии, обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и -профиль только для чтения. Управление друзьями и блоками, создание дружеских игр -(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG -появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток +профиль только для чтения. **Stage 8** добавляет управление друзьями (в т.ч. +одноразовые коды-приглашения) и блоками, дружеские приглашения в игру, +редактирование профиля и привязку email, экран статистики и просмотр истории +партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток (новичок / классика / без текста). Подсказка **выставляет предложенные фишки на доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии @@ -42,10 +43,10 @@ session-токен; backend сопоставляет его с внутренн Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4) -формируются приглашением игроков из списка друзей или по внутреннему ID -(приглашения по deep-link появятся с платформенной интеграцией): инициатор -выбирает настройки, и партия стартует, когда приняли все приглашённые — любой -отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. +формируются приглашением игроков из списка друзей (приглашения по deep-link +появятся с платформенной интеграцией): инициатор выбирает настройки, и партия +стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без +ответа приглашение протухает через семь дней. ### Игровой процесс *(Stage 3)* Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при @@ -74,9 +75,14 @@ session-токен; backend сопоставляет его с внутренн сам шлёт nudge, когда игрок надолго пропал. Носит человекоподобное имя, не общается в чате и не принимает заявки в друзья. -### Социальное: друзья, блок, чат, nudge *(Stage 4)* -Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление — -расторгает дружбу). Глобальная блокировка — отключить входящие чат и/или заявки — +### Социальное: друзья, блок, чат, nudge *(Stage 4 / 8)* +Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает +другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку +тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать +дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши +повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки +снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие +чат и/или заявки — и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны @@ -92,11 +98,13 @@ confirm-коду: backend шлёт на почту короткий код, и нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и слияние появятся в Stage 11. -### История и статистика *(Stage 3)* +### История и статистика *(Stage 3 / 8)* Завершённые партии архивируются в независимом от словаря виде и экспортируются -в GCG. Статистика (только у постоянных аккаунтов): победы, поражения, ничьи, -макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все -образованные им слова и бонус за все фишки). +в GCG; экспорт доступен **только после завершения партии** (экспорт идущей партии +раскрыл бы журнал ходов), и клиент делится файлом `.gcg` там, где платформа это +поддерживает, иначе скачивает его. Статистика (только у постоянных аккаунтов): +победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший +ход, уже включающий все образованные им слова и бонус за все фишки). ### Администрирование *(Stage 10)* Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями diff --git a/docs/TESTING.md b/docs/TESTING.md index cd6d2f9..a0c135f 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -15,7 +15,12 @@ tests or touching CI. `go test -tags=integration -count=1 -p=1 ./backend/...` (needs Docker), guarded by a separate CI workflow (`integration.yaml`; Ryuk disabled, serial). Slow. - **UI** *(introduced with the UI in Stage 7)* — Vitest (unit) + Playwright - (e2e), mirroring the chosen plain-Svelte + Vite toolchain. + (e2e), mirroring the chosen plain-Svelte + Vite toolchain. Stage 8 adds Vitest for + the new FlatBuffers codecs (friend list, invitation, stats), the win-rate + derivation and the GCG share/download choice, plus Playwright specs against the + mock for the friends screen (code issue/redeem, accept a request), the lobby + invitations section, the stats screen, profile editing, and the GCG export's + finished-only visibility. - **Engine** *(Stage 2+)* — correctness of scoring and move generation is owned by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on top of the embedded solver: per-variant smoke tests (load all three committed @@ -48,7 +53,11 @@ tests or touching CI. content and block-visibility rules, the nudge turn/rate-limit rules, the invitation flow (all-accept starts the game, decline cancels, lazy expiry, inviter-only cancel), and the email confirm-code flow (request/confirm, taken - email, expiry and attempt-cap) with a fixture mailer. + email, expiry and attempt-cap) with a fixture mailer. Stage 8 adds the + **befriend-an-opponent** gate (a request needs a shared game), the **permanent + decline** and 30-day re-send rule, the **one-time friend code** (issue/redeem, + self/single-use, decline-bypass), `ListInvitations`, the zero-value `GetStats`, and + the GCG **finished-only** gate. - **Robot** *(Stage 5+)* — `backend/internal/robot` unit-tests the pure strategy: the ≈ 40% play-to-win split over many seeds, the right-skewed move-delay (bounds, ~10-min median, determinism), the margin selection (win/lose, in-band @@ -70,7 +79,9 @@ tests or touching CI. (guest auth, unauthenticated rejection, unknown message type). The backend gains the **guest** lifecycle (a guest plays an auto-match to a natural end yet accrues no statistics) and the **email-as-login** flow (request/verify, returning user) - in `inttest`. + in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account + operations (friends list, friend code issue/redeem, invitation create, stats, GCG, + the profile-update away round-trip) and a `notify`-event constructor round-trip. ## Principles diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 719a251..82c5051 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -22,7 +22,11 @@ Login uses `Screen`. - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` `.chev`) — lighter than a glyph. - **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown - of items (lobby: Profile/Settings/About; game: History/Chat/Check word/Drop game). + of items (lobby: Friends/Profile/Settings/About; game: History/Chat/Check word, plus + *Export GCG* on a finished game and *Add to friends* per opponent, then Drop game). A + red count **badge** rides the hamburger (and the lobby *Friends* item) for pending + incoming friend requests + invitations; the same dot style serves any future + notification count. - **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large emoji icon over a tiny truncated label. A press highlights a rounded **square** behind the icon (slightly larger than it) until release; spacing keeps adjacent labels from @@ -77,6 +81,28 @@ Lobby rows show two lines (opponents, then result + score) with a large place-ba on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 / IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. +## Social, account & history surfaces (Stage 8) + +- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block + pairing a code **input** with a **Show my code** action that reveals a large 6-digit + code + its expiry; then the incoming **requests** (Accept / Decline), the **friends** + list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a + guest sees a sign-in prompt. +- **Invitations**: a lobby **section** (a 💌 row per open invitation) with Accept / + Decline for an invitee and a waiting/Cancel state for the inviter; creating one is the + **"Play with friends"** mode in `NewGame.svelte` (pick invitees, then variant / move + time / hints). +- **Statistics** (`screens/Stats.svelte`, the lobby 📊 tab): a 2-column grid of stat + cards (wins / losses / draws / games / win-rate / best game / best move) — pure + numbers, no charts. +- **Profile editing** (`screens/Profile.svelte`): an inline form (display name, timezone, + the away-window time pickers, block toggles) and an email-binding sub-flow (enter email + → enter the confirm code). Interface language stays in **Settings** (it writes through + to the account for durable users). +- **History / GCG**: the in-game slide-down history gains the running total per move; + *Export GCG* shares or downloads the `.gcg` file and appears only once the game is + finished. + ## Caveat Emoji are rendered by the platform's system emoji font, so their exact look varies across diff --git a/gateway/README.md b/gateway/README.md index a96d6fd..bd4d4e5 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -42,8 +42,12 @@ failures become Connect error codes. The Stage 6 message-type slice: `auth.telegram`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events -`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. Further -operations follow the same transcode pattern (added in Stage 7). +`your_turn`, `opponent_moved`, `chat_message`, `nudge`, `match_found`. Stage 7 +added the play-loop ops; **Stage 8** added the social/account/history ops — +`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem), +`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`, +`email.bind.*`, `stats.get`, `game.gcg`, and the `notify` live event — all via the +identical transcode pattern (`transcode_social.go`). ## Configuration diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 5c2afad..68a83bf 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -24,6 +24,8 @@ type ProfileResp struct { DisplayName string `json:"display_name"` PreferredLanguage string `json:"preferred_language"` TimeZone string `json:"time_zone"` + AwayStart string `json:"away_start"` + AwayEnd string `json:"away_end"` HintBalance int `json:"hint_balance"` BlockChat bool `json:"block_chat"` BlockFriendRequests bool `json:"block_friend_requests"` diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go new file mode 100644 index 0000000..ff6ddd5 --- /dev/null +++ b/gateway/internal/backendclient/api_social.go @@ -0,0 +1,256 @@ +package backendclient + +import ( + "context" + "net/http" + "net/url" +) + +// The Stage 8 response structs and client methods mirror the backend's social, +// account and history JSON DTOs. The transcode layer maps them to FlatBuffers. + +// AccountRefResp is a referenced account with its display name resolved. +type AccountRefResp struct { + AccountID string `json:"account_id"` + DisplayName string `json:"display_name"` +} + +// FriendListResp is the caller's accepted friends. +type FriendListResp struct { + Friends []AccountRefResp `json:"friends"` +} + +// IncomingListResp is the friend requests awaiting the caller. +type IncomingListResp struct { + Requests []AccountRefResp `json:"requests"` +} + +// FriendCodeResp is a freshly issued one-time friend code. +type FriendCodeResp struct { + Code string `json:"code"` + ExpiresAtUnix int64 `json:"expires_at_unix"` +} + +// RedeemResultResp reports the friend gained by redeeming a code. +type RedeemResultResp struct { + Friend AccountRefResp `json:"friend"` +} + +// BlockListResp is the accounts the caller has blocked. +type BlockListResp struct { + Blocked []AccountRefResp `json:"blocked"` +} + +// StatsResp is a durable account's lifetime statistics. +type StatsResp struct { + Wins int `json:"wins"` + Losses int `json:"losses"` + Draws int `json:"draws"` + MaxGamePoints int `json:"max_game_points"` + MaxWordPoints int `json:"max_word_points"` +} + +// InvitationInviteeResp is one invitee's seat and response with their name. +type InvitationInviteeResp struct { + AccountID string `json:"account_id"` + DisplayName string `json:"display_name"` + Seat int `json:"seat"` + Response string `json:"response"` +} + +// InvitationResp is a friend-game invitation with its settings and invitees. +type InvitationResp struct { + ID string `json:"id"` + Inviter AccountRefResp `json:"inviter"` + Invitees []InvitationInviteeResp `json:"invitees"` + Variant string `json:"variant"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + HintsAllowed bool `json:"hints_allowed"` + HintsPerPlayer int `json:"hints_per_player"` + DropoutTiles string `json:"dropout_tiles"` + Status string `json:"status"` + GameID string `json:"game_id"` + ExpiresAtUnix int64 `json:"expires_at_unix"` +} + +// InvitationListResp is the caller's open invitations. +type InvitationListResp struct { + Invitations []InvitationResp `json:"invitations"` +} + +// GcgResp is a finished game's GCG export. +type GcgResp struct { + GameID string `json:"game_id"` + Filename string `json:"filename"` + Content string `json:"content"` +} + +// InvitationParams are the settings the inviter chooses for a friend game. +type InvitationParams struct { + InviteeIDs []string + Variant string + TurnTimeoutSecs int + HintsAllowed bool + HintsPerPlayer int + DropoutTiles string +} + +// --- friends --- + +// SendFriendRequest sends a friend request to a played opponent. +func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/friends/request", userID, "", + map[string]string{"account_id": targetID}, nil) +} + +// RespondFriendRequest accepts or declines an incoming request. +func (c *Client) RespondFriendRequest(ctx context.Context, userID, requesterID string, accept bool) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/friends/respond", userID, "", + map[string]any{"requester_id": requesterID, "accept": accept}, nil) +} + +// CancelFriendRequest withdraws the caller's own pending request. +func (c *Client) CancelFriendRequest(ctx context.Context, userID, targetID string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/friends/cancel", userID, "", + map[string]string{"account_id": targetID}, nil) +} + +// Unfriend removes a friendship. +func (c *Client) Unfriend(ctx context.Context, userID, targetID string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/user/friends/"+url.PathEscape(targetID), userID, "", nil, nil) +} + +// ListFriends returns the caller's accepted friends. +func (c *Client) ListFriends(ctx context.Context, userID string) (FriendListResp, error) { + var out FriendListResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/friends", userID, "", nil, &out) + return out, err +} + +// ListIncoming returns the friend requests awaiting the caller. +func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListResp, error) { + var out IncomingListResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/incoming", userID, "", nil, &out) + return out, err +} + +// IssueFriendCode issues a one-time friend code for the caller. +func (c *Client) IssueFriendCode(ctx context.Context, userID string) (FriendCodeResp, error) { + var out FriendCodeResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/friends/code", userID, "", struct{}{}, &out) + return out, err +} + +// RedeemFriendCode redeems a friend code, befriending its issuer. +func (c *Client) RedeemFriendCode(ctx context.Context, userID, code string) (RedeemResultResp, error) { + var out RedeemResultResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/friends/code/redeem", userID, "", + map[string]string{"code": code}, &out) + return out, err +} + +// --- blocks --- + +// Block blocks an account. +func (c *Client) Block(ctx context.Context, userID, targetID string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/blocks", userID, "", + map[string]string{"account_id": targetID}, nil) +} + +// Unblock removes a block. +func (c *Client) Unblock(ctx context.Context, userID, targetID string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/user/blocks/"+url.PathEscape(targetID), userID, "", nil, nil) +} + +// ListBlocks returns the accounts the caller has blocked. +func (c *Client) ListBlocks(ctx context.Context, userID string) (BlockListResp, error) { + var out BlockListResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/blocks", userID, "", nil, &out) + return out, err +} + +// --- invitations --- + +// CreateInvitation proposes a friend game to the named invitees. +func (c *Client) CreateInvitation(ctx context.Context, userID string, p InvitationParams) (InvitationResp, error) { + var out InvitationResp + body := map[string]any{ + "invitee_ids": p.InviteeIDs, + "variant": p.Variant, + "turn_timeout_secs": p.TurnTimeoutSecs, + "hints_allowed": p.HintsAllowed, + "hints_per_player": p.HintsPerPlayer, + "dropout_tiles": p.DropoutTiles, + } + err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations", userID, "", body, &out) + return out, err +} + +// RespondInvitation accepts or declines an invitation by id. +func (c *Client) RespondInvitation(ctx context.Context, userID, invitationID string, accept bool) (InvitationResp, error) { + var out InvitationResp + action := "/decline" + if accept { + action = "/accept" + } + err := c.do(ctx, http.MethodPost, "/api/v1/user/invitations/"+url.PathEscape(invitationID)+action, userID, "", struct{}{}, &out) + return out, err +} + +// CancelInvitation withdraws the caller's own invitation. +func (c *Client) CancelInvitation(ctx context.Context, userID, invitationID string) error { + return c.do(ctx, http.MethodDelete, "/api/v1/user/invitations/"+url.PathEscape(invitationID), userID, "", nil, nil) +} + +// ListInvitations returns the caller's open invitations. +func (c *Client) ListInvitations(ctx context.Context, userID string) (InvitationListResp, error) { + var out InvitationListResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/invitations", userID, "", nil, &out) + return out, err +} + +// --- profile, email, stats, gcg --- + +// UpdateProfile overwrites the caller's editable profile and returns it. +func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp) (ProfileResp, error) { + var out ProfileResp + body := map[string]any{ + "display_name": p.DisplayName, + "preferred_language": p.PreferredLanguage, + "time_zone": p.TimeZone, + "away_start": p.AwayStart, + "away_end": p.AwayEnd, + "block_chat": p.BlockChat, + "block_friend_requests": p.BlockFriendRequests, + } + err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out) + return out, err +} + +// EmailBindRequest asks the backend to mail a confirm-code binding email. +func (c *Client) EmailBindRequest(ctx context.Context, userID, email string) error { + return c.do(ctx, http.MethodPost, "/api/v1/user/email/request", userID, "", + map[string]string{"email": email}, nil) +} + +// EmailBindConfirm verifies the code and binds the email, returning the profile. +func (c *Client) EmailBindConfirm(ctx context.Context, userID, email, code string) (ProfileResp, error) { + var out ProfileResp + err := c.do(ctx, http.MethodPost, "/api/v1/user/email/confirm", userID, "", + map[string]string{"email": email, "code": code}, &out) + return out, err +} + +// Stats returns the caller's lifetime statistics. +func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) { + var out StatsResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/stats", userID, "", nil, &out) + return out, err +} + +// ExportGCG returns a finished game's GCG transcript. +func (c *Client) ExportGCG(ctx context.Context, userID, gameID string) (GcgResp, error) { + var out GcgResp + err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/gcg"), userID, "", nil, &out) + return out, err +} diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 12fa03d..456c571 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -43,6 +43,8 @@ func encodeProfile(p backendclient.ProfileResp) []byte { name := b.CreateString(p.DisplayName) lang := b.CreateString(p.PreferredLanguage) tz := b.CreateString(p.TimeZone) + awayStart := b.CreateString(p.AwayStart) + awayEnd := b.CreateString(p.AwayEnd) fb.ProfileStart(b) fb.ProfileAddUserId(b, uid) fb.ProfileAddDisplayName(b, name) @@ -52,6 +54,8 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddBlockChat(b, p.BlockChat) fb.ProfileAddBlockFriendRequests(b, p.BlockFriendRequests) fb.ProfileAddIsGuest(b, p.IsGuest) + fb.ProfileAddAwayStart(b, awayStart) + fb.ProfileAddAwayEnd(b, awayEnd) b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go new file mode 100644 index 0000000..f6b6dca --- /dev/null +++ b/gateway/internal/transcode/encode_social.go @@ -0,0 +1,179 @@ +package transcode + +import ( + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/backendclient" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// Stage 8 encoders: friends, blocks, invitations, statistics and GCG. They follow +// encode.go's bottom-up rule (build every string/child vector before the table). + +// buildAccountRef builds an AccountRef table and returns its offset. +func buildAccountRef(b *flatbuffers.Builder, r backendclient.AccountRefResp) flatbuffers.UOffsetT { + id := b.CreateString(r.AccountID) + name := b.CreateString(r.DisplayName) + fb.AccountRefStart(b) + fb.AccountRefAddAccountId(b, id) + fb.AccountRefAddDisplayName(b, name) + return fb.AccountRefEnd(b) +} + +// buildAccountRefVector builds a [AccountRef] vector using the table-specific +// StartXVector function and returns the vector offset. +func buildAccountRefVector(b *flatbuffers.Builder, refs []backendclient.AccountRefResp, start func(*flatbuffers.Builder, int) flatbuffers.UOffsetT) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(refs)) + for i, r := range refs { + offs[i] = buildAccountRef(b, r) + } + start(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + +// encodeFriendList builds a FriendList payload. +func encodeFriendList(r backendclient.FriendListResp) []byte { + b := flatbuffers.NewBuilder(256) + v := buildAccountRefVector(b, r.Friends, fb.FriendListStartFriendsVector) + fb.FriendListStart(b) + fb.FriendListAddFriends(b, v) + b.Finish(fb.FriendListEnd(b)) + return b.FinishedBytes() +} + +// encodeIncomingList builds an IncomingRequestList payload. +func encodeIncomingList(r backendclient.IncomingListResp) []byte { + b := flatbuffers.NewBuilder(256) + v := buildAccountRefVector(b, r.Requests, fb.IncomingRequestListStartRequestsVector) + fb.IncomingRequestListStart(b) + fb.IncomingRequestListAddRequests(b, v) + b.Finish(fb.IncomingRequestListEnd(b)) + return b.FinishedBytes() +} + +// encodeBlockList builds a BlockList payload. +func encodeBlockList(r backendclient.BlockListResp) []byte { + b := flatbuffers.NewBuilder(256) + v := buildAccountRefVector(b, r.Blocked, fb.BlockListStartBlockedVector) + fb.BlockListStart(b) + fb.BlockListAddBlocked(b, v) + b.Finish(fb.BlockListEnd(b)) + return b.FinishedBytes() +} + +// encodeFriendCode builds a FriendCode payload. +func encodeFriendCode(r backendclient.FriendCodeResp) []byte { + b := flatbuffers.NewBuilder(64) + code := b.CreateString(r.Code) + fb.FriendCodeStart(b) + fb.FriendCodeAddCode(b, code) + fb.FriendCodeAddExpiresAtUnix(b, r.ExpiresAtUnix) + b.Finish(fb.FriendCodeEnd(b)) + return b.FinishedBytes() +} + +// encodeRedeemResult builds a RedeemResult payload. +func encodeRedeemResult(r backendclient.RedeemResultResp) []byte { + b := flatbuffers.NewBuilder(128) + friend := buildAccountRef(b, r.Friend) + fb.RedeemResultStart(b) + fb.RedeemResultAddFriend(b, friend) + b.Finish(fb.RedeemResultEnd(b)) + return b.FinishedBytes() +} + +// encodeStats builds a StatsView payload. +func encodeStats(r backendclient.StatsResp) []byte { + b := flatbuffers.NewBuilder(64) + fb.StatsViewStart(b) + fb.StatsViewAddWins(b, int32(r.Wins)) + fb.StatsViewAddLosses(b, int32(r.Losses)) + fb.StatsViewAddDraws(b, int32(r.Draws)) + fb.StatsViewAddMaxGamePoints(b, int32(r.MaxGamePoints)) + fb.StatsViewAddMaxWordPoints(b, int32(r.MaxWordPoints)) + b.Finish(fb.StatsViewEnd(b)) + return b.FinishedBytes() +} + +// buildInvitation builds an Invitation table and returns its offset. +func buildInvitation(b *flatbuffers.Builder, inv backendclient.InvitationResp) flatbuffers.UOffsetT { + inviteeOffs := make([]flatbuffers.UOffsetT, len(inv.Invitees)) + for i, iv := range inv.Invitees { + aid := b.CreateString(iv.AccountID) + name := b.CreateString(iv.DisplayName) + resp := b.CreateString(iv.Response) + fb.InvitationInviteeStart(b) + fb.InvitationInviteeAddAccountId(b, aid) + fb.InvitationInviteeAddDisplayName(b, name) + fb.InvitationInviteeAddSeat(b, int32(iv.Seat)) + fb.InvitationInviteeAddResponse(b, resp) + inviteeOffs[i] = fb.InvitationInviteeEnd(b) + } + fb.InvitationStartInviteesVector(b, len(inviteeOffs)) + for i := len(inviteeOffs) - 1; i >= 0; i-- { + b.PrependUOffsetT(inviteeOffs[i]) + } + invitees := b.EndVector(len(inviteeOffs)) + + inviter := buildAccountRef(b, inv.Inviter) + id := b.CreateString(inv.ID) + variant := b.CreateString(inv.Variant) + dropout := b.CreateString(inv.DropoutTiles) + status := b.CreateString(inv.Status) + gameID := b.CreateString(inv.GameID) + fb.InvitationStart(b) + fb.InvitationAddId(b, id) + fb.InvitationAddInviter(b, inviter) + fb.InvitationAddInvitees(b, invitees) + fb.InvitationAddVariant(b, variant) + fb.InvitationAddTurnTimeoutSecs(b, int32(inv.TurnTimeoutSecs)) + fb.InvitationAddHintsAllowed(b, inv.HintsAllowed) + fb.InvitationAddHintsPerPlayer(b, int32(inv.HintsPerPlayer)) + fb.InvitationAddDropoutTiles(b, dropout) + fb.InvitationAddStatus(b, status) + fb.InvitationAddGameId(b, gameID) + fb.InvitationAddExpiresAtUnix(b, inv.ExpiresAtUnix) + return fb.InvitationEnd(b) +} + +// encodeInvitation builds an Invitation payload. +func encodeInvitation(inv backendclient.InvitationResp) []byte { + b := flatbuffers.NewBuilder(512) + b.Finish(buildInvitation(b, inv)) + return b.FinishedBytes() +} + +// encodeInvitationList builds an InvitationList payload. +func encodeInvitationList(r backendclient.InvitationListResp) []byte { + b := flatbuffers.NewBuilder(1024) + offs := make([]flatbuffers.UOffsetT, len(r.Invitations)) + for i, inv := range r.Invitations { + offs[i] = buildInvitation(b, inv) + } + fb.InvitationListStartInvitationsVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + v := b.EndVector(len(offs)) + fb.InvitationListStart(b) + fb.InvitationListAddInvitations(b, v) + b.Finish(fb.InvitationListEnd(b)) + return b.FinishedBytes() +} + +// encodeGcg builds a GcgExport payload. +func encodeGcg(r backendclient.GcgResp) []byte { + b := flatbuffers.NewBuilder(1024) + gid := b.CreateString(r.GameID) + fn := b.CreateString(r.Filename) + content := b.CreateString(r.Content) + fb.GcgExportStart(b) + fb.GcgExportAddGameId(b, gid) + fb.GcgExportAddFilename(b, fn) + fb.GcgExportAddContent(b, content) + b.Finish(fb.GcgExportEnd(b)) + return b.FinishedBytes() +} diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index a6bdb9a..a88983f 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -91,6 +91,7 @@ func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Regi r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true} r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true} r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} + registerStage8(r, backend) return r } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go new file mode 100644 index 0000000..019fd9a --- /dev/null +++ b/gateway/internal/transcode/transcode_social.go @@ -0,0 +1,302 @@ +package transcode + +import ( + "context" + + "scrabble/gateway/internal/backendclient" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// Stage 8 message types: friends (incl. the one-time code path), per-user blocks, +// friend-game invitations, profile editing + email binding, statistics and GCG +// export. All are authenticated. Registered by registerStage8 from NewRegistry. +const ( + MsgFriendsList = "friends.list" + MsgFriendsIncoming = "friends.incoming" + MsgFriendRequest = "friends.request" + MsgFriendRespond = "friends.respond" + MsgFriendCancel = "friends.cancel" + MsgFriendUnfriend = "friends.unfriend" + MsgFriendCodeIssue = "friends.code.issue" + MsgFriendCodeRedeem = "friends.code.redeem" + MsgBlocksList = "blocks.list" + MsgBlockAdd = "blocks.add" + MsgBlockRemove = "blocks.remove" + MsgInvitationsList = "invitation.list" + MsgInvitationCreate = "invitation.create" + MsgInvitationAccept = "invitation.accept" + MsgInvitationDecline = "invitation.decline" + MsgInvitationCancel = "invitation.cancel" + MsgProfileUpdate = "profile.update" + MsgEmailBindReq = "email.bind.request" + MsgEmailBindConfirm = "email.bind.confirm" + MsgStatsGet = "stats.get" + MsgGameGCG = "game.gcg" +) + +// registerStage8 adds the Stage 8 social, account and history operations to the +// registry (all authenticated; the email-bind ops carry the costly-email flag). +func registerStage8(r *Registry, backend *backendclient.Client) { + r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true} + r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true} + r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true} + r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true} + r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true} + r.ops[MsgFriendUnfriend] = Op{Handler: friendUnfriendHandler(backend), Auth: true} + r.ops[MsgFriendCodeIssue] = Op{Handler: friendCodeIssueHandler(backend), Auth: true} + r.ops[MsgFriendCodeRedeem] = Op{Handler: friendCodeRedeemHandler(backend), Auth: true} + r.ops[MsgBlocksList] = Op{Handler: blocksListHandler(backend), Auth: true} + r.ops[MsgBlockAdd] = Op{Handler: blockAddHandler(backend), Auth: true} + r.ops[MsgBlockRemove] = Op{Handler: blockRemoveHandler(backend), Auth: true} + r.ops[MsgInvitationsList] = Op{Handler: invitationsListHandler(backend), Auth: true} + r.ops[MsgInvitationCreate] = Op{Handler: invitationCreateHandler(backend), Auth: true} + r.ops[MsgInvitationAccept] = Op{Handler: invitationRespondHandler(backend, true), Auth: true} + r.ops[MsgInvitationDecline] = Op{Handler: invitationRespondHandler(backend, false), Auth: true} + r.ops[MsgInvitationCancel] = Op{Handler: invitationCancelHandler(backend), Auth: true} + r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true} + r.ops[MsgEmailBindReq] = Op{Handler: emailBindRequestHandler(backend), Auth: true, Email: true} + r.ops[MsgEmailBindConfirm] = Op{Handler: emailBindConfirmHandler(backend), Auth: true, Email: true} + r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true} + r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true} +} + +// --- friends --- + +func friendsListHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.ListFriends(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeFriendList(res), nil + } +} + +func friendsIncomingHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.ListIncoming(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeIncomingList(res), nil + } +} + +func friendRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsTargetRequest(req.Payload, 0) + if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func friendRespondHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsFriendRespondRequest(req.Payload, 0) + if err := backend.RespondFriendRequest(ctx, req.UserID, string(in.RequesterId()), in.Accept()); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func friendCancelHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsTargetRequest(req.Payload, 0) + if err := backend.CancelFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func friendUnfriendHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsTargetRequest(req.Payload, 0) + if err := backend.Unfriend(ctx, req.UserID, string(in.AccountId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func friendCodeIssueHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.IssueFriendCode(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeFriendCode(res), nil + } +} + +func friendCodeRedeemHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsRedeemCodeRequest(req.Payload, 0) + res, err := backend.RedeemFriendCode(ctx, req.UserID, string(in.Code())) + if err != nil { + return nil, err + } + return encodeRedeemResult(res), nil + } +} + +// --- blocks --- + +func blocksListHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.ListBlocks(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeBlockList(res), nil + } +} + +func blockAddHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsTargetRequest(req.Payload, 0) + if err := backend.Block(ctx, req.UserID, string(in.AccountId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func blockRemoveHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsTargetRequest(req.Payload, 0) + if err := backend.Unblock(ctx, req.UserID, string(in.AccountId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +// --- invitations --- + +func invitationsListHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.ListInvitations(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeInvitationList(res), nil + } +} + +func invitationCreateHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsCreateInvitationRequest(req.Payload, 0) + params := backendclient.InvitationParams{ + InviteeIDs: decodeInviteeIDs(in), + Variant: string(in.Variant()), + TurnTimeoutSecs: int(in.TurnTimeoutSecs()), + HintsAllowed: in.HintsAllowed(), + HintsPerPlayer: int(in.HintsPerPlayer()), + DropoutTiles: string(in.DropoutTiles()), + } + res, err := backend.CreateInvitation(ctx, req.UserID, params) + if err != nil { + return nil, err + } + return encodeInvitation(res), nil + } +} + +func invitationRespondHandler(backend *backendclient.Client, accept bool) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsInvitationActionRequest(req.Payload, 0) + res, err := backend.RespondInvitation(ctx, req.UserID, string(in.InvitationId()), accept) + if err != nil { + return nil, err + } + return encodeInvitation(res), nil + } +} + +func invitationCancelHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsInvitationActionRequest(req.Payload, 0) + if err := backend.CancelInvitation(ctx, req.UserID, string(in.InvitationId())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +// --- profile, email, stats, gcg --- + +func profileUpdateHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0) + p := backendclient.ProfileResp{ + DisplayName: string(in.DisplayName()), + PreferredLanguage: string(in.PreferredLanguage()), + TimeZone: string(in.TimeZone()), + AwayStart: string(in.AwayStart()), + AwayEnd: string(in.AwayEnd()), + BlockChat: in.BlockChat(), + BlockFriendRequests: in.BlockFriendRequests(), + } + out, err := backend.UpdateProfile(ctx, req.UserID, p) + if err != nil { + return nil, err + } + return encodeProfile(out), nil + } +} + +func emailBindRequestHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsEmailBindRequest(req.Payload, 0) + if err := backend.EmailBindRequest(ctx, req.UserID, string(in.Email())); err != nil { + return nil, err + } + return encodeAck(true), nil + } +} + +func emailBindConfirmHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsEmailConfirmRequest(req.Payload, 0) + out, err := backend.EmailBindConfirm(ctx, req.UserID, string(in.Email()), string(in.Code())) + if err != nil { + return nil, err + } + return encodeProfile(out), nil + } +} + +func statsHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.Stats(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeStats(res), nil + } +} + +func gcgHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsGameActionRequest(req.Payload, 0) + res, err := backend.ExportGCG(ctx, req.UserID, string(in.GameId())) + if err != nil { + return nil, err + } + return encodeGcg(res), nil + } +} + +// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest. +func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string { + n := in.InviteeIdsLength() + out := make([]string, 0, n) + for i := range n { + out = append(out, string(in.InviteeIds(i))) + } + return out +} diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go new file mode 100644 index 0000000..06f3da2 --- /dev/null +++ b/gateway/internal/transcode/transcode_social_test.go @@ -0,0 +1,238 @@ +package transcode_test + +import ( + "context" + "net/http" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// targetPayload builds a TargetRequest payload (friend request/cancel, block). +func targetPayload(accountID string) []byte { + b := flatbuffers.NewBuilder(32) + id := b.CreateString(accountID) + fb.TargetRequestStart(b) + fb.TargetRequestAddAccountId(b, id) + b.Finish(fb.TargetRequestEnd(b)) + return b.FinishedBytes() +} + +func TestFriendsListRoundTripDecodesNames(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-User-ID"); got != "u-1" { + t.Errorf("X-User-ID = %q, want u-1", got) + } + if r.URL.Path != "/api/v1/user/friends" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"friends":[{"account_id":"a-1","display_name":"Ann"},{"account_id":"a-2","display_name":"Bob"}]}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgFriendsList) + if !ok { + t.Fatal("friends.list not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + fl := fb.GetRootAsFriendList(payload, 0) + if fl.FriendsLength() != 2 { + t.Fatalf("friends length = %d, want 2", fl.FriendsLength()) + } + var f fb.AccountRef + fl.Friends(&f, 1) + if string(f.AccountId()) != "a-2" || string(f.DisplayName()) != "Bob" { + t.Fatalf("friend[1] = (%q, %q), want (a-2, Bob)", f.AccountId(), f.DisplayName()) + } +} + +func TestFriendRequestForwardsTarget(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-User-ID"); got != "u-1" { + t.Errorf("X-User-ID = %q, want u-1", got) + } + if r.URL.Path != "/api/v1/user/friends/request" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgFriendRequest) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: targetPayload("b-1")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + if ack := fb.GetRootAsAck(payload, 0); !ack.Ok() { + t.Fatal("ack not ok") + } +} + +func TestFriendCodeIssueAndRedeem(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/user/friends/code": + _, _ = w.Write([]byte(`{"code":"123456","expires_at_unix":1717000000}`)) + case "/api/v1/user/friends/code/redeem": + _, _ = w.Write([]byte(`{"friend":{"account_id":"a-7","display_name":"Kaya"}}`)) + default: + t.Errorf("unexpected path %q", r.URL.Path) + } + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + + issue, _ := reg.Lookup(transcode.MsgFriendCodeIssue) + p1, err := issue.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("issue: %v", err) + } + fc := fb.GetRootAsFriendCode(p1, 0) + if string(fc.Code()) != "123456" || fc.ExpiresAtUnix() != 1717000000 { + t.Fatalf("friend code = (%q, %d)", fc.Code(), fc.ExpiresAtUnix()) + } + + b := flatbuffers.NewBuilder(32) + code := b.CreateString("123456") + fb.RedeemCodeRequestStart(b) + fb.RedeemCodeRequestAddCode(b, code) + b.Finish(fb.RedeemCodeRequestEnd(b)) + + redeem, _ := reg.Lookup(transcode.MsgFriendCodeRedeem) + p2, err := redeem.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) + if err != nil { + t.Fatalf("redeem: %v", err) + } + rr := fb.GetRootAsRedeemResult(p2, 0) + if f := rr.Friend(nil); f == nil || string(f.AccountId()) != "a-7" || string(f.DisplayName()) != "Kaya" { + t.Fatalf("redeem friend decoded wrong: %+v", f) + } +} + +func TestInvitationCreateRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/invitations" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"id":"i-1","inviter":{"account_id":"u-1","display_name":"Me"},"invitees":[{"account_id":"inv-1","display_name":"Friend","seat":1,"response":"pending"}],"variant":"english","turn_timeout_secs":86400,"hints_allowed":true,"hints_per_player":1,"dropout_tiles":"remove","status":"pending","expires_at_unix":42}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgInvitationCreate) + + b := flatbuffers.NewBuilder(128) + inviteeID := b.CreateString("inv-1") + fb.CreateInvitationRequestStartInviteeIdsVector(b, 1) + b.PrependUOffsetT(inviteeID) + ids := b.EndVector(1) + variant := b.CreateString("english") + dropout := b.CreateString("remove") + fb.CreateInvitationRequestStart(b) + fb.CreateInvitationRequestAddInviteeIds(b, ids) + fb.CreateInvitationRequestAddVariant(b, variant) + fb.CreateInvitationRequestAddTurnTimeoutSecs(b, 86400) + fb.CreateInvitationRequestAddHintsAllowed(b, true) + fb.CreateInvitationRequestAddHintsPerPlayer(b, 1) + fb.CreateInvitationRequestAddDropoutTiles(b, dropout) + b.Finish(fb.CreateInvitationRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) + if err != nil { + t.Fatalf("handler: %v", err) + } + inv := fb.GetRootAsInvitation(payload, 0) + if string(inv.Id()) != "i-1" || inv.InviteesLength() != 1 || string(inv.Variant()) != "english" { + t.Fatalf("invitation decoded wrong: id=%q invitees=%d variant=%q", inv.Id(), inv.InviteesLength(), inv.Variant()) + } + if iv := inv.Inviter(nil); iv == nil || string(iv.DisplayName()) != "Me" { + t.Fatalf("inviter decoded wrong: %+v", iv) + } +} + +func TestStatsRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/stats" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"wins":5,"losses":3,"draws":1,"max_game_points":420,"max_word_points":90}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgStatsGet) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + st := fb.GetRootAsStatsView(payload, 0) + if st.Wins() != 5 || st.Losses() != 3 || st.Draws() != 1 || st.MaxGamePoints() != 420 || st.MaxWordPoints() != 90 { + t.Fatalf("stats decoded wrong: %+v", st) + } +} + +func TestGcgRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/games/g-1/gcg" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"game_id":"g-1","filename":"game-g-1.gcg","content":"#character-encoding UTF-8\n"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgGameGCG) + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-1")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + gcg := fb.GetRootAsGcgExport(payload, 0) + if string(gcg.Filename()) != "game-g-1.gcg" || len(gcg.Content()) == 0 { + t.Fatalf("gcg decoded wrong: filename=%q content=%q", gcg.Filename(), gcg.Content()) + } +} + +func TestProfileUpdateRoundTripAway(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/profile" { + t.Errorf("unexpected %s %q", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"ru","time_zone":"Europe/Moscow","away_start":"00:00","away_end":"07:30"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, _ := reg.Lookup(transcode.MsgProfileUpdate) + + b := flatbuffers.NewBuilder(128) + name := b.CreateString("Kaya") + lang := b.CreateString("ru") + tz := b.CreateString("Europe/Moscow") + as := b.CreateString("00:00") + ae := b.CreateString("07:30") + fb.UpdateProfileRequestStart(b) + fb.UpdateProfileRequestAddDisplayName(b, name) + fb.UpdateProfileRequestAddPreferredLanguage(b, lang) + fb.UpdateProfileRequestAddTimeZone(b, tz) + fb.UpdateProfileRequestAddAwayStart(b, as) + fb.UpdateProfileRequestAddAwayEnd(b, ae) + b.Finish(fb.UpdateProfileRequestEnd(b)) + + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) + if err != nil { + t.Fatalf("handler: %v", err) + } + p := fb.GetRootAsProfile(payload, 0) + if string(p.AwayStart()) != "00:00" || string(p.AwayEnd()) != "07:30" || string(p.PreferredLanguage()) != "ru" { + t.Fatalf("profile away round-trip wrong: start=%q end=%q lang=%q", p.AwayStart(), p.AwayEnd(), p.PreferredLanguage()) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index ef8fda4..2452eb4 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -103,7 +103,8 @@ table Ack { // --- profile (authenticated) --- -// Profile is the authenticated account's own profile view. +// Profile is the authenticated account's own profile view. away_start/away_end are +// the "HH:MM" daily away-window bounds (added trailing — backward-compatible). table Profile { user_id:string; display_name:string; @@ -113,6 +114,8 @@ table Profile { block_chat:bool; block_friend_requests:bool; is_guest:bool; + away_start:string; + away_end:string; } // --- game (authenticated) --- @@ -242,6 +245,142 @@ table ChatList { messages:[ChatMessage]; } +// --- Stage 8: account, statistics, friends, blocks, invitations, history --- + +// AccountRef is a referenced account with its display name resolved — the shared +// shape for friends, blocked users and invitation participants. +table AccountRef { + account_id:string; + display_name:string; +} + +// UpdateProfileRequest overwrites the full editable profile (the client sends the +// complete desired profile). away_start/away_end are "HH:MM" bounds. +table UpdateProfileRequest { + display_name:string; + preferred_language:string; + time_zone:string; + away_start:string; + away_end:string; + block_chat:bool; + block_friend_requests:bool; +} + +// EmailBindRequest asks the backend to send a confirm-code binding email to the +// caller's account. +table EmailBindRequest { + email:string; +} + +// EmailConfirmRequest verifies the code and binds the email (returns Profile). +table EmailConfirmRequest { + email:string; + code:string; +} + +// StatsView is a durable account's lifetime statistics (games-played and win-rate +// are derived client-side). +table StatsView { + wins:int; + losses:int; + draws:int; + max_game_points:int; + max_word_points:int; +} + +// TargetRequest names a single counterpart account (friend request/cancel/unfriend, +// block/unblock). +table TargetRequest { + account_id:string; +} + +// FriendRespondRequest accepts or declines a pending request from a requester. +table FriendRespondRequest { + requester_id:string; + accept:bool; +} + +// FriendList is the caller's accepted friends. +table FriendList { + friends:[AccountRef]; +} + +// IncomingRequestList is the friend requests awaiting the caller's response. +table IncomingRequestList { + requests:[AccountRef]; +} + +// FriendCode is a freshly issued one-time add-a-friend code (returned once). +table FriendCode { + code:string; + expires_at_unix:long; +} + +// RedeemCodeRequest redeems a friend code, befriending its issuer. +table RedeemCodeRequest { + code:string; +} + +// RedeemResult reports the new friend gained by redeeming a code. +table RedeemResult { + friend:AccountRef; +} + +// BlockList is the accounts the caller has blocked. +table BlockList { + blocked:[AccountRef]; +} + +// InvitationInvitee is one invitee's seat and response, name resolved. +table InvitationInvitee { + account_id:string; + display_name:string; + seat:int; + response:string; +} + +// Invitation is a friend-game invitation with its settings and invitees. +table Invitation { + id:string; + inviter:AccountRef; + invitees:[InvitationInvitee]; + variant:string; + turn_timeout_secs:int; + hints_allowed:bool; + hints_per_player:int; + dropout_tiles:string; + status:string; + game_id:string; + expires_at_unix:long; +} + +// CreateInvitationRequest proposes a 2-4 player friend game to the named invitees. +table CreateInvitationRequest { + invitee_ids:[string]; + variant:string; + turn_timeout_secs:int; + hints_allowed:bool; + hints_per_player:int; + dropout_tiles:string; +} + +// InvitationActionRequest accepts / declines / cancels an invitation by id. +table InvitationActionRequest { + invitation_id:string; +} + +// InvitationList is the caller's open invitations. +table InvitationList { + invitations:[Invitation]; +} + +// GcgExport is a finished game's GCG transcript: a suggested filename and the text. +table GcgExport { + game_id:string; + filename:string; + content:string; +} + // --- push event payloads --- // YourTurnEvent signals that it is now the recipient's turn. @@ -271,3 +410,11 @@ table NudgeEvent { table MatchFoundEvent { game_id:string; } + +// NotificationEvent is a lightweight "something changed, re-poll" signal that +// drives the lobby badge (incoming friend requests, invitations). kind is a sub- +// discriminator ("friend_request", "friend_added", "invitation", "game_started"); +// the client re-fetches its lobby counters on any of them. +table NotificationEvent { + kind:string; +} diff --git a/pkg/fbs/scrabblefb/AccountRef.go b/pkg/fbs/scrabblefb/AccountRef.go new file mode 100644 index 0000000..ec30cb4 --- /dev/null +++ b/pkg/fbs/scrabblefb/AccountRef.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type AccountRef struct { + _tab flatbuffers.Table +} + +func GetRootAsAccountRef(buf []byte, offset flatbuffers.UOffsetT) *AccountRef { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &AccountRef{} + x.Init(buf, n+offset) + return x +} + +func FinishAccountRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsAccountRef(buf []byte, offset flatbuffers.UOffsetT) *AccountRef { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &AccountRef{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedAccountRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *AccountRef) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *AccountRef) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *AccountRef) AccountId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *AccountRef) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func AccountRefStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func AccountRefAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0) +} +func AccountRefAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0) +} +func AccountRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/BlockList.go b/pkg/fbs/scrabblefb/BlockList.go new file mode 100644 index 0000000..6ecba6e --- /dev/null +++ b/pkg/fbs/scrabblefb/BlockList.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type BlockList struct { + _tab flatbuffers.Table +} + +func GetRootAsBlockList(buf []byte, offset flatbuffers.UOffsetT) *BlockList { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &BlockList{} + x.Init(buf, n+offset) + return x +} + +func FinishBlockListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsBlockList(buf []byte, offset flatbuffers.UOffsetT) *BlockList { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &BlockList{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedBlockListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *BlockList) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *BlockList) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *BlockList) Blocked(obj *AccountRef, 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 *BlockList) BlockedLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func BlockListStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func BlockListAddBlocked(builder *flatbuffers.Builder, blocked flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(blocked), 0) +} +func BlockListStartBlockedVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func BlockListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/CreateInvitationRequest.go b/pkg/fbs/scrabblefb/CreateInvitationRequest.go new file mode 100644 index 0000000..bc4b512 --- /dev/null +++ b/pkg/fbs/scrabblefb/CreateInvitationRequest.go @@ -0,0 +1,139 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type CreateInvitationRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsCreateInvitationRequest(buf []byte, offset flatbuffers.UOffsetT) *CreateInvitationRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &CreateInvitationRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishCreateInvitationRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsCreateInvitationRequest(buf []byte, offset flatbuffers.UOffsetT) *CreateInvitationRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &CreateInvitationRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedCreateInvitationRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *CreateInvitationRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *CreateInvitationRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *CreateInvitationRequest) InviteeIds(j int) []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + a := rcv._tab.Vector(o) + return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4)) + } + return nil +} + +func (rcv *CreateInvitationRequest) InviteeIdsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *CreateInvitationRequest) Variant() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *CreateInvitationRequest) TurnTimeoutSecs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *CreateInvitationRequest) MutateTurnTimeoutSecs(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *CreateInvitationRequest) HintsAllowed() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *CreateInvitationRequest) MutateHintsAllowed(n bool) bool { + return rcv._tab.MutateBoolSlot(10, n) +} + +func (rcv *CreateInvitationRequest) HintsPerPlayer() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *CreateInvitationRequest) MutateHintsPerPlayer(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *CreateInvitationRequest) DropoutTiles() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func CreateInvitationRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(6) +} +func CreateInvitationRequestAddInviteeIds(builder *flatbuffers.Builder, inviteeIds flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(inviteeIds), 0) +} +func CreateInvitationRequestStartInviteeIdsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func CreateInvitationRequestAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(variant), 0) +} +func CreateInvitationRequestAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) { + builder.PrependInt32Slot(2, turnTimeoutSecs, 0) +} +func CreateInvitationRequestAddHintsAllowed(builder *flatbuffers.Builder, hintsAllowed bool) { + builder.PrependBoolSlot(3, hintsAllowed, false) +} +func CreateInvitationRequestAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) { + builder.PrependInt32Slot(4, hintsPerPlayer, 0) +} +func CreateInvitationRequestAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(dropoutTiles), 0) +} +func CreateInvitationRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailBindRequest.go b/pkg/fbs/scrabblefb/EmailBindRequest.go new file mode 100644 index 0000000..f7ebeca --- /dev/null +++ b/pkg/fbs/scrabblefb/EmailBindRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EmailBindRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsEmailBindRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailBindRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EmailBindRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishEmailBindRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEmailBindRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailBindRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EmailBindRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEmailBindRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EmailBindRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EmailBindRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EmailBindRequest) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func EmailBindRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func EmailBindRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) +} +func EmailBindRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/EmailConfirmRequest.go b/pkg/fbs/scrabblefb/EmailConfirmRequest.go new file mode 100644 index 0000000..0666a4c --- /dev/null +++ b/pkg/fbs/scrabblefb/EmailConfirmRequest.go @@ -0,0 +1,71 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type EmailConfirmRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsEmailConfirmRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &EmailConfirmRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishEmailConfirmRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsEmailConfirmRequest(buf []byte, offset flatbuffers.UOffsetT) *EmailConfirmRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &EmailConfirmRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedEmailConfirmRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *EmailConfirmRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *EmailConfirmRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *EmailConfirmRequest) Email() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *EmailConfirmRequest) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func EmailConfirmRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func EmailConfirmRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) +} +func EmailConfirmRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(code), 0) +} +func EmailConfirmRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/FriendCode.go b/pkg/fbs/scrabblefb/FriendCode.go new file mode 100644 index 0000000..3c34e19 --- /dev/null +++ b/pkg/fbs/scrabblefb/FriendCode.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FriendCode struct { + _tab flatbuffers.Table +} + +func GetRootAsFriendCode(buf []byte, offset flatbuffers.UOffsetT) *FriendCode { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FriendCode{} + x.Init(buf, n+offset) + return x +} + +func FinishFriendCodeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFriendCode(buf []byte, offset flatbuffers.UOffsetT) *FriendCode { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FriendCode{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFriendCodeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FriendCode) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FriendCode) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FriendCode) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FriendCode) ExpiresAtUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *FriendCode) MutateExpiresAtUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(6, n) +} + +func FriendCodeStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func FriendCodeAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0) +} +func FriendCodeAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) { + builder.PrependInt64Slot(1, expiresAtUnix, 0) +} +func FriendCodeEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/FriendList.go b/pkg/fbs/scrabblefb/FriendList.go new file mode 100644 index 0000000..50b7196 --- /dev/null +++ b/pkg/fbs/scrabblefb/FriendList.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FriendList struct { + _tab flatbuffers.Table +} + +func GetRootAsFriendList(buf []byte, offset flatbuffers.UOffsetT) *FriendList { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FriendList{} + x.Init(buf, n+offset) + return x +} + +func FinishFriendListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFriendList(buf []byte, offset flatbuffers.UOffsetT) *FriendList { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FriendList{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFriendListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FriendList) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FriendList) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FriendList) Friends(obj *AccountRef, 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 *FriendList) FriendsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func FriendListStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func FriendListAddFriends(builder *flatbuffers.Builder, friends flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(friends), 0) +} +func FriendListStartFriendsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func FriendListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/FriendRespondRequest.go b/pkg/fbs/scrabblefb/FriendRespondRequest.go new file mode 100644 index 0000000..cc7a161 --- /dev/null +++ b/pkg/fbs/scrabblefb/FriendRespondRequest.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type FriendRespondRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsFriendRespondRequest(buf []byte, offset flatbuffers.UOffsetT) *FriendRespondRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &FriendRespondRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishFriendRespondRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsFriendRespondRequest(buf []byte, offset flatbuffers.UOffsetT) *FriendRespondRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &FriendRespondRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedFriendRespondRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *FriendRespondRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *FriendRespondRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *FriendRespondRequest) RequesterId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *FriendRespondRequest) Accept() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *FriendRespondRequest) MutateAccept(n bool) bool { + return rcv._tab.MutateBoolSlot(6, n) +} + +func FriendRespondRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(2) +} +func FriendRespondRequestAddRequesterId(builder *flatbuffers.Builder, requesterId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requesterId), 0) +} +func FriendRespondRequestAddAccept(builder *flatbuffers.Builder, accept bool) { + builder.PrependBoolSlot(1, accept, false) +} +func FriendRespondRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/GcgExport.go b/pkg/fbs/scrabblefb/GcgExport.go new file mode 100644 index 0000000..0e79f5f --- /dev/null +++ b/pkg/fbs/scrabblefb/GcgExport.go @@ -0,0 +1,82 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type GcgExport struct { + _tab flatbuffers.Table +} + +func GetRootAsGcgExport(buf []byte, offset flatbuffers.UOffsetT) *GcgExport { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &GcgExport{} + x.Init(buf, n+offset) + return x +} + +func FinishGcgExportBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsGcgExport(buf []byte, offset flatbuffers.UOffsetT) *GcgExport { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &GcgExport{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedGcgExportBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *GcgExport) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *GcgExport) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *GcgExport) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GcgExport) Filename() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *GcgExport) Content() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func GcgExportStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func GcgExportAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0) +} +func GcgExportAddFilename(builder *flatbuffers.Builder, filename flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(filename), 0) +} +func GcgExportAddContent(builder *flatbuffers.Builder, content flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(content), 0) +} +func GcgExportEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/IncomingRequestList.go b/pkg/fbs/scrabblefb/IncomingRequestList.go new file mode 100644 index 0000000..ce1338e --- /dev/null +++ b/pkg/fbs/scrabblefb/IncomingRequestList.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type IncomingRequestList struct { + _tab flatbuffers.Table +} + +func GetRootAsIncomingRequestList(buf []byte, offset flatbuffers.UOffsetT) *IncomingRequestList { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &IncomingRequestList{} + x.Init(buf, n+offset) + return x +} + +func FinishIncomingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsIncomingRequestList(buf []byte, offset flatbuffers.UOffsetT) *IncomingRequestList { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &IncomingRequestList{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedIncomingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *IncomingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *IncomingRequestList) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *IncomingRequestList) Requests(obj *AccountRef, 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 *IncomingRequestList) RequestsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func IncomingRequestListStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func IncomingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0) +} +func IncomingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func IncomingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Invitation.go b/pkg/fbs/scrabblefb/Invitation.go new file mode 100644 index 0000000..0f77059 --- /dev/null +++ b/pkg/fbs/scrabblefb/Invitation.go @@ -0,0 +1,206 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type Invitation struct { + _tab flatbuffers.Table +} + +func GetRootAsInvitation(buf []byte, offset flatbuffers.UOffsetT) *Invitation { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &Invitation{} + x.Init(buf, n+offset) + return x +} + +func FinishInvitationBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsInvitation(buf []byte, offset flatbuffers.UOffsetT) *Invitation { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &Invitation{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedInvitationBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *Invitation) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *Invitation) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *Invitation) Id() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) Inviter(obj *AccountRef) *AccountRef { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(AccountRef) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func (rcv *Invitation) Invitees(obj *InvitationInvitee, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + x := rcv._tab.Vector(o) + x += flatbuffers.UOffsetT(j) * 4 + x = rcv._tab.Indirect(x) + obj.Init(rcv._tab.Bytes, x) + return true + } + return false +} + +func (rcv *Invitation) InviteesLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func (rcv *Invitation) Variant() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) TurnTimeoutSecs() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *Invitation) MutateTurnTimeoutSecs(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func (rcv *Invitation) HintsAllowed() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *Invitation) MutateHintsAllowed(n bool) bool { + return rcv._tab.MutateBoolSlot(14, n) +} + +func (rcv *Invitation) HintsPerPlayer() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *Invitation) MutateHintsPerPlayer(n int32) bool { + return rcv._tab.MutateInt32Slot(16, n) +} + +func (rcv *Invitation) DropoutTiles() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) Status() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Invitation) ExpiresAtUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *Invitation) MutateExpiresAtUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(24, n) +} + +func InvitationStart(builder *flatbuffers.Builder) { + builder.StartObject(11) +} +func InvitationAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) +} +func InvitationAddInviter(builder *flatbuffers.Builder, inviter flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(inviter), 0) +} +func InvitationAddInvitees(builder *flatbuffers.Builder, invitees flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(invitees), 0) +} +func InvitationStartInviteesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func InvitationAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(variant), 0) +} +func InvitationAddTurnTimeoutSecs(builder *flatbuffers.Builder, turnTimeoutSecs int32) { + builder.PrependInt32Slot(4, turnTimeoutSecs, 0) +} +func InvitationAddHintsAllowed(builder *flatbuffers.Builder, hintsAllowed bool) { + builder.PrependBoolSlot(5, hintsAllowed, false) +} +func InvitationAddHintsPerPlayer(builder *flatbuffers.Builder, hintsPerPlayer int32) { + builder.PrependInt32Slot(6, hintsPerPlayer, 0) +} +func InvitationAddDropoutTiles(builder *flatbuffers.Builder, dropoutTiles flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(dropoutTiles), 0) +} +func InvitationAddStatus(builder *flatbuffers.Builder, status flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(status), 0) +} +func InvitationAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(gameId), 0) +} +func InvitationAddExpiresAtUnix(builder *flatbuffers.Builder, expiresAtUnix int64) { + builder.PrependInt64Slot(10, expiresAtUnix, 0) +} +func InvitationEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/InvitationActionRequest.go b/pkg/fbs/scrabblefb/InvitationActionRequest.go new file mode 100644 index 0000000..5fef9f0 --- /dev/null +++ b/pkg/fbs/scrabblefb/InvitationActionRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type InvitationActionRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsInvitationActionRequest(buf []byte, offset flatbuffers.UOffsetT) *InvitationActionRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &InvitationActionRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishInvitationActionRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsInvitationActionRequest(buf []byte, offset flatbuffers.UOffsetT) *InvitationActionRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &InvitationActionRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedInvitationActionRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *InvitationActionRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *InvitationActionRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *InvitationActionRequest) InvitationId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func InvitationActionRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func InvitationActionRequestAddInvitationId(builder *flatbuffers.Builder, invitationId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(invitationId), 0) +} +func InvitationActionRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/InvitationInvitee.go b/pkg/fbs/scrabblefb/InvitationInvitee.go new file mode 100644 index 0000000..d8db84b --- /dev/null +++ b/pkg/fbs/scrabblefb/InvitationInvitee.go @@ -0,0 +1,97 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type InvitationInvitee struct { + _tab flatbuffers.Table +} + +func GetRootAsInvitationInvitee(buf []byte, offset flatbuffers.UOffsetT) *InvitationInvitee { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &InvitationInvitee{} + x.Init(buf, n+offset) + return x +} + +func FinishInvitationInviteeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsInvitationInvitee(buf []byte, offset flatbuffers.UOffsetT) *InvitationInvitee { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &InvitationInvitee{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedInvitationInviteeBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *InvitationInvitee) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *InvitationInvitee) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *InvitationInvitee) AccountId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *InvitationInvitee) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *InvitationInvitee) Seat() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *InvitationInvitee) MutateSeat(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *InvitationInvitee) Response() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func InvitationInviteeStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func InvitationInviteeAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0) +} +func InvitationInviteeAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0) +} +func InvitationInviteeAddSeat(builder *flatbuffers.Builder, seat int32) { + builder.PrependInt32Slot(2, seat, 0) +} +func InvitationInviteeAddResponse(builder *flatbuffers.Builder, response flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(response), 0) +} +func InvitationInviteeEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/InvitationList.go b/pkg/fbs/scrabblefb/InvitationList.go new file mode 100644 index 0000000..067b966 --- /dev/null +++ b/pkg/fbs/scrabblefb/InvitationList.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type InvitationList struct { + _tab flatbuffers.Table +} + +func GetRootAsInvitationList(buf []byte, offset flatbuffers.UOffsetT) *InvitationList { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &InvitationList{} + x.Init(buf, n+offset) + return x +} + +func FinishInvitationListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsInvitationList(buf []byte, offset flatbuffers.UOffsetT) *InvitationList { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &InvitationList{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedInvitationListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *InvitationList) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *InvitationList) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *InvitationList) Invitations(obj *Invitation, 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 *InvitationList) InvitationsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func InvitationListStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func InvitationListAddInvitations(builder *flatbuffers.Builder, invitations flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(invitations), 0) +} +func InvitationListStartInvitationsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func InvitationListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/NotificationEvent.go b/pkg/fbs/scrabblefb/NotificationEvent.go new file mode 100644 index 0000000..89314bb --- /dev/null +++ b/pkg/fbs/scrabblefb/NotificationEvent.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type NotificationEvent struct { + _tab flatbuffers.Table +} + +func GetRootAsNotificationEvent(buf []byte, offset flatbuffers.UOffsetT) *NotificationEvent { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &NotificationEvent{} + x.Init(buf, n+offset) + return x +} + +func FinishNotificationEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsNotificationEvent(buf []byte, offset flatbuffers.UOffsetT) *NotificationEvent { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &NotificationEvent{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedNotificationEventBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *NotificationEvent) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *NotificationEvent) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *NotificationEvent) Kind() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func NotificationEventStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func NotificationEventAddKind(builder *flatbuffers.Builder, kind flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(kind), 0) +} +func NotificationEventEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index 6039eb2..c5fd6e3 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -121,8 +121,24 @@ func (rcv *Profile) MutateIsGuest(n bool) bool { return rcv._tab.MutateBoolSlot(18, n) } +func (rcv *Profile) AwayStart() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(20)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *Profile) AwayEnd() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(22)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(8) + builder.StartObject(10) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -148,6 +164,12 @@ func ProfileAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequ func ProfileAddIsGuest(builder *flatbuffers.Builder, isGuest bool) { builder.PrependBoolSlot(7, isGuest, false) } +func ProfileAddAwayStart(builder *flatbuffers.Builder, awayStart flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(awayStart), 0) +} +func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(awayEnd), 0) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/RedeemCodeRequest.go b/pkg/fbs/scrabblefb/RedeemCodeRequest.go new file mode 100644 index 0000000..0e86361 --- /dev/null +++ b/pkg/fbs/scrabblefb/RedeemCodeRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type RedeemCodeRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsRedeemCodeRequest(buf []byte, offset flatbuffers.UOffsetT) *RedeemCodeRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &RedeemCodeRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishRedeemCodeRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsRedeemCodeRequest(buf []byte, offset flatbuffers.UOffsetT) *RedeemCodeRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &RedeemCodeRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedRedeemCodeRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *RedeemCodeRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *RedeemCodeRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *RedeemCodeRequest) Code() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func RedeemCodeRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func RedeemCodeRequestAddCode(builder *flatbuffers.Builder, code flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(code), 0) +} +func RedeemCodeRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/RedeemResult.go b/pkg/fbs/scrabblefb/RedeemResult.go new file mode 100644 index 0000000..56378cc --- /dev/null +++ b/pkg/fbs/scrabblefb/RedeemResult.go @@ -0,0 +1,65 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type RedeemResult struct { + _tab flatbuffers.Table +} + +func GetRootAsRedeemResult(buf []byte, offset flatbuffers.UOffsetT) *RedeemResult { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &RedeemResult{} + x.Init(buf, n+offset) + return x +} + +func FinishRedeemResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsRedeemResult(buf []byte, offset flatbuffers.UOffsetT) *RedeemResult { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &RedeemResult{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedRedeemResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *RedeemResult) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *RedeemResult) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *RedeemResult) Friend(obj *AccountRef) *AccountRef { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + x := rcv._tab.Indirect(o + rcv._tab.Pos) + if obj == nil { + obj = new(AccountRef) + } + obj.Init(rcv._tab.Bytes, x) + return obj + } + return nil +} + +func RedeemResultStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func RedeemResultAddFriend(builder *flatbuffers.Builder, friend flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(friend), 0) +} +func RedeemResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/StatsView.go b/pkg/fbs/scrabblefb/StatsView.go new file mode 100644 index 0000000..36748a4 --- /dev/null +++ b/pkg/fbs/scrabblefb/StatsView.go @@ -0,0 +1,124 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type StatsView struct { + _tab flatbuffers.Table +} + +func GetRootAsStatsView(buf []byte, offset flatbuffers.UOffsetT) *StatsView { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &StatsView{} + x.Init(buf, n+offset) + return x +} + +func FinishStatsViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsStatsView(buf []byte, offset flatbuffers.UOffsetT) *StatsView { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &StatsView{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedStatsViewBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *StatsView) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *StatsView) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *StatsView) Wins() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateWins(n int32) bool { + return rcv._tab.MutateInt32Slot(4, n) +} + +func (rcv *StatsView) Losses() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateLosses(n int32) bool { + return rcv._tab.MutateInt32Slot(6, n) +} + +func (rcv *StatsView) Draws() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateDraws(n int32) bool { + return rcv._tab.MutateInt32Slot(8, n) +} + +func (rcv *StatsView) MaxGamePoints() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateMaxGamePoints(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func (rcv *StatsView) MaxWordPoints() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *StatsView) MutateMaxWordPoints(n int32) bool { + return rcv._tab.MutateInt32Slot(12, n) +} + +func StatsViewStart(builder *flatbuffers.Builder) { + builder.StartObject(5) +} +func StatsViewAddWins(builder *flatbuffers.Builder, wins int32) { + builder.PrependInt32Slot(0, wins, 0) +} +func StatsViewAddLosses(builder *flatbuffers.Builder, losses int32) { + builder.PrependInt32Slot(1, losses, 0) +} +func StatsViewAddDraws(builder *flatbuffers.Builder, draws int32) { + builder.PrependInt32Slot(2, draws, 0) +} +func StatsViewAddMaxGamePoints(builder *flatbuffers.Builder, maxGamePoints int32) { + builder.PrependInt32Slot(3, maxGamePoints, 0) +} +func StatsViewAddMaxWordPoints(builder *flatbuffers.Builder, maxWordPoints int32) { + builder.PrependInt32Slot(4, maxWordPoints, 0) +} +func StatsViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/TargetRequest.go b/pkg/fbs/scrabblefb/TargetRequest.go new file mode 100644 index 0000000..a211e8c --- /dev/null +++ b/pkg/fbs/scrabblefb/TargetRequest.go @@ -0,0 +1,60 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type TargetRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsTargetRequest(buf []byte, offset flatbuffers.UOffsetT) *TargetRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &TargetRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishTargetRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsTargetRequest(buf []byte, offset flatbuffers.UOffsetT) *TargetRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &TargetRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedTargetRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *TargetRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *TargetRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *TargetRequest) AccountId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func TargetRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func TargetRequestAddAccountId(builder *flatbuffers.Builder, accountId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(accountId), 0) +} +func TargetRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/pkg/fbs/scrabblefb/UpdateProfileRequest.go b/pkg/fbs/scrabblefb/UpdateProfileRequest.go new file mode 100644 index 0000000..81f843d --- /dev/null +++ b/pkg/fbs/scrabblefb/UpdateProfileRequest.go @@ -0,0 +1,134 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type UpdateProfileRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsUpdateProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateProfileRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &UpdateProfileRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishUpdateProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsUpdateProfileRequest(buf []byte, offset flatbuffers.UOffsetT) *UpdateProfileRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &UpdateProfileRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedUpdateProfileRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *UpdateProfileRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *UpdateProfileRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *UpdateProfileRequest) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *UpdateProfileRequest) PreferredLanguage() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *UpdateProfileRequest) TimeZone() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *UpdateProfileRequest) AwayStart() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *UpdateProfileRequest) AwayEnd() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(12)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *UpdateProfileRequest) BlockChat() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(14)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *UpdateProfileRequest) MutateBlockChat(n bool) bool { + return rcv._tab.MutateBoolSlot(14, n) +} + +func (rcv *UpdateProfileRequest) BlockFriendRequests() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(16)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return false +} + +func (rcv *UpdateProfileRequest) MutateBlockFriendRequests(n bool) bool { + return rcv._tab.MutateBoolSlot(16, n) +} + +func UpdateProfileRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(7) +} +func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0) +} +func UpdateProfileRequestAddPreferredLanguage(builder *flatbuffers.Builder, preferredLanguage flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(preferredLanguage), 0) +} +func UpdateProfileRequestAddTimeZone(builder *flatbuffers.Builder, timeZone flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(timeZone), 0) +} +func UpdateProfileRequestAddAwayStart(builder *flatbuffers.Builder, awayStart flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(awayStart), 0) +} +func UpdateProfileRequestAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(awayEnd), 0) +} +func UpdateProfileRequestAddBlockChat(builder *flatbuffers.Builder, blockChat bool) { + builder.PrependBoolSlot(5, blockChat, false) +} +func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequests bool) { + builder.PrependBoolSlot(6, blockFriendRequests, false) +} +func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/README.md b/ui/README.md index c8f93f3..15edc71 100644 --- a/ui/README.md +++ b/ui/README.md @@ -7,8 +7,10 @@ platform webviews and packageable to native via Capacitor. Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby, auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint, word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru), -theme, and a read-only profile. Friends/blocks, friend-game invitations, profile -editing, the stats screen and the history/GCG viewer are Stage 8. +theme, and the profile. **Stage 8** adds friends/blocks (with one-time friend codes), +friend-game invitations, profile editing + email binding, the statistics screen, the +lobby notification badge, and the in-game history + GCG export (share or download, +finished games only). ## Scripts @@ -60,10 +62,10 @@ runtime; the Telegram SDK itself is wired in the Telegram stage. ``` src/ lib/ model, client facade, transport (+ mock), codec, board replay, - placement state machine, premiums, i18n, theme, session, router, app store - components/ Header, Modal, Toast - screens/ Login, Lobby, NewGame, Profile, Settings, About + placement state machine, premiums, stats, share, i18n, theme, session, router, app store + components/ Header, Menu (+ badge), Modal, Toast, TabBar, Screen + screens/ Login, Lobby, NewGame, Profile, Settings, About, Friends, Stats game/ Game, Board, Rack, Controls, MakeMove, Chat gen/ committed edge codegen (FlatBuffers + Connect) -e2e/ Playwright smoke (mock) +e2e/ Playwright smoke + social specs (mock) ``` diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index d8c7abb..75e0342 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -64,7 +64,7 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => { test('dropping the game ends it and shows the result', async ({ page }) => { await openGame(page); await page.locator('.burger').click(); - await page.locator('.dropdown button').nth(3).click(); // Drop game + await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth await page.locator('button.danger').click(); // confirm in the modal await expect(page.locator('.status .over')).toBeVisible(); }); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts new file mode 100644 index 0000000..cc16c91 --- /dev/null +++ b/ui/e2e/social.spec.ts @@ -0,0 +1,75 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Stage 8 social / account / history surfaces against the mock transport (no backend). +// The mock profile is a durable account, so friends, invitations, stats and the GCG +// export are reachable from the seeded fixture. + +async function loginLobby(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await expect(page.getByText('Active games')).toBeVisible(); +} + +async function openFriends(page: Page): Promise { + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: /Friends/ }).click(); +} + +test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => { + await loginLobby(page); + await openFriends(page); + + // Issue a one-time code — it is shown to share. + await page.getByRole('button', { name: /Show my code/i }).click(); + await expect(page.getByTestId('friend-code')).toContainText('246813'); + + // The seeded incoming request (Rick) can be accepted; the requests section clears. + await expect(page.getByText('Friend requests')).toBeVisible(); + await page.getByRole('button', { name: /^Accept$/ }).click(); + await expect(page.getByText('Friend requests')).toBeHidden(); + + // Redeeming a code adds a new friend to the list. + await page.locator('.codein').fill('111111'); + await page.getByRole('button', { name: /^Add$/ }).click(); + await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible(); +}); + +test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => { + await loginLobby(page); + await expect(page.getByText('Invitations')).toBeVisible(); + await expect(page.getByText(/From Kaya/)).toBeVisible(); + await page.getByRole('button', { name: /^Accept$/ }).click(); + await expect(page.getByText(/From Kaya/)).toBeHidden(); +}); + +test('stats screen shows the metrics', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Stats/ }).click(); + await expect(page.getByText('Win rate')).toBeVisible(); + await expect(page.getByText('Best move')).toBeVisible(); +}); + +test('profile edit saves a new display name', async ({ page }) => { + await loginLobby(page); + await page.locator('.burger').first().click(); + await page.getByRole('button', { name: /Profile/ }).click(); + await page.getByRole('button', { name: /Edit profile/ }).click(); + await page.locator('.edit input').first().fill('Kaya Test'); + await page.getByRole('button', { name: /^Save$/ }).click(); + await expect(page.locator('.name')).toHaveText('Kaya Test'); +}); + +test('GCG export appears only for a finished game', async ({ page }) => { + await loginLobby(page); + // The finished game vs Kaya exposes the export; the menu carries the item. + await page.getByRole('button', { name: /Kaya/ }).click(); + await page.locator('.burger').first().click(); + await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible(); +}); + +test('GCG export is hidden for an active game', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Ann/ }).click(); + await page.locator('.burger').first().click(); + await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0); +}); diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs index 69bc781..f8d71dd 100644 --- a/ui/scripts/bundle-size.mjs +++ b/ui/scripts/bundle-size.mjs @@ -1,6 +1,7 @@ // Bundle-size budget gate. Sums the gzipped size of the built app JS and fails if it // exceeds the budget — a guard against an accidental heavy dependency. The real -// transport build is ~69 KB gzip today; the budget leaves headroom. +// transport build is ~82 KB gzip after the Stage 8 social/account/history surfaces; +// the budget leaves headroom. import { readdirSync, readFileSync } from 'node:fs'; import { gzipSync } from 'node:zlib'; diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 267b2e6..1acb335 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -10,6 +10,8 @@ import Profile from './screens/Profile.svelte'; import Settings from './screens/Settings.svelte'; import About from './screens/About.svelte'; + import Friends from './screens/Friends.svelte'; + import Stats from './screens/Stats.svelte'; import Game from './game/Game.svelte'; onMount(() => { @@ -31,6 +33,10 @@ {:else if router.route.name === 'about'} +{:else if router.route.name === 'friends'} + +{:else if router.route.name === 'stats'} + {:else} {/if} diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte index 3fb4a08..cbdca83 100644 --- a/ui/src/components/Menu.svelte +++ b/ui/src/components/Menu.svelte @@ -1,6 +1,13 @@ @@ -400,7 +426,7 @@
  • {view.game.seats[m.player]?.displayName ?? m.player} {m.action === 'play' ? m.words.join(', ') : m.action} - {m.score} + {m.score} ({m.total})
  • {/each} {#if moves.length === 0}
  • {/if} @@ -628,6 +654,11 @@ font-variant-numeric: tabular-nums; font-weight: 600; } + .ht { + color: var(--text-muted); + font-weight: 400; + font-size: 0.85em; + } .hempty { justify-content: center; color: var(--text-muted); diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index fe1bf12..6262d10 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -1,36 +1,56 @@ // automatically generated by the FlatBuffers compiler, do not modify +export { AccountRef } from './scrabblefb/account-ref.js'; export { Ack } from './scrabblefb/ack.js'; +export { BlockList } from './scrabblefb/block-list.js'; export { ChatList } from './scrabblefb/chat-list.js'; export { ChatMessage } from './scrabblefb/chat-message.js'; export { ChatPostRequest } from './scrabblefb/chat-post-request.js'; export { CheckWordRequest } from './scrabblefb/check-word-request.js'; export { ComplaintRequest } from './scrabblefb/complaint-request.js'; +export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js'; +export { EmailBindRequest } from './scrabblefb/email-bind-request.js'; +export { EmailConfirmRequest } from './scrabblefb/email-confirm-request.js'; export { EmailLoginRequest } from './scrabblefb/email-login-request.js'; export { EmailRequestRequest } from './scrabblefb/email-request-request.js'; export { EnqueueRequest } from './scrabblefb/enqueue-request.js'; export { EvalRequest } from './scrabblefb/eval-request.js'; export { EvalResult } from './scrabblefb/eval-result.js'; export { ExchangeRequest } from './scrabblefb/exchange-request.js'; +export { FriendCode } from './scrabblefb/friend-code.js'; +export { FriendList } from './scrabblefb/friend-list.js'; +export { FriendRespondRequest } from './scrabblefb/friend-respond-request.js'; export { GameActionRequest } from './scrabblefb/game-action-request.js'; export { GameList } from './scrabblefb/game-list.js'; export { GameView } from './scrabblefb/game-view.js'; +export { GcgExport } from './scrabblefb/gcg-export.js'; export { GuestLoginRequest } from './scrabblefb/guest-login-request.js'; export { HintResult } from './scrabblefb/hint-result.js'; export { History } from './scrabblefb/history.js'; +export { IncomingRequestList } from './scrabblefb/incoming-request-list.js'; +export { Invitation } from './scrabblefb/invitation.js'; +export { InvitationActionRequest } from './scrabblefb/invitation-action-request.js'; +export { InvitationInvitee } from './scrabblefb/invitation-invitee.js'; +export { InvitationList } from './scrabblefb/invitation-list.js'; export { MatchFoundEvent } from './scrabblefb/match-found-event.js'; export { MatchResult } from './scrabblefb/match-result.js'; export { MoveRecord } from './scrabblefb/move-record.js'; export { MoveResult } from './scrabblefb/move-result.js'; +export { NotificationEvent } from './scrabblefb/notification-event.js'; export { NudgeEvent } from './scrabblefb/nudge-event.js'; export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js'; export { Profile } from './scrabblefb/profile.js'; +export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; +export { RedeemResult } from './scrabblefb/redeem-result.js'; export { SeatView } from './scrabblefb/seat-view.js'; export { Session } from './scrabblefb/session.js'; export { StateRequest } from './scrabblefb/state-request.js'; export { StateView } from './scrabblefb/state-view.js'; +export { StatsView } from './scrabblefb/stats-view.js'; export { SubmitPlayRequest } from './scrabblefb/submit-play-request.js'; +export { TargetRequest } from './scrabblefb/target-request.js'; export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js'; export { TileRecord } from './scrabblefb/tile-record.js'; +export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js'; export { WordCheckResult } from './scrabblefb/word-check-result.js'; export { YourTurnEvent } from './scrabblefb/your-turn-event.js'; diff --git a/ui/src/gen/fbs/scrabblefb/account-ref.ts b/ui/src/gen/fbs/scrabblefb/account-ref.ts new file mode 100644 index 0000000..f2b30a1 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/account-ref.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class AccountRef { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):AccountRef { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsAccountRef(bb:flatbuffers.ByteBuffer, obj?:AccountRef):AccountRef { + return (obj || new AccountRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsAccountRef(bb:flatbuffers.ByteBuffer, obj?:AccountRef):AccountRef { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new AccountRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +accountId():string|null +accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +accountId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +displayName():string|null +displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +displayName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startAccountRef(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, accountIdOffset, 0); +} + +static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, displayNameOffset, 0); +} + +static endAccountRef(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createAccountRef(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset { + AccountRef.startAccountRef(builder); + AccountRef.addAccountId(builder, accountIdOffset); + AccountRef.addDisplayName(builder, displayNameOffset); + return AccountRef.endAccountRef(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/block-list.ts b/ui/src/gen/fbs/scrabblefb/block-list.ts new file mode 100644 index 0000000..91bedc1 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/block-list.ts @@ -0,0 +1,66 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { AccountRef } from '../scrabblefb/account-ref.js'; + + +export class BlockList { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):BlockList { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsBlockList(bb:flatbuffers.ByteBuffer, obj?:BlockList):BlockList { + return (obj || new BlockList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsBlockList(bb:flatbuffers.ByteBuffer, obj?:BlockList):BlockList { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new BlockList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +blocked(index: number, obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +blockedLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startBlockList(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addBlocked(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, blockedOffset, 0); +} + +static createBlockedVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startBlockedVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endBlockList(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createBlockList(builder:flatbuffers.Builder, blockedOffset:flatbuffers.Offset):flatbuffers.Offset { + BlockList.startBlockList(builder); + BlockList.addBlocked(builder, blockedOffset); + return BlockList.endBlockList(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts b/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts new file mode 100644 index 0000000..90efe4d --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/create-invitation-request.ts @@ -0,0 +1,119 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class CreateInvitationRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):CreateInvitationRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsCreateInvitationRequest(bb:flatbuffers.ByteBuffer, obj?:CreateInvitationRequest):CreateInvitationRequest { + return (obj || new CreateInvitationRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsCreateInvitationRequest(bb:flatbuffers.ByteBuffer, obj?:CreateInvitationRequest):CreateInvitationRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new CreateInvitationRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +inviteeIds(index: number):string +inviteeIds(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array +inviteeIds(index: number,optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null; +} + +inviteeIdsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +variant():string|null +variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +variant(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +turnTimeoutSecs():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +hintsAllowed():boolean { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +hintsPerPlayer():number { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +dropoutTiles():string|null +dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +dropoutTiles(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startCreateInvitationRequest(builder:flatbuffers.Builder) { + builder.startObject(6); +} + +static addInviteeIds(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, inviteeIdsOffset, 0); +} + +static createInviteeIdsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startInviteeIdsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, variantOffset, 0); +} + +static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) { + builder.addFieldInt32(2, turnTimeoutSecs, 0); +} + +static addHintsAllowed(builder:flatbuffers.Builder, hintsAllowed:boolean) { + builder.addFieldInt8(3, +hintsAllowed, +false); +} + +static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) { + builder.addFieldInt32(4, hintsPerPlayer, 0); +} + +static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) { + builder.addFieldOffset(5, dropoutTilesOffset, 0); +} + +static endCreateInvitationRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createCreateInvitationRequest(builder:flatbuffers.Builder, inviteeIdsOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, turnTimeoutSecs:number, hintsAllowed:boolean, hintsPerPlayer:number, dropoutTilesOffset:flatbuffers.Offset):flatbuffers.Offset { + CreateInvitationRequest.startCreateInvitationRequest(builder); + CreateInvitationRequest.addInviteeIds(builder, inviteeIdsOffset); + CreateInvitationRequest.addVariant(builder, variantOffset); + CreateInvitationRequest.addTurnTimeoutSecs(builder, turnTimeoutSecs); + CreateInvitationRequest.addHintsAllowed(builder, hintsAllowed); + CreateInvitationRequest.addHintsPerPlayer(builder, hintsPerPlayer); + CreateInvitationRequest.addDropoutTiles(builder, dropoutTilesOffset); + return CreateInvitationRequest.endCreateInvitationRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/email-bind-request.ts b/ui/src/gen/fbs/scrabblefb/email-bind-request.ts new file mode 100644 index 0000000..c4aabdf --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/email-bind-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class EmailBindRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):EmailBindRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest { + return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsEmailBindRequest(bb:flatbuffers.ByteBuffer, obj?:EmailBindRequest):EmailBindRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new EmailBindRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +email():string|null +email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +email(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startEmailBindRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, emailOffset, 0); +} + +static endEmailBindRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createEmailBindRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset { + EmailBindRequest.startEmailBindRequest(builder); + EmailBindRequest.addEmail(builder, emailOffset); + return EmailBindRequest.endEmailBindRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/email-confirm-request.ts b/ui/src/gen/fbs/scrabblefb/email-confirm-request.ts new file mode 100644 index 0000000..6ec5bcf --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/email-confirm-request.ts @@ -0,0 +1,60 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class EmailConfirmRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):EmailConfirmRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest { + return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsEmailConfirmRequest(bb:flatbuffers.ByteBuffer, obj?:EmailConfirmRequest):EmailConfirmRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new EmailConfirmRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +email():string|null +email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +email(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +code():string|null +code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +code(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startEmailConfirmRequest(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, emailOffset, 0); +} + +static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, codeOffset, 0); +} + +static endEmailConfirmRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createEmailConfirmRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset { + EmailConfirmRequest.startEmailConfirmRequest(builder); + EmailConfirmRequest.addEmail(builder, emailOffset); + EmailConfirmRequest.addCode(builder, codeOffset); + return EmailConfirmRequest.endEmailConfirmRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/friend-code.ts b/ui/src/gen/fbs/scrabblefb/friend-code.ts new file mode 100644 index 0000000..8409c2e --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/friend-code.ts @@ -0,0 +1,58 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class FriendCode { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FriendCode { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFriendCode(bb:flatbuffers.ByteBuffer, obj?:FriendCode):FriendCode { + return (obj || new FriendCode()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFriendCode(bb:flatbuffers.ByteBuffer, obj?:FriendCode):FriendCode { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FriendCode()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +code():string|null +code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +code(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +expiresAtUnix():bigint { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); +} + +static startFriendCode(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, codeOffset, 0); +} + +static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) { + builder.addFieldInt64(1, expiresAtUnix, BigInt('0')); +} + +static endFriendCode(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createFriendCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset, expiresAtUnix:bigint):flatbuffers.Offset { + FriendCode.startFriendCode(builder); + FriendCode.addCode(builder, codeOffset); + FriendCode.addExpiresAtUnix(builder, expiresAtUnix); + return FriendCode.endFriendCode(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/friend-list.ts b/ui/src/gen/fbs/scrabblefb/friend-list.ts new file mode 100644 index 0000000..602e1b0 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/friend-list.ts @@ -0,0 +1,66 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { AccountRef } from '../scrabblefb/account-ref.js'; + + +export class FriendList { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FriendList { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFriendList(bb:flatbuffers.ByteBuffer, obj?:FriendList):FriendList { + return (obj || new FriendList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFriendList(bb:flatbuffers.ByteBuffer, obj?:FriendList):FriendList { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FriendList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +friends(index: number, obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +friendsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startFriendList(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addFriends(builder:flatbuffers.Builder, friendsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, friendsOffset, 0); +} + +static createFriendsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startFriendsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endFriendList(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createFriendList(builder:flatbuffers.Builder, friendsOffset:flatbuffers.Offset):flatbuffers.Offset { + FriendList.startFriendList(builder); + FriendList.addFriends(builder, friendsOffset); + return FriendList.endFriendList(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/friend-respond-request.ts b/ui/src/gen/fbs/scrabblefb/friend-respond-request.ts new file mode 100644 index 0000000..df7cc21 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/friend-respond-request.ts @@ -0,0 +1,58 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class FriendRespondRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):FriendRespondRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsFriendRespondRequest(bb:flatbuffers.ByteBuffer, obj?:FriendRespondRequest):FriendRespondRequest { + return (obj || new FriendRespondRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsFriendRespondRequest(bb:flatbuffers.ByteBuffer, obj?:FriendRespondRequest):FriendRespondRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new FriendRespondRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +requesterId():string|null +requesterId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +requesterId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +accept():boolean { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +static startFriendRespondRequest(builder:flatbuffers.Builder) { + builder.startObject(2); +} + +static addRequesterId(builder:flatbuffers.Builder, requesterIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, requesterIdOffset, 0); +} + +static addAccept(builder:flatbuffers.Builder, accept:boolean) { + builder.addFieldInt8(1, +accept, +false); +} + +static endFriendRespondRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createFriendRespondRequest(builder:flatbuffers.Builder, requesterIdOffset:flatbuffers.Offset, accept:boolean):flatbuffers.Offset { + FriendRespondRequest.startFriendRespondRequest(builder); + FriendRespondRequest.addRequesterId(builder, requesterIdOffset); + FriendRespondRequest.addAccept(builder, accept); + return FriendRespondRequest.endFriendRespondRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/gcg-export.ts b/ui/src/gen/fbs/scrabblefb/gcg-export.ts new file mode 100644 index 0000000..ab844fa --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/gcg-export.ts @@ -0,0 +1,72 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class GcgExport { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):GcgExport { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsGcgExport(bb:flatbuffers.ByteBuffer, obj?:GcgExport):GcgExport { + return (obj || new GcgExport()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsGcgExport(bb:flatbuffers.ByteBuffer, obj?:GcgExport):GcgExport { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new GcgExport()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +gameId():string|null +gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +gameId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +filename():string|null +filename(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +filename(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +content():string|null +content(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +content(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startGcgExport(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, gameIdOffset, 0); +} + +static addFilename(builder:flatbuffers.Builder, filenameOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, filenameOffset, 0); +} + +static addContent(builder:flatbuffers.Builder, contentOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, contentOffset, 0); +} + +static endGcgExport(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createGcgExport(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, filenameOffset:flatbuffers.Offset, contentOffset:flatbuffers.Offset):flatbuffers.Offset { + GcgExport.startGcgExport(builder); + GcgExport.addGameId(builder, gameIdOffset); + GcgExport.addFilename(builder, filenameOffset); + GcgExport.addContent(builder, contentOffset); + return GcgExport.endGcgExport(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/incoming-request-list.ts b/ui/src/gen/fbs/scrabblefb/incoming-request-list.ts new file mode 100644 index 0000000..7be8a28 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/incoming-request-list.ts @@ -0,0 +1,66 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { AccountRef } from '../scrabblefb/account-ref.js'; + + +export class IncomingRequestList { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):IncomingRequestList { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsIncomingRequestList(bb:flatbuffers.ByteBuffer, obj?:IncomingRequestList):IncomingRequestList { + return (obj || new IncomingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsIncomingRequestList(bb:flatbuffers.ByteBuffer, obj?:IncomingRequestList):IncomingRequestList { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new IncomingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +requests(index: number, obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +requestsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startIncomingRequestList(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, requestsOffset, 0); +} + +static createRequestsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startRequestsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endIncomingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createIncomingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset { + IncomingRequestList.startIncomingRequestList(builder); + IncomingRequestList.addRequests(builder, requestsOffset); + return IncomingRequestList.endIncomingRequestList(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/invitation-action-request.ts b/ui/src/gen/fbs/scrabblefb/invitation-action-request.ts new file mode 100644 index 0000000..52dec24 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/invitation-action-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class InvitationActionRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):InvitationActionRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsInvitationActionRequest(bb:flatbuffers.ByteBuffer, obj?:InvitationActionRequest):InvitationActionRequest { + return (obj || new InvitationActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsInvitationActionRequest(bb:flatbuffers.ByteBuffer, obj?:InvitationActionRequest):InvitationActionRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new InvitationActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +invitationId():string|null +invitationId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +invitationId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startInvitationActionRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addInvitationId(builder:flatbuffers.Builder, invitationIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, invitationIdOffset, 0); +} + +static endInvitationActionRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createInvitationActionRequest(builder:flatbuffers.Builder, invitationIdOffset:flatbuffers.Offset):flatbuffers.Offset { + InvitationActionRequest.startInvitationActionRequest(builder); + InvitationActionRequest.addInvitationId(builder, invitationIdOffset); + return InvitationActionRequest.endInvitationActionRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/invitation-invitee.ts b/ui/src/gen/fbs/scrabblefb/invitation-invitee.ts new file mode 100644 index 0000000..b2d8dd1 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/invitation-invitee.ts @@ -0,0 +1,82 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class InvitationInvitee { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):InvitationInvitee { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsInvitationInvitee(bb:flatbuffers.ByteBuffer, obj?:InvitationInvitee):InvitationInvitee { + return (obj || new InvitationInvitee()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsInvitationInvitee(bb:flatbuffers.ByteBuffer, obj?:InvitationInvitee):InvitationInvitee { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new InvitationInvitee()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +accountId():string|null +accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +accountId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +displayName():string|null +displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +displayName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +seat():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +response():string|null +response(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +response(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startInvitationInvitee(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, accountIdOffset, 0); +} + +static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, displayNameOffset, 0); +} + +static addSeat(builder:flatbuffers.Builder, seat:number) { + builder.addFieldInt32(2, seat, 0); +} + +static addResponse(builder:flatbuffers.Builder, responseOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, responseOffset, 0); +} + +static endInvitationInvitee(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createInvitationInvitee(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, seat:number, responseOffset:flatbuffers.Offset):flatbuffers.Offset { + InvitationInvitee.startInvitationInvitee(builder); + InvitationInvitee.addAccountId(builder, accountIdOffset); + InvitationInvitee.addDisplayName(builder, displayNameOffset); + InvitationInvitee.addSeat(builder, seat); + InvitationInvitee.addResponse(builder, responseOffset); + return InvitationInvitee.endInvitationInvitee(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/invitation-list.ts b/ui/src/gen/fbs/scrabblefb/invitation-list.ts new file mode 100644 index 0000000..b9f058c --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/invitation-list.ts @@ -0,0 +1,66 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { Invitation } from '../scrabblefb/invitation.js'; + + +export class InvitationList { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):InvitationList { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsInvitationList(bb:flatbuffers.ByteBuffer, obj?:InvitationList):InvitationList { + return (obj || new InvitationList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsInvitationList(bb:flatbuffers.ByteBuffer, obj?:InvitationList):InvitationList { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new InvitationList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +invitations(index: number, obj?:Invitation):Invitation|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new Invitation()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +invitationsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +static startInvitationList(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addInvitations(builder:flatbuffers.Builder, invitationsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, invitationsOffset, 0); +} + +static createInvitationsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startInvitationsVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static endInvitationList(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createInvitationList(builder:flatbuffers.Builder, invitationsOffset:flatbuffers.Offset):flatbuffers.Offset { + InvitationList.startInvitationList(builder); + InvitationList.addInvitations(builder, invitationsOffset); + return InvitationList.endInvitationList(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/invitation.ts b/ui/src/gen/fbs/scrabblefb/invitation.ts new file mode 100644 index 0000000..0634f1e --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/invitation.ts @@ -0,0 +1,162 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { AccountRef } from '../scrabblefb/account-ref.js'; +import { InvitationInvitee } from '../scrabblefb/invitation-invitee.js'; + + +export class Invitation { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):Invitation { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsInvitation(bb:flatbuffers.ByteBuffer, obj?:Invitation):Invitation { + return (obj || new Invitation()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsInvitation(bb:flatbuffers.ByteBuffer, obj?:Invitation):Invitation { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new Invitation()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +id():string|null +id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +id(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +inviter(obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +invitees(index: number, obj?:InvitationInvitee):InvitationInvitee|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? (obj || new InvitationInvitee()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +inviteesLength():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + +variant():string|null +variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +variant(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +turnTimeoutSecs():number { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +hintsAllowed():boolean { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +hintsPerPlayer():number { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +dropoutTiles():string|null +dropoutTiles(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +dropoutTiles(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 18); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +status():string|null +status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +status(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 20); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +gameId():string|null +gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +gameId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 22); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +expiresAtUnix():bigint { + const offset = this.bb!.__offset(this.bb_pos, 24); + return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); +} + +static startInvitation(builder:flatbuffers.Builder) { + builder.startObject(11); +} + +static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, idOffset, 0); +} + +static addInviter(builder:flatbuffers.Builder, inviterOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, inviterOffset, 0); +} + +static addInvitees(builder:flatbuffers.Builder, inviteesOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, inviteesOffset, 0); +} + +static createInviteesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset { + builder.startVector(4, data.length, 4); + for (let i = data.length - 1; i >= 0; i--) { + builder.addOffset(data[i]!); + } + return builder.endVector(); +} + +static startInviteesVector(builder:flatbuffers.Builder, numElems:number) { + builder.startVector(4, numElems, 4); +} + +static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, variantOffset, 0); +} + +static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) { + builder.addFieldInt32(4, turnTimeoutSecs, 0); +} + +static addHintsAllowed(builder:flatbuffers.Builder, hintsAllowed:boolean) { + builder.addFieldInt8(5, +hintsAllowed, +false); +} + +static addHintsPerPlayer(builder:flatbuffers.Builder, hintsPerPlayer:number) { + builder.addFieldInt32(6, hintsPerPlayer, 0); +} + +static addDropoutTiles(builder:flatbuffers.Builder, dropoutTilesOffset:flatbuffers.Offset) { + builder.addFieldOffset(7, dropoutTilesOffset, 0); +} + +static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) { + builder.addFieldOffset(8, statusOffset, 0); +} + +static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(9, gameIdOffset, 0); +} + +static addExpiresAtUnix(builder:flatbuffers.Builder, expiresAtUnix:bigint) { + builder.addFieldInt64(10, expiresAtUnix, BigInt('0')); +} + +static endInvitation(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +} diff --git a/ui/src/gen/fbs/scrabblefb/notification-event.ts b/ui/src/gen/fbs/scrabblefb/notification-event.ts new file mode 100644 index 0000000..d3532a6 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/notification-event.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class NotificationEvent { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):NotificationEvent { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsNotificationEvent(bb:flatbuffers.ByteBuffer, obj?:NotificationEvent):NotificationEvent { + return (obj || new NotificationEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsNotificationEvent(bb:flatbuffers.ByteBuffer, obj?:NotificationEvent):NotificationEvent { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new NotificationEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +kind():string|null +kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +kind(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startNotificationEvent(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, kindOffset, 0); +} + +static endNotificationEvent(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createNotificationEvent(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset):flatbuffers.Offset { + NotificationEvent.startNotificationEvent(builder); + NotificationEvent.addKind(builder, kindOffset); + return NotificationEvent.endNotificationEvent(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts index f70e192..e1338f0 100644 --- a/ui/src/gen/fbs/scrabblefb/profile.ts +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -68,8 +68,22 @@ isGuest():boolean { return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; } +awayStart():string|null +awayStart(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +awayStart(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 20); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +awayEnd():string|null +awayEnd(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +awayEnd(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 22); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + static startProfile(builder:flatbuffers.Builder) { - builder.startObject(8); + builder.startObject(10); } static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { @@ -104,12 +118,20 @@ static addIsGuest(builder:flatbuffers.Builder, isGuest:boolean) { builder.addFieldInt8(7, +isGuest, +false); } +static addAwayStart(builder:flatbuffers.Builder, awayStartOffset:flatbuffers.Offset) { + builder.addFieldOffset(8, awayStartOffset, 0); +} + +static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset) { + builder.addFieldOffset(9, awayEndOffset, 0); +} + static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean):flatbuffers.Offset { +static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset):flatbuffers.Offset { Profile.startProfile(builder); Profile.addUserId(builder, userIdOffset); Profile.addDisplayName(builder, displayNameOffset); @@ -119,6 +141,8 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse Profile.addBlockChat(builder, blockChat); Profile.addBlockFriendRequests(builder, blockFriendRequests); Profile.addIsGuest(builder, isGuest); + Profile.addAwayStart(builder, awayStartOffset); + Profile.addAwayEnd(builder, awayEndOffset); return Profile.endProfile(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/redeem-code-request.ts b/ui/src/gen/fbs/scrabblefb/redeem-code-request.ts new file mode 100644 index 0000000..15f3c8f --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/redeem-code-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class RedeemCodeRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):RedeemCodeRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsRedeemCodeRequest(bb:flatbuffers.ByteBuffer, obj?:RedeemCodeRequest):RedeemCodeRequest { + return (obj || new RedeemCodeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsRedeemCodeRequest(bb:flatbuffers.ByteBuffer, obj?:RedeemCodeRequest):RedeemCodeRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new RedeemCodeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +code():string|null +code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +code(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startRedeemCodeRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, codeOffset, 0); +} + +static endRedeemCodeRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createRedeemCodeRequest(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset):flatbuffers.Offset { + RedeemCodeRequest.startRedeemCodeRequest(builder); + RedeemCodeRequest.addCode(builder, codeOffset); + return RedeemCodeRequest.endRedeemCodeRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/redeem-result.ts b/ui/src/gen/fbs/scrabblefb/redeem-result.ts new file mode 100644 index 0000000..b52d508 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/redeem-result.ts @@ -0,0 +1,49 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +import { AccountRef } from '../scrabblefb/account-ref.js'; + + +export class RedeemResult { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):RedeemResult { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsRedeemResult(bb:flatbuffers.ByteBuffer, obj?:RedeemResult):RedeemResult { + return (obj || new RedeemResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsRedeemResult(bb:flatbuffers.ByteBuffer, obj?:RedeemResult):RedeemResult { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new RedeemResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +friend(obj?:AccountRef):AccountRef|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? (obj || new AccountRef()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; +} + +static startRedeemResult(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addFriend(builder:flatbuffers.Builder, friendOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, friendOffset, 0); +} + +static endRedeemResult(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createRedeemResult(builder:flatbuffers.Builder, friendOffset:flatbuffers.Offset):flatbuffers.Offset { + RedeemResult.startRedeemResult(builder); + RedeemResult.addFriend(builder, friendOffset); + return RedeemResult.endRedeemResult(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/stats-view.ts b/ui/src/gen/fbs/scrabblefb/stats-view.ts new file mode 100644 index 0000000..dc49989 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/stats-view.ts @@ -0,0 +1,86 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class StatsView { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):StatsView { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsStatsView(bb:flatbuffers.ByteBuffer, obj?:StatsView):StatsView { + return (obj || new StatsView()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsStatsView(bb:flatbuffers.ByteBuffer, obj?:StatsView):StatsView { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new StatsView()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +wins():number { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +losses():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +draws():number { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +maxGamePoints():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +maxWordPoints():number { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +static startStatsView(builder:flatbuffers.Builder) { + builder.startObject(5); +} + +static addWins(builder:flatbuffers.Builder, wins:number) { + builder.addFieldInt32(0, wins, 0); +} + +static addLosses(builder:flatbuffers.Builder, losses:number) { + builder.addFieldInt32(1, losses, 0); +} + +static addDraws(builder:flatbuffers.Builder, draws:number) { + builder.addFieldInt32(2, draws, 0); +} + +static addMaxGamePoints(builder:flatbuffers.Builder, maxGamePoints:number) { + builder.addFieldInt32(3, maxGamePoints, 0); +} + +static addMaxWordPoints(builder:flatbuffers.Builder, maxWordPoints:number) { + builder.addFieldInt32(4, maxWordPoints, 0); +} + +static endStatsView(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createStatsView(builder:flatbuffers.Builder, wins:number, losses:number, draws:number, maxGamePoints:number, maxWordPoints:number):flatbuffers.Offset { + StatsView.startStatsView(builder); + StatsView.addWins(builder, wins); + StatsView.addLosses(builder, losses); + StatsView.addDraws(builder, draws); + StatsView.addMaxGamePoints(builder, maxGamePoints); + StatsView.addMaxWordPoints(builder, maxWordPoints); + return StatsView.endStatsView(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/target-request.ts b/ui/src/gen/fbs/scrabblefb/target-request.ts new file mode 100644 index 0000000..7cf9276 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/target-request.ts @@ -0,0 +1,48 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class TargetRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):TargetRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsTargetRequest(bb:flatbuffers.ByteBuffer, obj?:TargetRequest):TargetRequest { + return (obj || new TargetRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsTargetRequest(bb:flatbuffers.ByteBuffer, obj?:TargetRequest):TargetRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new TargetRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +accountId():string|null +accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +accountId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startTargetRequest(builder:flatbuffers.Builder) { + builder.startObject(1); +} + +static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, accountIdOffset, 0); +} + +static endTargetRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createTargetRequest(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset):flatbuffers.Offset { + TargetRequest.startTargetRequest(builder); + TargetRequest.addAccountId(builder, accountIdOffset); + return TargetRequest.endTargetRequest(builder); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/update-profile-request.ts b/ui/src/gen/fbs/scrabblefb/update-profile-request.ts new file mode 100644 index 0000000..8bb3919 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/update-profile-request.ts @@ -0,0 +1,116 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class UpdateProfileRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):UpdateProfileRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsUpdateProfileRequest(bb:flatbuffers.ByteBuffer, obj?:UpdateProfileRequest):UpdateProfileRequest { + return (obj || new UpdateProfileRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsUpdateProfileRequest(bb:flatbuffers.ByteBuffer, obj?:UpdateProfileRequest):UpdateProfileRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new UpdateProfileRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +displayName():string|null +displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +displayName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +preferredLanguage():string|null +preferredLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +preferredLanguage(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +timeZone():string|null +timeZone(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +timeZone(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +awayStart():string|null +awayStart(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +awayStart(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +awayEnd():string|null +awayEnd(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +awayEnd(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 12); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +blockChat():boolean { + const offset = this.bb!.__offset(this.bb_pos, 14); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +blockFriendRequests():boolean { + const offset = this.bb!.__offset(this.bb_pos, 16); + return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; +} + +static startUpdateProfileRequest(builder:flatbuffers.Builder) { + builder.startObject(7); +} + +static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, displayNameOffset, 0); +} + +static addPreferredLanguage(builder:flatbuffers.Builder, preferredLanguageOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, preferredLanguageOffset, 0); +} + +static addTimeZone(builder:flatbuffers.Builder, timeZoneOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, timeZoneOffset, 0); +} + +static addAwayStart(builder:flatbuffers.Builder, awayStartOffset:flatbuffers.Offset) { + builder.addFieldOffset(3, awayStartOffset, 0); +} + +static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset) { + builder.addFieldOffset(4, awayEndOffset, 0); +} + +static addBlockChat(builder:flatbuffers.Builder, blockChat:boolean) { + builder.addFieldInt8(5, +blockChat, +false); +} + +static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:boolean) { + builder.addFieldInt8(6, +blockFriendRequests, +false); +} + +static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean):flatbuffers.Offset { + UpdateProfileRequest.startUpdateProfileRequest(builder); + UpdateProfileRequest.addDisplayName(builder, displayNameOffset); + UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset); + UpdateProfileRequest.addTimeZone(builder, timeZoneOffset); + UpdateProfileRequest.addAwayStart(builder, awayStartOffset); + UpdateProfileRequest.addAwayEnd(builder, awayEndOffset); + UpdateProfileRequest.addBlockChat(builder, blockChat); + UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests); + return UpdateProfileRequest.endUpdateProfileRequest(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 2fbce2f..fa1581c 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -28,6 +28,8 @@ export const app = $state<{ reduceMotion: boolean; boardLabels: BoardLabelMode; localeLocked: boolean; + /** Pending incoming friend requests + invitations, for the lobby badge. */ + notifications: number; }>({ ready: false, session: null, @@ -39,6 +41,7 @@ export const app = $state<{ reduceMotion: false, boardLabels: 'beginner', localeLocked: false, + notifications: 0, }); let unsubscribeStream: (() => void) | null = null; @@ -76,12 +79,35 @@ function openStream(): void { showToast(t('game.yourTurn'), 'info'); } else if (e.kind === 'match_found') { navigate(`/game/${e.gameId}`); + } else if (e.kind === 'notify') { + void refreshNotifications(); } }, () => showToast(t('error.unavailable'), 'error'), ); } +/** + * refreshNotifications recomputes the lobby badge count (incoming friend requests + * plus open invitations). Authoritative poll, complementing the live 'notify' push. + * Guests have no social surfaces, so it is a no-op for them. + */ +export async function refreshNotifications(): Promise { + if (!app.session || app.profile?.isGuest) { + app.notifications = 0; + return; + } + try { + const [incoming, invitations] = await Promise.all([ + gateway.friendsIncoming(), + gateway.invitationsList(), + ]); + app.notifications = incoming.length + invitations.length; + } catch { + // Best-effort; leave the previous count on a transient failure. + } +} + function closeStream(): void { unsubscribeStream?.(); unsubscribeStream = null; @@ -98,6 +124,7 @@ async function adoptSession(s: Session): Promise { handleError(err); } openStream(); + void refreshNotifications(); } export async function bootstrap(): Promise { @@ -186,6 +213,30 @@ export function setLocalePref(locale: Locale): void { app.localeLocked = true; setLocale(locale); persistPrefs(); + void persistLanguageToServer(locale); +} + +/** + * persistLanguageToServer writes the chosen interface language through to the + * durable account's preferred_language, so the single Settings control is the + * source of truth (guests keep only the client preference). Best-effort. + */ +async function persistLanguageToServer(locale: Locale): Promise { + const p = app.profile; + if (!p || p.isGuest || p.preferredLanguage === locale) return; + try { + app.profile = await gateway.profileUpdate({ + displayName: p.displayName, + preferredLanguage: locale, + timeZone: p.timeZone, + awayStart: p.awayStart, + awayEnd: p.awayEnd, + blockChat: p.blockChat, + blockFriendRequests: p.blockFriendRequests, + }); + } catch { + // The client locale already changed; the server sync is best-effort. + } } export function setReduceMotion(on: boolean): void { @@ -198,3 +249,11 @@ export function setBoardLabels(mode: BoardLabelMode): void { app.boardLabels = mode; persistPrefs(); } + +// Refresh the lobby badge when the app returns to the foreground — a push 'notify' +// may have been missed while the client was hidden/closed (poll + push, see §10). +if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && app.session) void refreshNotifications(); + }); +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 9dc5578..318ec51 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -5,18 +5,25 @@ // message. import type { + AccountRef, ChatMessage, EvalResult, + FriendCode, GameList, GameView, + GcgExport, History, HintResult, + Invitation, + InvitationSettings, MatchResult, MoveResult, Profile, + ProfileUpdate, PushEvent, Session, StateView, + Stats, Tile, Variant, WordCheckResult, @@ -74,6 +81,35 @@ export interface GatewayClient { chatList(gameId: string): Promise; nudge(gameId: string): Promise; + // --- friends (Stage 8) --- + friendsList(): Promise; + friendsIncoming(): Promise; + friendRequest(accountId: string): Promise; + friendRespond(requesterId: string, accept: boolean): Promise; + friendCancel(accountId: string): Promise; + unfriend(accountId: string): Promise; + friendCodeIssue(): Promise; + friendCodeRedeem(code: string): Promise; + + // --- blocks (Stage 8) --- + blocksList(): Promise; + block(accountId: string): Promise; + unblock(accountId: string): Promise; + + // --- invitations (Stage 8) --- + invitationsList(): Promise; + invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise; + invitationAccept(invitationId: string): Promise; + invitationDecline(invitationId: string): Promise; + invitationCancel(invitationId: string): Promise; + + // --- profile / stats / history (Stage 8) --- + profileUpdate(p: ProfileUpdate): Promise; + emailBindRequest(email: string): Promise; + emailBindConfirm(email: string, code: string): Promise; + statsGet(): Promise; + exportGcg(gameId: string): Promise; + // --- live stream --- subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index ef28f5d..d5f5022 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -1,7 +1,15 @@ import { Builder, ByteBuffer } from 'flatbuffers'; import { describe, expect, it } from 'vitest'; import * as fb from '../gen/fbs/scrabblefb'; -import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec'; +import { + decodeFriendList, + decodeGameList, + decodeInvitation, + decodeSession, + decodeStats, + encodeSubmitPlay, + encodeTarget, +} from './codec'; describe('codec', () => { it('encodes a SubmitPlayRequest the gateway can read', () => { @@ -77,4 +85,88 @@ describe('codec', () => { expect(gl.games[0].seats[0].displayName).toBe('Ann'); expect(gl.games[0].seats[0].score).toBe(13); }); + + it('encodes a TargetRequest', () => { + const r = fb.TargetRequest.getRootAsTargetRequest(new ByteBuffer(encodeTarget('a-1'))); + expect(r.accountId()).toBe('a-1'); + }); + + it('decodes a StatsView', () => { + const b = new Builder(64); + fb.StatsView.startStatsView(b); + fb.StatsView.addWins(b, 7); + fb.StatsView.addLosses(b, 4); + fb.StatsView.addDraws(b, 1); + fb.StatsView.addMaxGamePoints(b, 420); + fb.StatsView.addMaxWordPoints(b, 90); + b.finish(fb.StatsView.endStatsView(b)); + expect(decodeStats(b.asUint8Array())).toEqual({ + wins: 7, + losses: 4, + draws: 1, + maxGamePoints: 420, + maxWordPoints: 90, + }); + }); + + it('decodes a FriendList of account refs', () => { + const b = new Builder(128); + const id = b.createString('a-1'); + const dn = b.createString('Ann'); + fb.AccountRef.startAccountRef(b); + fb.AccountRef.addAccountId(b, id); + fb.AccountRef.addDisplayName(b, dn); + const ref = fb.AccountRef.endAccountRef(b); + const vec = fb.FriendList.createFriendsVector(b, [ref]); + fb.FriendList.startFriendList(b); + fb.FriendList.addFriends(b, vec); + b.finish(fb.FriendList.endFriendList(b)); + expect(decodeFriendList(b.asUint8Array())).toEqual([{ accountId: 'a-1', displayName: 'Ann' }]); + }); + + it('decodes an Invitation with inviter and invitees', () => { + const b = new Builder(256); + const iid = b.createString('u-1'); + const idn = b.createString('Me'); + fb.AccountRef.startAccountRef(b); + fb.AccountRef.addAccountId(b, iid); + fb.AccountRef.addDisplayName(b, idn); + const inviter = fb.AccountRef.endAccountRef(b); + + const aid = b.createString('inv-1'); + const adn = b.createString('Friend'); + const resp = b.createString('pending'); + fb.InvitationInvitee.startInvitationInvitee(b); + fb.InvitationInvitee.addAccountId(b, aid); + fb.InvitationInvitee.addDisplayName(b, adn); + fb.InvitationInvitee.addSeat(b, 1); + fb.InvitationInvitee.addResponse(b, resp); + const invitee = fb.InvitationInvitee.endInvitationInvitee(b); + const invitees = fb.Invitation.createInviteesVector(b, [invitee]); + + const id = b.createString('i-1'); + const variant = b.createString('english'); + const dropout = b.createString('remove'); + const status = b.createString('pending'); + const gid = b.createString(''); + fb.Invitation.startInvitation(b); + fb.Invitation.addId(b, id); + fb.Invitation.addInviter(b, inviter); + fb.Invitation.addInvitees(b, invitees); + fb.Invitation.addVariant(b, variant); + fb.Invitation.addTurnTimeoutSecs(b, 86400); + fb.Invitation.addHintsAllowed(b, true); + fb.Invitation.addHintsPerPlayer(b, 1); + fb.Invitation.addDropoutTiles(b, dropout); + fb.Invitation.addStatus(b, status); + fb.Invitation.addGameId(b, gid); + b.finish(fb.Invitation.endInvitation(b)); + + const inv = decodeInvitation(b.asUint8Array()); + expect(inv.id).toBe('i-1'); + expect(inv.inviter).toEqual({ accountId: 'u-1', displayName: 'Me' }); + expect(inv.invitees).toHaveLength(1); + expect(inv.invitees[0]).toEqual({ accountId: 'inv-1', displayName: 'Friend', seat: 1, response: 'pending' }); + expect(inv.variant).toBe('english'); + }); }); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 383b846..5148151 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -7,20 +7,28 @@ import { Builder, ByteBuffer, type Offset } from 'flatbuffers'; import * as fb from '../gen/fbs/scrabblefb'; import type { PlacedTile } from './client'; import type { + AccountRef, ChatMessage, EvalResult, + FriendCode, GameList, GameView, + GcgExport, History, HintResult, + Invitation, + InvitationInvitee, + InvitationSettings, MatchResult, MoveRecord, MoveResult, Profile, + ProfileUpdate, PushEvent, Seat, Session, StateView, + Stats, Tile, Variant, WordCheckResult, @@ -250,6 +258,8 @@ export function decodeProfile(buf: Uint8Array): Profile { displayName: s(p.displayName()), preferredLanguage: s(p.preferredLanguage()), timeZone: s(p.timeZone()), + awayStart: s(p.awayStart()), + awayEnd: s(p.awayEnd()), hintBalance: p.hintBalance(), blockChat: p.blockChat(), blockFriendRequests: p.blockFriendRequests(), @@ -357,6 +367,10 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb); return { kind: 'match_found', gameId: s(e.gameId()) }; } + case 'notify': { + const e = fb.NotificationEvent.getRootAsNotificationEvent(bb); + return { kind: 'notify', sub: s(e.kind()) }; + } case 'heartbeat': return { kind: 'heartbeat' }; default: @@ -364,6 +378,199 @@ export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null } } +// --- Stage 8 encoders --- + +export function encodeTarget(accountId: string): Uint8Array { + const b = new Builder(64); + const id = b.createString(accountId); + fb.TargetRequest.startTargetRequest(b); + fb.TargetRequest.addAccountId(b, id); + return finish(b, fb.TargetRequest.endTargetRequest(b)); +} + +export function encodeFriendRespond(requesterId: string, accept: boolean): Uint8Array { + const b = new Builder(64); + const id = b.createString(requesterId); + fb.FriendRespondRequest.startFriendRespondRequest(b); + fb.FriendRespondRequest.addRequesterId(b, id); + fb.FriendRespondRequest.addAccept(b, accept); + return finish(b, fb.FriendRespondRequest.endFriendRespondRequest(b)); +} + +export function encodeRedeemCode(code: string): Uint8Array { + const b = new Builder(32); + const c = b.createString(code); + fb.RedeemCodeRequest.startRedeemCodeRequest(b); + fb.RedeemCodeRequest.addCode(b, c); + return finish(b, fb.RedeemCodeRequest.endRedeemCodeRequest(b)); +} + +export function encodeCreateInvitation(inviteeIds: string[], st: InvitationSettings): Uint8Array { + const b = new Builder(256); + const idOffs = inviteeIds.map((id) => b.createString(id)); + const ids = fb.CreateInvitationRequest.createInviteeIdsVector(b, idOffs); + const variant = b.createString(st.variant); + const dropout = b.createString(st.dropoutTiles); + fb.CreateInvitationRequest.startCreateInvitationRequest(b); + fb.CreateInvitationRequest.addInviteeIds(b, ids); + fb.CreateInvitationRequest.addVariant(b, variant); + fb.CreateInvitationRequest.addTurnTimeoutSecs(b, st.turnTimeoutSecs); + fb.CreateInvitationRequest.addHintsAllowed(b, st.hintsAllowed); + fb.CreateInvitationRequest.addHintsPerPlayer(b, st.hintsPerPlayer); + fb.CreateInvitationRequest.addDropoutTiles(b, dropout); + return finish(b, fb.CreateInvitationRequest.endCreateInvitationRequest(b)); +} + +export function encodeInvitationAction(invitationId: string): Uint8Array { + const b = new Builder(64); + const id = b.createString(invitationId); + fb.InvitationActionRequest.startInvitationActionRequest(b); + fb.InvitationActionRequest.addInvitationId(b, id); + return finish(b, fb.InvitationActionRequest.endInvitationActionRequest(b)); +} + +export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array { + const b = new Builder(256); + const name = b.createString(p.displayName); + const lang = b.createString(p.preferredLanguage); + const tz = b.createString(p.timeZone); + const as = b.createString(p.awayStart); + const ae = b.createString(p.awayEnd); + fb.UpdateProfileRequest.startUpdateProfileRequest(b); + fb.UpdateProfileRequest.addDisplayName(b, name); + fb.UpdateProfileRequest.addPreferredLanguage(b, lang); + fb.UpdateProfileRequest.addTimeZone(b, tz); + fb.UpdateProfileRequest.addAwayStart(b, as); + fb.UpdateProfileRequest.addAwayEnd(b, ae); + fb.UpdateProfileRequest.addBlockChat(b, p.blockChat); + fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests); + return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); +} + +export function encodeEmailBind(email: string): Uint8Array { + const b = new Builder(128); + const e = b.createString(email); + fb.EmailBindRequest.startEmailBindRequest(b); + fb.EmailBindRequest.addEmail(b, e); + return finish(b, fb.EmailBindRequest.endEmailBindRequest(b)); +} + +export function encodeEmailConfirm(email: string, code: string): Uint8Array { + const b = new Builder(128); + const e = b.createString(email); + const c = b.createString(code); + fb.EmailConfirmRequest.startEmailConfirmRequest(b); + fb.EmailConfirmRequest.addEmail(b, e); + fb.EmailConfirmRequest.addCode(b, c); + return finish(b, fb.EmailConfirmRequest.endEmailConfirmRequest(b)); +} + +// --- Stage 8 decoders --- + +function decodeAccountRef(r: fb.AccountRef): AccountRef { + return { accountId: s(r.accountId()), displayName: s(r.displayName()) }; +} + +export function decodeFriendList(buf: Uint8Array): AccountRef[] { + const l = fb.FriendList.getRootAsFriendList(new ByteBuffer(buf)); + const out: AccountRef[] = []; + for (let i = 0; i < l.friendsLength(); i++) { + const r = l.friends(i); + if (r) out.push(decodeAccountRef(r)); + } + return out; +} + +export function decodeIncomingList(buf: Uint8Array): AccountRef[] { + const l = fb.IncomingRequestList.getRootAsIncomingRequestList(new ByteBuffer(buf)); + const out: AccountRef[] = []; + for (let i = 0; i < l.requestsLength(); i++) { + const r = l.requests(i); + if (r) out.push(decodeAccountRef(r)); + } + return out; +} + +export function decodeBlockList(buf: Uint8Array): AccountRef[] { + const l = fb.BlockList.getRootAsBlockList(new ByteBuffer(buf)); + const out: AccountRef[] = []; + for (let i = 0; i < l.blockedLength(); i++) { + const r = l.blocked(i); + if (r) out.push(decodeAccountRef(r)); + } + return out; +} + +export function decodeFriendCode(buf: Uint8Array): FriendCode { + const c = fb.FriendCode.getRootAsFriendCode(new ByteBuffer(buf)); + return { code: s(c.code()), expiresAtUnix: Number(c.expiresAtUnix()) }; +} + +export function decodeRedeemResult(buf: Uint8Array): AccountRef { + const r = fb.RedeemResult.getRootAsRedeemResult(new ByteBuffer(buf)); + const f = r.friend(); + return f ? decodeAccountRef(f) : { accountId: '', displayName: '' }; +} + +export function decodeStats(buf: Uint8Array): Stats { + const v = fb.StatsView.getRootAsStatsView(new ByteBuffer(buf)); + return { + wins: v.wins(), + losses: v.losses(), + draws: v.draws(), + maxGamePoints: v.maxGamePoints(), + maxWordPoints: v.maxWordPoints(), + }; +} + +function decodeInvitationTable(i: fb.Invitation): Invitation { + const inviter = i.inviter(); + const invitees: InvitationInvitee[] = []; + for (let k = 0; k < i.inviteesLength(); k++) { + const iv = i.invitees(k); + if (iv) { + invitees.push({ + accountId: s(iv.accountId()), + displayName: s(iv.displayName()), + seat: iv.seat(), + response: s(iv.response()), + }); + } + } + return { + id: s(i.id()), + inviter: inviter ? decodeAccountRef(inviter) : { accountId: '', displayName: '' }, + invitees, + variant: s(i.variant()) as Variant, + turnTimeoutSecs: i.turnTimeoutSecs(), + hintsAllowed: i.hintsAllowed(), + hintsPerPlayer: i.hintsPerPlayer(), + dropoutTiles: s(i.dropoutTiles()), + status: s(i.status()), + gameId: s(i.gameId()), + expiresAtUnix: Number(i.expiresAtUnix()), + }; +} + +export function decodeInvitation(buf: Uint8Array): Invitation { + return decodeInvitationTable(fb.Invitation.getRootAsInvitation(new ByteBuffer(buf))); +} + +export function decodeInvitationList(buf: Uint8Array): Invitation[] { + const l = fb.InvitationList.getRootAsInvitationList(new ByteBuffer(buf)); + const out: Invitation[] = []; + for (let i = 0; i < l.invitationsLength(); i++) { + const inv = l.invitations(i); + if (inv) out.push(decodeInvitationTable(inv)); + } + return out; +} + +export function decodeGcg(buf: Uint8Array): GcgExport { + const g = fb.GcgExport.getRootAsGcgExport(new ByteBuffer(buf)); + return { gameId: s(g.gameId()), filename: s(g.filename()), content: s(g.content()) }; +} + function emptyGame(): GameView { return { id: '', diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1279f19..cfb4497 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -102,7 +102,21 @@ export const en = { 'profile.timezone': 'Time zone', 'profile.hintBalance': 'Hint balance', 'profile.guest': 'Guest account', - 'profile.readonly': 'Editing your profile arrives in a later update.', + 'profile.edit': 'Edit profile', + 'profile.displayName': 'Display name', + 'profile.awayWindow': 'Away window', + 'profile.awayHint': 'You are not auto-resigned during these hours.', + 'profile.from': 'From', + 'profile.to': 'To', + 'profile.blockChat': 'Disable chat', + 'profile.blockFriendRequests': 'Disable friend requests', + 'profile.email': 'Email', + 'profile.bindEmail': 'Bind email', + 'profile.emailCode': 'Confirmation code', + 'profile.emailSent': 'We sent a code to {email}.', + 'profile.emailBound': 'Email confirmed.', + 'profile.saved': 'Profile saved.', + 'profile.guestLocked': 'Sign in with email to manage your profile.', 'settings.title': 'Settings', 'settings.theme': 'Theme', @@ -143,6 +157,83 @@ export const en = { 'error.unavailable': 'Connection problem. Retrying…', 'error.internal': 'Something went wrong.', 'error.generic': 'Something went wrong.', + + 'lobby.invitations': 'Invitations', + 'lobby.friends': 'Friends', + + 'friends.title': 'Friends', + 'friends.yours': 'Your friends', + 'friends.none': 'No friends yet.', + 'friends.incoming': 'Friend requests', + 'friends.accept': 'Accept', + 'friends.decline': 'Decline', + 'friends.unfriend': 'Remove', + 'friends.block': 'Block', + 'friends.add': 'Add a friend', + 'friends.addFromGame': 'Add to friends', + 'friends.requestSent': 'Friend request sent.', + 'friends.getCode': 'Show my code', + 'friends.codeHint': 'Give this code to a friend within 12 hours.', + 'friends.codeExpires': 'Expires at {time}', + 'friends.enterCode': 'Have a code? Add a friend', + 'friends.codePlaceholder': '6-digit code', + 'friends.redeem': 'Add', + 'friends.added': 'Added {name}.', + 'friends.blockedList': 'Blocked players', + 'friends.unblock': 'Unblock', + 'friends.noneBlocked': 'No blocked players.', + + 'invitations.none': 'No invitations.', + 'invitations.from': 'From {name}', + 'invitations.with': 'With {names}', + 'invitations.accept': 'Accept', + 'invitations.decline': 'Decline', + 'invitations.cancel': 'Cancel', + 'invitations.waiting': 'Waiting for replies', + + 'new.auto': 'Quick match', + 'new.withFriends': 'Play with friends', + 'new.pickFriends': 'Choose who to invite', + 'new.invite': 'Send invitation', + 'new.moveTime': 'Move time', + 'new.hintsPerPlayer': 'Hints per player', + 'new.invited': 'Invitation sent.', + 'new.noFriends': 'Add friends first to invite them.', + + 'stats.title': 'Statistics', + 'stats.wins': 'Wins', + 'stats.losses': 'Losses', + 'stats.draws': 'Draws', + 'stats.played': 'Games', + 'stats.winRate': 'Win rate', + 'stats.maxGame': 'Best game', + 'stats.maxWord': 'Best move', + 'stats.guestHint': 'Sign in to track your statistics.', + + 'game.exportGcg': 'Export GCG', + 'game.gcgActiveOnly': 'Available once the game is finished.', + + 'time.minutes': '{n} min', + 'time.hours': '{n} h', + + 'error.self_relation': 'You cannot do that to yourself.', + 'error.request_exists': 'A request or friendship already exists.', + 'error.request_blocked': 'This player is not accepting requests.', + 'error.request_not_found': 'No matching friend request.', + 'error.no_shared_game': 'You can only add someone you have played with.', + 'error.request_declined': 'This player declined your request.', + 'error.friend_code_invalid': 'That friend code is invalid or expired.', + 'error.invalid_invitation': 'Invalid invitation.', + 'error.invitation_blocked': 'You cannot invite this player.', + 'error.invitation_not_found': 'Invitation not found.', + 'error.invitation_not_pending': 'This invitation is no longer open.', + 'error.invitation_expired': 'This invitation has expired.', + 'error.not_invited': 'You were not invited.', + 'error.already_responded': 'You already responded.', + 'error.not_inviter': 'Only the inviter can do that.', + 'error.game_active': 'Available only after the game is finished.', + 'error.invalid_profile': 'Some profile fields are invalid.', + 'error.already_confirmed': 'This email is already confirmed.', } as const; export type MessageKey = keyof typeof en; diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index f541894..c46f94b 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -103,7 +103,21 @@ export const ru: Record = { 'profile.timezone': 'Часовой пояс', 'profile.hintBalance': 'Баланс подсказок', 'profile.guest': 'Гостевой аккаунт', - 'profile.readonly': 'Редактирование профиля появится в следующем обновлении.', + 'profile.edit': 'Редактировать профиль', + 'profile.displayName': 'Отображаемое имя', + 'profile.awayWindow': 'Окно отсутствия', + 'profile.awayHint': 'В эти часы вам не засчитывают автопоражение.', + 'profile.from': 'С', + 'profile.to': 'До', + 'profile.blockChat': 'Отключить чат', + 'profile.blockFriendRequests': 'Отключить заявки в друзья', + 'profile.email': 'Эл. почта', + 'profile.bindEmail': 'Привязать почту', + 'profile.emailCode': 'Код подтверждения', + 'profile.emailSent': 'Мы отправили код на {email}.', + 'profile.emailBound': 'Почта подтверждена.', + 'profile.saved': 'Профиль сохранён.', + 'profile.guestLocked': 'Войдите по почте, чтобы управлять профилем.', 'settings.title': 'Настройки', 'settings.theme': 'Тема', @@ -144,4 +158,81 @@ export const ru: Record = { 'error.unavailable': 'Проблема соединения. Повторяем…', 'error.internal': 'Что-то пошло не так.', 'error.generic': 'Что-то пошло не так.', + + 'lobby.invitations': 'Приглашения', + 'lobby.friends': 'Друзья', + + 'friends.title': 'Друзья', + 'friends.yours': 'Ваши друзья', + 'friends.none': 'Друзей пока нет.', + 'friends.incoming': 'Заявки в друзья', + 'friends.accept': 'Принять', + 'friends.decline': 'Отклонить', + 'friends.unfriend': 'Удалить', + 'friends.block': 'Заблокировать', + 'friends.add': 'Добавить друга', + 'friends.addFromGame': 'В друзья', + 'friends.requestSent': 'Заявка в друзья отправлена.', + 'friends.getCode': 'Показать мой код', + 'friends.codeHint': 'Передайте этот код другу в течение 12 часов.', + 'friends.codeExpires': 'Истекает в {time}', + 'friends.enterCode': 'Есть код? Добавить друга', + 'friends.codePlaceholder': 'Код из 6 цифр', + 'friends.redeem': 'Добавить', + 'friends.added': 'Добавлен(а) {name}.', + 'friends.blockedList': 'Заблокированные', + 'friends.unblock': 'Разблокировать', + 'friends.noneBlocked': 'Заблокированных нет.', + + 'invitations.none': 'Приглашений нет.', + 'invitations.from': 'От {name}', + 'invitations.with': 'С {names}', + 'invitations.accept': 'Принять', + 'invitations.decline': 'Отклонить', + 'invitations.cancel': 'Отменить', + 'invitations.waiting': 'Ожидаем ответы', + + 'new.auto': 'Быстрая игра', + 'new.withFriends': 'Игра с друзьями', + 'new.pickFriends': 'Кого пригласить', + 'new.invite': 'Отправить приглашение', + 'new.moveTime': 'Время на ход', + 'new.hintsPerPlayer': 'Подсказок на игрока', + 'new.invited': 'Приглашение отправлено.', + 'new.noFriends': 'Сначала добавьте друзей, чтобы пригласить их.', + + 'stats.title': 'Статистика', + 'stats.wins': 'Победы', + 'stats.losses': 'Поражения', + 'stats.draws': 'Ничьи', + 'stats.played': 'Игр', + 'stats.winRate': 'Доля побед', + 'stats.maxGame': 'Лучшая игра', + 'stats.maxWord': 'Лучший ход', + 'stats.guestHint': 'Войдите, чтобы вести статистику.', + + 'game.exportGcg': 'Экспорт GCG', + 'game.gcgActiveOnly': 'Доступно после завершения игры.', + + 'time.minutes': '{n} мин', + 'time.hours': '{n} ч', + + 'error.self_relation': 'Нельзя сделать это с самим собой.', + 'error.request_exists': 'Заявка или дружба уже существует.', + 'error.request_blocked': 'Игрок не принимает заявки.', + 'error.request_not_found': 'Подходящей заявки нет.', + 'error.no_shared_game': 'Можно добавить только того, с кем вы играли.', + 'error.request_declined': 'Игрок отклонил вашу заявку.', + 'error.friend_code_invalid': 'Код недействителен или истёк.', + 'error.invalid_invitation': 'Неверное приглашение.', + 'error.invitation_blocked': 'Нельзя пригласить этого игрока.', + 'error.invitation_not_found': 'Приглашение не найдено.', + 'error.invitation_not_pending': 'Приглашение больше не открыто.', + 'error.invitation_expired': 'Приглашение истекло.', + 'error.not_invited': 'Вас не приглашали.', + 'error.already_responded': 'Вы уже ответили.', + 'error.not_inviter': 'Только пригласивший может это сделать.', + 'error.game_active': 'Доступно только после завершения игры.', + 'error.invalid_profile': 'Некоторые поля профиля некорректны.', + 'error.already_confirmed': 'Эта почта уже подтверждена.', }; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index c948cf0..fd8ae7b 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -12,22 +12,39 @@ import type { } from '../client'; import { GatewayError } from '../client'; import type { + AccountRef, ChatMessage, EvalResult, + FriendCode, GameList, + GcgExport, History, HintResult, + Invitation, + InvitationSettings, MatchResult, MoveResult, Profile, + ProfileUpdate, PushEvent, Session, StateView, + Stats, Variant, WordCheckResult, } from '../model'; import { tileValue } from '../premiums'; -import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data'; +import { + ME, + MOCK_FRIENDS, + MOCK_INCOMING, + MOCK_STATS, + PROFILE, + SESSION, + mockInvitations, + seedGames, + type MockGame, +} from './data'; const POOL: Record = { english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', @@ -57,6 +74,11 @@ export class MockGateway implements GatewayClient { private readonly profile: Profile = { ...PROFILE }; private readonly subs = new Set<(e: PushEvent) => void>(); private pendingMatch: string | null = null; + private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); + private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f })); + private blocks: AccountRef[] = []; + private invitations: Invitation[] = mockInvitations(); + private readonly stats: Stats = { ...MOCK_STATS }; setToken(_token: string | null): void { // The mock needs no auth; the real transport stores the bearer token. @@ -300,6 +322,114 @@ export class MockGateway implements GatewayClient { return msg; } + // --- friends --- + private nameFor(id: string): string { + return this.friends.find((f) => f.accountId === id)?.displayName ?? id; + } + async friendsList(): Promise { + return this.friends.map((f) => ({ ...f })); + } + async friendsIncoming(): Promise { + return this.incoming.map((f) => ({ ...f })); + } + async friendRequest(_accountId: string): Promise { + // The real backend requires a shared game; the mock simply acknowledges. + } + async friendRespond(requesterId: string, accept: boolean): Promise { + const i = this.incoming.findIndex((r) => r.accountId === requesterId); + if (i < 0) throw new GatewayError('request_not_found'); + const [r] = this.incoming.splice(i, 1); + if (accept) this.friends.push(r); + this.emit({ kind: 'notify', sub: 'friend_request' }); + } + async friendCancel(_accountId: string): Promise {} + async unfriend(accountId: string): Promise { + this.friends = this.friends.filter((f) => f.accountId !== accountId); + } + async friendCodeIssue(): Promise { + return { code: '246813', expiresAtUnix: Math.floor(Date.now() / 1000) + 12 * 3600 }; + } + async friendCodeRedeem(code: string): Promise { + const friend = { accountId: `code-${code}`, displayName: `Friend ${code}` }; + this.friends.push(friend); + return { ...friend }; + } + + // --- blocks --- + async blocksList(): Promise { + return this.blocks.map((b) => ({ ...b })); + } + async block(accountId: string): Promise { + this.friends = this.friends.filter((f) => f.accountId !== accountId); + if (!this.blocks.some((b) => b.accountId === accountId)) { + this.blocks.push({ accountId, displayName: this.nameFor(accountId) }); + } + } + async unblock(accountId: string): Promise { + this.blocks = this.blocks.filter((b) => b.accountId !== accountId); + } + + // --- invitations --- + async invitationsList(): Promise { + return this.invitations.map((i) => structuredClone(i)); + } + async invitationCreate(inviteeIds: string[], settings: InvitationSettings): Promise { + const inv: Invitation = { + id: crypto.randomUUID(), + inviter: { accountId: ME, displayName: 'You' }, + invitees: inviteeIds.map((id, k) => ({ accountId: id, displayName: this.nameFor(id), seat: k + 1, response: 'pending' })), + variant: settings.variant, + turnTimeoutSecs: settings.turnTimeoutSecs, + hintsAllowed: settings.hintsAllowed, + hintsPerPlayer: settings.hintsPerPlayer, + dropoutTiles: settings.dropoutTiles, + status: 'pending', + gameId: '', + expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400, + }; + this.invitations.push(inv); + return structuredClone(inv); + } + private respondInvitation(invitationId: string, status: string): Invitation { + const inv = this.invitations.find((i) => i.id === invitationId); + if (!inv) throw new GatewayError('invitation_not_found'); + inv.status = status; + this.invitations = this.invitations.filter((i) => i.id !== invitationId); + return structuredClone(inv); + } + async invitationAccept(invitationId: string): Promise { + return this.respondInvitation(invitationId, 'started'); + } + async invitationDecline(invitationId: string): Promise { + return this.respondInvitation(invitationId, 'declined'); + } + async invitationCancel(invitationId: string): Promise { + this.invitations = this.invitations.filter((i) => i.id !== invitationId); + } + + // --- profile / stats / history --- + async profileUpdate(p: ProfileUpdate): Promise { + Object.assign(this.profile, p); + return { ...this.profile }; + } + async emailBindRequest(_email: string): Promise {} + async emailBindConfirm(_email: string, _code: string): Promise { + this.profile.isGuest = false; + return { ...this.profile }; + } + async statsGet(): Promise { + return { ...this.stats }; + } + async exportGcg(gameId: string): Promise { + const g = this.game(gameId); + if (g.view.status !== 'finished') throw new GatewayError('game_active'); + return { + gameId, + filename: `game-${gameId}.gcg`, + content: `#character-encoding UTF-8\n#player1 p1 You\n#player2 p2 Opp\n`, + }; + } + // --- live stream --- subscribe(onEvent: (e: PushEvent) => void): Unsubscribe { this.subs.add(onEvent); diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 42801f8..82bc2cf 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -4,7 +4,17 @@ // not need to be strictly legal here — this is a visual/interaction fixture; real // legality and scoring come from the backend. -import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model'; +import type { + AccountRef, + ChatMessage, + GameView, + Invitation, + MoveRecord, + Profile, + Seat, + Session, + Stats, +} from '../model'; export const ME = 'me'; @@ -20,12 +30,43 @@ export const PROFILE: Profile = { displayName: 'You', preferredLanguage: 'en', timeZone: 'UTC', + awayStart: '00:00', + awayEnd: '07:00', hintBalance: 3, blockChat: false, blockFriendRequests: false, - isGuest: true, + isGuest: false, }; +// Seed social/account data for the mock (pnpm start + Playwright). The mock profile +// is a durable account so the Stage 8 surfaces (friends, stats, history) are reachable. +export const MOCK_FRIENDS: AccountRef[] = [ + { accountId: 'ann', displayName: 'Ann' }, + { accountId: 'kaya', displayName: 'Kaya' }, +]; + +export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }]; + +export const MOCK_STATS: Stats = { wins: 7, losses: 4, draws: 1, maxGamePoints: 421, maxWordPoints: 95 }; + +export function mockInvitations(): Invitation[] { + return [ + { + id: 'inv1', + inviter: { accountId: 'kaya', displayName: 'Kaya' }, + invitees: [{ accountId: ME, displayName: 'You', seat: 1, response: 'pending' }], + variant: 'english', + turnTimeoutSecs: 86400, + hintsAllowed: true, + hintsPerPlayer: 1, + dropoutTiles: 'remove', + status: 'pending', + gameId: '', + expiresAtUnix: Math.floor(Date.now() / 1000) + 7 * 86400, + }, + ]; +} + function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat { return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner }; } diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index ea3e8b1..2d4cfb8 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -100,12 +100,84 @@ export interface Profile { displayName: string; preferredLanguage: string; timeZone: string; + /** "HH:MM" daily away-window bounds, in timeZone. */ + awayStart: string; + awayEnd: string; hintBalance: number; blockChat: boolean; blockFriendRequests: boolean; isGuest: boolean; } +/** The full editable profile sent to profileUpdate (overwrites every field). */ +export interface ProfileUpdate { + displayName: string; + preferredLanguage: string; + timeZone: string; + awayStart: string; + awayEnd: string; + blockChat: boolean; + blockFriendRequests: boolean; +} + +/** A referenced account with its display name (friend, blocked user, invitee). */ +export interface AccountRef { + accountId: string; + displayName: string; +} + +/** A freshly issued one-time friend code (the plaintext is returned once). */ +export interface FriendCode { + code: string; + expiresAtUnix: number; +} + +/** A durable account's lifetime statistics. */ +export interface Stats { + wins: number; + losses: number; + draws: number; + maxGamePoints: number; + maxWordPoints: number; +} + +/** Settings the inviter chooses for a friend game. */ +export interface InvitationSettings { + variant: Variant; + turnTimeoutSecs: number; + hintsAllowed: boolean; + hintsPerPlayer: number; + dropoutTiles: 'remove' | 'return'; +} + +export interface InvitationInvitee { + accountId: string; + displayName: string; + seat: number; + response: 'pending' | 'accepted' | 'declined' | string; +} + +export interface Invitation { + id: string; + inviter: AccountRef; + invitees: InvitationInvitee[]; + variant: Variant; + turnTimeoutSecs: number; + hintsAllowed: boolean; + hintsPerPlayer: number; + dropoutTiles: string; + status: string; + gameId: string; + expiresAtUnix: number; +} + +/** A finished game's GCG transcript for download/share. */ +export interface GcgExport { + gameId: string; + filename: string; + content: string; +} + export interface Session { token: string; userId: string; @@ -134,4 +206,5 @@ export type PushEvent = | { kind: 'chat_message'; message: ChatMessage } | { kind: 'nudge'; gameId: string; fromUserId: string } | { kind: 'match_found'; gameId: string } + | { kind: 'notify'; sub: string } | { kind: 'heartbeat' }; diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts index 333c7e7..94bdd25 100644 --- a/ui/src/lib/router.svelte.ts +++ b/ui/src/lib/router.svelte.ts @@ -10,6 +10,8 @@ export type RouteName = | 'profile' | 'settings' | 'about' + | 'friends' + | 'stats' | 'notfound'; export interface Route { @@ -34,6 +36,10 @@ function parse(hash: string): Route { return { name: 'settings', params: {} }; case 'about': return { name: 'about', params: {} }; + case 'friends': + return { name: 'friends', params: {} }; + case 'stats': + return { name: 'stats', params: {} }; default: return { name: 'notfound', params: {} }; } diff --git a/ui/src/lib/share.test.ts b/ui/src/lib/share.test.ts new file mode 100644 index 0000000..fbb98ff --- /dev/null +++ b/ui/src/lib/share.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { pickGcgDelivery } from './share'; + +const file = {} as File; + +describe('pickGcgDelivery', () => { + it('shares when the platform can share files', () => { + expect(pickGcgDelivery({ canShare: () => true, share: async () => {} }, file)).toBe('share'); + }); + + it('downloads when the platform cannot share files', () => { + expect(pickGcgDelivery({ canShare: () => false, share: async () => {} }, file)).toBe('download'); + }); + + it('downloads when there is no navigator', () => { + expect(pickGcgDelivery(undefined, file)).toBe('download'); + }); + + it('downloads when the Web Share API is incomplete', () => { + expect(pickGcgDelivery({ canShare: () => true } as never, file)).toBe('download'); + }); +}); diff --git a/ui/src/lib/share.ts b/ui/src/lib/share.ts new file mode 100644 index 0000000..33587b4 --- /dev/null +++ b/ui/src/lib/share.ts @@ -0,0 +1,48 @@ +// GCG export delivery: share on mobile (Web Share API with a file) where supported, +// otherwise download via a Blob + on desktop. The Capacitor-native file +// save lands with the native wrapper; the Web Share path already covers mobile +// browsers. pickGcgDelivery is the pure decision, unit-tested with a mock navigator. + +import type { GcgExport } from './model'; + +type ShareNav = Pick; + +/** pickGcgDelivery decides between the Web Share API and a Blob download for a file. */ +export function pickGcgDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' { + if ( + nav && + typeof nav.canShare === 'function' && + typeof nav.share === 'function' && + nav.canShare({ files: [file] }) + ) { + return 'share'; + } + return 'download'; +} + +/** shareOrDownloadGcg shares the GCG file where supported, else triggers a download. */ +export async function shareOrDownloadGcg(gcg: GcgExport): Promise { + const file = new File([gcg.content], gcg.filename, { type: 'application/x-gcg' }); + const nav = typeof navigator !== 'undefined' ? navigator : undefined; + if (pickGcgDelivery(nav, file) === 'share' && nav) { + try { + await nav.share({ files: [file], title: gcg.filename }); + return; + } catch { + // The user cancelled or sharing failed — fall back to a download. + } + } + downloadFile(gcg.content, gcg.filename); +} + +function downloadFile(content: string, filename: string): void { + if (typeof document === 'undefined') return; + const url = URL.createObjectURL(new Blob([content], { type: 'application/x-gcg' })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/ui/src/lib/stats.test.ts b/ui/src/lib/stats.test.ts new file mode 100644 index 0000000..ccfdee2 --- /dev/null +++ b/ui/src/lib/stats.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { gamesPlayed, winRate } from './stats'; +import type { Stats } from './model'; + +const s = (wins: number, losses: number, draws: number): Stats => ({ + wins, + losses, + draws, + maxGamePoints: 0, + maxWordPoints: 0, +}); + +describe('stats', () => { + it('sums games played', () => { + expect(gamesPlayed(s(7, 4, 1))).toBe(12); + }); + + it('computes a rounded win rate', () => { + expect(winRate(s(7, 4, 1))).toBe(58); // 7/12 = 58.33 -> 58 + expect(winRate(s(1, 1, 0))).toBe(50); + }); + + it('win rate is 0 with no games', () => { + expect(winRate(s(0, 0, 0))).toBe(0); + }); +}); diff --git a/ui/src/lib/stats.ts b/ui/src/lib/stats.ts new file mode 100644 index 0000000..5c7e753 --- /dev/null +++ b/ui/src/lib/stats.ts @@ -0,0 +1,14 @@ +// Pure derivations for the statistics screen, extracted so they are unit-testable. + +import type { Stats } from './model'; + +/** gamesPlayed is the total finished games (wins + losses + draws). */ +export function gamesPlayed(s: Stats): number { + return s.wins + s.losses + s.draws; +} + +/** winRate is the percentage of finished games won, rounded; 0 when none played. */ +export function winRate(s: Stats): number { + const n = gamesPlayed(s); + return n > 0 ? Math.round((s.wins / n) * 100) : 0; +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 1d3bc5c..5d7e5c4 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -119,6 +119,73 @@ export function createTransport(baseUrl: string): GatewayClient { return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id))); }, + async friendsList() { + return codec.decodeFriendList(await exec('friends.list', codec.empty())); + }, + async friendsIncoming() { + return codec.decodeIncomingList(await exec('friends.incoming', codec.empty())); + }, + async friendRequest(accountId) { + await exec('friends.request', codec.encodeTarget(accountId)); + }, + async friendRespond(requesterId, accept) { + await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept)); + }, + async friendCancel(accountId) { + await exec('friends.cancel', codec.encodeTarget(accountId)); + }, + async unfriend(accountId) { + await exec('friends.unfriend', codec.encodeTarget(accountId)); + }, + async friendCodeIssue() { + return codec.decodeFriendCode(await exec('friends.code.issue', codec.empty())); + }, + async friendCodeRedeem(code) { + return codec.decodeRedeemResult(await exec('friends.code.redeem', codec.encodeRedeemCode(code))); + }, + + async blocksList() { + return codec.decodeBlockList(await exec('blocks.list', codec.empty())); + }, + async block(accountId) { + await exec('blocks.add', codec.encodeTarget(accountId)); + }, + async unblock(accountId) { + await exec('blocks.remove', codec.encodeTarget(accountId)); + }, + + async invitationsList() { + return codec.decodeInvitationList(await exec('invitation.list', codec.empty())); + }, + async invitationCreate(inviteeIds, settings) { + return codec.decodeInvitation(await exec('invitation.create', codec.encodeCreateInvitation(inviteeIds, settings))); + }, + async invitationAccept(invitationId) { + return codec.decodeInvitation(await exec('invitation.accept', codec.encodeInvitationAction(invitationId))); + }, + async invitationDecline(invitationId) { + return codec.decodeInvitation(await exec('invitation.decline', codec.encodeInvitationAction(invitationId))); + }, + async invitationCancel(invitationId) { + await exec('invitation.cancel', codec.encodeInvitationAction(invitationId)); + }, + + async profileUpdate(p) { + return codec.decodeProfile(await exec('profile.update', codec.encodeUpdateProfile(p))); + }, + async emailBindRequest(email) { + await exec('email.bind.request', codec.encodeEmailBind(email)); + }, + async emailBindConfirm(email, code) { + return codec.decodeProfile(await exec('email.bind.confirm', codec.encodeEmailConfirm(email, code))); + }, + async statsGet() { + return codec.decodeStats(await exec('stats.get', codec.empty())); + }, + async exportGcg(gameId) { + return codec.decodeGcg(await exec('game.gcg', codec.encodeGameAction(gameId))); + }, + subscribe(onEvent, onError) { const ctrl = new AbortController(); void (async () => { diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte new file mode 100644 index 0000000..b0261ad --- /dev/null +++ b/ui/src/screens/Friends.svelte @@ -0,0 +1,243 @@ + + + +
    + {#if app.profile?.isGuest} +

    {t('profile.guestLocked')}

    + {:else} +
    +

    {t('friends.add')}

    +
    + + +
    + {#if code} +
    + {code.code} + + {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} + +
    + {:else} + + {/if} +
    + + {#if incoming.length} +
    +

    {t('friends.incoming')}

    + {#each incoming as r (r.accountId)} +
    + {r.displayName} + + + + +
    + {/each} +
    + {/if} + +
    +

    {t('friends.yours')}

    + {#if friends.length} + {#each friends as f (f.accountId)} +
    + {f.displayName} + + + + +
    + {/each} + {:else} +

    {t('friends.none')}

    + {/if} +
    + + {#if blocked.length} +
    +

    {t('friends.blockedList')}

    + {#each blocked as b (b.accountId)} +
    + {b.displayName} + +
    + {/each} +
    + {/if} + {/if} +
    +
    + + diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 22175a8..ad0513a 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -6,15 +6,23 @@ import { app, handleError, showToast } from '../lib/app.svelte'; import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; - import { t } from '../lib/i18n/index.svelte'; + import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; - import type { GameView } from '../lib/model'; + import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); + let invitations = $state([]); + let incoming = $state([]); + + const guest = $derived(app.profile?.isGuest ?? true); async function load() { try { games = (await gateway.gamesList()).games; + if (!guest) { + [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); + app.notifications = invitations.length + incoming.length; + } } catch (e) { handleError(e); } @@ -42,18 +50,73 @@ } const menuItems = $derived([ + ...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]), { label: t('lobby.profile'), onclick: () => navigate('/profile') }, { label: t('lobby.settings'), onclick: () => navigate('/settings') }, { label: t('lobby.about'), onclick: () => navigate('/about') }, ]); + + async function acceptInvite(inv: Invitation) { + try { + const r = await gateway.invitationAccept(inv.id); + if (r.gameId) navigate(`/game/${r.gameId}`); + else await load(); + } catch (e) { + handleError(e); + } + } + const declineInvite = (inv: Invitation) => act(() => gateway.invitationDecline(inv.id)); + const cancelInvite = (inv: Invitation) => act(() => gateway.invitationCancel(inv.id)); + async function act(fn: () => Promise) { + try { + await fn(); + await load(); + } catch (e) { + handleError(e); + } + } + + const variantKey: Record = { + english: 'new.english', + russian: 'new.russian', + erudit: 'new.erudit', + }; {#snippet menu()} - + {/snippet}
    + {#if invitations.length} +
    +

    {t('lobby.invitations')}

    + {#each invitations as inv (inv.id)} +
    + 💌 + + {#if inv.inviter.accountId === myId} + {t('invitations.with', { names: inv.invitees.map((i) => i.displayName).join(', ') })} + {t('invitations.waiting')} + {:else} + {t('invitations.from', { name: inv.inviter.displayName })} + {t(variantKey[inv.variant] ?? 'new.english')} + {/if} + + + {#if inv.inviter.accountId === myId} + + {:else} + + + {/if} + +
    + {/each} +
    + {/if} + {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)} {#if group.list.length}
    @@ -72,7 +135,7 @@ {/if} {/each} - {#if !active.length && !finished.length} + {#if !active.length && !finished.length && !invitations.length}

    {t('lobby.noActive')}

    {/if}
    @@ -82,11 +145,11 @@ - {/snippet} @@ -111,7 +174,8 @@ font-size: 0.9rem; margin: 0; } - .row { + .row, + .invite { display: flex; align-items: center; justify-content: space-between; @@ -147,4 +211,23 @@ line-height: 1; flex: 0 0 auto; } + .acts { + display: flex; + gap: 8px; + flex: 0 0 auto; + } + .btn { + padding: 8px 12px; + border: 1px solid var(--accent); + background: var(--accent); + color: var(--accent-text); + border-radius: var(--radius-sm); + } + .ghost { + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + } diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 40d9c04..8db6cad 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -1,18 +1,28 @@ @@ -60,12 +107,60 @@ {:else} -

    {t('new.subtitle')}

    -
    - {#each variants as v (v.id)} - - {/each} -
    + {#if !guest} +
    + + +
    + {/if} + + {#if mode === 'auto'} +

    {t('new.subtitle')}

    +
    + {#each variants as v (v.id)} + + {/each} +
    + {:else} + {#if friends.length === 0} +

    {t('new.noFriends')}

    + {:else} +

    {t('new.pickFriends')}

    +
    + {#each friends as f (f.accountId)} + + {/each} +
    + +

    {t('new.title')}

    +
    + {#each variants as v (v.id)} + + {/each} +
    + +

    {t('new.moveTime')}

    +
    + {#each timeouts as to (to.secs)} + + {/each} +
    + +

    {t('new.hintsPerPlayer')}

    +
    + {#each [0, 1, 2] as h (h)} + + {/each} +
    + + + {/if} + {/if} {/if} @@ -73,15 +168,23 @@