Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
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:
@@ -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 (2–4), 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)
|
||||
|
||||
Reference in New Issue
Block a user