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
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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user