Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 10s

New public ingress and the first network edge. Framework + a vertical slice of
operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7.

Contracts (new module scrabble/pkg):
- push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers
  edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen).

Backend:
- REST handlers on the /api/v1 groups: internal session endpoints
  (telegram/guest/email login -> mint, resolve, revoke) and the user slice
  (profile, submit_play, state, lobby enqueue/poll, chat).
- internal/notify in-process Publisher hub + internal/pushgrpc gRPC server
  (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found;
  emission in game.commit, social, matchmaker.
- migration 00005 accounts.is_guest; guests are durable rows excluded from stats;
  ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode).

Gateway (new module scrabble/gateway):
- Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON
  transcode registry, Telegram initData HMAC validator (seam), session cache,
  token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push
  gRPC client, admin Basic-Auth reverse proxy.

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
This commit is contained in:
Ilia Denisov
2026-06-02 22:38:24 +02:00
parent 104eb2a978
commit 408da3f201
98 changed files with 8134 additions and 57 deletions
+76 -2
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
)
// Service is the game domain: it drives the engine over a single match, persists
@@ -31,6 +32,7 @@ type Service struct {
version string
clock func() time.Time
rng func() int64
pub notify.Publisher
log *zap.Logger
}
@@ -48,10 +50,23 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
version: cfg.DictVersion,
clock: clock,
rng: randomSeed,
pub: notify.Nop{},
log: log,
}
}
// SetNotifier installs the live-event publisher. It must be called during
// startup wiring, before the service serves traffic or the sweeper runs; the
// default is notify.Nop (no live events). The game service emits your_turn and
// opponent_moved after every committed move, whatever the source (a player's
// request, the robot driver or the timeout sweeper, which all funnel through
// commit).
func (svc *Service) SetNotifier(p notify.Publisher) {
if p != nil {
svc.pub = p
}
}
// Create starts and persists a new game seating the given accounts in turn order
// (seat 0 first), deals the racks, and warms the live-game cache. It validates
// the player count (24), the move clock, the hint allowance and that every seat
@@ -239,7 +254,12 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
c.endReason = "timeout"
}
c.winner = g.Result().Winner
c.stats = buildStats(g, seats)
statSeats, err := svc.nonGuestSeats(ctx, seats)
if err != nil {
svc.cache.remove(gameID)
return Game{}, err
}
c.stats = buildStats(g, statSeats)
}
if err := svc.store.CommitMove(ctx, c); err != nil {
svc.cache.remove(gameID)
@@ -248,7 +268,43 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
if c.finished {
svc.cache.remove(gameID)
}
return svc.store.GetGame(ctx, gameID)
post, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return Game{}, err
}
svc.emitMove(post, rec)
return post, nil
}
// emitMove publishes the live events for a just-committed move: opponent_moved to
// every seat other than the actor, and your_turn to the next mover while the game
// is still active. Delivery is best-effort (notify.Publisher never blocks).
func (svc *Service) emitMove(post Game, rec engine.MoveRecord) {
intents := make([]notify.Intent, 0, len(post.Seats)+1)
for _, s := range post.Seats {
if s.Seat == rec.Player {
continue
}
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec.Player, rec.Action.String(), rec.Score, rec.Total))
}
if post.Status == StatusActive {
if next, ok := seatAccount(post.Seats, post.ToMove); ok {
deadline := post.TurnStartedAt.Add(post.TurnTimeout)
intents = append(intents, notify.YourTurn(next, post.ID, deadline))
}
}
svc.pub.Publish(intents...)
}
// seatAccount returns the account seated at the given seat index, or false when
// no seat matches (the slice is not assumed to be ordered by seat).
func seatAccount(seats []Seat, seat int) (uuid.UUID, bool) {
for _, s := range seats {
if s.Seat == seat {
return s.AccountID, true
}
}
return uuid.UUID{}, false
}
// timeoutGame auto-resigns the to-move player of an overdue game. It re-checks,
@@ -633,6 +689,24 @@ func buildStats(g *engine.Game, seats []Seat) []statDelta {
return out
}
// nonGuestSeats filters out guest seats so the finish-time statistics are
// recomputed for durable non-guest accounts only — guests never accrue
// statistics (docs/ARCHITECTURE.md §9). It is called once per game, on finish.
func (svc *Service) nonGuestSeats(ctx context.Context, seats []Seat) ([]Seat, error) {
out := make([]Seat, 0, len(seats))
for _, s := range seats {
acc, err := svc.accounts.GetByID(ctx, s.AccountID)
if err != nil {
return nil, err
}
if acc.IsGuest {
continue
}
out = append(out, s)
}
return out, nil
}
// seatNames resolves each seat's display name for GCG export.
func (svc *Service) seatNames(ctx context.Context, g Game) []string {
names := make([]string, g.Players)