From 6b6baf5710383eac1390d72905a7eb0992b8e842 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Mon, 8 Jun 2026 19:23:48 +0200 Subject: [PATCH] Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lobby: group the my-games list into your-turn / opponent-turn / finished (empty sections hidden), ordered by last activity (your-turn oldest-first, the other two newest-first), as a compact line-separated list. gameDTO and FB GameView gain last_activity_unix (turn start while active, finish time once finished); a pure lib/lobbysort.ts holds the grouping/ordering. Friends: the in-game 'add to friends' item is now server-derived via a new GET /user/friends/outgoing (+ friends.outgoing op), returning addressees with a pending OR declined request (both read as 'request sent'), so it is correct across reloads; it shows a disabled '✓ in friends' once accepted. It live-updates when the opponent answers: RespondFriendRequest now publishes friend_added (accept) / friend_declined (new notify sub-kind, decline) to the original requester, whose open game re-derives its friend state. Tests: lobbysort unit test; gateway outgoing + last_activity transcode tests; backend integration ListOutgoingRequests + respond-publishes-to-requester; e2e updated for the new lobby section labels + a non-friend active opponent. Docs: ARCHITECTURE notify catalog, FUNCTIONAL(+ru) lobby/friends, PLAN. --- PLAN.md | 28 ++++- backend/internal/inttest/social_test.go | 118 ++++++++++++++++++ backend/internal/notify/events.go | 4 +- backend/internal/notify/notify.go | 7 +- backend/internal/server/dto.go | 48 ++++--- backend/internal/server/handlers.go | 1 + backend/internal/server/handlers_friends.go | 22 ++++ backend/internal/social/friends.go | 39 ++++++ docs/ARCHITECTURE.md | 6 +- docs/FUNCTIONAL.md | 11 +- docs/FUNCTIONAL_ru.md | 11 +- gateway/internal/backendclient/api.go | 21 ++-- gateway/internal/backendclient/api_social.go | 14 +++ gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/encode_social.go | 10 ++ .../internal/transcode/transcode_social.go | 12 ++ .../transcode/transcode_social_test.go | 29 +++++ gateway/internal/transcode/transcode_test.go | 5 +- pkg/fbs/scrabble.fbs | 15 ++- pkg/fbs/scrabblefb/GameView.go | 17 ++- pkg/fbs/scrabblefb/OutgoingRequestList.go | 75 +++++++++++ ui/e2e/smoke.spec.ts | 2 +- ui/e2e/social.spec.ts | 4 +- ui/e2e/telegram.spec.ts | 2 +- ui/src/game/Game.svelte | 32 ++++- ui/src/gen/fbs/scrabblefb.ts | 1 + ui/src/gen/fbs/scrabblefb/game-view.ts | 14 ++- .../fbs/scrabblefb/outgoing-request-list.ts | 66 ++++++++++ ui/src/lib/client.ts | 2 + ui/src/lib/codec.ts | 12 ++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/lobbysort.test.ts | 68 ++++++++++ ui/src/lib/lobbysort.ts | 39 ++++++ ui/src/lib/mock/client.ts | 13 +- ui/src/lib/mock/data.ts | 10 +- ui/src/lib/model.ts | 2 + ui/src/lib/result.test.ts | 1 + ui/src/lib/transport.ts | 3 + ui/src/screens/Lobby.svelte | 57 ++++++--- 40 files changed, 743 insertions(+), 81 deletions(-) create mode 100644 pkg/fbs/scrabblefb/OutgoingRequestList.go create mode 100644 ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts create mode 100644 ui/src/lib/lobbysort.test.ts create mode 100644 ui/src/lib/lobbysort.ts diff --git a/PLAN.md b/PLAN.md index 62cd4e2..549c9aa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1355,11 +1355,29 @@ provided cert) at the contour caddy; prod VPN; rollback. and a ☼/☾ theme toggle that is **ephemeral** (follows the system scheme, never persisted, no "auto"). The "Play in browser" CTA was dropped (no standalone-web onboarding yet). - **Game/Telegram review-pass polish:** the USSR flag emblem redrawn (canonical hammer & sickle, - scaled down ×1.5 below the star); touch drag enlarges the drag ghost ×1.5 (touch only — the - finger hides the tile) and suppresses the iOS tap-highlight that lingered on a rack tile sliding - into a dragged tile's slot; and **Telegram fullscreen** no longer hides our header under its - native nav — the header drops below the content-safe-area top inset and the menu (hamburger) - lifts into the nav band, centred (`--tg-content-top` from the SDK + a `tg-fullscreen` class). + scaled down ×1.5 below the star, the star itself +25%); touch drag enlarges the drag ghost ×1.5 + (touch only — the finger hides the tile) and suppresses the iOS tap-highlight that lingered on a + rack tile sliding into a dragged tile's slot; a placed tile can be **dragged to another board + cell** (it lifts off its origin for the drag, and `touch-action:none` lets the drag win over the + board pan when zoomed) and the manual-select ring clears when a tile is recalled; and **Telegram + fullscreen** no longer hides our header under its native nav — the whole header drops below the + content-safe-area top inset (title and the right-aligned menu both clear the nav), via + `--tg-content-top` from the SDK + a `tg-fullscreen` class. (Telegram's Mini App SDK exposes no way + to set the native nav-bar title, move its buttons, or add items to its "⋯" menu, so we keep our + own header and simply push it clear.) + - **Lobby sort + in-game friend state (review pass, PR C):** the **my-games** lobby now groups games + into *your turn* / *opponent's turn* / *finished* (empty sections hidden) and orders them by last + activity — your-turn oldest-first (the longest-waiting on top), the other two newest-first — in a + compact, line-separated list (the owner's density pick over bordered cards). `gameDTO` / FB + `GameView` gained `last_activity_unix` (the turn start while active, the finish time once + finished). The in-game **"add to friends"** item is now **server-derived** (new `GET + /user/friends/outgoing` + `friends.outgoing` op, returning the addressees already requested — + pending **or** declined, which both read as "request sent") so it is correct across reloads, shows + a disabled **"✓ in friends"** once accepted, and **live-updates** when the opponent answers: + `RespondFriendRequest` now publishes `friend_added` (accept) / `friend_declined` (a new notify + sub-kind, decline) to the **original requester**, whose open game re-derives its friend state. + Owner decisions: a declined request stays "request sent" (non-revealing); an accepted opponent + reads "✓ in friends"; rack-tile reorder while tiles are placed stays disabled by design. ## Deferred TODOs (cross-stage) diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go index 33908fa..3ecb7f0 100644 --- a/backend/internal/inttest/social_test.go +++ b/backend/internal/inttest/social_test.go @@ -6,6 +6,7 @@ import ( "context" "errors" "strings" + "sync" "testing" "time" @@ -14,9 +15,36 @@ import ( "scrabble/backend/internal/account" "scrabble/backend/internal/engine" "scrabble/backend/internal/game" + "scrabble/backend/internal/notify" "scrabble/backend/internal/social" + fb "scrabble/pkg/fbs/scrabblefb" ) +// capturePublisher records every published intent for assertions on live events. +type capturePublisher struct { + mu sync.Mutex + intents []notify.Intent +} + +func (c *capturePublisher) Publish(in ...notify.Intent) { + c.mu.Lock() + defer c.mu.Unlock() + c.intents = append(c.intents, in...) +} + +// notified reports whether a Notification with the given sub-kind was published to user. +func (c *capturePublisher) notified(user uuid.UUID, sub string) bool { + c.mu.Lock() + defer c.mu.Unlock() + for _, in := range c.intents { + if in.UserID == user && in.Kind == notify.KindNotification && + string(fb.GetRootAsNotificationEvent(in.Payload, 0).Kind()) == sub { + return true + } + } + return false +} + // newSocialService builds a social service over the shared pool, reading game // state through a real game service. func newSocialService() *social.Service { @@ -383,3 +411,93 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) { t.Fatalf("nudge after acting = %v, want allowed (cooldown reset)", err) } } + +// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to +// friends" item (Stage 17): a pending request shows for the requester only; an accepted one +// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as +// still "sent"); a lazily expired pending one drops (it may be re-sent). +func TestListOutgoingRequests(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + + // Pending: outgoing for the requester, not the addressee. + _, s1 := newGameWithSeats(t, 2) + a, b := s1[0], s1[1] + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("send: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 1 || got[0] != b { + t.Fatalf("outgoing pending = %v, want [b]", got) + } + if got, _ := svc.ListOutgoingRequests(ctx, b); len(got) != 0 { + t.Fatalf("addressee outgoing = %v, want none", got) + } + // Accepted: a friendship, no longer an outgoing request. + if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { + t.Fatalf("accept: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, a); len(got) != 0 { + t.Fatalf("outgoing after accept = %v, want none", got) + } + + // Declined: stays outgoing (reads as sent; cannot re-send). + _, s2 := newGameWithSeats(t, 2) + c, d := s2[0], s2[1] + if err := svc.SendFriendRequest(ctx, c, d); err != nil { + t.Fatalf("send2: %v", err) + } + if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil { + t.Fatalf("decline: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, c); len(got) != 1 || got[0] != d { + t.Fatalf("outgoing after decline = %v, want [d]", got) + } + + // Lazily expired pending: omitted (may be re-sent). + _, s3 := newGameWithSeats(t, 2) + e, f := s3[0], s3[1] + if err := svc.SendFriendRequest(ctx, e, f); err != nil { + t.Fatalf("send3: %v", err) + } + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.friendships SET created_at = now() - interval '31 days' WHERE requester_id = $1 AND addressee_id = $2`, e, f); err != nil { + t.Fatalf("backdate: %v", err) + } + if got, _ := svc.ListOutgoingRequests(ctx, e); len(got) != 0 { + t.Fatalf("expired outgoing = %v, want none", got) + } +} + +// TestRespondPublishesToRequester checks that answering a request notifies the original +// requester over the live channel (Stage 17): accept -> friend_added, decline -> +// friend_declined, so a game screen watching that opponent re-derives its friend state. +func TestRespondPublishesToRequester(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + pub := &capturePublisher{} + svc.SetNotifier(pub) + + _, s1 := newGameWithSeats(t, 2) + a, b := s1[0], s1[1] + if err := svc.SendFriendRequest(ctx, a, b); err != nil { + t.Fatalf("send: %v", err) + } + if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil { + t.Fatalf("accept: %v", err) + } + if !pub.notified(a, notify.NotifyFriendAdded) { + t.Errorf("accept did not notify requester with %q", notify.NotifyFriendAdded) + } + + _, s2 := newGameWithSeats(t, 2) + c, d := s2[0], s2[1] + if err := svc.SendFriendRequest(ctx, c, d); err != nil { + t.Fatalf("send2: %v", err) + } + if err := svc.RespondFriendRequest(ctx, d, c, false); err != nil { + t.Fatalf("decline: %v", err) + } + if !pub.notified(c, notify.NotifyFriendDeclined) { + t.Errorf("decline did not notify requester with %q", notify.NotifyFriendDeclined) + } +} diff --git a/backend/internal/notify/events.go b/backend/internal/notify/events.go index 27de53e..0d443c6 100644 --- a/backend/internal/notify/events.go +++ b/backend/internal/notify/events.go @@ -85,8 +85,8 @@ func MatchFound(userID, gameID uuid.UUID) Intent { // 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. +// NotifyFriendAdded, NotifyFriendDeclined, 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) diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go index fd1fb91..89decb9 100644 --- a/backend/internal/notify/notify.go +++ b/backend/internal/notify/notify.go @@ -34,8 +34,11 @@ const ( const ( NotifyFriendRequest = "friend_request" NotifyFriendAdded = "friend_added" - NotifyInvitation = "invitation" - NotifyGameStarted = "game_started" + // NotifyFriendDeclined tells the original requester their request was declined, so a + // game screen watching that opponent re-derives its "add to friends" state. + NotifyFriendDeclined = "friend_declined" + NotifyInvitation = "invitation" + NotifyGameStarted = "game_started" ) // Intent is one live event destined for a single user. Payload is the diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 8fb648c..83eb07a 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -83,16 +83,19 @@ type seatDTO struct { // gameDTO is the shared game summary. type gameDTO struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` - Seats []seatDTO `json:"seats"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` + // LastActivityUnix is the lobby sort key: the current turn's start for an active + // game, the finish time once finished (Stage 17). + LastActivityUnix int64 `json:"last_activity_unix"` + Seats []seatDTO `json:"seats"` } // moveResultDTO is the outcome of a committed move. @@ -189,17 +192,22 @@ func gameDTOFromGame(g game.Game) gameDTO { IsWinner: s.IsWinner, }) } + last := g.TurnStartedAt + if g.FinishedAt != nil { + last = *g.FinishedAt + } return gameDTO{ - ID: g.ID.String(), - Variant: g.Variant.String(), - DictVersion: g.DictVersion, - Status: g.Status, - Players: g.Players, - ToMove: g.ToMove, - TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), - MoveCount: g.MoveCount, - EndReason: g.EndReason, - Seats: seats, + ID: g.ID.String(), + Variant: g.Variant.String(), + DictVersion: g.DictVersion, + Status: g.Status, + Players: g.Players, + ToMove: g.ToMove, + TurnTimeoutSecs: int(g.TurnTimeout.Seconds()), + MoveCount: g.MoveCount, + EndReason: g.EndReason, + LastActivityUnix: last.Unix(), + Seats: seats, } } diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 45c5262..e1e1965 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -87,6 +87,7 @@ func (s *Server) registerRoutes() { u.POST("/games/:id/nudge", s.handleNudge) u.GET("/friends", s.handleListFriends) u.GET("/friends/incoming", s.handleIncomingRequests) + u.GET("/friends/outgoing", s.handleOutgoingRequests) u.POST("/friends/request", s.handleFriendRequest) u.POST("/friends/respond", s.handleFriendRespond) u.POST("/friends/cancel", s.handleFriendCancel) diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go index 83e0f3f..6e650fb 100644 --- a/backend/internal/server/handlers_friends.go +++ b/backend/internal/server/handlers_friends.go @@ -31,6 +31,12 @@ type incomingListDTO struct { Requests []accountRefDTO `json:"requests"` } +// outgoingListDTO is the addressees the caller has already requested (a live pending +// request or one the addressee declined) and therefore cannot re-request. +type outgoingListDTO struct { + Requests []accountRefDTO `json:"requests"` +} + // friendCodeDTO is a freshly issued one-time friend code (returned once). type friendCodeDTO struct { Code string `json:"code"` @@ -218,6 +224,22 @@ func (s *Server) handleIncomingRequests(c *gin.Context) { c.JSON(http.StatusOK, incomingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) } +// handleOutgoingRequests returns the addressees the caller has already requested +// (pending or declined) and cannot re-request. +func (s *Server) handleOutgoingRequests(c *gin.Context) { + uid, ok := userID(c) + if !ok { + abortBadRequest(c, "missing identity") + return + } + ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, outgoingListDTO{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) diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go index d879535..7c7406a 100644 --- a/backend/internal/social/friends.go +++ b/backend/internal/social/friends.go @@ -124,6 +124,14 @@ func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, reque if !ok { return ErrRequestNotFound } + // Tell the original requester their request was answered, so a game screen watching + // this opponent re-derives its "add to friends" state (accepted -> friends, declined + // -> stays "request sent"). + if accept { + svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendAdded)) + } else { + svc.pub.Publish(notify.Notification(requesterID, notify.NotifyFriendDeclined)) + } return nil } @@ -156,6 +164,14 @@ func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUI return svc.store.listIncomingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) } +// ListOutgoingRequests returns the account IDs the caller has already requested and +// cannot (re-)request: a live (not yet expired) pending request, or one the addressee +// permanently declined. The game's "add to friends" item reads it to stay disabled +// across reloads (a declined request reads identically to a still-pending one). +func (svc *Service) ListOutgoingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) { + return svc.store.listOutgoingRequests(ctx, accountID, svc.now().Add(-friendRequestTTL)) +} + // 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) { @@ -294,6 +310,29 @@ func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID, c return out, nil } +// listOutgoingRequests returns the addressees of the caller's requests that block a +// re-send: a live (created after cutoff) pending request, or a permanently declined +// one. An ignored pending request that has lazily expired is omitted (it may be re-sent). +func (s *Store) listOutgoingRequests(ctx context.Context, accountID uuid.UUID, cutoff time.Time) ([]uuid.UUID, error) { + stmt := postgres.SELECT(table.Friendships.AddresseeID). + FROM(table.Friendships). + WHERE( + table.Friendships.RequesterID.EQ(postgres.UUID(accountID)). + AND(table.Friendships.Status.EQ(postgres.String(friendDeclined)). + OR(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 { + return nil, fmt.Errorf("social: list outgoing requests: %w", err) + } + out := make([]uuid.UUID, 0, len(rows)) + for _, r := range rows { + out = append(out, r.AddresseeID) + } + return out, nil +} + // edgeEither matches a friendship row between a and b in either direction. func edgeEither(a, b uuid.UUID) postgres.BoolExpression { return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 404d898..ea18f3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -473,8 +473,10 @@ including the mover**, so the mover's own other devices and their lobby refresh in-app only, so the actor gets no out-of-app push for their own move), **chat-message** and **nudge** (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 +friend-added, friend-declined, invitation or game-started; emitted on a friend-request, +on answering one (accept → friend-added, decline → friend-declined — to the original +requester, so a game screen watching that opponent re-derives its "add to friends" state, +Stage 17), and on an invitation create or its 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 350f045..39b93c5 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -58,7 +58,11 @@ account is kept and the guest's games move into it. A merge is blocked only whil two accounts share a game still in progress. ### Lobby & matchmaking *(Stage 4 / 15)* -Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are +Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three +sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and +orders them so the games awaiting your move come first, the longest-waiting on top, while +opponent-turn and finished games are most-recent first; it renders as a compact, +line-separated list (Stage 17). The game types offered on **New Game** are limited to the languages the player's sign-in service supports (English → Scrabble; Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is unrestricted). Variants are shown by their **display name** — both Scrabble variants read @@ -111,7 +115,10 @@ 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 +friendship. In a game, an **add to friends** item for each opponent mirrors the live +relationship: it reads *request sent* (disabled) while a request is pending or was +declined, and *in friends* once accepted — updating in place the moment the opponent +answers, and staying correct across reloads (Stage 17). 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index cf56299..1add5a3 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -59,7 +59,11 @@ Mini App** авторизует по подписанным `initData` плат запрещено, только пока у аккаунтов есть общая незавершённая игра. ### Лобби и подбор *(Stage 4 / 15)* -Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра** +Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — +*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, +что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу +соперника и завершённые — самые свежие сверху; отображается компактным списком с +линиями-разделителями (Stage 17). Типы партий на экране **Новая игра** ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble @@ -113,7 +117,10 @@ Mini App** авторизует по подписанным `initData` плат тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки -снимает её; удаление расторгает дружбу. Глобальная блокировка — отключить входящие +снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого +соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный), +пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте +в момент ответа соперника и оставаясь верным после перезагрузки (Stage 17). Глобальная блокировка — отключить входящие чат и/или заявки — и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 42299eb..94ce576 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -93,16 +93,17 @@ type SeatResp struct { // GameResp is the shared game summary. type GameResp struct { - ID string `json:"id"` - Variant string `json:"variant"` - DictVersion string `json:"dict_version"` - Status string `json:"status"` - Players int `json:"players"` - ToMove int `json:"to_move"` - TurnTimeoutSecs int `json:"turn_timeout_secs"` - MoveCount int `json:"move_count"` - EndReason string `json:"end_reason"` - Seats []SeatResp `json:"seats"` + ID string `json:"id"` + Variant string `json:"variant"` + DictVersion string `json:"dict_version"` + Status string `json:"status"` + Players int `json:"players"` + ToMove int `json:"to_move"` + TurnTimeoutSecs int `json:"turn_timeout_secs"` + MoveCount int `json:"move_count"` + EndReason string `json:"end_reason"` + LastActivityUnix int64 `json:"last_activity_unix"` + Seats []SeatResp `json:"seats"` } // MoveResultResp is the outcome of a committed move. diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index 28399eb..34609dc 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -25,6 +25,12 @@ type IncomingListResp struct { Requests []AccountRefResp `json:"requests"` } +// OutgoingListResp is the addressees the caller has already requested (a live pending +// request or one the addressee declined) and cannot re-request. +type OutgoingListResp struct { + Requests []AccountRefResp `json:"requests"` +} + // FriendCodeResp is a freshly issued one-time friend code. type FriendCodeResp struct { Code string `json:"code"` @@ -134,6 +140,14 @@ func (c *Client) ListIncoming(ctx context.Context, userID string) (IncomingListR return out, err } +// ListOutgoing returns the addressees the caller has already requested (pending or +// declined) and cannot re-request. +func (c *Client) ListOutgoing(ctx context.Context, userID string) (OutgoingListResp, error) { + var out OutgoingListResp + err := c.do(ctx, http.MethodGet, "/api/v1/user/friends/outgoing", 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 diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 06dd632..8c61144 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -357,6 +357,7 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers fb.GameViewAddMoveCount(b, int32(g.MoveCount)) fb.GameViewAddEndReason(b, endReason) fb.GameViewAddSeats(b, seats) + fb.GameViewAddLastActivityUnix(b, g.LastActivityUnix) return fb.GameViewEnd(b) } diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index f6b6dca..618e378 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -54,6 +54,16 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte { return b.FinishedBytes() } +// encodeOutgoingList builds an OutgoingRequestList payload. +func encodeOutgoingList(r backendclient.OutgoingListResp) []byte { + b := flatbuffers.NewBuilder(256) + v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector) + fb.OutgoingRequestListStart(b) + fb.OutgoingRequestListAddRequests(b, v) + b.Finish(fb.OutgoingRequestListEnd(b)) + return b.FinishedBytes() +} + // encodeBlockList builds a BlockList payload. func encodeBlockList(r backendclient.BlockListResp) []byte { b := flatbuffers.NewBuilder(256) diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index fdfa6f6..b89c65c 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -13,6 +13,7 @@ import ( const ( MsgFriendsList = "friends.list" MsgFriendsIncoming = "friends.incoming" + MsgFriendsOutgoing = "friends.outgoing" MsgFriendRequest = "friends.request" MsgFriendRespond = "friends.respond" MsgFriendCancel = "friends.cancel" @@ -37,6 +38,7 @@ const ( 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[MsgFriendsOutgoing] = Op{Handler: friendsOutgoingHandler(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} @@ -78,6 +80,16 @@ func friendsIncomingHandler(backend *backendclient.Client) Handler { } } +func friendsOutgoingHandler(backend *backendclient.Client) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + res, err := backend.ListOutgoing(ctx, req.UserID) + if err != nil { + return nil, err + } + return encodeOutgoingList(res), nil + } +} + func friendRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTargetRequest(req.Payload, 0) diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index e510df0..c5e01fb 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -54,6 +54,35 @@ func TestFriendsListRoundTripDecodesNames(t *testing.T) { } } +func TestFriendsOutgoingRoundTrip(t *testing.T) { + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/user/friends/outgoing" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}]}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil) + op, ok := reg.Lookup(transcode.MsgFriendsOutgoing) + if !ok { + t.Fatal("friends.outgoing not registered") + } + payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"}) + if err != nil { + t.Fatalf("handler: %v", err) + } + ol := fb.GetRootAsOutgoingRequestList(payload, 0) + if ol.RequestsLength() != 1 { + t.Fatalf("outgoing length = %d, want 1", ol.RequestsLength()) + } + var ref fb.AccountRef + ol.Requests(&ref, 0) + if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" { + t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.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" { diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index f9cb2b7..8e1420f 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -158,7 +158,7 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if r.URL.Path != "/api/v1/user/games" { t.Errorf("unexpected path %q", r.URL.Path) } - _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) + _, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"last_activity_unix":1717000000,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`)) }) defer cleanup() @@ -177,6 +177,9 @@ func TestGamesListRoundTripDecodesSeatNames(t *testing.T) { if string(g.Id()) != "g-1" { t.Errorf("game id = %q, want g-1", g.Id()) } + if g.LastActivityUnix() != 1717000000 { + t.Errorf("last activity = %d, want 1717000000", g.LastActivityUnix()) + } var seat fb.SeatView g.Seats(&seat, 1) if string(seat.DisplayName()) != "Ann" { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index b1d8400..b1d5651 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -66,6 +66,9 @@ table GameView { move_count:int; end_reason:string; seats:[SeatView]; + // last_activity_unix is the lobby sort key: the current turn's start for an active + // game, the finish time for a finished one (Stage 17). + last_activity_unix:long; } // MoveRecord is one decoded move (a committed play, or a hint preview). @@ -389,6 +392,13 @@ table IncomingRequestList { requests:[AccountRef]; } +// OutgoingRequestList is the accounts the caller has already requested and cannot +// (re-)request: a live pending request or one the addressee declined. The game's +// "add to friends" item reads it to stay disabled across reloads (Stage 17). +table OutgoingRequestList { + requests:[AccountRef]; +} + // FriendCode is a freshly issued one-time add-a-friend code (returned once). table FriendCode { code:string; @@ -492,8 +502,9 @@ table MatchFoundEvent { // 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. +// discriminator ("friend_request", "friend_added", "friend_declined", "invitation", +// "game_started"); the client re-fetches its lobby counters (and, for a requester +// watching a game, its friend state) on any of them. table NotificationEvent { kind:string; } diff --git a/pkg/fbs/scrabblefb/GameView.go b/pkg/fbs/scrabblefb/GameView.go index 1dcd1a4..01c977a 100644 --- a/pkg/fbs/scrabblefb/GameView.go +++ b/pkg/fbs/scrabblefb/GameView.go @@ -149,8 +149,20 @@ func (rcv *GameView) SeatsLength() int { return 0 } +func (rcv *GameView) LastActivityUnix() int64 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetInt64(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *GameView) MutateLastActivityUnix(n int64) bool { + return rcv._tab.MutateInt64Slot(24, n) +} + func GameViewStart(builder *flatbuffers.Builder) { - builder.StartObject(10) + builder.StartObject(11) } func GameViewAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) @@ -185,6 +197,9 @@ func GameViewAddSeats(builder *flatbuffers.Builder, seats flatbuffers.UOffsetT) func GameViewStartSeatsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func GameViewAddLastActivityUnix(builder *flatbuffers.Builder, lastActivityUnix int64) { + builder.PrependInt64Slot(10, lastActivityUnix, 0) +} func GameViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/OutgoingRequestList.go b/pkg/fbs/scrabblefb/OutgoingRequestList.go new file mode 100644 index 0000000..5ad4d29 --- /dev/null +++ b/pkg/fbs/scrabblefb/OutgoingRequestList.go @@ -0,0 +1,75 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type OutgoingRequestList struct { + _tab flatbuffers.Table +} + +func GetRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &OutgoingRequestList{} + x.Init(buf, n+offset) + return x +} + +func FinishOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsOutgoingRequestList(buf []byte, offset flatbuffers.UOffsetT) *OutgoingRequestList { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &OutgoingRequestList{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedOutgoingRequestListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *OutgoingRequestList) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *OutgoingRequestList) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *OutgoingRequestList) 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 *OutgoingRequestList) RequestsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + +func OutgoingRequestListStart(builder *flatbuffers.Builder) { + builder.StartObject(1) +} +func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0) +} +func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { + return builder.StartVector(4, numElems, 4) +} +func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index ace5d86..041b507 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -8,7 +8,7 @@ test('guest reaches a board and previews a placement', async ({ page }) => { await page.getByRole('button', { name: /guest/i }).click(); - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); const activeRow = page.getByRole('button', { name: /Ann/ }); await expect(activeRow).toBeVisible(); await activeRow.click(); diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 33cdd70..a0ce08b 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures'; async function loginLobby(page: Page): Promise { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); } async function openFriends(page: Page): Promise { @@ -107,7 +107,7 @@ test('play with friends: a game type is required to send an invitation', async ( await expect(send).toBeEnabled(); await send.click(); // the mock creates it and returns to the lobby - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); }); test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => { diff --git a/ui/e2e/telegram.spec.ts b/ui/e2e/telegram.spec.ts index a710e5e..0700bd2 100644 --- a/ui/e2e/telegram.spec.ts +++ b/ui/e2e/telegram.spec.ts @@ -27,7 +27,7 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme', await page.goto('/'); // No guest-login click: the Mini App authenticates from initData and lands on the lobby. - await expect(page.getByText('Active games')).toBeVisible(); + await expect(page.getByText('Your turn')).toBeVisible(); // The Telegram themeParams override the background token at runtime. await expect diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 058f299..1b152b0 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -189,6 +189,7 @@ rackIds = cached.view.rack.map((_, i) => i); } void load(); + void loadFriends(); }); $effect(() => { @@ -201,6 +202,9 @@ } else if (e.kind === 'your_turn' && e.gameId === id) void load(); else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat(); else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat(); + // A request the player sent was answered (accepted -> now friends; declined -> stays + // "request sent"): re-derive the in-game friend state. + else if (e.kind === 'notify' && (e.sub === 'friend_added' || e.sub === 'friend_declined')) void loadFriends(); }); // Tick the nudge cooldown while the chat is open so the control re-enables on time. @@ -681,13 +685,31 @@ } } + // Friend state for the in-game "add to friends" item, derived from the server so it is + // correct across reloads and live-updates when a request is answered (Stage 17): + // `friends` are the caller's accepted friends; `requested` are the addressees already + // requested (pending or declined — both block a re-send and read as "request sent"). + let friends = $state(new Set()); let requested = $state(new Set()); const noop = () => {}; + // loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social + // surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets. + async function loadFriends() { + if (app.profile?.isGuest) return; + try { + const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]); + friends = new Set(fl.map((f) => f.accountId)); + requested = new Set(out.map((f) => f.accountId)); + } catch { + /* best-effort */ + } + } + async function addFriend(accountId: string) { try { await gateway.friendRequest(accountId); - requested = new Set([...requested, accountId]); + requested = new Set([...requested, accountId]); // optimistic; reconciled by loadFriends showToast(t('friends.requestSent')); } catch (e) { handleError(e); @@ -707,9 +729,11 @@ ...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []), ...(!app.profile?.isGuest ? opponents.map((s) => - requested.has(s.accountId) - ? { label: t('game.requestSent'), onclick: noop, disabled: true } - : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }, + friends.has(s.accountId) + ? { label: t('game.alreadyFriends'), onclick: noop, disabled: true } + : requested.has(s.accountId) + ? { label: t('game.requestSent'), onclick: noop, disabled: true } + : { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) }, ) : []), ...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]), diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 7c3f820..5ea566f 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -44,6 +44,7 @@ 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 { OutgoingRequestList } from './scrabblefb/outgoing-request-list.js'; export { PlayTile } from './scrabblefb/play-tile.js'; export { Profile } from './scrabblefb/profile.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts index 7e0f8e9..61a516b 100644 --- a/ui/src/gen/fbs/scrabblefb/game-view.ts +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -88,8 +88,13 @@ seatsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +lastActivityUnix():bigint { + const offset = this.bb!.__offset(this.bb_pos, 24); + return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0'); +} + static startGameView(builder:flatbuffers.Builder) { - builder.startObject(10); + builder.startObject(11); } static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { @@ -144,12 +149,16 @@ static startSeatsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addLastActivityUnix(builder:flatbuffers.Builder, lastActivityUnix:bigint) { + builder.addFieldInt64(10, lastActivityUnix, BigInt('0')); +} + static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset { const offset = builder.endObject(); return offset; } -static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset):flatbuffers.Offset { +static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset, lastActivityUnix:bigint):flatbuffers.Offset { GameView.startGameView(builder); GameView.addId(builder, idOffset); GameView.addVariant(builder, variantOffset); @@ -161,6 +170,7 @@ static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, GameView.addMoveCount(builder, moveCount); GameView.addEndReason(builder, endReasonOffset); GameView.addSeats(builder, seatsOffset); + GameView.addLastActivityUnix(builder, lastActivityUnix); return GameView.endGameView(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts new file mode 100644 index 0000000..ded6fa1 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/outgoing-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 OutgoingRequestList { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):OutgoingRequestList { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList { + return (obj || new OutgoingRequestList()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsOutgoingRequestList(bb:flatbuffers.ByteBuffer, obj?:OutgoingRequestList):OutgoingRequestList { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new OutgoingRequestList()).__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 startOutgoingRequestList(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 endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset):flatbuffers.Offset { + OutgoingRequestList.startOutgoingRequestList(builder); + OutgoingRequestList.addRequests(builder, requestsOffset); + return OutgoingRequestList.endOutgoingRequestList(builder); +} +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index cb01b0e..878a743 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -98,6 +98,8 @@ export interface GatewayClient { // --- friends (Stage 8) --- friendsList(): Promise; friendsIncoming(): Promise; + /** Addressees the caller has already requested (pending or declined); cannot re-request. */ + friendsOutgoing(): Promise; friendRequest(accountId: string): Promise; friendRespond(requesterId: string, accept: boolean): Promise; friendCancel(accountId: string): Promise; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index ea1c51f..b5e69ca 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -249,6 +249,7 @@ function decodeGameView(g: fb.GameView): GameView { turnTimeoutSecs: g.turnTimeoutSecs(), moveCount: g.moveCount(), endReason: s(g.endReason()), + lastActivityUnix: Number(g.lastActivityUnix()), seats, }; } @@ -587,6 +588,16 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] { return out; } +export function decodeOutgoingList(buf: Uint8Array): AccountRef[] { + const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(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[] = []; @@ -678,6 +689,7 @@ function emptyGame(): GameView { turnTimeoutSecs: 0, moveCount: 0, endReason: '', + lastActivityUnix: 0, seats: [], }; } diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index afd94d7..d8a353a 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -241,6 +241,7 @@ export const en = { 'game.exportGcg': 'Export GCG', 'game.gcgActiveOnly': 'Available once the game is finished.', 'game.requestSent': 'Request sent', + 'game.alreadyFriends': '✓ In friends', 'time.minutes': '{n} min', 'time.hours': '{n} h', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b318565..aa35294 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -242,6 +242,7 @@ export const ru: Record = { 'game.exportGcg': 'Экспорт GCG', 'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.requestSent': 'Запрос отправлен', + 'game.alreadyFriends': '✓ В друзьях', 'time.minutes': '{n} мин', 'time.hours': '{n} ч', diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts new file mode 100644 index 0000000..3f5b9f9 --- /dev/null +++ b/ui/src/lib/lobbysort.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { groupGames, isMyTurn } from './lobbysort'; +import type { GameView, Seat } from './model'; + +const ME = 'me'; +const seat = (s: number, accountId: string): Seat => ({ + seat: s, + accountId, + displayName: accountId, + score: 0, + hintsUsed: 0, + isWinner: false, +}); + +function game(id: string, status: GameView['status'], toMove: number, lastActivityUnix: number): GameView { + return { + id, + variant: 'english', + dictVersion: 'v1', + status, + players: 2, + toMove, + turnTimeoutSecs: 0, + moveCount: 0, + endReason: '', + lastActivityUnix, + seats: [seat(0, ME), seat(1, 'opp')], + }; +} + +describe('groupGames', () => { + it('partitions into your-turn, their-turn and finished', () => { + const g = groupGames( + [ + game('a', 'active', 0, 100), // toMove 0 == my seat -> my turn + game('b', 'active', 1, 100), // their turn + game('c', 'finished', 0, 100), + ], + ME, + ); + expect(g.yourTurn.map((x) => x.id)).toEqual(['a']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['b']); + expect(g.finished.map((x) => x.id)).toEqual(['c']); + }); + + it('orders your-turn oldest-first, the other two newest-first', () => { + const g = groupGames( + [ + game('y_new', 'active', 0, 200), + game('y_old', 'active', 0, 100), + game('t_new', 'active', 1, 200), + game('t_old', 'active', 1, 100), + game('f_new', 'finished', 0, 200), + game('f_old', 'finished', 0, 100), + ], + ME, + ); + expect(g.yourTurn.map((x) => x.id)).toEqual(['y_old', 'y_new']); + expect(g.theirTurn.map((x) => x.id)).toEqual(['t_new', 't_old']); + expect(g.finished.map((x) => x.id)).toEqual(['f_new', 'f_old']); + }); + + it('isMyTurn is false for a finished game even at my seat', () => { + expect(isMyTurn(game('x', 'finished', 0, 0), ME)).toBe(false); + expect(isMyTurn(game('x', 'active', 0, 0), ME)).toBe(true); + expect(isMyTurn(game('x', 'active', 1, 0), ME)).toBe(false); + }); +}); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts new file mode 100644 index 0000000..1735d95 --- /dev/null +++ b/ui/src/lib/lobbysort.ts @@ -0,0 +1,39 @@ +// Pure grouping + ordering of the lobby's game list (Stage 17). The lobby shows three +// sections — games awaiting the caller's move, games awaiting the opponent, and finished +// games — each ordered by last activity: your-turn oldest-first (the longest-neglected on +// top), the other two newest-first. + +import type { GameView } from './model'; + +/** isMyTurn reports whether an active game's seat-to-move belongs to the caller. */ +export function isMyTurn(game: GameView, myId: string): boolean { + const me = game.seats.find((s) => s.accountId === myId); + return game.status === 'active' && !!me && game.toMove === me.seat; +} + +/** LobbyGroups holds the three ordered lobby sections. */ +export interface LobbyGroups { + yourTurn: GameView[]; + theirTurn: GameView[]; + finished: GameView[]; +} + +/** + * groupGames partitions games for myId into the three lobby sections and orders each: the + * your-turn games by ascending last activity (the longest-waiting first), the opponent-turn + * and finished games by descending last activity (the most recent first). + */ +export function groupGames(games: GameView[], myId: string): LobbyGroups { + const yourTurn: GameView[] = []; + const theirTurn: GameView[] = []; + const finished: GameView[] = []; + for (const g of games) { + if (g.status !== 'active') finished.push(g); + else if (isMyTurn(g, myId)) yourTurn.push(g); + else theirTurn.push(g); + } + yourTurn.sort((a, b) => a.lastActivityUnix - b.lastActivityUnix); + theirTurn.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); + finished.sort((a, b) => b.lastActivityUnix - a.lastActivityUnix); + return { yourTurn, theirTurn, finished }; +} diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 80c8187..e555407 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -90,6 +90,7 @@ export class MockGateway implements GatewayClient { private pendingMatch: string | null = null; private friends: AccountRef[] = MOCK_FRIENDS.map((f) => ({ ...f })); private incoming: AccountRef[] = MOCK_INCOMING.map((f) => ({ ...f })); + private outgoing: AccountRef[] = []; private blocks: AccountRef[] = []; private invitations: Invitation[] = mockInvitations(); private readonly stats: Stats = { ...MOCK_STATS }; @@ -155,6 +156,7 @@ export class MockGateway implements GatewayClient { turnTimeoutSecs: 86400, moveCount: 0, endReason: '', + lastActivityUnix: Math.floor(Date.now() / 1000), seats: [ { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, @@ -372,8 +374,15 @@ export class MockGateway implements GatewayClient { 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 friendsOutgoing(): Promise { + return this.outgoing.map((f) => ({ ...f })); + } + async friendRequest(accountId: string): Promise { + // The real backend requires a shared game; the mock records the outgoing request so + // the game's "add to friends" item reads as sent across reloads. + if (!this.outgoing.some((o) => o.accountId === accountId)) { + this.outgoing.push({ accountId, displayName: this.nameFor(accountId) }); + } } async friendRespond(requesterId: string, accept: boolean): Promise { const i = this.incoming.findIndex((r) => r.accountId === requesterId); diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index d9ab895..402f243 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -43,10 +43,9 @@ export const PROFILE: Profile = { // 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' }, -]; +// Ann is the active game's opponent but deliberately not a friend, so the in-game +// "add to friends" flow is demonstrable; Kaya (a finished-game opponent) is the friend. +export const MOCK_FRIENDS: AccountRef[] = [{ accountId: 'kaya', displayName: 'Kaya' }]; export const MOCK_INCOMING: AccountRef[] = [{ accountId: 'rick', displayName: 'Rick' }]; @@ -144,6 +143,7 @@ function activeGame(): MockGame { turnTimeoutSecs: 86400, moveCount: G1_MOVES.length, endReason: '', + lastActivityUnix: Math.floor(Date.now() / 1000) - 7200, seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)], }, moves: G1_MOVES, @@ -177,6 +177,7 @@ function finishedG2(): MockGame { turnTimeoutSecs: 86400, moveCount: 2, endReason: 'normal', + lastActivityUnix: Math.floor(Date.now() / 1000) - 86400, seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)], }, moves: [ @@ -211,6 +212,7 @@ function finishedG3(): MockGame { turnTimeoutSecs: 86400, moveCount: 1, endReason: 'resignation', + lastActivityUnix: Math.floor(Date.now() / 1000) - 172800, seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)], }, moves: [ diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 318a2d2..9ce89d3 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -40,6 +40,8 @@ export interface GameView { turnTimeoutSecs: number; moveCount: number; endReason: string; + /** Lobby sort key: the current turn's start (active) or the finish time (finished), Unix seconds. */ + lastActivityUnix: number; seats: Seat[]; } diff --git a/ui/src/lib/result.test.ts b/ui/src/lib/result.test.ts index 3a92bb0..d12bb59 100644 --- a/ui/src/lib/result.test.ts +++ b/ui/src/lib/result.test.ts @@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView { turnTimeoutSecs: 0, moveCount: 0, endReason: '', + lastActivityUnix: 0, seats, }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 607db57..324a3f3 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -137,6 +137,9 @@ export function createTransport(baseUrl: string): GatewayClient { async friendsIncoming() { return codec.decodeIncomingList(await exec('friends.incoming', codec.empty())); }, + async friendsOutgoing() { + return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty())); + }, async friendRequest(accountId) { await exec('friends.request', codec.encodeTarget(accountId)); }, diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index daf61a4..e4480a1 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -9,6 +9,7 @@ import { t, type MessageKey } from '../lib/i18n/index.svelte'; import { resultBadge } from '../lib/result'; import { getLobby, setLobby } from '../lib/lobbycache'; + import { groupGames } from '../lib/lobbysort'; import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); @@ -46,8 +47,7 @@ }); const myId = $derived(app.session?.userId ?? ''); - const active = $derived(games.filter((g) => g.status === 'active')); - const finished = $derived(games.filter((g) => g.status !== 'active')); + const groups = $derived(groupGames(games, myId)); function opponents(g: GameView): string { return g.seats @@ -129,25 +129,26 @@ {/if} - {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)} + {#each [{ h: 'lobby.yourTurn', list: groups.yourTurn }, { h: 'lobby.theirTurn', list: groups.theirTurn }, { h: 'lobby.finishedGames', list: groups.finished }] as group (group.h)} {#if group.list.length}
-

{t(group.h as 'lobby.activeGames')}

- {#each group.list as g (g.id)} - {@const b = resultBadge(g, myId)} - - {/each} +

{t(group.h as 'lobby.yourTurn')}

+
+ {#each group.list as g (g.id)} + + {/each} +
{/if} {/each} - {#if !active.length && !finished.length && !invitations.length} + {#if !games.length && !invitations.length}

{t('lobby.noActive')}

{/if} @@ -186,7 +187,6 @@ font-size: 0.9rem; margin: 0; } - .row, .invite { display: flex; align-items: center; @@ -202,6 +202,31 @@ border-radius: var(--radius); user-select: none; } + /* Game rows are a compact, flat list: no per-card frame, a hairline divider between + consecutive rows (Stage 17). */ + .list { + display: flex; + flex-direction: column; + } + .row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + text-align: left; + padding: 10px 6px; + border: none; + background: none; + color: var(--text); + user-select: none; + } + .row + .row { + border-top: 1px solid var(--border); + } + .row:active { + background: var(--surface-2); + } .info { display: flex; flex-direction: column;