diff --git a/backend/README.md b/backend/README.md index ed6fc53..22fd1af 100644 --- a/backend/README.md +++ b/backend/README.md @@ -45,6 +45,9 @@ including obfuscated forms) and stored with the sender's IP. Each message carrie `unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead` clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer` clears a nudge when its recipient moves — both record the publish-to-read latency. +A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat +in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a +background reaper drops a robot friend request once its game has been finished for **7 days**. `internal/account` gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a development log mailer). The engine now also handles **multi-player drop-out**: in diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 1663c5b..0dce367 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -171,6 +171,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) // A nudge the recipient answered by moving is marked read on the move path. games.SetNudgeClearer(socialSvc.ClearNudges) + // Reap per-game disguised-robot friend requests once their game is long finished + // (the robot ignores them; the row only pins the in-game "request sent" state). + robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger) + go robotReqReaper.Run(ctx) + logger.Info("robot friend request reaper started", + zap.Duration("interval", robotReqReaper.Interval()), + zap.Duration("retention", robotReqReaper.Retention())) feedbackSvc := feedback.NewService(feedback.NewStore(db), accounts) feedbackSvc.SetNotifier(hub) diff --git a/backend/internal/inttest/robot_friend_test.go b/backend/internal/inttest/robot_friend_test.go new file mode 100644 index 0000000..2adb19b --- /dev/null +++ b/backend/internal/inttest/robot_friend_test.go @@ -0,0 +1,82 @@ +//go:build integration + +package inttest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "scrabble/backend/internal/account" + "scrabble/backend/internal/engine" + "scrabble/backend/internal/game" +) + +// TestRobotFriendRequestIsPerGameAndReaped checks that sending a friend request to a +// disguised-robot opponent is recorded per-game in robot_friend_requests — never in the +// friendships table or against the shared robot account — that a re-send is idempotent, and +// that the reaper deletes the row once its game has been finished past the retention window +// while keeping the row for an active game. +func TestRobotFriendRequestIsPerGameAndReaped(t *testing.T) { + ctx := context.Background() + svc := newSocialService() + accs := account.NewStore(testDB) + human := provisionAccount(t) + robot, err := accs.ProvisionRobot(ctx, "robot-friend-"+uuid.NewString(), "Robbie") + if err != nil { + t.Fatalf("provision robot: %v", err) + } + g, err := newGameService().Create(ctx, game.CreateParams{ + Variant: engine.VariantEnglish, Seats: []uuid.UUID{human, robot.ID}, + TurnTimeout: 24 * time.Hour, Seed: openingSeed(t), + }) + if err != nil { + t.Fatalf("create game: %v", err) + } + + if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil { + t.Fatalf("request robot: %v", err) + } + rfr, err := svc.ListRobotFriendRequests(ctx, human) + if err != nil { + t.Fatalf("list robot friend requests: %v", err) + } + if len(rfr) != 1 || rfr[0].GameID != g.ID || rfr[0].Seat != 1 { + t.Fatalf("robot friend requests = %v, want one for game %s seat 1", rfr, g.ID) + } + // The friendships table must stay empty: no outgoing request against the shared robot account. + if out, _ := svc.ListOutgoingRequests(ctx, human); len(out) != 0 { + t.Errorf("friendships outgoing must be empty for a robot request, got %v", out) + } + + // A re-send to the same game+seat is idempotent (the UNIQUE constraint collapses it). + if err := svc.RequestInGame(ctx, human, robot.ID, g.ID); err != nil { + t.Fatalf("re-request robot: %v", err) + } + if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 { + t.Fatalf("re-request must not duplicate, got %v", rfr) + } + + // While the game is active the reaper keeps the row. + if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 0 { + t.Fatalf("reap on active game = (%d, %v), want (0, nil)", n, err) + } + if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 1 { + t.Fatalf("active-game request must survive the reaper, got %v", rfr) + } + + // Finish the game well past the retention window: the reaper now deletes the row. + finished := time.Now().UTC().Add(-8 * 24 * time.Hour) + if _, err := testDB.ExecContext(ctx, + `UPDATE backend.games SET status='finished', finished_at=$1 WHERE game_id=$2`, finished, g.ID); err != nil { + t.Fatalf("finish game: %v", err) + } + if n, err := svc.ReapExpiredRobotFriendRequests(ctx); err != nil || n != 1 { + t.Fatalf("reap on long-finished game = (%d, %v), want (1, nil)", n, err) + } + if rfr, _ := svc.ListRobotFriendRequests(ctx, human); len(rfr) != 0 { + t.Errorf("long-finished request not reaped: %v", rfr) + } +} diff --git a/backend/internal/postgres/migrations/00012_robot_friend_requests.sql b/backend/internal/postgres/migrations/00012_robot_friend_requests.sql new file mode 100644 index 0000000..4ec1c39 --- /dev/null +++ b/backend/internal/postgres/migrations/00012_robot_friend_requests.sql @@ -0,0 +1,28 @@ +-- +goose Up +-- Per-game friend requests sent to a disguised-robot opponent. A disguised robot is a +-- shared pool account reused across games under different per-game names, so such a +-- request must never go into `friendships` (that would befriend/await the shared robot +-- account, leak that it is a bot across the requester's other games under other names, +-- and pin the in-game "request sent" state to the shared account instead of this seat). +-- Each request is recorded here against the specific game + seat, snapshotting the name +-- the player saw, so the in-game scoreboard can re-mark that one seat as already +-- requested. The robot ignores it (it never becomes a friendship); a background reaper +-- deletes a row once its game has been finished for more than the retention window. +SET search_path = backend, pg_catalog; + +CREATE TABLE robot_friend_requests ( + id uuid PRIMARY KEY, + requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + seat smallint NOT NULL, + robot_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE, + display_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (requester_id, game_id, seat) +); + +CREATE INDEX robot_friend_requests_requester_idx ON robot_friend_requests (requester_id); + +-- +goose Down +SET search_path = backend, pg_catalog; +DROP TABLE robot_friend_requests; diff --git a/backend/internal/server/handlers_friends.go b/backend/internal/server/handlers_friends.go index c300694..1df393b 100644 --- a/backend/internal/server/handlers_friends.go +++ b/backend/internal/server/handlers_friends.go @@ -32,9 +32,22 @@ type incomingListDTO struct { } // outgoingListDTO is the addressees the caller has already requested (a live pending -// request or one the addressee declined) and therefore cannot re-request. +// request or one the addressee declined) and therefore cannot re-request, plus the +// per-game disguised-robot requests (not real accounts), which keep the in-game +// "request sent" state pinned to a seat. type outgoingListDTO struct { - Requests []accountRefDTO `json:"requests"` + Requests []accountRefDTO `json:"requests"` + Robots []robotFriendRequestDTO `json:"robots"` +} + +// robotFriendRequestDTO is one per-game disguised-robot friend request: the row id, the +// game name the player saw, and the game + seat it was sent in (so the in-game card can +// re-mark that seat as already requested). +type robotFriendRequestDTO struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + GameID string `json:"game_id"` + Seat int `json:"seat"` } // friendCodeDTO is a freshly issued one-time friend code (returned once). @@ -123,7 +136,19 @@ func (s *Server) handleFriendRequest(c *gin.Context) { abortBadRequest(c, "invalid account id") return } - if err := s.social.SendFriendRequest(c.Request.Context(), uid, target); err != nil { + // An in-game request carries the game id, so a disguised-robot opponent is recorded as a + // per-game request (RequestInGame); a request without a game (or to a human) goes through + // SendFriendRequest directly. + var err error + if strings.TrimSpace(req.GameID) == "" { + err = s.social.SendFriendRequest(c.Request.Context(), uid, target) + } else if gameID, ok := parseUUIDField(req.GameID); !ok { + abortBadRequest(c, "invalid game id") + return + } else { + err = s.social.RequestInGame(c.Request.Context(), uid, target, gameID) + } + if err != nil { s.abortErr(c, err) return } @@ -235,12 +260,22 @@ func (s *Server) handleOutgoingRequests(c *gin.Context) { abortBadRequest(c, "missing identity") return } - ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid) + ctx := c.Request.Context() + ids, err := s.social.ListOutgoingRequests(ctx, uid) if err != nil { s.abortErr(c, err) return } - c.JSON(http.StatusOK, outgoingListDTO{Requests: s.accountRefs(c.Request.Context(), ids)}) + robots, err := s.social.ListRobotFriendRequests(ctx, uid) + if err != nil { + s.abortErr(c, err) + return + } + dto := outgoingListDTO{Requests: s.accountRefs(ctx, ids)} + for _, r := range robots { + dto.Robots = append(dto.Robots, robotFriendRequestDTO{ID: r.ID.String(), DisplayName: r.DisplayName, GameID: r.GameID.String(), Seat: r.Seat}) + } + c.JSON(http.StatusOK, dto) } // handleIssueFriendCode issues a one-time add-a-friend code for the caller. diff --git a/backend/internal/social/robotfriends.go b/backend/internal/social/robotfriends.go new file mode 100644 index 0000000..99e58f9 --- /dev/null +++ b/backend/internal/social/robotfriends.go @@ -0,0 +1,175 @@ +package social + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// robotFriendRequestTTL is how long a per-game disguised-robot friend request is kept +// after its game has finished before the reaper deletes it. It is a sibling of +// friendRequestTTL; the request is housekeeping for the in-game "request sent" state, +// so it is purged once the finished game is well past its lobby lifetime. +const robotFriendRequestTTL = 7 * 24 * time.Hour + +// robotFriendRequestReapInterval is how often the reaper sweeps expired rows. +const robotFriendRequestReapInterval = time.Hour + +// RobotFriendRequest is one per-game friend request to a disguised-robot opponent: the +// row id, the game name the player saw, and the game + seat it was sent in (so the +// in-game scoreboard can re-mark that seat as already requested). +type RobotFriendRequest struct { + ID uuid.UUID + DisplayName string + GameID uuid.UUID + Seat int +} + +// RequestInGame sends a friend request to addresseeID for requesterID from within gameID. +// A human is requested normally (the friendships table, via SendFriendRequest). A +// disguised-robot opponent is instead recorded per-game in robot_friend_requests — never +// the shared robot account — so the matchmaker keeps robots free, the same robot stays +// un-requested in the requester's other games (under its other names), and the in-game +// "request sent" state is pinned to this seat rather than the shared account. The robot +// ignores the request (it never becomes a friendship); the reaper deletes the row once +// the game is long finished. It is the entry point for the in-game add-friend control; +// the friend-code path goes elsewhere. +func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID, gameID uuid.UUID) error { + if requesterID == addresseeID { + return ErrSelfRelation + } + isRobot, err := svc.accounts.IsRobot(ctx, addresseeID) + if err != nil { + return err + } + if !isRobot { + return svc.SendFriendRequest(ctx, requesterID, addresseeID) + } + if gameID == uuid.Nil { + return ErrNotParticipant + } + seats, _, _, err := svc.games.Participants(ctx, gameID) + if err != nil { + return err + } + seat := slices.Index(seats, addresseeID) + if seat < 0 { + return ErrNotParticipant + } + name, _ := svc.games.SeatName(ctx, gameID, addresseeID) + return svc.store.insertRobotFriendRequest(ctx, requesterID, gameID, seat, addresseeID, name) +} + +// ListRobotFriendRequests returns requesterID's per-game disguised-robot friend +// requests, newest first. +func (svc *Service) ListRobotFriendRequests(ctx context.Context, requesterID uuid.UUID) ([]RobotFriendRequest, error) { + return svc.store.listRobotFriendRequests(ctx, requesterID) +} + +// insertRobotFriendRequest records a per-game robot friend request; a duplicate (same +// requester, game, seat) is ignored. +func (s *Store) insertRobotFriendRequest(ctx context.Context, requester, gameID uuid.UUID, seat int, robotID uuid.UUID, name string) error { + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("social: new robot friend request id: %w", err) + } + const q = `INSERT INTO backend.robot_friend_requests (id, requester_id, game_id, seat, robot_id, display_name) +VALUES ($1, $2, $3, $4, $5, $6) +ON CONFLICT (requester_id, game_id, seat) DO NOTHING` + if _, err := s.db.ExecContext(ctx, q, id, requester, gameID, int16(seat), robotID, name); err != nil { + return fmt.Errorf("social: insert robot friend request: %w", err) + } + return nil +} + +// listRobotFriendRequests returns requester's robot friend requests, newest first. +func (s *Store) listRobotFriendRequests(ctx context.Context, requester uuid.UUID) ([]RobotFriendRequest, error) { + const q = `SELECT id, display_name, game_id, seat FROM backend.robot_friend_requests +WHERE requester_id = $1 ORDER BY created_at DESC` + rows, err := s.db.QueryContext(ctx, q, requester) + if err != nil { + return nil, fmt.Errorf("social: list robot friend requests: %w", err) + } + defer rows.Close() + var out []RobotFriendRequest + for rows.Next() { + var r RobotFriendRequest + var seat int16 + if err := rows.Scan(&r.ID, &r.DisplayName, &r.GameID, &seat); err != nil { + return nil, fmt.Errorf("social: scan robot friend request: %w", err) + } + r.Seat = int(seat) + out = append(out, r) + } + return out, rows.Err() +} + +// ReapExpiredRobotFriendRequests deletes every per-game robot friend request whose game has +// been finished for longer than robotFriendRequestTTL, reporting how many rows were removed. +// Rows for active (not-yet-finished) games are kept so the in-game "request sent" state +// survives. It backs the RobotFriendRequestReaper and is directly callable in tests. +func (svc *Service) ReapExpiredRobotFriendRequests(ctx context.Context) (int64, error) { + return svc.store.deleteExpiredRobotFriendRequests(ctx, svc.now().Add(-robotFriendRequestTTL)) +} + +// deleteExpiredRobotFriendRequests removes robot friend requests whose game has been +// finished since before cutoff, reporting how many rows were deleted. +func (s *Store) deleteExpiredRobotFriendRequests(ctx context.Context, cutoff time.Time) (int64, error) { + const q = `DELETE FROM backend.robot_friend_requests r +USING backend.games g +WHERE r.game_id = g.game_id AND g.status = 'finished' AND g.finished_at < $1` + res, err := s.db.ExecContext(ctx, q, cutoff) + if err != nil { + return 0, fmt.Errorf("social: delete expired robot friend requests: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("social: delete expired robot friend requests rows: %w", err) + } + return n, nil +} + +// RobotFriendRequestReaper periodically reaps expired per-game disguised-robot friend +// requests via Service.ReapExpiredRobotFriendRequests. It mirrors the account.GuestReaper: +// one background goroutine, started once from main. +type RobotFriendRequestReaper struct { + svc *Service + log *zap.Logger +} + +// NewRobotFriendRequestReaper constructs a reaper over svc. log may be nil. +func NewRobotFriendRequestReaper(svc *Service, log *zap.Logger) *RobotFriendRequestReaper { + if log == nil { + log = zap.NewNop() + } + return &RobotFriendRequestReaper{svc: svc, log: log} +} + +// Interval is the reaper's sweep cadence, for the startup log line. +func (r *RobotFriendRequestReaper) Interval() time.Duration { return robotFriendRequestReapInterval } + +// Retention is the reaper's post-finish retention window, for the startup log line. +func (r *RobotFriendRequestReaper) Retention() time.Duration { return robotFriendRequestTTL } + +// Run reaps expired robot friend requests on each tick until ctx is cancelled. +func (r *RobotFriendRequestReaper) Run(ctx context.Context) { + ticker := time.NewTicker(robotFriendRequestReapInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + n, err := r.svc.ReapExpiredRobotFriendRequests(ctx) + if err != nil { + r.log.Warn("robot friend request reap failed", zap.Error(err)) + } else if n > 0 { + r.log.Info("reaped expired robot friend requests", zap.Int64("count", n)) + } + } + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aa461fc..e5229a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -519,7 +519,13 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set); expires after **30 days** and may be re-sent), or **decline** — a decline is remembered (`status='declined'`) and blocks further requests from that sender, unless they hand them a code, which overrides it. The requester's own cancel still - deletes the row. (Discovery by friend list or platform deep-link is future work.) + deletes the row. A request sent in-game to an **auto-match opponent who is secretly a + pooled robot** is recorded instead in a separate **`robot_friend_requests`** table, keyed on + the requester + game + seat with the seen name snapshotted (`RequestInGame`): the shared robot + account is never put in `friendships` (so it is not befriended/awaited under its other per-game + names and the in-game 🤝 stays pinned to that seat as *sent*), the robot never accepts, the row + is not surfaced in Settings → Friends, and a background reaper deletes it once its game has been + finished for **7 days**. (Discovery by friend list or platform deep-link is future work.) - **Block**: two independent **global** account toggles (`block_chat`, `block_friend_requests`) **plus** a **per-user block list**. A per-user block is **asymmetric and non-destructive**: the blocker stops receiving everything **from** the diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f76a17c..7a78adb 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -207,7 +207,10 @@ Blocking an **auto-match opponent who is secretly a robot** behaves the same in (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is recorded only against that game — the disguise holds, the shared robot is never globally blocked, and the matchmaker keeps pairing you with robots (so you can never block yourself out -of opponents). Per-game chat is for quick reactions: messages are short +of opponents). Sending the 🤝 to such a **disguised robot** likewise records the request only +against that game (pinned to the seat, so it stays *sent* there without touching that robot in +your other games); the robot **ignores it**, the request never appears in **Settings → Friends**, +and it drops automatically about a week after the game ends. Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, even disguised. You may send **one message per turn, on your own turn**; once it is sent the field gives way to a short caption until your next turn. Nudge the player whose turn diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 1124539..9e22b63 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -212,7 +212,11 @@ nudge) приходят от бота **этой партии** — по язы (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что -заблокировать себя без соперников нельзя). Чат +заблокировать себя без соперников нельзя). Отправка 🤝 такому **замаскированному роботу** так же +записывает заявку только для этой партии (привязанной к месту, поэтому она читается как +*отправленная* здесь, не затрагивая этого робота в твоих других играх); робот её **игнорирует**, +заявка не показывается в **Настройки → Друзья** и автоматически удаляется примерно через неделю +после завершения партии. Чат партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 30ad8e7..45cc441 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -237,7 +237,11 @@ and a long message does not scroll. Lobby rows show two lines (opponents, then result + score) with a large place-based emoji on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 3–4-player games II 🥈 / III 🥉 / -IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. When a listed +IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌. The score line +lists seats in **seat-number order** (matching the over-the-board scoreboard) in a **bold**, +slightly smaller line; on an **in-progress** game the viewer's **own** number is tinted `--ok` +when leading or tied and `--danger` when trailing (other numbers stay muted), a quick "am I +ahead" read that finished games leave to the place emoji. When a listed game **becomes your turn or finishes** while the lobby is open, that status emoji **blinks twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping @@ -294,11 +298,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's notation (e.g. "4.8%" / "4,8%") — followed by a **full-width "best move" card** that breaks the best move - down per variant: one row per played variant (catalogue order, empty variants - omitted) with the variant name on the left, the **word drawn as game tiles** + down per variant: two lines per played variant (catalogue order, empty variants + omitted) — the variant name on its own line, then below it the score (right-aligned + in a shared column so the scores align) and the **word drawn as game tiles** (`components/WordTiles.svelte`, the board's placed-tile look at a small fixed size; - a wildcard shows its letter but no value) right-aligned to a shared edge, and the - score right-aligned in its own column. + a wildcard shows its letter but no value) left-aligned, so long words get the full width. - **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a **UTC-offset** timezone dropdown (defaulting to the browser's offset), the away window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index f9b9fb4..7d774a4 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -26,9 +26,20 @@ type IncomingListResp struct { } // OutgoingListResp is the addressees the caller has already requested (a live pending -// request or one the addressee declined) and cannot re-request. +// request or one the addressee declined) and cannot re-request, plus the per-game +// disguised-robot requests (not real accounts). type OutgoingListResp struct { - Requests []AccountRefResp `json:"requests"` + Requests []AccountRefResp `json:"requests"` + Robots []RobotFriendReqResp `json:"robots"` +} + +// RobotFriendReqResp is one per-game disguised-robot friend request: the row id, the game +// name the player saw, and the game + seat it was sent in. +type RobotFriendReqResp struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + GameID string `json:"game_id"` + Seat int `json:"seat"` } // FriendCodeResp is a freshly issued one-time friend code. @@ -137,10 +148,12 @@ type InvitationParams struct { // --- friends --- -// SendFriendRequest sends a friend request to a played opponent. -func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID string) error { +// SendFriendRequest sends a friend request to a played opponent. A non-empty gameID +// marks an in-game request, so a disguised-robot opponent is recorded as a per-game +// request; it is empty for any non-game path. +func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID, gameID string) error { return c.do(ctx, http.MethodPost, "/api/v1/user/friends/request", userID, "", - map[string]string{"account_id": targetID}, nil) + map[string]string{"account_id": targetID, "game_id": gameID}, nil) } // RespondFriendRequest accepts or declines an incoming request. diff --git a/gateway/internal/transcode/encode_social.go b/gateway/internal/transcode/encode_social.go index ca4cad3..9a136fb 100644 --- a/gateway/internal/transcode/encode_social.go +++ b/gateway/internal/transcode/encode_social.go @@ -50,12 +50,36 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte { return b.FinishedBytes() } +// buildRobotFriendVector builds the OutgoingRequestList robots vector (per-game +// disguised-robot friend requests). +func buildRobotFriendVector(b *flatbuffers.Builder, robots []backendclient.RobotFriendReqResp) flatbuffers.UOffsetT { + offs := make([]flatbuffers.UOffsetT, len(robots)) + for i, r := range robots { + id := b.CreateString(r.ID) + name := b.CreateString(r.DisplayName) + gid := b.CreateString(r.GameID) + fb.RobotFriendRefStart(b) + fb.RobotFriendRefAddId(b, id) + fb.RobotFriendRefAddDisplayName(b, name) + fb.RobotFriendRefAddGameId(b, gid) + fb.RobotFriendRefAddSeat(b, int32(r.Seat)) + offs[i] = fb.RobotFriendRefEnd(b) + } + fb.OutgoingRequestListStartRobotsVector(b, len(offs)) + for i := len(offs) - 1; i >= 0; i-- { + b.PrependUOffsetT(offs[i]) + } + return b.EndVector(len(offs)) +} + // encodeOutgoingList builds an OutgoingRequestList payload. func encodeOutgoingList(r backendclient.OutgoingListResp) []byte { b := flatbuffers.NewBuilder(256) v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector) + rv := buildRobotFriendVector(b, r.Robots) fb.OutgoingRequestListStart(b) fb.OutgoingRequestListAddRequests(b, v) + fb.OutgoingRequestListAddRobots(b, rv) b.Finish(fb.OutgoingRequestListEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index c3a900c..72d1473 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -93,7 +93,9 @@ func friendsOutgoingHandler(backend *backendclient.Client) Handler { func friendRequestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTargetRequest(req.Payload, 0) - if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil { + // game_id is set only by an in-game request, so a disguised-robot opponent is recorded + // per-game; it is empty for any non-game path. + if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId()), string(in.GameId())); err != nil { return nil, err } return encodeAck(true), nil diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index b0892e1..96206a1 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -59,7 +59,8 @@ func TestFriendsOutgoingRoundTrip(t *testing.T) { 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"}]}`)) + _, _ = w.Write([]byte(`{"requests":[{"account_id":"o-1","display_name":"Pat"}],` + + `"robots":[{"id":"r-1","display_name":"Robbie","game_id":"g-1","seat":1}]}`)) }) defer cleanup() @@ -81,6 +82,15 @@ func TestFriendsOutgoingRoundTrip(t *testing.T) { if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" { t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName()) } + // The per-game disguised-robot requests round-trip in their own vector. + if ol.RobotsLength() != 1 { + t.Fatalf("robots length = %d, want 1", ol.RobotsLength()) + } + var rr fb.RobotFriendRef + ol.Robots(&rr, 0) + if string(rr.Id()) != "r-1" || string(rr.DisplayName()) != "Robbie" || string(rr.GameId()) != "g-1" || rr.Seat() != 1 { + t.Fatalf("robot[0] = (%q, %q, %q, %d), want (r-1, Robbie, g-1, 1)", rr.Id(), rr.DisplayName(), rr.GameId(), rr.Seat()) + } } func TestFriendRequestForwardsTarget(t *testing.T) { diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 9fe46cc..c396b82 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -532,11 +532,24 @@ table IncomingRequestList { requests:[AccountRef]; } +// RobotFriendRef is one per-game friend request to a disguised-robot opponent: a +// per-game record (not a real account) carrying the game name the player saw and the +// game/seat it was sent in, so the in-game card can re-mark that seat as already +// requested. Its id is the robot_friend_requests row. +table RobotFriendRef { + id:string; + display_name:string; + game_id:string; + seat:int; +} + // 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. +// "add to friends" item reads it to stay disabled across reloads. robots are the +// per-game disguised-robot requests (not real accounts), added trailing. table OutgoingRequestList { requests:[AccountRef]; + robots:[RobotFriendRef]; } // FriendCode is a freshly issued one-time add-a-friend code (returned once). diff --git a/pkg/fbs/scrabblefb/OutgoingRequestList.go b/pkg/fbs/scrabblefb/OutgoingRequestList.go index 5ad4d29..818335e 100644 --- a/pkg/fbs/scrabblefb/OutgoingRequestList.go +++ b/pkg/fbs/scrabblefb/OutgoingRequestList.go @@ -61,8 +61,28 @@ func (rcv *OutgoingRequestList) RequestsLength() int { return 0 } +func (rcv *OutgoingRequestList) Robots(obj *RobotFriendRef, j int) bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + 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) RobotsLength() int { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.VectorLen(o) + } + return 0 +} + func OutgoingRequestListStart(builder *flatbuffers.Builder) { - builder.StartObject(1) + builder.StartObject(2) } func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0) @@ -70,6 +90,12 @@ func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatb func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT { return builder.StartVector(4, numElems, 4) } +func OutgoingRequestListAddRobots(builder *flatbuffers.Builder, robots flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(robots), 0) +} +func OutgoingRequestListStartRobotsVector(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/pkg/fbs/scrabblefb/RobotFriendRef.go b/pkg/fbs/scrabblefb/RobotFriendRef.go new file mode 100644 index 0000000..466efb2 --- /dev/null +++ b/pkg/fbs/scrabblefb/RobotFriendRef.go @@ -0,0 +1,97 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type RobotFriendRef struct { + _tab flatbuffers.Table +} + +func GetRootAsRobotFriendRef(buf []byte, offset flatbuffers.UOffsetT) *RobotFriendRef { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &RobotFriendRef{} + x.Init(buf, n+offset) + return x +} + +func FinishRobotFriendRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsRobotFriendRef(buf []byte, offset flatbuffers.UOffsetT) *RobotFriendRef { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &RobotFriendRef{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedRobotFriendRefBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *RobotFriendRef) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *RobotFriendRef) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *RobotFriendRef) Id() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *RobotFriendRef) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *RobotFriendRef) GameId() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *RobotFriendRef) Seat() int32 { + o := flatbuffers.UOffsetT(rcv._tab.Offset(10)) + if o != 0 { + return rcv._tab.GetInt32(o + rcv._tab.Pos) + } + return 0 +} + +func (rcv *RobotFriendRef) MutateSeat(n int32) bool { + return rcv._tab.MutateInt32Slot(10, n) +} + +func RobotFriendRefStart(builder *flatbuffers.Builder) { + builder.StartObject(4) +} +func RobotFriendRefAddId(builder *flatbuffers.Builder, id flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(id), 0) +} +func RobotFriendRefAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(displayName), 0) +} +func RobotFriendRefAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(gameId), 0) +} +func RobotFriendRefAddSeat(builder *flatbuffers.Builder, seat int32) { + builder.PrependInt32Slot(3, seat, 0) +} +func RobotFriendRefEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/src/app.css b/ui/src/app.css index 9729f05..c2fb4ba 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -73,7 +73,7 @@ --board-bg: #2a3330; --cell-bg: #222a27; --cell-line: #56655c; - --tile-bg: #d9c79a; + --tile-bg: #cdba88; --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #d8b75e; diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 6894761..d05ffd9 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -932,6 +932,10 @@ // re-send and disable the 🤝). let friends = $state(new Set()); let requested = $state(new Set()); + // `requestedRobotSeats` are the seat indices of disguised-robot opponents already requested in + // THIS game: a robot request is recorded per game+seat (never the shared robot account), so the + // 🤝 disables for just this seat and never leaks across the requester's other games. + let requestedRobotSeats = $state(new Set()); // `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and // their name is struck. Derived from the server so it is correct across reloads and live-updates // on a user_blocked / user_unblocked event. `blockedRobotSeats` are the seat indices of blocked @@ -952,7 +956,8 @@ 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)); + requested = new Set(out.requests.map((f) => f.accountId)); + requestedRobotSeats = new Set(out.robots.filter((r) => r.gameId === id).map((r) => r.seat)); } catch { /* best-effort */ } @@ -981,14 +986,17 @@ // caption update the instant the confirm fires) and roll back to the prior state if the command // fails to reach the server. The confirming user_blocked / user_added event then just reconciles // the same state in place (no flicker). - async function addFriend(accountId: string) { - const had = requested.has(accountId); - requested = new Set([...requested, accountId]); + async function addFriend(s: { accountId: string; seat: number }) { + // Optimistically mark this seat requested (disables the 🤝 for both a human and a disguised + // robot until the server confirms). The request carries the game id so a robot opponent is + // recorded as a per-game request pinned to this seat, not against the shared robot account. + const hadSeat = requestedRobotSeats.has(s.seat); + requestedRobotSeats = new Set([...requestedRobotSeats, s.seat]); try { - await gateway.friendRequest(accountId); + await gateway.friendRequest(s.accountId, id); showToast(t('friends.requestSent')); } catch (e) { - if (!had) requested = new Set([...requested].filter((id) => id !== accountId)); + if (!hadSeat) requestedRobotSeats = new Set([...requestedRobotSeats].filter((x) => x !== s.seat)); handleError(e); } } @@ -1118,9 +1126,9 @@ (addConfirm[s.seat] = v)} - onconfirm={() => addFriend(s.accountId)} + onconfirm={() => addFriend(s)} > 🤝 diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 741bab3..8f70548 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -60,6 +60,7 @@ export { Profile } from './scrabblefb/profile.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; export { RedeemResult } from './scrabblefb/redeem-result.js'; export { RobotBlockRef } from './scrabblefb/robot-block-ref.js'; +export { RobotFriendRef } from './scrabblefb/robot-friend-ref.js'; export { SeatView } from './scrabblefb/seat-view.js'; export { Session } from './scrabblefb/session.js'; export { StateRequest } from './scrabblefb/state-request.js'; diff --git a/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts index ded6fa1..6bc5c7c 100644 --- a/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts +++ b/ui/src/gen/fbs/scrabblefb/outgoing-request-list.ts @@ -3,6 +3,7 @@ import * as flatbuffers from 'flatbuffers'; import { AccountRef } from '../scrabblefb/account-ref.js'; +import { RobotFriendRef } from '../scrabblefb/robot-friend-ref.js'; export class OutgoingRequestList { @@ -33,8 +34,18 @@ requestsLength():number { return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; } +robots(index: number, obj?:RobotFriendRef):RobotFriendRef|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? (obj || new RobotFriendRef()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null; +} + +robotsLength():number { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; +} + static startOutgoingRequestList(builder:flatbuffers.Builder) { - builder.startObject(1); + builder.startObject(2); } static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) { @@ -53,14 +64,31 @@ static startRequestsVector(builder:flatbuffers.Builder, numElems:number) { builder.startVector(4, numElems, 4); } +static addRobots(builder:flatbuffers.Builder, robotsOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, robotsOffset, 0); +} + +static createRobotsVector(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 startRobotsVector(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 { +static createOutgoingRequestList(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset, robotsOffset:flatbuffers.Offset):flatbuffers.Offset { OutgoingRequestList.startOutgoingRequestList(builder); OutgoingRequestList.addRequests(builder, requestsOffset); + OutgoingRequestList.addRobots(builder, robotsOffset); return OutgoingRequestList.endOutgoingRequestList(builder); } } diff --git a/ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts b/ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts new file mode 100644 index 0000000..7f54b27 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/robot-friend-ref.ts @@ -0,0 +1,82 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class RobotFriendRef { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):RobotFriendRef { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsRobotFriendRef(bb:flatbuffers.ByteBuffer, obj?:RobotFriendRef):RobotFriendRef { + return (obj || new RobotFriendRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsRobotFriendRef(bb:flatbuffers.ByteBuffer, obj?:RobotFriendRef):RobotFriendRef { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new RobotFriendRef()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +id():string|null +id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +id(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +displayName():string|null +displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +displayName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +gameId():string|null +gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +gameId(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +seat():number { + const offset = this.bb!.__offset(this.bb_pos, 10); + return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0; +} + +static startRobotFriendRef(builder:flatbuffers.Builder) { + builder.startObject(4); +} + +static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, idOffset, 0); +} + +static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, displayNameOffset, 0); +} + +static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, gameIdOffset, 0); +} + +static addSeat(builder:flatbuffers.Builder, seat:number) { + builder.addFieldInt32(3, seat, 0); +} + +static endRobotFriendRef(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createRobotFriendRef(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, gameIdOffset:flatbuffers.Offset, seat:number):flatbuffers.Offset { + RobotFriendRef.startRobotFriendRef(builder); + RobotFriendRef.addId(builder, idOffset); + RobotFriendRef.addDisplayName(builder, displayNameOffset); + RobotFriendRef.addGameId(builder, gameIdOffset); + RobotFriendRef.addSeat(builder, seat); + return RobotFriendRef.endRobotFriendRef(builder); +} +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index bb55630..4b45b90 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -22,6 +22,7 @@ import type { LinkResult, MatchResult, MoveResult, + OutgoingList, Profile, ProfileUpdate, PushEvent, @@ -118,9 +119,12 @@ export interface GatewayClient { // --- friends --- friendsList(): Promise; friendsIncoming(): Promise; - /** Addressees the caller has already requested (pending or declined); cannot re-request. */ - friendsOutgoing(): Promise; - friendRequest(accountId: string): Promise; + /** Addressees the caller has already requested (pending or declined; cannot re-request), + * plus the per-game disguised-robot requests that keep the in-game 🤝 disabled by seat. */ + friendsOutgoing(): Promise; + /** Send a friend request. A non-empty gameId marks an in-game request, so a disguised-robot + * opponent is recorded as a per-game request rather than against the shared robot account. */ + friendRequest(accountId: string, gameId?: string): Promise; friendRespond(requesterId: string, accept: boolean): Promise; friendCancel(accountId: string): Promise; unfriend(accountId: string): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 089a465..9bfe3fd 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -256,7 +256,7 @@ describe('codec', () => { expect(gl.atGameLimit).toBe(true); }); - it('decodes an OutgoingRequestList of account refs', () => { + it('decodes an OutgoingRequestList of account refs plus per-game robot requests', () => { const b = new Builder(128); const id = b.createString('o-1'); const dn = b.createString('Pat'); @@ -264,11 +264,20 @@ describe('codec', () => { fb.AccountRef.addAccountId(b, id); fb.AccountRef.addDisplayName(b, dn); const ref = fb.AccountRef.endAccountRef(b); - const vec = fb.OutgoingRequestList.createRequestsVector(b, [ref]); + const reqVec = fb.OutgoingRequestList.createRequestsVector(b, [ref]); + const rid = b.createString('r-1'); + const rdn = b.createString('Robbie'); + const rgid = b.createString('g-1'); + const robot = fb.RobotFriendRef.createRobotFriendRef(b, rid, rdn, rgid, 1); + const robVec = fb.OutgoingRequestList.createRobotsVector(b, [robot]); fb.OutgoingRequestList.startOutgoingRequestList(b); - fb.OutgoingRequestList.addRequests(b, vec); + fb.OutgoingRequestList.addRequests(b, reqVec); + fb.OutgoingRequestList.addRobots(b, robVec); b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b)); - expect(decodeOutgoingList(b.asUint8Array())).toEqual([{ accountId: 'o-1', displayName: 'Pat' }]); + expect(decodeOutgoingList(b.asUint8Array())).toEqual({ + requests: [{ accountId: 'o-1', displayName: 'Pat' }], + robots: [{ id: 'r-1', displayName: 'Robbie', gameId: 'g-1', seat: 1 }], + }); }); it('encodes a TargetRequest', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index aeb995e..2708a16 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -10,7 +10,9 @@ import type { PlacedTile } from './client'; import type { AccountRef, BlockList, + OutgoingList, RobotBlockEntry, + RobotFriendRequestEntry, Banner, BannerCampaign, BestMove, @@ -719,14 +721,25 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] { return out; } -export function decodeOutgoingList(buf: Uint8Array): AccountRef[] { +export function decodeOutgoingList(buf: Uint8Array): OutgoingList { const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf)); - const out: AccountRef[] = []; + const requests: AccountRef[] = []; for (let i = 0; i < l.requestsLength(); i++) { const r = l.requests(i); - if (r) out.push(decodeAccountRef(r)); + if (r) requests.push(decodeAccountRef(r)); } - return out; + const robots: RobotFriendRequestEntry[] = []; + for (let i = 0; i < l.robotsLength(); i++) { + const r = l.robots(i); + if (r) + robots.push({ + id: r.id() ?? '', + displayName: r.displayName() ?? '', + gameId: r.gameId() ?? '', + seat: r.seat(), + }); + } + return { requests, robots }; } export function decodeBlockList(buf: Uint8Array): BlockList { diff --git a/ui/src/lib/lobbysort.test.ts b/ui/src/lib/lobbysort.test.ts index f4e3007..3e65ee8 100644 --- a/ui/src/lib/lobbysort.test.ts +++ b/ui/src/lib/lobbysort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { gamePhase, groupGames, isMyTurn, shouldBlink } from './lobbysort'; +import { gamePhase, groupGames, isMyTurn, orderedSeats, scoreStanding, shouldBlink } from './lobbysort'; import type { GameView, Seat } from './model'; const ME = 'me'; @@ -118,6 +118,59 @@ describe('gamePhase', () => { }); }); +describe('scoreStanding', () => { + const withScores = (status: GameView['status'], myScore: number, oppScore: number): GameView => ({ + ...game('g', status, 0, 0), + seats: [ + { ...seat(0, ME), score: myScore }, + { ...seat(1, 'opp'), score: oppScore }, + ], + }); + + it('greens a leading or tied active game, reds a losing one', () => { + expect(scoreStanding(withScores('active', 30, 10), ME)).toBe('win'); + expect(scoreStanding(withScores('active', 10, 10), ME)).toBe('win'); // a tie counts as green + expect(scoreStanding(withScores('active', 5, 10), ME)).toBe('lose'); + }); + + it('is null for any non-active game (open or finished)', () => { + expect(scoreStanding(withScores('finished', 30, 10), ME)).toBeNull(); + expect(scoreStanding(withScores('open', 0, 0), ME)).toBeNull(); + }); + + it('is null when the viewer is not seated or has no opponent', () => { + expect(scoreStanding(withScores('active', 30, 10), 'stranger')).toBeNull(); + const solo: GameView = { ...game('g', 'active', 0, 0), seats: [{ ...seat(0, ME), score: 9 }] }; + expect(scoreStanding(solo, ME)).toBeNull(); + }); + + it('compares against the strongest opponent in a multiplayer game', () => { + const g3: GameView = { + ...game('g', 'active', 0, 0), + players: 3, + seats: [ + { ...seat(0, ME), score: 40 }, + { ...seat(1, 'a'), score: 35 }, + { ...seat(2, 'b'), score: 50 }, // b is ahead -> losing + ], + }; + expect(scoreStanding(g3, ME)).toBe('lose'); + }); +}); + +describe('orderedSeats', () => { + it('returns seats in seat-number order without mutating the source', () => { + const g: GameView = { + ...game('g', 'active', 0, 0), + seats: [seat(2, 'c'), seat(0, ME), seat(1, 'b')], + }; + const src = g.seats; + expect(orderedSeats(g).map((s) => s.seat)).toEqual([0, 1, 2]); + expect(g.seats).toBe(src); // the source array is left untouched + expect(g.seats.map((s) => s.seat)).toEqual([2, 0, 1]); + }); +}); + describe('shouldBlink', () => { it('blinks on a transition into mine or finished', () => { expect(shouldBlink('theirs', 'mine')).toBe(true); diff --git a/ui/src/lib/lobbysort.ts b/ui/src/lib/lobbysort.ts index 6cc4906..7292da0 100644 --- a/ui/src/lib/lobbysort.ts +++ b/ui/src/lib/lobbysort.ts @@ -12,6 +12,29 @@ export function isMyTurn(game: GameView, myId: string): boolean { return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; } +/** + * scoreStanding colours the viewer's own score on an in-progress game: 'win' when leading or + * tied (green), 'lose' when trailing (red). It is null for any game that is not active (open or + * finished — finished games show the result emoji instead) or where myId is not seated against + * an opponent, so the lobby leaves those numbers in the muted default. + */ +export function scoreStanding(game: GameView, myId: string): 'win' | 'lose' | null { + if (game.status !== 'active') return null; + const me = game.seats.find((s) => s.accountId === myId); + if (!me) return null; + const others = game.seats.filter((s) => s.accountId && s.accountId !== myId).map((s) => s.score); + if (!others.length) return null; + return me.score >= Math.max(...others) ? 'win' : 'lose'; +} + +/** + * orderedSeats returns a game's seats in seat-number order (matching the over-the-board + * scoreboard), without mutating the source array. + */ +export function orderedSeats(game: GameView): GameView['seats'] { + return [...game.seats].sort((a, b) => a.seat - b.seat); +} + /** LobbyPhase is a game's lobby bucket — which of the three card sections it currently sits in. */ export type LobbyPhase = 'mine' | 'theirs' | 'finished'; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index ee2fd8e..941ea33 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -29,6 +29,7 @@ import type { LinkResult, MatchResult, MoveResult, + OutgoingList, Profile, ProfileUpdate, PushEvent, @@ -527,10 +528,11 @@ export class MockGateway implements GatewayClient { async friendsIncoming(): Promise { return this.incoming.map((f) => ({ ...f })); } - async friendsOutgoing(): Promise { - return this.outgoing.map((f) => ({ ...f })); + async friendsOutgoing(): Promise { + // The mock models human outgoing requests only (no disguised robots); robots stays empty. + return { requests: this.outgoing.map((f) => ({ ...f })), robots: [] }; } - async friendRequest(accountId: string): Promise { + async friendRequest(accountId: string, _gameId?: 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)) { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index b5483ec..f2be57e 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -225,6 +225,23 @@ export interface BlockList { robots: RobotBlockEntry[]; } +// RobotFriendRequestEntry is one per-game friend request to a disguised-robot opponent: a +// per-game record (not a real account) carrying the game name the player saw and the +// game/seat it was sent in, so the in-game card can re-mark that seat as already requested. +export interface RobotFriendRequestEntry { + id: string; + displayName: string; + gameId: string; + seat: number; +} + +// OutgoingList is the caller's outgoing friend requests: human addressees already requested +// (pending or declined) plus the per-game disguised-robot requests. +export interface OutgoingList { + requests: AccountRef[]; + robots: RobotFriendRequestEntry[]; +} + /** A freshly issued one-time friend code (the plaintext is returned once). */ export interface FriendCode { code: string; diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 4219024..3979d7f 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -165,8 +165,8 @@ export function createTransport(baseUrl: string): GatewayClient { async friendsOutgoing() { return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty())); }, - async friendRequest(accountId) { - await exec('friends.request', codec.encodeTarget(accountId)); + async friendRequest(accountId, gameId) { + await exec('friends.request', codec.encodeTarget(accountId, gameId)); }, async friendRespond(requesterId, accept) { await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept)); diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 212cd70..28fb22f 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -12,7 +12,7 @@ import { badgeKind } from '../lib/unread'; import { getLobby, setLobby } from '../lib/lobbycache'; import { preloadGames } from '../lib/preload'; - import { gamePhase, groupGames, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; + import { gamePhase, groupGames, orderedSeats, scoreStanding, shouldBlink, type LobbyPhase } from '../lib/lobbysort'; import type { AccountRef, GameView, Invitation } from '../lib/model'; let games = $state([]); @@ -130,11 +130,6 @@ .map((s) => s.displayName) .join(', '); } - function scoreline(g: GameView): string { - const me = g.seats.find((s) => s.accountId === myId); - const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score); - return `${me?.score ?? 0} : ${opp.join(', ')}`; - } // Hiding a finished game. The delete action sits behind each finished row and is // revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is @@ -271,7 +266,14 @@ {opponents(g) || '—'} {#if badge}{/if} - {scoreline(g)} + {#each orderedSeats(g) as s, i (s.seat)}{#if i > 0}{' : '}{/if}{s.score}{/each} {#key blinkNonce.get(g.id) ?? 0} @@ -476,6 +478,19 @@ font-size: 0.85rem; color: var(--text-muted); } + /* The score line is bold and a touch smaller than the surrounding muted sub-text. */ + .scoreline { + font-weight: 700; + font-size: 0.8rem; + } + /* The viewer's own number on an in-progress game: green when leading or tied, red when + losing; other numbers and the separators keep the muted .sub colour. */ + .num.win { + color: var(--ok); + } + .num.lose { + color: var(--danger); + } .emoji { font-size: 1.35rem; line-height: 1; diff --git a/ui/src/screens/Stats.svelte b/ui/src/screens/Stats.svelte index 5cc6805..74c48f1 100644 --- a/ui/src/screens/Stats.svelte +++ b/ui/src/screens/Stats.svelte @@ -72,8 +72,8 @@
{#each bestMoves as bm (bm.variant)} {t(variantNameKey(bm.variant))} - {bm.score} + {/each}
@@ -115,23 +115,30 @@ margin-top: 12px; gap: 12px; } - /* One grid for all rows so columns align across them: variant on the left, the word - tiles right-aligned to a shared edge, the score right-aligned in its own column. */ + /* Two lines per variant: the variant label on its own line, then the score and the word + tiles below it. One shared grid so the score column aligns across all rows — the score + right-aligned in its column, the word left-aligned. */ .rows { display: grid; - /* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a - narrow screen (the cell then scrolls) instead of overlapping the variant label. */ - grid-template-columns: auto minmax(0, 1fr) auto; + /* score column (sized to the widest score) then the word; minmax(0, 1fr) lets the word + shrink below its tiles' intrinsic width on a narrow screen (the cell then scrolls). */ + grid-template-columns: auto minmax(0, 1fr); align-items: center; - row-gap: 12px; + row-gap: 4px; column-gap: 8px; } .variant { + grid-column: 1 / -1; color: var(--text-muted); font-size: 0.95rem; } + /* Extra space above each variant after the first, separating the variant blocks while the + variant-to-score gap stays tight (row-gap). */ + .variant:not(:first-child) { + margin-top: 10px; + } .wordcell { - justify-self: end; + justify-self: start; min-width: 0; overflow-x: auto; }