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,108 @@
package redisstate
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/lobby/internal/domain/common"
"galaxy/lobby/internal/ports"
"github.com/redis/go-redis/v9"
)
// GapActivationRecordTTL is the Redis retention applied to gap activation
// timestamps. uses zero (no expiry); the worker that consumes
// these records will revisit retention when the surface
// stabilizes.
const GapActivationRecordTTL time.Duration = 0
// gapActivationRecord stores the strict Redis JSON shape used for one
// gap-window activation timestamp.
type gapActivationRecord struct {
ActivatedAtMS int64 `json:"activated_at_ms"`
}
// GapActivationStore provides Redis-backed durable storage for gap-window
// activation timestamps used by enrollment automation.
type GapActivationStore struct {
client *redis.Client
keys Keyspace
}
// NewGapActivationStore constructs one Redis-backed gap activation store.
// It returns an error when client is nil.
func NewGapActivationStore(client *redis.Client) (*GapActivationStore, error) {
if client == nil {
return nil, errors.New("new gap activation store: nil redis client")
}
return &GapActivationStore{client: client, keys: Keyspace{}}, nil
}
// MarkActivated writes at as the gap activation timestamp for gameID iff
// no prior activation exists. A second call is a silent no-op.
func (store *GapActivationStore) MarkActivated(ctx context.Context, gameID common.GameID, at time.Time) error {
if store == nil || store.client == nil {
return errors.New("mark gap activation: nil store")
}
if ctx == nil {
return errors.New("mark gap activation: nil context")
}
if err := gameID.Validate(); err != nil {
return fmt.Errorf("mark gap activation: %w", err)
}
if at.IsZero() {
return errors.New("mark gap activation: at must not be zero")
}
payload, err := json.Marshal(gapActivationRecord{ActivatedAtMS: at.UTC().UnixMilli()})
if err != nil {
return fmt.Errorf("mark gap activation: %w", err)
}
args := redis.SetArgs{Mode: "NX"}
if GapActivationRecordTTL > 0 {
args.TTL = GapActivationRecordTTL
}
if _, err := store.client.SetArgs(ctx, store.keys.GapActivatedAt(gameID), payload, args).Result(); err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("mark gap activation: %w", err)
}
return nil
}
// Get returns the gap-window activation time previously recorded for
// gameID. The second return value is false when no activation has been
// recorded.
func (store *GapActivationStore) Get(ctx context.Context, gameID common.GameID) (time.Time, bool, error) {
if store == nil || store.client == nil {
return time.Time{}, false, errors.New("get gap activation: nil store")
}
if ctx == nil {
return time.Time{}, false, errors.New("get gap activation: nil context")
}
if err := gameID.Validate(); err != nil {
return time.Time{}, false, fmt.Errorf("get gap activation: %w", err)
}
raw, err := store.client.Get(ctx, store.keys.GapActivatedAt(gameID)).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return time.Time{}, false, nil
}
return time.Time{}, false, fmt.Errorf("get gap activation: %w", err)
}
var record gapActivationRecord
if err := json.Unmarshal(raw, &record); err != nil {
return time.Time{}, false, fmt.Errorf("get gap activation: %w", err)
}
if record.ActivatedAtMS <= 0 {
return time.Time{}, false, fmt.Errorf("get gap activation: activated_at_ms %d must be positive", record.ActivatedAtMS)
}
return time.UnixMilli(record.ActivatedAtMS).UTC(), true, nil
}
// Compile-time interface assertion.
var _ ports.GapActivationStore = (*GapActivationStore)(nil)