Merge pull request 'Stage 17 round 6 (#16/#17, PR C): lobby sort + server-derived in-game friend state' (#24) from feature/lobby-friends into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 12s
CI / ui (push) Successful in 31s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m6s

This commit was merged in pull request #24.
This commit is contained in:
2026-06-08 18:28:26 +00:00
40 changed files with 743 additions and 81 deletions
+23 -5
View File
@@ -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)
+118
View File
@@ -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)
}
}
+2 -2
View File
@@ -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)
+5 -2
View File
@@ -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
+28 -20
View File
@@ -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,
}
}
+1
View File
@@ -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)
@@ -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)
+39
View File
@@ -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))).
+4 -2
View File
@@ -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
+9 -2
View File
@@ -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
+9 -2
View File
@@ -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). Глобальная блокировка — отключить входящие
чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
+11 -10
View File
@@ -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.
@@ -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
+1
View File
@@ -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)
}
@@ -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)
@@ -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)
@@ -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" {
+4 -1
View File
@@ -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" {
+13 -2
View File
@@ -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;
}
+16 -1
View File
@@ -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()
}
+75
View File
@@ -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()
}
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -7,7 +7,7 @@ import { expect, test, type Page } from './fixtures';
async function loginLobby(page: Page): Promise<void> {
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<void> {
@@ -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 }) => {
+1 -1
View File
@@ -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
+28 -4
View File
@@ -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<string>());
let requested = $state(new Set<string>());
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) }]),
+1
View File
@@ -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';
+12 -2
View File
@@ -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);
}
}
@@ -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);
}
}
+2
View File
@@ -98,6 +98,8 @@ export interface GatewayClient {
// --- friends (Stage 8) ---
friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>;
/** Addressees the caller has already requested (pending or declined); cannot re-request. */
friendsOutgoing(): Promise<AccountRef[]>;
friendRequest(accountId: string): Promise<void>;
friendRespond(requesterId: string, accept: boolean): Promise<void>;
friendCancel(accountId: string): Promise<void>;
+12
View File
@@ -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: [],
};
}
+1
View File
@@ -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',
+1
View File
@@ -242,6 +242,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.requestSent': 'Запрос отправлен',
'game.alreadyFriends': '✓ В друзьях',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',
+68
View File
@@ -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);
});
});
+39
View File
@@ -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 };
}
+11 -2
View File
@@ -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<AccountRef[]> {
return this.incoming.map((f) => ({ ...f }));
}
async friendRequest(_accountId: string): Promise<void> {
// The real backend requires a shared game; the mock simply acknowledges.
async friendsOutgoing(): Promise<AccountRef[]> {
return this.outgoing.map((f) => ({ ...f }));
}
async friendRequest(accountId: string): Promise<void> {
// 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<void> {
const i = this.incoming.findIndex((r) => r.accountId === requesterId);
+6 -4
View File
@@ -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: [
+2
View File
@@ -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[];
}
+1
View File
@@ -22,6 +22,7 @@ function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
turnTimeoutSecs: 0,
moveCount: 0,
endReason: '',
lastActivityUnix: 0,
seats,
};
}
+3
View File
@@ -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));
},
+41 -16
View File
@@ -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<GameView[]>([]);
@@ -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 @@
</section>
{/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}
<section>
<h2>{t(group.h as 'lobby.activeGames')}</h2>
{#each group.list as g (g.id)}
{@const b = resultBadge(g, myId)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
</span>
<span class="emoji">{b.emoji}</span>
</button>
{/each}
<h2>{t(group.h as 'lobby.yourTurn')}</h2>
<div class="list">
{#each group.list as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{scoreline(g)}</span>
</span>
<span class="emoji">{resultBadge(g, myId).emoji}</span>
</button>
{/each}
</div>
</section>
{/if}
{/each}
{#if !active.length && !finished.length && !invitations.length}
{#if !games.length && !invitations.length}
<p class="empty">{t('lobby.noActive')}</p>
{/if}
</div>
@@ -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;