feat(social): per-game friend request to disguised robots + lobby/stats/tile cosmetics
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m25s

Functional: the in-game add-friend handshake aimed at an auto-match opponent who is
secretly a pooled robot now records the request per (game, seat) in a new
robot_friend_requests table -- never against the shared robot account -- mirroring the
robot_blocks pattern. The shared account stays out of friendships, the "requested" state
is pinned to the seat (not leaked across the player's other games), the robot ignores it,
and a background reaper drops the row 7 days after its game finishes. The outgoing-requests
list carries these per-game rows so the seat control stays disabled across reloads. No
withdraw UI, per owner decision.

Cosmetics:
- Lobby: an in-progress game tints the viewer's own score number green when leading or
  tied, red when trailing (reusing --ok/--danger); scores now render in seat-number order,
  matching the over-the-board scoreboard.
- Stats: the per-variant best move is laid out on two lines -- the variant label, then the
  score (right-aligned in its column) and the word tiles (left-aligned) below it.
- Dark theme: the played-tile background is a touch darker so a player-placed tile reads
  with more contrast (light theme unchanged).

Docs (ARCHITECTURE, FUNCTIONAL + _ru mirror, backend README, UI_DESIGN) updated in the
same change.
This commit is contained in:
Ilia Denisov
2026-06-19 21:39:27 +02:00
parent 9ded5d3d86
commit c127bc9f0e
32 changed files with 854 additions and 65 deletions
+3
View File
@@ -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` `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 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. 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` `internal/account`
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a 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 development log mailer). The engine now also handles **multi-player drop-out**: in
+7
View File
@@ -171,6 +171,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social")) socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path. // A nudge the recipient answered by moving is marked read on the move path.
games.SetNudgeClearer(socialSvc.ClearNudges) 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 := feedback.NewService(feedback.NewStore(db), accounts)
feedbackSvc.SetNotifier(hub) feedbackSvc.SetNotifier(hub)
@@ -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)
}
}
@@ -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;
+40 -5
View File
@@ -32,9 +32,22 @@ type incomingListDTO struct {
} }
// outgoingListDTO is the addressees the caller has already requested (a live pending // 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 { 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). // 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") abortBadRequest(c, "invalid account id")
return 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) s.abortErr(c, err)
return return
} }
@@ -235,12 +260,22 @@ func (s *Server) handleOutgoingRequests(c *gin.Context) {
abortBadRequest(c, "missing identity") abortBadRequest(c, "missing identity")
return return
} }
ids, err := s.social.ListOutgoingRequests(c.Request.Context(), uid) ctx := c.Request.Context()
ids, err := s.social.ListOutgoingRequests(ctx, uid)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return 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. // handleIssueFriendCode issues a one-time add-a-friend code for the caller.
+175
View File
@@ -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))
}
}
}
}
+7 -1
View File
@@ -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 expires after **30 days** and may be re-sent), or **decline** — a decline is
remembered (`status='declined'`) and blocks further requests from that sender, 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 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**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is `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 **asymmetric and non-destructive**: the blocker stops receiving everything **from** the
+4 -1
View File
@@ -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 (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 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 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, (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 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 the field gives way to a short caption until your next turn. Nudge the player whose turn
+5 -1
View File
@@ -212,7 +212,11 @@ nudge) приходят от бота **этой партии** — по язы
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что аккаунт робота глобально не блокируется, и матчмейкер продолжает давать роботов (так что
заблокировать себя без соперников нельзя). Чат заблокировать себя без соперников нельзя). Отправка 🤝 такому **замаскированному роботу** так же
записывает заявку только для этой партии (привязанной к месту, поэтому она читается как
*отправленная* здесь, не затрагивая этого робота в твоих других играх); робот её **игнорирует**,
заявка не показывается в **Настройки → Друзья** и автоматически удаляется примерно через неделю
после завершения партии. Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить содержать ссылок, email и телефонов, даже завуалированных. В свой ход можно отправить
**одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего **одно сообщение за ход**; после отправки поле сменяется короткой подписью до следующего
+9 -5
View File
@@ -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 Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 / on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-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); 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; no bold), 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 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 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 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 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 notation (e.g. "4.8%" / "4,8%") — followed by a **full-width "best move" card** that
breaks the best move breaks the best move
down per variant: one row per played variant (catalogue order, empty variants down per variant: two lines per played variant (catalogue order, empty variants
omitted) with the variant name on the left, the **word drawn as game tiles** 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; (`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 a wildcard shows its letter but no value) left-aligned, so long words get the full width.
score right-aligned in its own column.
- **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a - **Profile editing** (`screens/Profile.svelte`): an inline form — display name, a
**UTC-offset** timezone dropdown (defaulting to the browser's offset), the away **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 window as hour + 10-minute dropdowns (24-hour, ≤ 12 h), and block toggles — plus an
+18 -5
View File
@@ -26,9 +26,20 @@ type IncomingListResp struct {
} }
// OutgoingListResp is the addressees the caller has already requested (a live pending // 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 { 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. // FriendCodeResp is a freshly issued one-time friend code.
@@ -137,10 +148,12 @@ type InvitationParams struct {
// --- friends --- // --- friends ---
// SendFriendRequest sends a friend request to a played opponent. // SendFriendRequest sends a friend request to a played opponent. A non-empty gameID
func (c *Client) SendFriendRequest(ctx context.Context, userID, targetID string) error { // 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, "", 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. // RespondFriendRequest accepts or declines an incoming request.
@@ -50,12 +50,36 @@ func encodeIncomingList(r backendclient.IncomingListResp) []byte {
return b.FinishedBytes() 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. // encodeOutgoingList builds an OutgoingRequestList payload.
func encodeOutgoingList(r backendclient.OutgoingListResp) []byte { func encodeOutgoingList(r backendclient.OutgoingListResp) []byte {
b := flatbuffers.NewBuilder(256) b := flatbuffers.NewBuilder(256)
v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector) v := buildAccountRefVector(b, r.Requests, fb.OutgoingRequestListStartRequestsVector)
rv := buildRobotFriendVector(b, r.Robots)
fb.OutgoingRequestListStart(b) fb.OutgoingRequestListStart(b)
fb.OutgoingRequestListAddRequests(b, v) fb.OutgoingRequestListAddRequests(b, v)
fb.OutgoingRequestListAddRobots(b, rv)
b.Finish(fb.OutgoingRequestListEnd(b)) b.Finish(fb.OutgoingRequestListEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -93,7 +93,9 @@ func friendsOutgoingHandler(backend *backendclient.Client) Handler {
func friendRequestHandler(backend *backendclient.Client) Handler { func friendRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0) 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 nil, err
} }
return encodeAck(true), nil return encodeAck(true), nil
@@ -59,7 +59,8 @@ func TestFriendsOutgoingRoundTrip(t *testing.T) {
if r.URL.Path != "/api/v1/user/friends/outgoing" { if r.URL.Path != "/api/v1/user/friends/outgoing" {
t.Errorf("unexpected path %q", r.URL.Path) 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() defer cleanup()
@@ -81,6 +82,15 @@ func TestFriendsOutgoingRoundTrip(t *testing.T) {
if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" { if string(ref.AccountId()) != "o-1" || string(ref.DisplayName()) != "Pat" {
t.Fatalf("outgoing[0] = (%q, %q), want (o-1, Pat)", ref.AccountId(), ref.DisplayName()) 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) { func TestFriendRequestForwardsTarget(t *testing.T) {
+14 -1
View File
@@ -532,11 +532,24 @@ table IncomingRequestList {
requests:[AccountRef]; 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 // 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 // (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 { table OutgoingRequestList {
requests:[AccountRef]; requests:[AccountRef];
robots:[RobotFriendRef];
} }
// FriendCode is a freshly issued one-time add-a-friend code (returned once). // FriendCode is a freshly issued one-time add-a-friend code (returned once).
+27 -1
View File
@@ -61,8 +61,28 @@ func (rcv *OutgoingRequestList) RequestsLength() int {
return 0 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) { func OutgoingRequestListStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) { func OutgoingRequestListAddRequests(builder *flatbuffers.Builder, requests flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(requests), 0) 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 { func OutgoingRequestListStartRequestsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4) 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 { func OutgoingRequestListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+97
View File
@@ -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()
}
+1 -1
View File
@@ -73,7 +73,7 @@
--board-bg: #2a3330; --board-bg: #2a3330;
--cell-bg: #222a27; --cell-bg: #222a27;
--cell-line: #56655c; --cell-line: #56655c;
--tile-bg: #d9c79a; --tile-bg: #cdba88;
--tile-edge: #b6a473; --tile-edge: #b6a473;
--tile-text: #20190d; --tile-text: #20190d;
--tile-pending: #d8b75e; --tile-pending: #d8b75e;
+16 -8
View File
@@ -932,6 +932,10 @@
// re-send and disable the 🤝). // re-send and disable the 🤝).
let friends = $state(new Set<string>()); let friends = $state(new Set<string>());
let requested = $state(new Set<string>()); let requested = $state(new Set<string>());
// `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<number>());
// `blocked` are the opponents the viewer has blocked: their 🤝 and ✖️ controls disappear and // `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 // 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 // on a user_blocked / user_unblocked event. `blockedRobotSeats` are the seat indices of blocked
@@ -952,7 +956,8 @@
try { try {
const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]); const [fl, out] = await Promise.all([gateway.friendsList(), gateway.friendsOutgoing()]);
friends = new Set(fl.map((f) => f.accountId)); 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 { } catch {
/* best-effort */ /* best-effort */
} }
@@ -981,14 +986,17 @@
// caption update the instant the confirm fires) and roll back to the prior state if the command // 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 // fails to reach the server. The confirming user_blocked / user_added event then just reconciles
// the same state in place (no flicker). // the same state in place (no flicker).
async function addFriend(accountId: string) { async function addFriend(s: { accountId: string; seat: number }) {
const had = requested.has(accountId); // Optimistically mark this seat requested (disables the 🤝 for both a human and a disguised
requested = new Set([...requested, accountId]); // 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 { try {
await gateway.friendRequest(accountId); await gateway.friendRequest(s.accountId, id);
showToast(t('friends.requestSent')); showToast(t('friends.requestSent'));
} catch (e) { } 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); handleError(e);
} }
} }
@@ -1118,9 +1126,9 @@
<span class="addfriend"> <span class="addfriend">
<TapConfirm <TapConfirm
label={t('friends.addFromGame')} label={t('friends.addFromGame')}
disabled={requested.has(s.accountId)} disabled={requested.has(s.accountId) || requestedRobotSeats.has(s.seat)}
onConfirming={(v) => (addConfirm[s.seat] = v)} onConfirming={(v) => (addConfirm[s.seat] = v)}
onconfirm={() => addFriend(s.accountId)} onconfirm={() => addFriend(s)}
> >
<span class="fico">🤝</span> <span class="fico">🤝</span>
</TapConfirm> </TapConfirm>
+1
View File
@@ -60,6 +60,7 @@ export { Profile } from './scrabblefb/profile.js';
export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js'; export { RedeemCodeRequest } from './scrabblefb/redeem-code-request.js';
export { RedeemResult } from './scrabblefb/redeem-result.js'; export { RedeemResult } from './scrabblefb/redeem-result.js';
export { RobotBlockRef } from './scrabblefb/robot-block-ref.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 { SeatView } from './scrabblefb/seat-view.js';
export { Session } from './scrabblefb/session.js'; export { Session } from './scrabblefb/session.js';
export { StateRequest } from './scrabblefb/state-request.js'; export { StateRequest } from './scrabblefb/state-request.js';
@@ -3,6 +3,7 @@
import * as flatbuffers from 'flatbuffers'; import * as flatbuffers from 'flatbuffers';
import { AccountRef } from '../scrabblefb/account-ref.js'; import { AccountRef } from '../scrabblefb/account-ref.js';
import { RobotFriendRef } from '../scrabblefb/robot-friend-ref.js';
export class OutgoingRequestList { export class OutgoingRequestList {
@@ -33,8 +34,18 @@ requestsLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0; 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) { static startOutgoingRequestList(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) { static addRequests(builder:flatbuffers.Builder, requestsOffset:flatbuffers.Offset) {
@@ -53,14 +64,31 @@ static startRequestsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4); 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 { static endOutgoingRequestList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; 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.startOutgoingRequestList(builder);
OutgoingRequestList.addRequests(builder, requestsOffset); OutgoingRequestList.addRequests(builder, requestsOffset);
OutgoingRequestList.addRobots(builder, robotsOffset);
return OutgoingRequestList.endOutgoingRequestList(builder); return OutgoingRequestList.endOutgoingRequestList(builder);
} }
} }
@@ -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);
}
}
+7 -3
View File
@@ -22,6 +22,7 @@ import type {
LinkResult, LinkResult,
MatchResult, MatchResult,
MoveResult, MoveResult,
OutgoingList,
Profile, Profile,
ProfileUpdate, ProfileUpdate,
PushEvent, PushEvent,
@@ -118,9 +119,12 @@ export interface GatewayClient {
// --- friends --- // --- friends ---
friendsList(): Promise<AccountRef[]>; friendsList(): Promise<AccountRef[]>;
friendsIncoming(): Promise<AccountRef[]>; friendsIncoming(): Promise<AccountRef[]>;
/** Addressees the caller has already requested (pending or declined); cannot re-request. */ /** Addressees the caller has already requested (pending or declined; cannot re-request),
friendsOutgoing(): Promise<AccountRef[]>; * plus the per-game disguised-robot requests that keep the in-game 🤝 disabled by seat. */
friendRequest(accountId: string): Promise<void>; friendsOutgoing(): Promise<OutgoingList>;
/** 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<void>;
friendRespond(requesterId: string, accept: boolean): Promise<void>; friendRespond(requesterId: string, accept: boolean): Promise<void>;
friendCancel(accountId: string): Promise<void>; friendCancel(accountId: string): Promise<void>;
unfriend(accountId: string): Promise<void>; unfriend(accountId: string): Promise<void>;
+13 -4
View File
@@ -256,7 +256,7 @@ describe('codec', () => {
expect(gl.atGameLimit).toBe(true); 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 b = new Builder(128);
const id = b.createString('o-1'); const id = b.createString('o-1');
const dn = b.createString('Pat'); const dn = b.createString('Pat');
@@ -264,11 +264,20 @@ describe('codec', () => {
fb.AccountRef.addAccountId(b, id); fb.AccountRef.addAccountId(b, id);
fb.AccountRef.addDisplayName(b, dn); fb.AccountRef.addDisplayName(b, dn);
const ref = fb.AccountRef.endAccountRef(b); 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.startOutgoingRequestList(b);
fb.OutgoingRequestList.addRequests(b, vec); fb.OutgoingRequestList.addRequests(b, reqVec);
fb.OutgoingRequestList.addRobots(b, robVec);
b.finish(fb.OutgoingRequestList.endOutgoingRequestList(b)); 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', () => { it('encodes a TargetRequest', () => {
+17 -4
View File
@@ -10,7 +10,9 @@ import type { PlacedTile } from './client';
import type { import type {
AccountRef, AccountRef,
BlockList, BlockList,
OutgoingList,
RobotBlockEntry, RobotBlockEntry,
RobotFriendRequestEntry,
Banner, Banner,
BannerCampaign, BannerCampaign,
BestMove, BestMove,
@@ -719,14 +721,25 @@ export function decodeIncomingList(buf: Uint8Array): AccountRef[] {
return out; return out;
} }
export function decodeOutgoingList(buf: Uint8Array): AccountRef[] { export function decodeOutgoingList(buf: Uint8Array): OutgoingList {
const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf)); const l = fb.OutgoingRequestList.getRootAsOutgoingRequestList(new ByteBuffer(buf));
const out: AccountRef[] = []; const requests: AccountRef[] = [];
for (let i = 0; i < l.requestsLength(); i++) { for (let i = 0; i < l.requestsLength(); i++) {
const r = l.requests(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 { export function decodeBlockList(buf: Uint8Array): BlockList {
+54 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; 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'; import type { GameView, Seat } from './model';
const ME = 'me'; 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', () => { describe('shouldBlink', () => {
it('blinks on a transition into mine or finished', () => { it('blinks on a transition into mine or finished', () => {
expect(shouldBlink('theirs', 'mine')).toBe(true); expect(shouldBlink('theirs', 'mine')).toBe(true);
+23
View File
@@ -12,6 +12,29 @@ export function isMyTurn(game: GameView, myId: string): boolean {
return (game.status === 'active' || game.status === 'open') && !!me && game.toMove === me.seat; 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. */ /** LobbyPhase is a game's lobby bucket — which of the three card sections it currently sits in. */
export type LobbyPhase = 'mine' | 'theirs' | 'finished'; export type LobbyPhase = 'mine' | 'theirs' | 'finished';
+5 -3
View File
@@ -29,6 +29,7 @@ import type {
LinkResult, LinkResult,
MatchResult, MatchResult,
MoveResult, MoveResult,
OutgoingList,
Profile, Profile,
ProfileUpdate, ProfileUpdate,
PushEvent, PushEvent,
@@ -527,10 +528,11 @@ export class MockGateway implements GatewayClient {
async friendsIncoming(): Promise<AccountRef[]> { async friendsIncoming(): Promise<AccountRef[]> {
return this.incoming.map((f) => ({ ...f })); return this.incoming.map((f) => ({ ...f }));
} }
async friendsOutgoing(): Promise<AccountRef[]> { async friendsOutgoing(): Promise<OutgoingList> {
return this.outgoing.map((f) => ({ ...f })); // 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<void> { async friendRequest(accountId: string, _gameId?: string): Promise<void> {
// The real backend requires a shared game; the mock records the outgoing request so // 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. // the game's "add to friends" item reads as sent across reloads.
if (!this.outgoing.some((o) => o.accountId === accountId)) { if (!this.outgoing.some((o) => o.accountId === accountId)) {
+17
View File
@@ -225,6 +225,23 @@ export interface BlockList {
robots: RobotBlockEntry[]; 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). */ /** A freshly issued one-time friend code (the plaintext is returned once). */
export interface FriendCode { export interface FriendCode {
code: string; code: string;
+2 -2
View File
@@ -165,8 +165,8 @@ export function createTransport(baseUrl: string): GatewayClient {
async friendsOutgoing() { async friendsOutgoing() {
return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty())); return codec.decodeOutgoingList(await exec('friends.outgoing', codec.empty()));
}, },
async friendRequest(accountId) { async friendRequest(accountId, gameId) {
await exec('friends.request', codec.encodeTarget(accountId)); await exec('friends.request', codec.encodeTarget(accountId, gameId));
}, },
async friendRespond(requesterId, accept) { async friendRespond(requesterId, accept) {
await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept)); await exec('friends.respond', codec.encodeFriendRespond(requesterId, accept));
+17 -7
View File
@@ -12,7 +12,7 @@
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { getLobby, setLobby } from '../lib/lobbycache'; import { getLobby, setLobby } from '../lib/lobbycache';
import { preloadGames } from '../lib/preload'; 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'; import type { AccountRef, GameView, Invitation } from '../lib/model';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
@@ -130,11 +130,6 @@
.map((s) => s.displayName) .map((s) => s.displayName)
.join(', '); .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 // 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 // revealed by swiping the row left (touch) or tapping its kebab (any pointer); the action is
@@ -271,7 +266,14 @@
<span class="who-name">{opponents(g) || '—'}</span> <span class="who-name">{opponents(g) || '—'}</span>
{#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if} {#if badge}<span class="unread-dot" class:nudge={badge === 'nudge'}></span>{/if}
</span> </span>
<span class="sub">{scoreline(g)}</span> <span class="sub scoreline"
>{#each orderedSeats(g) as s, i (s.seat)}{#if i > 0}<span class="sep"> : </span
>{/if}<span
class="num"
class:win={s.accountId === myId && scoreStanding(g, myId) === 'win'}
class:lose={s.accountId === myId && scoreStanding(g, myId) === 'lose'}>{s.score}</span
>{/each}</span
>
</span> </span>
<!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. --> <!-- Re-key on the per-card blink nonce so a repeat blink restarts the fade. -->
{#key blinkNonce.get(g.id) ?? 0} {#key blinkNonce.get(g.id) ?? 0}
@@ -476,6 +478,14 @@
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
} }
/* 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; no bold. */
.num.win {
color: var(--ok);
}
.num.lose {
color: var(--danger);
}
.emoji { .emoji {
font-size: 1.35rem; font-size: 1.35rem;
line-height: 1; line-height: 1;
+15 -8
View File
@@ -72,8 +72,8 @@
<div class="rows"> <div class="rows">
{#each bestMoves as bm (bm.variant)} {#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span> <span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span>
<span class="score">{bm.score}</span> <span class="score">{bm.score}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span>
{/each} {/each}
</div> </div>
</div> </div>
@@ -115,23 +115,30 @@
margin-top: 12px; margin-top: 12px;
gap: 12px; gap: 12px;
} }
/* One grid for all rows so columns align across them: variant on the left, the word /* Two lines per variant: the variant label on its own line, then the score and the word
tiles right-aligned to a shared edge, the score right-aligned in its own column. */ 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 { .rows {
display: grid; display: grid;
/* minmax(0, 1fr) lets the word column shrink below its tiles' intrinsic width on a /* score column (sized to the widest score) then the word; minmax(0, 1fr) lets the word
narrow screen (the cell then scrolls) instead of overlapping the variant label. */ shrink below its tiles' intrinsic width on a narrow screen (the cell then scrolls). */
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr);
align-items: center; align-items: center;
row-gap: 12px; row-gap: 4px;
column-gap: 8px; column-gap: 8px;
} }
.variant { .variant {
grid-column: 1 / -1;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.95rem; 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 { .wordcell {
justify-self: end; justify-self: start;
min-width: 0; min-width: 0;
overflow-x: auto; overflow-x: auto;
} }