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
@@ -0,0 +1,135 @@
// Package racenameintents adapts the per-game capability evaluator's
// RaceNameIntents interface to the shared galaxy/notificationintent
// publisher. introduced a NoopRaceNameIntents shim while the
// notification catalog lacked the lobby.race_name.* types; lands
// those types and this adapter replaces the shim in production wiring.
package racenameintents
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"galaxy/lobby/internal/ports"
"galaxy/lobby/internal/service/capabilityevaluation"
"galaxy/notificationintent"
)
// Publisher implements capabilityevaluation.RaceNameIntents by composing
// the type-specific notificationintent constructors with the shared
// IntentPublisher port.
type Publisher struct {
publisher ports.IntentPublisher
clock func() time.Time
logger *slog.Logger
}
// Config groups the dependencies required to construct a Publisher.
type Config struct {
// Publisher receives every constructed notification intent. The
// adapter never falls back to a noop; transport errors are wrapped
// and returned so the evaluator's logging path can record them.
Publisher ports.IntentPublisher
// Clock supplies the wall-clock used for log timestamps. The
// adapter copies FinishedAt from the inbound event into the intent
// metadata, so the clock is currently unused inside Publish*; it is
// retained on the struct for parity with other lobby adapters and
// for forthcoming tracing hooks.
Clock func() time.Time
// Logger receives optional adapter-level structured logs. Defaults
// to slog.Default() if nil.
Logger *slog.Logger
}
// NewPublisher constructs one Publisher.
func NewPublisher(cfg Config) (*Publisher, error) {
if cfg.Publisher == nil {
return nil, errors.New("new race name intents publisher: nil intent publisher")
}
clock := cfg.Clock
if clock == nil {
clock = time.Now
}
logger := cfg.Logger
if logger == nil {
logger = slog.Default()
}
return &Publisher{
publisher: cfg.Publisher,
clock: clock,
logger: logger.With("adapter", "lobby.racenameintents"),
}, nil
}
// PublishEligible builds a lobby.race_name.registration_eligible intent
// from ev and forwards it to the underlying intent publisher. Idempotency
// is scoped by (game_id, user_id) so retries of the same evaluator pass
// collapse to a single notification at the consumer.
func (publisher *Publisher) PublishEligible(ctx context.Context, ev capabilityevaluation.EligibleEvent) error {
if publisher == nil {
return errors.New("publish race name eligible intent: nil publisher")
}
if ctx == nil {
return errors.New("publish race name eligible intent: nil context")
}
gameID := ev.GameID.String()
intent, err := notificationintent.NewLobbyRaceNameRegistrationEligibleIntent(
notificationintent.Metadata{
IdempotencyKey: "game-lobby:race-name-eligible:" + gameID + ":" + ev.UserID,
OccurredAt: ev.FinishedAt,
},
ev.UserID,
notificationintent.LobbyRaceNameRegistrationEligiblePayload{
GameID: gameID,
GameName: ev.GameName,
RaceName: ev.RaceName,
EligibleUntilMs: ev.EligibleUntil.UnixMilli(),
},
)
if err != nil {
return fmt.Errorf("publish race name eligible intent: build intent: %w", err)
}
if _, err := publisher.publisher.Publish(ctx, intent); err != nil {
return fmt.Errorf("publish race name eligible intent: %w", err)
}
return nil
}
// PublishDenied builds a lobby.race_name.registration_denied intent from
// ev and forwards it to the underlying intent publisher.
func (publisher *Publisher) PublishDenied(ctx context.Context, ev capabilityevaluation.DeniedEvent) error {
if publisher == nil {
return errors.New("publish race name denied intent: nil publisher")
}
if ctx == nil {
return errors.New("publish race name denied intent: nil context")
}
gameID := ev.GameID.String()
intent, err := notificationintent.NewLobbyRaceNameRegistrationDeniedIntent(
notificationintent.Metadata{
IdempotencyKey: "game-lobby:race-name-denied:" + gameID + ":" + ev.UserID,
OccurredAt: ev.FinishedAt,
},
ev.UserID,
notificationintent.LobbyRaceNameRegistrationDeniedPayload{
GameID: gameID,
GameName: ev.GameName,
RaceName: ev.RaceName,
Reason: ev.Reason,
},
)
if err != nil {
return fmt.Errorf("publish race name denied intent: build intent: %w", err)
}
if _, err := publisher.publisher.Publish(ctx, intent); err != nil {
return fmt.Errorf("publish race name denied intent: %w", err)
}
return nil
}
// Compile-time interface assertion.
var _ capabilityevaluation.RaceNameIntents = (*Publisher)(nil)
@@ -0,0 +1,105 @@
package racenameintents_test
import (
"context"
"errors"
"testing"
"time"
"galaxy/lobby/internal/adapters/intentpubstub"
"galaxy/lobby/internal/adapters/racenameintents"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/service/capabilityevaluation"
"galaxy/notificationintent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPublisherEligibleProducesExpectedIntent(t *testing.T) {
t.Parallel()
stub := intentpubstub.NewPublisher()
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
require.NoError(t, err)
finishedAt := time.UnixMilli(1775121700000).UTC()
eligibleUntil := finishedAt.Add(30 * 24 * time.Hour)
require.NoError(t, publisher.PublishEligible(context.Background(), capabilityevaluation.EligibleEvent{
GameID: common.GameID("game-1"),
GameName: "Nebula Clash",
UserID: "user-7",
RaceName: "Skylancer",
EligibleUntil: eligibleUntil,
FinishedAt: finishedAt,
}))
published := stub.Published()
require.Len(t, published, 1)
intent := published[0]
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationEligible, intent.NotificationType)
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
assert.Equal(t, []string{"user-7"}, intent.RecipientUserIDs)
assert.Equal(t, "game-lobby:race-name-eligible:game-1:user-7", intent.IdempotencyKey)
assert.Equal(t, finishedAt, intent.OccurredAt)
assert.JSONEq(
t,
`{"game_id":"game-1","game_name":"Nebula Clash","race_name":"Skylancer","eligible_until_ms":1777713700000}`,
intent.PayloadJSON,
)
}
func TestPublisherDeniedProducesExpectedIntent(t *testing.T) {
t.Parallel()
stub := intentpubstub.NewPublisher()
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
require.NoError(t, err)
finishedAt := time.UnixMilli(1775121700000).UTC()
require.NoError(t, publisher.PublishDenied(context.Background(), capabilityevaluation.DeniedEvent{
GameID: common.GameID("game-2"),
GameName: "Nova",
UserID: "user-9",
RaceName: "Skylancer",
FinishedAt: finishedAt,
Reason: capabilityevaluation.ReasonCapabilityNotMet,
}))
published := stub.Published()
require.Len(t, published, 1)
intent := published[0]
assert.Equal(t, notificationintent.NotificationTypeLobbyRaceNameRegistrationDenied, intent.NotificationType)
assert.Equal(t, notificationintent.ProducerGameLobby, intent.Producer)
assert.Equal(t, notificationintent.AudienceKindUser, intent.AudienceKind)
assert.Equal(t, []string{"user-9"}, intent.RecipientUserIDs)
assert.Equal(t, "game-lobby:race-name-denied:game-2:user-9", intent.IdempotencyKey)
assert.Equal(t, finishedAt, intent.OccurredAt)
assert.JSONEq(
t,
`{"game_id":"game-2","game_name":"Nova","race_name":"Skylancer","reason":"capability_not_met"}`,
intent.PayloadJSON,
)
}
func TestPublisherSurfacesPublisherError(t *testing.T) {
t.Parallel()
stub := intentpubstub.NewPublisher()
stub.SetError(errors.New("transport unavailable"))
publisher, err := racenameintents.NewPublisher(racenameintents.Config{Publisher: stub})
require.NoError(t, err)
finishedAt := time.UnixMilli(1775121700000).UTC()
err = publisher.PublishEligible(context.Background(), capabilityevaluation.EligibleEvent{
GameID: common.GameID("game-1"),
GameName: "Nebula Clash",
UserID: "user-7",
RaceName: "Skylancer",
EligibleUntil: finishedAt.Add(30 * 24 * time.Hour),
FinishedAt: finishedAt,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "transport unavailable")
}