feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
+48 -3
View File
@@ -82,6 +82,13 @@ const (
// `lobby.membership.rejected` notification.
NotificationTypeLobbyMembershipRejected NotificationType = "lobby.membership.rejected"
// NotificationTypeLobbyMembershipBlocked identifies the
// `lobby.membership.blocked` notification published by Game Lobby
// to the private-game owner when an active membership is blocked
// by the user-lifecycle cascade reacting to a `permanent_block` or
// `DeleteUser` event.
NotificationTypeLobbyMembershipBlocked NotificationType = "lobby.membership.blocked"
// NotificationTypeLobbyInviteCreated identifies the
// `lobby.invite.created` notification.
NotificationTypeLobbyInviteCreated NotificationType = "lobby.invite.created"
@@ -93,6 +100,24 @@ const (
// NotificationTypeLobbyInviteExpired identifies the
// `lobby.invite.expired` notification.
NotificationTypeLobbyInviteExpired NotificationType = "lobby.invite.expired"
// NotificationTypeLobbyRaceNameRegistrationEligible identifies the
// `lobby.race_name.registration_eligible` notification published by
// Game Lobby when capability evaluation at game finish promotes a
// reservation to `pending_registration`.
NotificationTypeLobbyRaceNameRegistrationEligible NotificationType = "lobby.race_name.registration_eligible"
// NotificationTypeLobbyRaceNameRegistered identifies the
// `lobby.race_name.registered` notification published by Game Lobby
// when a user converts a `pending_registration` into a permanent
// registered race name.
NotificationTypeLobbyRaceNameRegistered NotificationType = "lobby.race_name.registered"
// NotificationTypeLobbyRaceNameRegistrationDenied identifies the
// `lobby.race_name.registration_denied` notification published by
// Game Lobby when capability evaluation at game finish releases a
// reservation because the member did not meet the capability rule.
NotificationTypeLobbyRaceNameRegistrationDenied NotificationType = "lobby.race_name.registration_denied"
)
// String returns the wire value for notificationType.
@@ -111,9 +136,13 @@ func (notificationType NotificationType) IsKnown() bool {
NotificationTypeLobbyApplicationSubmitted,
NotificationTypeLobbyMembershipApproved,
NotificationTypeLobbyMembershipRejected,
NotificationTypeLobbyMembershipBlocked,
NotificationTypeLobbyInviteCreated,
NotificationTypeLobbyInviteRedeemed,
NotificationTypeLobbyInviteExpired:
NotificationTypeLobbyInviteExpired,
NotificationTypeLobbyRaceNameRegistrationEligible,
NotificationTypeLobbyRaceNameRegistered,
NotificationTypeLobbyRaceNameRegistrationDenied:
return true
default:
return false
@@ -133,9 +162,13 @@ func (notificationType NotificationType) ExpectedProducer() Producer {
NotificationTypeLobbyApplicationSubmitted,
NotificationTypeLobbyMembershipApproved,
NotificationTypeLobbyMembershipRejected,
NotificationTypeLobbyMembershipBlocked,
NotificationTypeLobbyInviteCreated,
NotificationTypeLobbyInviteRedeemed,
NotificationTypeLobbyInviteExpired:
NotificationTypeLobbyInviteExpired,
NotificationTypeLobbyRaceNameRegistrationEligible,
NotificationTypeLobbyRaceNameRegistered,
NotificationTypeLobbyRaceNameRegistrationDenied:
return ProducerGameLobby
default:
return ""
@@ -169,7 +202,8 @@ func (notificationType NotificationType) SupportsChannel(audienceKind AudienceKi
return channel == ChannelEmail
}
return channel == ChannelPush || channel == ChannelEmail
case NotificationTypeLobbyInviteExpired:
case NotificationTypeLobbyInviteExpired,
NotificationTypeLobbyRaceNameRegistrationDenied:
return audienceKind == AudienceKindUser && channel == ChannelEmail
default:
return audienceKind == AudienceKindUser && (channel == ChannelPush || channel == ChannelEmail)
@@ -752,10 +786,21 @@ func validatePayloadObject(notificationType NotificationType, payload map[string
return validateStringFields(payload, "game_id", "game_name", "applicant_user_id", "applicant_name")
case NotificationTypeLobbyMembershipApproved, NotificationTypeLobbyMembershipRejected:
return validateStringFields(payload, "game_id", "game_name")
case NotificationTypeLobbyMembershipBlocked:
return validateStringFields(payload, "game_id", "game_name", "membership_user_id", "membership_user_name", "reason")
case NotificationTypeLobbyInviteCreated:
return validateStringFields(payload, "game_id", "game_name", "inviter_user_id", "inviter_name")
case NotificationTypeLobbyInviteRedeemed, NotificationTypeLobbyInviteExpired:
return validateStringFields(payload, "game_id", "game_name", "invitee_user_id", "invitee_name")
case NotificationTypeLobbyRaceNameRegistrationEligible:
if err := validateStringFields(payload, "game_id", "game_name", "race_name"); err != nil {
return err
}
return validatePositiveIntFields(payload, "eligible_until_ms")
case NotificationTypeLobbyRaceNameRegistered:
return validateStringFields(payload, "race_name")
case NotificationTypeLobbyRaceNameRegistrationDenied:
return validateStringFields(payload, "game_id", "game_name", "race_name", "reason")
default:
return fmt.Errorf("payload_json notification type %q is unsupported", notificationType)
}
+62
View File
@@ -159,6 +159,23 @@ func TestConstructorsBuildExpectedIntentValues(t *testing.T) {
recipientUserIDs: []string{"applicant-1"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash"}`,
},
{
name: "lobby membership blocked",
build: func() (Intent, error) {
return NewLobbyMembershipBlockedIntent(metadata, "owner-1", LobbyMembershipBlockedPayload{
GameID: "game-1",
GameName: "Nebula Clash",
MembershipUserID: "user-2",
MembershipUserName: "player-aabbccdd",
Reason: "permanent_blocked",
})
},
notificationType: NotificationTypeLobbyMembershipBlocked,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"owner-1"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","membership_user_id":"user-2","membership_user_name":"player-aabbccdd","reason":"permanent_blocked"}`,
},
{
name: "lobby invite created",
build: func() (Intent, error) {
@@ -207,6 +224,51 @@ func TestConstructorsBuildExpectedIntentValues(t *testing.T) {
recipientUserIDs: []string{"owner-1"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","invitee_user_id":"invitee-1","invitee_name":"Nova Pilot"}`,
},
{
name: "lobby race name registration eligible",
build: func() (Intent, error) {
return NewLobbyRaceNameRegistrationEligibleIntent(metadata, "user-7", LobbyRaceNameRegistrationEligiblePayload{
GameID: "game-1",
GameName: "Nebula Clash",
RaceName: "Skylancer",
EligibleUntilMs: 1775208100000,
})
},
notificationType: NotificationTypeLobbyRaceNameRegistrationEligible,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"user-7"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","eligible_until_ms":1775208100000}`,
},
{
name: "lobby race name registered",
build: func() (Intent, error) {
return NewLobbyRaceNameRegisteredIntent(metadata, "user-8", LobbyRaceNameRegisteredPayload{
RaceName: "Skylancer",
})
},
notificationType: NotificationTypeLobbyRaceNameRegistered,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"user-8"},
payloadJSON: `{"race_name":"Skylancer"}`,
},
{
name: "lobby race name registration denied",
build: func() (Intent, error) {
return NewLobbyRaceNameRegistrationDeniedIntent(metadata, "user-9", LobbyRaceNameRegistrationDeniedPayload{
GameID: "game-1",
GameName: "Nebula Clash",
RaceName: "Skylancer",
Reason: "capability_not_met",
})
},
notificationType: NotificationTypeLobbyRaceNameRegistrationDenied,
producer: ProducerGameLobby,
audienceKind: AudienceKindUser,
recipientUserIDs: []string{"user-9"},
payloadJSON: `{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","reason":"capability_not_met"}`,
},
}
for _, tt := range tests {
+66
View File
@@ -62,6 +62,20 @@ type LobbyMembershipRejectedPayload struct {
GameName string `json:"game_name"`
}
// LobbyMembershipBlockedPayload stores the normalized payload for
// `lobby.membership.blocked` published by the user-lifecycle cascade
// when an active membership is blocked because the underlying user was
// permanently blocked or deleted.
type LobbyMembershipBlockedPayload struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
MembershipUserID string `json:"membership_user_id"`
MembershipUserName string `json:"membership_user_name"`
// Reason captures the upstream lifecycle event that triggered the
// cascade. Frozen vocabulary: `permanent_blocked`, `deleted`.
Reason string `json:"reason"`
}
// LobbyInviteCreatedPayload stores the normalized payload for
// `lobby.invite.created`.
type LobbyInviteCreatedPayload struct {
@@ -89,6 +103,30 @@ type LobbyInviteExpiredPayload struct {
InviteeName string `json:"invitee_name"`
}
// LobbyRaceNameRegistrationEligiblePayload stores the normalized payload
// for `lobby.race_name.registration_eligible`.
type LobbyRaceNameRegistrationEligiblePayload struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
RaceName string `json:"race_name"`
EligibleUntilMs int64 `json:"eligible_until_ms"`
}
// LobbyRaceNameRegisteredPayload stores the normalized payload for
// `lobby.race_name.registered`.
type LobbyRaceNameRegisteredPayload struct {
RaceName string `json:"race_name"`
}
// LobbyRaceNameRegistrationDeniedPayload stores the normalized payload for
// `lobby.race_name.registration_denied`.
type LobbyRaceNameRegistrationDeniedPayload struct {
GameID string `json:"game_id"`
GameName string `json:"game_name"`
RaceName string `json:"race_name"`
Reason string `json:"reason"`
}
// NewGeoReviewRecommendedIntent builds the admin-email intent published by Geo
// Profile Service when a user becomes review-worthy.
func NewGeoReviewRecommendedIntent(metadata Metadata, payload GeoReviewRecommendedPayload) (Intent, error) {
@@ -143,6 +181,14 @@ func NewLobbyMembershipRejectedIntent(metadata Metadata, applicantUserID string,
return newIntent(NotificationTypeLobbyMembershipRejected, ProducerGameLobby, AudienceKindUser, []string{applicantUserID}, metadata, payload)
}
// NewLobbyMembershipBlockedIntent builds the private-game owner intent
// published by Game Lobby when an active membership is blocked by the
// user-lifecycle cascade. ownerUserID is the recipient (private-game
// owner whose roster lost the affected member).
func NewLobbyMembershipBlockedIntent(metadata Metadata, ownerUserID string, payload LobbyMembershipBlockedPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyMembershipBlocked, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
}
// NewLobbyInviteCreatedIntent builds the invited-user intent published by Game
// Lobby when a private-game invite is created.
func NewLobbyInviteCreatedIntent(metadata Metadata, invitedUserID string, payload LobbyInviteCreatedPayload) (Intent, error) {
@@ -160,3 +206,23 @@ func NewLobbyInviteRedeemedIntent(metadata Metadata, ownerUserID string, payload
func NewLobbyInviteExpiredIntent(metadata Metadata, ownerUserID string, payload LobbyInviteExpiredPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyInviteExpired, ProducerGameLobby, AudienceKindUser, []string{ownerUserID}, metadata, payload)
}
// NewLobbyRaceNameRegistrationEligibleIntent builds the capable-member intent
// published by Game Lobby at game finish when a reservation is promoted to
// `pending_registration`.
func NewLobbyRaceNameRegistrationEligibleIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegistrationEligiblePayload) (Intent, error) {
return newIntent(NotificationTypeLobbyRaceNameRegistrationEligible, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
}
// NewLobbyRaceNameRegisteredIntent builds the registering-user intent
// published by Game Lobby on successful `lobby.race_name.register` commit.
func NewLobbyRaceNameRegisteredIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegisteredPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyRaceNameRegistered, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
}
// NewLobbyRaceNameRegistrationDeniedIntent builds the incapable-member intent
// published by Game Lobby at game finish when a reservation is released
// without a pending-registration window.
func NewLobbyRaceNameRegistrationDeniedIntent(metadata Metadata, recipientUserID string, payload LobbyRaceNameRegistrationDeniedPayload) (Intent, error) {
return newIntent(NotificationTypeLobbyRaceNameRegistrationDenied, ProducerGameLobby, AudienceKindUser, []string{recipientUserID}, metadata, payload)
}