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
+144
View File
@@ -0,0 +1,144 @@
// Package idgen provides the default crypto/rand-backed implementation of
// ports.IDGenerator for Game Lobby Service.
package idgen
import (
"crypto/rand"
"encoding/base32"
"fmt"
"io"
"strings"
"galaxy/lobby/internal/domain/common"
)
// gameIDTokenBytes stores the number of random bytes consumed per
// NewGameID call. Ten bytes produce a 16-character base32 suffix, which
// gives 80 bits of entropy — well above the birthday-collision bound for the
// expected Game Lobby record volume.
const gameIDTokenBytes = 10
// applicationIDTokenBytes mirrors gameIDTokenBytes for application records.
// 80 bits of entropy is well above the birthday-collision bound for the
// expected application volume.
const applicationIDTokenBytes = 10
// inviteIDTokenBytes mirrors gameIDTokenBytes for invite records.
const inviteIDTokenBytes = 10
// membershipIDTokenBytes mirrors gameIDTokenBytes for membership records.
const membershipIDTokenBytes = 10
// base32NoPadding is the standard RFC 4648 base32 alphabet without padding,
// matching the identifier shape used by `galaxy/user/internal/adapters/local`.
var base32NoPadding = base32.StdEncoding.WithPadding(base32.NoPadding)
// Generator is the default opaque-identifier generator for Game Lobby
// records. Zero value is ready for use and draws randomness from
// crypto/rand.Reader.
type Generator struct {
// reader stores the cryptographic randomness source. A nil reader falls
// back to crypto/rand.Reader.
reader io.Reader
}
// Option configures an optional Generator setting.
type Option func(*Generator)
// WithRandomSource overrides the cryptographic randomness source. It is
// intended for deterministic tests; production code relies on the default
// crypto/rand.Reader.
func WithRandomSource(reader io.Reader) Option {
return func(gen *Generator) {
gen.reader = reader
}
}
// NewGenerator constructs one Generator with the supplied options applied.
func NewGenerator(opts ...Option) *Generator {
gen := &Generator{}
for _, opt := range opts {
opt(gen)
}
return gen
}
// NewGameID returns one newly generated opaque game identifier with the
// frozen `game-*` prefix.
func (gen *Generator) NewGameID() (common.GameID, error) {
token, err := gen.randomToken(gameIDTokenBytes)
if err != nil {
return "", fmt.Errorf("generate game id: %w", err)
}
gameID := common.GameID("game-" + token)
if err := gameID.Validate(); err != nil {
return "", fmt.Errorf("generate game id: %w", err)
}
return gameID, nil
}
// NewApplicationID returns one newly generated opaque application
// identifier with the frozen `application-*` prefix.
func (gen *Generator) NewApplicationID() (common.ApplicationID, error) {
token, err := gen.randomToken(applicationIDTokenBytes)
if err != nil {
return "", fmt.Errorf("generate application id: %w", err)
}
applicationID := common.ApplicationID("application-" + token)
if err := applicationID.Validate(); err != nil {
return "", fmt.Errorf("generate application id: %w", err)
}
return applicationID, nil
}
// NewInviteID returns one newly generated opaque invite identifier with the
// frozen `invite-*` prefix.
func (gen *Generator) NewInviteID() (common.InviteID, error) {
token, err := gen.randomToken(inviteIDTokenBytes)
if err != nil {
return "", fmt.Errorf("generate invite id: %w", err)
}
inviteID := common.InviteID("invite-" + token)
if err := inviteID.Validate(); err != nil {
return "", fmt.Errorf("generate invite id: %w", err)
}
return inviteID, nil
}
// NewMembershipID returns one newly generated opaque membership identifier
// with the frozen `membership-*` prefix.
func (gen *Generator) NewMembershipID() (common.MembershipID, error) {
token, err := gen.randomToken(membershipIDTokenBytes)
if err != nil {
return "", fmt.Errorf("generate membership id: %w", err)
}
membershipID := common.MembershipID("membership-" + token)
if err := membershipID.Validate(); err != nil {
return "", fmt.Errorf("generate membership id: %w", err)
}
return membershipID, nil
}
// randomToken returns one lowercase base32 token of the specified byte
// entropy.
func (gen *Generator) randomToken(byteCount int) (string, error) {
buffer := make([]byte, byteCount)
reader := gen.reader
if reader == nil {
reader = rand.Reader
}
if _, err := io.ReadFull(reader, buffer); err != nil {
return "", err
}
return strings.ToLower(base32NoPadding.EncodeToString(buffer)), nil
}
@@ -0,0 +1,230 @@
package idgen
import (
"bytes"
"io"
"strings"
"testing"
"galaxy/lobby/internal/domain/common"
"github.com/stretchr/testify/require"
)
func TestNewGameIDShape(t *testing.T) {
t.Parallel()
gen := NewGenerator()
gameID, err := gen.NewGameID()
require.NoError(t, err)
require.NoError(t, gameID.Validate())
require.True(t, strings.HasPrefix(gameID.String(), "game-"))
require.Equal(t, strings.ToLower(gameID.String()), gameID.String())
}
func TestNewGameIDDeterministicWithFixedReader(t *testing.T) {
t.Parallel()
source := bytes.NewReader(bytes.Repeat([]byte{0x00}, gameIDTokenBytes*2))
gen := NewGenerator(WithRandomSource(source))
first, err := gen.NewGameID()
require.NoError(t, err)
require.Equal(t, common.GameID("game-aaaaaaaaaaaaaaaa"), first)
second, err := gen.NewGameID()
require.NoError(t, err)
require.Equal(t, common.GameID("game-aaaaaaaaaaaaaaaa"), second)
}
func TestNewGameIDUniquenessOverManyDraws(t *testing.T) {
t.Parallel()
gen := NewGenerator()
seen := make(map[common.GameID]struct{}, 1024)
for i := range 1024 {
gameID, err := gen.NewGameID()
require.NoError(t, err)
_, dup := seen[gameID]
require.False(t, dup, "duplicate game id %q on draw %d", gameID, i)
seen[gameID] = struct{}{}
}
}
func TestNewGameIDSourceError(t *testing.T) {
t.Parallel()
gen := NewGenerator(WithRandomSource(failingReader{}))
_, err := gen.NewGameID()
require.Error(t, err)
require.Contains(t, err.Error(), "generate game id")
}
func TestNewApplicationIDShape(t *testing.T) {
t.Parallel()
gen := NewGenerator()
applicationID, err := gen.NewApplicationID()
require.NoError(t, err)
require.NoError(t, applicationID.Validate())
require.True(t, strings.HasPrefix(applicationID.String(), "application-"))
require.Equal(t, strings.ToLower(applicationID.String()), applicationID.String())
}
func TestNewApplicationIDDeterministicWithFixedReader(t *testing.T) {
t.Parallel()
source := bytes.NewReader(bytes.Repeat([]byte{0x00}, applicationIDTokenBytes*2))
gen := NewGenerator(WithRandomSource(source))
first, err := gen.NewApplicationID()
require.NoError(t, err)
require.Equal(t, common.ApplicationID("application-aaaaaaaaaaaaaaaa"), first)
second, err := gen.NewApplicationID()
require.NoError(t, err)
require.Equal(t, common.ApplicationID("application-aaaaaaaaaaaaaaaa"), second)
}
func TestNewApplicationIDUniquenessOverManyDraws(t *testing.T) {
t.Parallel()
gen := NewGenerator()
seen := make(map[common.ApplicationID]struct{}, 1024)
for i := range 1024 {
applicationID, err := gen.NewApplicationID()
require.NoError(t, err)
_, dup := seen[applicationID]
require.False(t, dup, "duplicate application id %q on draw %d", applicationID, i)
seen[applicationID] = struct{}{}
}
}
func TestNewApplicationIDSourceError(t *testing.T) {
t.Parallel()
gen := NewGenerator(WithRandomSource(failingReader{}))
_, err := gen.NewApplicationID()
require.Error(t, err)
require.Contains(t, err.Error(), "generate application id")
}
func TestNewInviteIDShape(t *testing.T) {
t.Parallel()
gen := NewGenerator()
inviteID, err := gen.NewInviteID()
require.NoError(t, err)
require.NoError(t, inviteID.Validate())
require.True(t, strings.HasPrefix(inviteID.String(), "invite-"))
require.Equal(t, strings.ToLower(inviteID.String()), inviteID.String())
}
func TestNewInviteIDDeterministicWithFixedReader(t *testing.T) {
t.Parallel()
source := bytes.NewReader(bytes.Repeat([]byte{0x00}, inviteIDTokenBytes*2))
gen := NewGenerator(WithRandomSource(source))
first, err := gen.NewInviteID()
require.NoError(t, err)
require.Equal(t, common.InviteID("invite-aaaaaaaaaaaaaaaa"), first)
second, err := gen.NewInviteID()
require.NoError(t, err)
require.Equal(t, common.InviteID("invite-aaaaaaaaaaaaaaaa"), second)
}
func TestNewInviteIDUniquenessOverManyDraws(t *testing.T) {
t.Parallel()
gen := NewGenerator()
seen := make(map[common.InviteID]struct{}, 1024)
for i := range 1024 {
inviteID, err := gen.NewInviteID()
require.NoError(t, err)
_, dup := seen[inviteID]
require.False(t, dup, "duplicate invite id %q on draw %d", inviteID, i)
seen[inviteID] = struct{}{}
}
}
func TestNewInviteIDSourceError(t *testing.T) {
t.Parallel()
gen := NewGenerator(WithRandomSource(failingReader{}))
_, err := gen.NewInviteID()
require.Error(t, err)
require.Contains(t, err.Error(), "generate invite id")
}
func TestNewMembershipIDShape(t *testing.T) {
t.Parallel()
gen := NewGenerator()
membershipID, err := gen.NewMembershipID()
require.NoError(t, err)
require.NoError(t, membershipID.Validate())
require.True(t, strings.HasPrefix(membershipID.String(), "membership-"))
require.Equal(t, strings.ToLower(membershipID.String()), membershipID.String())
}
func TestNewMembershipIDDeterministicWithFixedReader(t *testing.T) {
t.Parallel()
source := bytes.NewReader(bytes.Repeat([]byte{0x00}, membershipIDTokenBytes*2))
gen := NewGenerator(WithRandomSource(source))
first, err := gen.NewMembershipID()
require.NoError(t, err)
require.Equal(t, common.MembershipID("membership-aaaaaaaaaaaaaaaa"), first)
second, err := gen.NewMembershipID()
require.NoError(t, err)
require.Equal(t, common.MembershipID("membership-aaaaaaaaaaaaaaaa"), second)
}
func TestNewMembershipIDUniquenessOverManyDraws(t *testing.T) {
t.Parallel()
gen := NewGenerator()
seen := make(map[common.MembershipID]struct{}, 1024)
for i := range 1024 {
membershipID, err := gen.NewMembershipID()
require.NoError(t, err)
_, dup := seen[membershipID]
require.False(t, dup, "duplicate membership id %q on draw %d", membershipID, i)
seen[membershipID] = struct{}{}
}
}
func TestNewMembershipIDSourceError(t *testing.T) {
t.Parallel()
gen := NewGenerator(WithRandomSource(failingReader{}))
_, err := gen.NewMembershipID()
require.Error(t, err)
require.Contains(t, err.Error(), "generate membership id")
}
type failingReader struct{}
func (failingReader) Read(_ []byte) (int, error) {
return 0, io.ErrUnexpectedEOF
}