feat(social): asymmetric per-user block, in-game block control, admin lists
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 51s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Make a per-user block one-directional and non-destructive: the blocker stops
receiving everything from the blocked user (chat, nudge, friend requests,
invitations) and the matchmaker never pairs them, while the blocked user
notices nothing — their sends still persist by the normal rules but are never
delivered or surfaced (born-read). A block no longer deletes the friendship
(an unblock cleanly restores it) and instant-reads any unread the blocked user
had left for the blocker.
- backend: a directional blockExists guard across chat/nudge/friends/invitations
(store-but-hide for the blocked->blocker direction, refuse blocker->blocked);
the matchmaker excludes a block-related pair (both directions) from auto-match;
user_blocked/user_unblocked notifications to the blocker only (in-app only).
- ui: the opponent score card gains a block ✖️ control mirroring add-friend
(red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat
composer when blocked); optimistic apply + event confirm + rollback for both.
- admin: the user card gains cross-linked blocks / blocked-by / friends lists.
- docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.
This commit is contained in:
@@ -18,7 +18,10 @@ import (
|
||||
// reading a player's view to enrich the opponent_joined event. game.Service satisfies
|
||||
// it.
|
||||
type GameMatcher interface {
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time) (game.Game, bool, error)
|
||||
// OpenOrJoin joins the caller into another waiting player's open game or opens a fresh
|
||||
// one; exclude lists account IDs whose open game must never be joined (the caller's
|
||||
// per-user block set), so a block keeps the pair out of the same anonymous game.
|
||||
OpenOrJoin(ctx context.Context, accountID uuid.UUID, params game.CreateParams, openDeadline time.Time, exclude []uuid.UUID) (game.Game, bool, error)
|
||||
AttachRobot(ctx context.Context, gameID, robotID uuid.UUID, displayName string) (game.Game, bool, error)
|
||||
ExpiredOpen(ctx context.Context, now time.Time) ([]game.OpenGame, error)
|
||||
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
|
||||
@@ -35,8 +38,9 @@ type GameMatcher interface {
|
||||
// Matchmaker holds only the wait policy and the live-event publisher, and is safe for
|
||||
// concurrent use.
|
||||
//
|
||||
// Auto-match is anonymous, so it does not consult per-user blocks (those govern
|
||||
// friends, chat and invitations between known players).
|
||||
// Auto-match is anonymous, but it still consults per-user blocks: a player is never
|
||||
// paired into a game whose waiting opponent they have blocked, or who has blocked them
|
||||
// (either direction), so a block keeps two players apart everywhere.
|
||||
type Matchmaker struct {
|
||||
games GameMatcher
|
||||
robots RobotProvider
|
||||
@@ -44,6 +48,7 @@ type Matchmaker struct {
|
||||
jitter time.Duration
|
||||
clock func() time.Time
|
||||
pub notify.Publisher
|
||||
blocker Blocker
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
@@ -75,6 +80,16 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetBlocker installs the per-user block source used to keep auto-match from pairing a
|
||||
// player with someone they have a block with (either direction). It must be called
|
||||
// during startup wiring; the default is no blocker, in which case auto-match pairs
|
||||
// anonymously (no block exclusion).
|
||||
func (m *Matchmaker) SetBlocker(b Blocker) {
|
||||
if b != nil {
|
||||
m.blocker = b
|
||||
}
|
||||
}
|
||||
|
||||
// EnqueueResult is the outcome of an auto-match enqueue: the game the caller now plays
|
||||
// in, and whether it already had an opponent (they joined a waiting game) rather than
|
||||
// being freshly opened and still awaiting one.
|
||||
@@ -90,7 +105,17 @@ type EnqueueResult struct {
|
||||
// caller's own. When the caller joins an existing game, opponent_joined is pushed to
|
||||
// that game's waiting starter.
|
||||
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant, multipleWords bool) (EnqueueResult, error) {
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline())
|
||||
// Exclude every account the caller has a block with (either direction) from the
|
||||
// candidate open games, so auto-match never pairs a blocked pair into one game.
|
||||
var exclude []uuid.UUID
|
||||
if m.blocker != nil {
|
||||
var err error
|
||||
exclude, err = m.blocker.BlockedWith(ctx, accountID)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
}
|
||||
g, joined, err := m.games.OpenOrJoin(ctx, accountID, autoMatchParams(variant, multipleWords), m.openDeadline(), exclude)
|
||||
if err != nil {
|
||||
return EnqueueResult{}, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user