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
@@ -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
// 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.
+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))
}
}
}
}