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
+18 -5
View File
@@ -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.
@@ -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()
}
@@ -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
@@ -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) {