Merge pull request 'Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)' (#4) from feature/stage-4-lobby-social into master
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 10s

This commit was merged in pull request #4.
This commit is contained in:
2026-06-02 17:33:39 +00:00
54 changed files with 4270 additions and 81 deletions
+51 -1
View File
@@ -37,7 +37,7 @@ independent (see ARCHITECTURE §9.1).
| 1 | Backend foundation (config, server, Postgres+goose, sessions, accounts) | **done** |
| 2 | Engine package over scrabble-solver | **done** |
| 3 | Game domain (lifecycle, rules, hint, word-check, history+GCG, stats) | **done** |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | todo |
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | todo |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | todo |
| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo |
@@ -261,6 +261,56 @@ Open details: deployment target/host; dashboards; load expectations.
`BACKEND_DICT_DIR`. `accounts` gained `away_start`/`away_end`/`hint_balance`
and the `account` package gained `SpendHint` (it owns its table).
- **Stage 4** (interview + implementation):
- Scope, as in Stages 13: **domain service/store layer, no HTTP** — REST/stream
is Stage 6. Chat and nudges are **persisted** now; live delivery (push /
in-app stream) is Stage 6/8. New packages `internal/social` (friends, blocks,
chat+nudge) and `internal/lobby` (matchmaking + invitations); profile editing
and the email confirm-code extend `internal/account`. The services have no
active driver this stage, so `main` builds them and hands them to the server,
which exposes them via accessors (the Stage 1 scaffolding-accessor pattern) for
the Stage 6 handlers.
- **Friends** (interview): request → accept on a single `friendships` table;
decline/cancel delete the pending row; **blocking severs** any friendship.
- **Blocks** (interview): the existing global toggles **plus** a per-user
`blocks` table; block effects are **mutual** (a block either way suppresses
chat visibility and prevents requests/invitations between the pair).
- **Friend games** (interview): invitation → accept; the game starts only when
**all** invitees accept, any decline cancels it, and a pending invitation
**lazily expires after 7 days** (checked on access — no new sweeper).
- **Chat** (interview): ≤ **60 runes**, stored with the game forever, the
sender **IP** kept for moderation (as `text`, following Stage 1's no-`bytea`
precedent; the gateway forwards it in Stage 6), input **content-filtered**
(links/emails/phone numbers incl. obfuscated forms) via `mvdan.cc/xurls/v2`
plus a compact leet/separator normaliser and a ≥7-digit phone heuristic — the
one new dependency. **Nudge is a chat message** (`kind='nudge'`), rate-limited
to once per hour per game per sender.
- **Matchmaking** (interview): an **in-memory** FIFO pool keyed by **variant**
only (variant fixes the board language), pairing two humans (seat order
randomised). The 10 s wait and **robot substitution are deferred to Stage 5**.
The pool does **not** consult blocks (auto-match is anonymous) — a deliberate
simplification of the plan's optional block-skip that also avoids a DB call
under the pool lock.
- **Email confirm-code** (interview): 6-digit code, 15-min TTL, ≤ 5 attempts,
stored as a **SHA-256 hash**; a `Mailer` seam with an SMTP relay
(`BACKEND_SMTP_*`) and a default **log mailer**. It binds an email to the
current account; an email already confirmed by another account → `ErrEmailTaken`
(**merge is Stage 10**); email-as-login is Stage 6 and reuses this mechanism.
- **Multi-player drop-out** (interview; discharges the Stage 3 deferral): the
engine's `Resign` now drops a seat and the rest **play on** while ≥ 2 are
active, finishing (last-survivor wins) when one remains; `winner` excludes all
resigned seats. A per-game **`dropout_tiles`** setting (`remove` default |
`return`) governs the leaver's rack, which is **never revealed** to the others.
Timeout reuses `Resign`, so a multi-player timeout drops one seat and play
continues; `game.commit`/`timeoutGame` were already keyed on `g.Over()`, so they
only needed the setting threaded through create/replay.
- **Build/deps**: `go mod tidy` is not run — the bare-path `scrabble-solver`
replace lives only in `go.work`, so `tidy`/`go get` cannot resolve it; the
`xurls` dependency was added with `go mod edit -require` + `go mod download`,
its checksums recorded in the committed **`go.work.sum`**. No CI workflow change
(both Go workflows already clone the solver sibling and export
`BACKEND_DICT_DIR`).
## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
+23 -1
View File
@@ -29,10 +29,25 @@ that auto-resigns overdue turns (honouring each player's daily away window). Lik
Stages 12 it is a service/store layer; the HTTP surface lands with the
`gateway` (Stage 6).
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. `internal/account`
gains profile editing and the email confirm-code flow (a `Mailer` seam: SMTP or a
development log mailer). The engine now also handles **multi-player drop-out**: in
a 34 player game a resignation or timeout drops that seat and the rest play on
(the tile disposition is a per-game setting), the game ending when one active seat
remains. As before this is a service/store layer — chat and nudges are persisted
but their live delivery, and all REST endpoints, arrive with the `gateway`
(Stage 6); the services are exposed via `Server` accessors for those handlers.
## Package layout
```
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> server
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> lobby+social -> server
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
internal/config/ # env configuration (composes postgres + telemetry + game config)
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
@@ -44,6 +59,8 @@ internal/session/ # opaque tokens, sessions store, write-through cache, servi
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool + friend-game invitations
```
## Configuration (environment)
@@ -64,6 +81,11 @@ internal/game/ # game domain: lifecycle, journal+cache, hint, word-check,
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
| `BACKEND_GAME_CACHE_TTL` | `24h` | Idle window before a live game is evicted from cache. |
| `BACKEND_SMTP_HOST` | — | Email relay host. **Empty selects the development log mailer** (the confirm-code is logged, not sent). |
| `BACKEND_SMTP_PORT` | `587` | Email relay port. |
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
## Run
+33 -5
View File
@@ -1,10 +1,10 @@
// Command backend is the Scrabble platform's internal domain service. It boots
// the OpenTelemetry runtime, opens the Postgres pool and applies migrations,
// loads the dictionaries into the engine registry, warms the session cache,
// constructs the game domain and starts its turn-timeout sweeper, then serves the
// HTTP listener with the infrastructure probes and the /api/v1 route-group
// skeleton. Domain HTTP endpoints are added with the gateway in a later stage
// described in PLAN.md.
// constructs the game domain and starts its turn-timeout sweeper, constructs the
// lobby and social domains, then serves the HTTP listener with the infrastructure
// probes and the /api/v1 route-group skeleton. Domain HTTP endpoints are added
// with the gateway in a later stage described in PLAN.md.
package main
import (
@@ -21,9 +21,11 @@ import (
"scrabble/backend/internal/config"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
)
@@ -95,20 +97,46 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("session cache warmed")
games := game.NewService(game.NewStore(db), account.NewStore(db), registry, cfg.Game, logger)
accounts := account.NewStore(db)
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
// Stage 4 lobby & social domains. They have no active driver yet — their REST
// and stream surface is added with the gateway in Stage 6 — so they are handed
// to the server (like the route groups) for the handlers to come.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer)
socialSvc := social.NewService(social.NewStore(db), accounts, games)
matchmaker := lobby.NewMatchmaker(games)
invitations := lobby.NewInvitationService(lobby.NewStore(db), games, accounts, socialSvc)
logger.Info("lobby and social domains ready")
srv := server.New(cfg.HTTPAddr, server.Deps{
Logger: logger,
DB: db,
PingTimeout: cfg.Postgres.OperationTimeout,
SessionsReady: sessions.Ready,
Social: socialSvc,
Matchmaker: matchmaker,
Invitations: invitations,
Emails: emails,
})
return srv.Run(ctx)
}
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
// configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
if cfg.Host == "" {
logger.Info("email: using log mailer (BACKEND_SMTP_HOST unset)")
return account.NewLogMailer(logger)
}
logger.Info("email: using SMTP relay", zap.String("host", cfg.Host))
return account.NewSMTPMailer(cfg)
}
// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
var lvl zap.AtomicLevel
+1
View File
@@ -110,4 +110,5 @@ require (
golang.org/x/text v0.36.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mvdan.cc/xurls/v2 v2.6.0
)
+278
View File
@@ -0,0 +1,278 @@
package account
import (
"context"
crand "crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"math/big"
"net/mail"
"strings"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
const (
// emailCodeTTL bounds how long an issued confirm-code stays valid.
emailCodeTTL = 15 * time.Minute
// emailCodeMaxAttempts caps wrong-code submissions before a code is dead.
emailCodeMaxAttempts = 5
)
// Errors returned by the email confirm-code flow.
var (
// ErrInvalidEmail is returned for an unparseable email address.
ErrInvalidEmail = errors.New("account: invalid email address")
// ErrEmailTaken is returned when the email is already confirmed by another
// account; binding it would be a merge, which Stage 10 owns.
ErrEmailTaken = errors.New("account: email already confirmed by another account")
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
// requesting account.
ErrAlreadyConfirmed = errors.New("account: email already confirmed for this account")
// ErrNoPendingCode is returned when no live confirm-code exists to verify.
ErrNoPendingCode = errors.New("account: no pending confirmation code")
// ErrCodeExpired is returned when the confirm-code has passed its TTL.
ErrCodeExpired = errors.New("account: confirmation code expired")
// ErrTooManyAttempts is returned when the code is locked after too many tries.
ErrTooManyAttempts = errors.New("account: too many confirmation attempts")
// ErrCodeMismatch is returned when the submitted code does not match.
ErrCodeMismatch = errors.New("account: confirmation code does not match")
)
// EmailService runs the email confirm-code flow: it issues a 6-digit code over a
// Mailer and verifies it, binding a confirmed email identity to the requesting
// account. Only the SHA-256 hash of a code is stored (never the plaintext),
// matching the session model. Binding an email already confirmed by a different
// account is refused (ErrEmailTaken) — merging two accounts is Stage 10 — and
// using an email as a login is Stage 6, which reuses this mechanism.
type EmailService struct {
store *Store
mailer Mailer
now func() time.Time
}
// NewEmailService constructs an EmailService over store, sending via mailer.
func NewEmailService(store *Store, mailer Mailer) *EmailService {
return &EmailService{store: store, mailer: mailer, now: func() time.Time { return time.Now().UTC() }}
}
// RequestCode issues a fresh confirm-code for email to accountID and mails it,
// replacing any prior pending code for the same account and address. It returns
// ErrInvalidEmail, ErrEmailTaken or ErrAlreadyConfirmed without sending.
func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
return err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return err
}
if ok {
if owner == accountID {
return ErrAlreadyConfirmed
}
return ErrEmailTaken
}
code, hash, err := generateCode()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
}
// ConfirmCode verifies code for accountID and email. On success it attaches a
// confirmed email identity and returns the account. It returns ErrNoPendingCode,
// ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch (counting the attempt), or
// ErrEmailTaken if the address was confirmed elsewhere in the meantime.
func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, email, code string) (Account, error) {
addr, err := normalizeEmail(email)
if err != nil {
return Account{}, err
}
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
if err != nil {
return Account{}, err
}
if s.now().After(conf.expiresAt) {
return Account{}, ErrCodeExpired
}
if conf.attempts >= emailCodeMaxAttempts {
return Account{}, ErrTooManyAttempts
}
if hashCode(code) != conf.codeHash {
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
return Account{}, err
}
return Account{}, ErrCodeMismatch
}
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return Account{}, err
}
return s.store.GetByID(ctx, accountID)
}
// emailConfirmation is a pending confirm-code row in domain form.
type emailConfirmation struct {
id uuid.UUID
codeHash string
expiresAt time.Time
attempts int
}
// confirmedEmailAccount returns the account that holds a confirmed email identity
// for email and true, or (zero, false) when none does.
func (s *Store) confirmedEmailAccount(ctx context.Context, email string) (uuid.UUID, bool, error) {
stmt := postgres.SELECT(table.Identities.AccountID).
FROM(table.Identities).
WHERE(
table.Identities.Kind.EQ(postgres.String(KindEmail)).
AND(table.Identities.ExternalID.EQ(postgres.String(email))).
AND(table.Identities.Confirmed.EQ(postgres.Bool(true))),
).LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return uuid.UUID{}, false, nil
}
return uuid.UUID{}, false, fmt.Errorf("account: confirmed email owner %s: %w", email, err)
}
return row.AccountID, true, nil
}
// replacePendingConfirmation clears any pending code for (accountID, email) and
// inserts a fresh one, inside one transaction.
func (s *Store) replacePendingConfirmation(ctx context.Context, accountID uuid.UUID, email, codeHash string, expiresAt time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new confirmation id: %w", err)
}
return withTx(ctx, s.db, func(tx *sql.Tx) error {
del := table.EmailConfirmations.DELETE().WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
)
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear pending confirmations: %w", err)
}
ins := table.EmailConfirmations.INSERT(
table.EmailConfirmations.ConfirmationID, table.EmailConfirmations.AccountID,
table.EmailConfirmations.Email, table.EmailConfirmations.CodeHash, table.EmailConfirmations.ExpiresAt,
).VALUES(id, accountID, email, codeHash, expiresAt)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert confirmation: %w", err)
}
return nil
})
}
// latestPendingConfirmation loads the newest unconsumed confirm-code for
// (accountID, email), or ErrNoPendingCode.
func (s *Store) latestPendingConfirmation(ctx context.Context, accountID uuid.UUID, email string) (emailConfirmation, error) {
stmt := postgres.SELECT(table.EmailConfirmations.AllColumns).
FROM(table.EmailConfirmations).
WHERE(
table.EmailConfirmations.AccountID.EQ(postgres.UUID(accountID)).
AND(table.EmailConfirmations.Email.EQ(postgres.String(email))).
AND(table.EmailConfirmations.ConsumedAt.IS_NULL()),
).ORDER_BY(table.EmailConfirmations.CreatedAt.DESC()).LIMIT(1)
var row model.EmailConfirmations
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return emailConfirmation{}, ErrNoPendingCode
}
return emailConfirmation{}, fmt.Errorf("account: load confirmation: %w", err)
}
return emailConfirmation{
id: row.ConfirmationID,
codeHash: row.CodeHash,
expiresAt: row.ExpiresAt,
attempts: int(row.Attempts),
}, nil
}
// bumpConfirmationAttempts increments a code's wrong-attempt counter by one.
func (s *Store) bumpConfirmationAttempts(ctx context.Context, id uuid.UUID) error {
stmt := table.EmailConfirmations.
UPDATE(table.EmailConfirmations.Attempts).
SET(table.EmailConfirmations.Attempts.ADD(postgres.Int(1))).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: bump confirmation attempts: %w", err)
}
return nil
}
// confirmEmailIdentity consumes the code and inserts a confirmed email identity,
// inside one transaction. A unique-constraint violation means the address was
// confirmed by another account first, surfaced as ErrEmailTaken.
func (s *Store) confirmEmailIdentity(ctx context.Context, confirmationID, accountID uuid.UUID, email string, now time.Time) error {
identityID, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
upd := table.EmailConfirmations.
UPDATE(table.EmailConfirmations.ConsumedAt).
SET(postgres.TimestampzT(now)).
WHERE(table.EmailConfirmations.ConfirmationID.EQ(postgres.UUID(confirmationID)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("consume confirmation: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(identityID, accountID, KindEmail, email, true)
if _, err := ins.ExecContext(ctx, tx); err != nil {
return err
}
return nil
})
if err != nil {
if isUniqueViolation(err) {
return ErrEmailTaken
}
return fmt.Errorf("account: confirm email identity: %w", err)
}
return nil
}
// normalizeEmail parses and lower-cases an email address, or returns ErrInvalidEmail.
func normalizeEmail(email string) (string, error) {
addr, err := mail.ParseAddress(strings.TrimSpace(email))
if err != nil {
return "", fmt.Errorf("%w: %q", ErrInvalidEmail, email)
}
return strings.ToLower(addr.Address), nil
}
// generateCode returns a random 6-digit code and its SHA-256 hex hash.
func generateCode() (code, hash string, err error) {
n, err := crand.Int(crand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", "", fmt.Errorf("account: generate code: %w", err)
}
code = fmt.Sprintf("%06d", n.Int64())
return code, hashCode(code), nil
}
// hashCode returns the hex-encoded SHA-256 of a confirm-code.
func hashCode(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
+67
View File
@@ -0,0 +1,67 @@
package account
import (
"errors"
"regexp"
"testing"
)
func TestNormalizeEmail(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr bool
}{
{"lowercases", "User@Example.COM", "user@example.com", false},
{"trims", " a@b.io ", "a@b.io", false},
{"strips display name", "Jane Doe <jane@x.org>", "jane@x.org", false},
{"empty", "", "", true},
{"no at sign", "notanemail", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := normalizeEmail(tc.in)
if tc.wantErr {
if !errors.Is(err, ErrInvalidEmail) {
t.Fatalf("err = %v, want ErrInvalidEmail", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestGenerateCodeFormat(t *testing.T) {
sixDigits := regexp.MustCompile(`^\d{6}$`)
for range 50 {
code, hash, err := generateCode()
if err != nil {
t.Fatalf("generate: %v", err)
}
if !sixDigits.MatchString(code) {
t.Fatalf("code %q is not exactly six digits", code)
}
if hash != hashCode(code) {
t.Errorf("returned hash does not match hashCode(%q)", code)
}
}
}
func TestHashCodeStable(t *testing.T) {
if hashCode("123456") != hashCode("123456") {
t.Fatal("hashCode is not deterministic")
}
if hashCode("123456") == hashCode("654321") {
t.Fatal("distinct codes must not share a hash")
}
if got := len(hashCode("000000")); got != 64 {
t.Errorf("hex SHA-256 length = %d, want 64", got)
}
}
+84
View File
@@ -0,0 +1,84 @@
package account
import (
"context"
"fmt"
"net"
"net/smtp"
"go.uber.org/zap"
)
// Mailer delivers a transactional email. It is the seam behind which the email
// confirm-code flow sends codes, so the relay is swappable and unit tests use a
// fixture (see docs/TESTING.md: no real network in tests). The context is offered
// for cancellation; the standard-library SMTP implementation sends synchronously
// and ignores it.
type Mailer interface {
Send(ctx context.Context, to, subject, body string) error
}
// SMTPConfig configures the SMTP relay. An empty Host selects the LogMailer
// instead, so a deployment without a relay still runs (the code lands in the log).
type SMTPConfig struct {
Host string
Port string
Username string
Password string
From string
}
// SMTPMailer sends mail through an SMTP relay using the standard library. When a
// username is set it authenticates with PLAIN; otherwise it relays unauthenticated.
type SMTPMailer struct {
cfg SMTPConfig
}
// NewSMTPMailer constructs an SMTPMailer for cfg.
func NewSMTPMailer(cfg SMTPConfig) SMTPMailer {
return SMTPMailer{cfg: cfg}
}
// Send delivers a plain-text UTF-8 message to to via the configured relay.
func (m SMTPMailer) Send(_ context.Context, to, subject, body string) error {
addr := net.JoinHostPort(m.cfg.Host, m.cfg.Port)
var auth smtp.Auth
if m.cfg.Username != "" {
auth = smtp.PlainAuth("", m.cfg.Username, m.cfg.Password, m.cfg.Host)
}
if err := smtp.SendMail(addr, auth, m.cfg.From, []string{to}, message(m.cfg.From, to, subject, body)); err != nil {
return fmt.Errorf("account: send mail to %s: %w", to, err)
}
return nil
}
// message renders a minimal RFC 5322 plain-text email.
func message(from, to, subject, body string) []byte {
return []byte("From: " + from + "\r\n" +
"To: " + to + "\r\n" +
"Subject: " + subject + "\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: text/plain; charset=UTF-8\r\n" +
"\r\n" + body + "\r\n")
}
// LogMailer logs the message instead of sending it. It is the default when no
// SMTP relay is configured and is intended for development only: it logs the body,
// which carries the confirm-code, so it must not be used in production.
type LogMailer struct {
log *zap.Logger
}
// NewLogMailer constructs a LogMailer that logs through log.
func NewLogMailer(log *zap.Logger) LogMailer {
return LogMailer{log: log}
}
// Send logs the message at info level and reports success.
func (m LogMailer) Send(_ context.Context, to, subject, body string) error {
if m.log != nil {
m.log.Info("email not sent (log mailer)",
zap.String("to", to), zap.String("subject", subject), zap.String("body", body))
}
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package account
import (
"context"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// maxDisplayName caps a display name's length in runes.
const maxDisplayName = 64
// ErrInvalidProfile is returned when a profile update carries an unacceptable
// field (an unknown language, an invalid timezone, or an over-long display name).
var ErrInvalidProfile = errors.New("account: invalid profile")
// ProfileUpdate is the full set of player-editable profile fields. UpdateProfile
// overwrites every field, so callers send the complete desired profile. AwayStart
// and AwayEnd carry only the hour and minute of the daily away window, in the
// account's TimeZone.
type ProfileUpdate struct {
DisplayName string
PreferredLanguage string // "en" or "ru"
TimeZone string // an IANA location name
AwayStart time.Time
AwayEnd time.Time
BlockChat bool
BlockFriendRequests bool
}
// UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id.
func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate) (Account, error) {
lang := strings.TrimSpace(p.PreferredLanguage)
if lang != "en" && lang != "ru" {
return Account{}, fmt.Errorf("%w: preferred_language %q", ErrInvalidProfile, p.PreferredLanguage)
}
tz := strings.TrimSpace(p.TimeZone)
if _, err := time.LoadLocation(tz); err != nil {
return Account{}, fmt.Errorf("%w: time_zone %q: %v", ErrInvalidProfile, p.TimeZone, err)
}
name := strings.TrimSpace(p.DisplayName)
if utf8.RuneCountInString(name) > maxDisplayName {
return Account{}, fmt.Errorf("%w: display name exceeds %d characters", ErrInvalidProfile, maxDisplayName)
}
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: update profile %s: %w", id, err)
}
return modelToAccount(row), nil
}
+34
View File
@@ -0,0 +1,34 @@
package account
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
)
// TestUpdateProfileValidation checks that bad fields are rejected before any
// database access, so a nil-backed Store is enough to exercise the guards.
func TestUpdateProfileValidation(t *testing.T) {
s := &Store{}
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"}
tests := []struct {
name string
mut func(p *ProfileUpdate)
}{
{"unknown language", func(p *ProfileUpdate) { p.PreferredLanguage = "fr" }},
{"invalid timezone", func(p *ProfileUpdate) { p.TimeZone = "Mars/Olympus" }},
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p := base
tc.mut(&p)
if _, err := s.UpdateProfile(context.Background(), uuid.New(), p); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("err = %v, want ErrInvalidProfile", err)
}
})
}
}
+13
View File
@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"scrabble/backend/internal/account"
"scrabble/backend/internal/game"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/telemetry"
@@ -25,6 +26,9 @@ type Config struct {
Telemetry telemetry.Config
// Game configures the game subsystem (dictionaries, sweeper, live-game cache).
Game game.Config
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
}
// Defaults applied when the corresponding environment variable is unset.
@@ -67,12 +71,21 @@ func Load() (Config, error) {
return Config{}, err
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
Username: os.Getenv("BACKEND_SMTP_USERNAME"),
Password: os.Getenv("BACKEND_SMTP_PASSWORD"),
From: envOr("BACKEND_SMTP_FROM", "no-reply@localhost"),
}
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
Postgres: pg,
Telemetry: tel,
Game: gm,
SMTP: smtp,
}
if err := c.validate(); err != nil {
return Config{}, err
+3
View File
@@ -97,6 +97,9 @@ var (
// ErrUnknownVersion is returned when no dictionary is registered for a
// (variant, version) pair.
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
// ErrUnknownDropoutTiles is returned by ParseDropoutTiles for a label that is
// neither "remove" nor "return".
ErrUnknownDropoutTiles = errors.New("engine: unknown drop-out tile disposition")
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
// word absent from the dictionary, or a play that does not connect.
ErrIllegalPlay = errors.New("engine: illegal play")
+102 -17
View File
@@ -42,6 +42,43 @@ func (r EndReason) String() string {
return "unknown"
}
// DropoutTiles is the per-game disposition of a dropped-out player's rack when
// they resign or time out of a game with three or more seats: the tiles are
// either removed from play or returned to the bag. It is agreed at game creation
// (docs/ARCHITECTURE.md §6) and is irrelevant to a two-player game, which ends on
// the first drop-out. In both dispositions the leaver's rack is never revealed to
// the remaining players.
type DropoutTiles uint8
const (
// DropoutRemove removes the dropped player's tiles from play; this is the
// default, so the zero value matches it.
DropoutRemove DropoutTiles = iota
// DropoutReturn returns the dropped player's tiles to the bag, where the
// remaining players may draw them.
DropoutReturn
)
// String renders the disposition as the stable label the game domain persists.
func (d DropoutTiles) String() string {
if d == DropoutReturn {
return "return"
}
return "remove"
}
// ParseDropoutTiles maps a persisted label back to a DropoutTiles, reporting
// ErrUnknownDropoutTiles for an unrecognised value.
func ParseDropoutTiles(s string) (DropoutTiles, error) {
switch s {
case "remove":
return DropoutRemove, nil
case "return":
return DropoutReturn, nil
}
return 0, fmt.Errorf("%w: %q", ErrUnknownDropoutTiles, s)
}
// Options configures a new game.
type Options struct {
// Variant selects the rules and dictionary.
@@ -52,6 +89,9 @@ type Options struct {
Players int
// Seed seeds the tile bag, making the game reproducible.
Seed int64
// DropoutTiles is the disposition of a dropped-out player's tiles in a game
// with three or more seats; the zero value removes them from play.
DropoutTiles DropoutTiles
}
// Game is the in-memory state of a single match and the pure rules engine over
@@ -72,7 +112,8 @@ type Game struct {
scorelessRun int
over bool
reason EndReason
resignedSeat int // seat that resigned, or -1; excludes the resigner from winning
resigned []bool // per seat; a resigned seat is skipped and cannot win
dropoutTiles DropoutTiles // disposition of a resigned seat's tiles
log []MoveRecord
}
@@ -107,7 +148,8 @@ func New(reg *Registry, opts Options) (*Game, error) {
bag: NewBag(rs, opts.Seed),
hands: make([][]byte, opts.Players),
scores: make([]int, opts.Players),
resignedSeat: -1,
resigned: make([]bool, opts.Players),
dropoutTiles: opts.DropoutTiles,
}
for i := range g.hands {
g.hands[i] = g.bag.Draw(rs.RackSize)
@@ -195,22 +237,30 @@ func (g *Game) Exchange(tiles []byte) (MoveRecord, error) {
return rec, nil
}
// Resign ends the game on the current player's turn (EndReason EndResign). The
// resigner always forfeits the win and keeps their accumulated score (it is
// neither zeroed nor docked a rack adjustment); the win goes to the highest
// score among the remaining seats — in a two-player match, unconditionally to
// the other player. A missed-turn timeout reuses Resign in the game domain, so
// it inherits this win/loss. Richer multi-player drop-out handling belongs to
// the game domain in a later stage.
// Resign drops the current player out of the game. The resigner always forfeits
// the win and keeps their accumulated score (it is neither zeroed nor docked a
// rack adjustment), and their rack is disposed of per the game's DropoutTiles
// setting without ever being revealed to the remaining players. In a game with
// three or more seats the others play on with the resigned seat skipped, until
// one active seat is left (it wins) or the game ends by the ordinary conditions;
// the game finishes with EndResign only once a single active seat remains. A
// two-player game therefore ends on the first resignation, the other player
// winning regardless of score. A missed-turn timeout reuses Resign in the game
// domain, so it inherits this win/loss.
func (g *Game) Resign() (MoveRecord, error) {
if g.over {
return MoveRecord{}, ErrGameOver
}
player := g.toMove
g.resignedSeat = player
g.resigned[player] = true
g.disposeHand(player)
rec := MoveRecord{Player: player, Action: ActionResign, Total: g.scores[player]}
g.log = append(g.log, rec)
g.finish(EndResign)
if g.activeCount() <= 1 {
g.finish(EndResign)
} else {
g.advance()
}
return rec, nil
}
@@ -330,20 +380,55 @@ func (g *Game) endTurnAfterScoreless() {
g.advance()
}
// advance moves play to the next seat.
func (g *Game) advance() { g.toMove = (g.toMove + 1) % len(g.hands) }
// advance moves play to the next active (non-resigned) seat. While a game is in
// progress at least two seats are active, so a next active seat always exists;
// the loop leaves toMove unchanged in the degenerate all-but-one-resigned case,
// which Resign turns into a finished game instead.
func (g *Game) advance() {
n := len(g.hands)
for i := 1; i <= n; i++ {
next := (g.toMove + i) % n
if !g.resigned[next] {
g.toMove = next
return
}
}
}
// activeCount returns the number of seats that have not resigned.
func (g *Game) activeCount() int {
n := 0
for _, r := range g.resigned {
if !r {
n++
}
}
return n
}
// disposeHand empties a resigned player's rack per the game's DropoutTiles
// setting: it returns the tiles to the bag or removes them from play. Either way
// the hand is cleared, so the end-game rack adjustment ignores the seat and the
// rack is never exposed.
func (g *Game) disposeHand(player int) {
if g.dropoutTiles == DropoutReturn {
g.bag.Return(g.hands[player])
}
g.hands[player] = nil
}
// winner returns the index of the single highest-scoring player, or -1 on a tie
// for the lead or while the game is unfinished. After a resignation the resigner
// is excluded, so a two-player game returns the remaining player even when the
// resigner led on score.
// for the lead or while the game is unfinished. Resigned (dropped-out) seats are
// always excluded, so a two-player game returns the remaining player even when
// the resigner led on score, and a multi-player game never awards the win to a
// seat that left.
func (g *Game) winner() int {
if !g.over {
return -1
}
best, tie := -1, false
for i := range g.scores {
if g.reason == EndResign && i == g.resignedSeat {
if g.resigned[i] {
continue
}
switch {
+197
View File
@@ -79,3 +79,200 @@ func TestResignOnFinishedGame(t *testing.T) {
t.Error("resign on a finished game must error")
}
}
// openingGameN returns a players-seat English game whose opening rack has a legal
// move, searching a deterministic range of seeds.
func openingGameN(t *testing.T, players int, dt DropoutTiles) *Game {
t.Helper()
for seed := int64(1); seed <= 100; seed++ {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: players, Seed: seed, DropoutTiles: dt})
if err != nil {
t.Fatalf("new game: %v", err)
}
if len(g.GenerateMoves()) > 0 {
return g
}
}
t.Fatal("no opening move found in seeds 1..100")
return nil
}
// TestMultiplayerResignContinues proves that in a three-player game one
// resignation does not end the game and the resigned seat is skipped in rotation.
func TestMultiplayerResignContinues(t *testing.T) {
g := openingGameN(t, 3, DropoutRemove)
if _, err := g.Resign(); err != nil { // seat 0
t.Fatalf("seat 0 resign: %v", err)
}
if g.Over() {
t.Fatal("a three-player game must continue after one resignation")
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 (seat 0 skipped)", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 1
t.Fatalf("seat 1 pass: %v", err)
}
if g.ToMove() != 2 {
t.Errorf("to move = %d, want 2", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if g.ToMove() != 1 {
t.Errorf("to move = %d, want 1 (seat 0 skipped on wrap)", g.ToMove())
}
}
// TestMultiplayerLastActiveWins proves that as seats drop out the sole survivor
// wins even when trailing, and resigners keep their (frozen) scores.
func TestMultiplayerLastActiveWins(t *testing.T) {
g := openingGameN(t, 3, DropoutRemove)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 takes the lead
if err != nil {
t.Fatalf("seat 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // seat 1
t.Fatalf("seat 1 pass: %v", err)
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if _, err := g.Resign(); err != nil { // seat 0 (leader) drops out
t.Fatalf("seat 0 resign: %v", err)
}
if g.Over() {
t.Fatal("game must continue with two active seats")
}
if g.ToMove() != 1 {
t.Fatalf("to move = %d, want 1", g.ToMove())
}
if _, err := g.Resign(); err != nil { // seat 1 drops out, leaving only seat 2
t.Fatalf("seat 1 resign: %v", err)
}
if !g.Over() || g.Reason() != EndResign {
t.Fatalf("over=%v reason=%v, want over with resign", g.Over(), g.Reason())
}
res := g.Result()
if res.Winner != 2 {
t.Errorf("winner = %d, want 2 (sole survivor) despite trailing", res.Winner)
}
if g.Score(0) != played.Score {
t.Errorf("resigner seat 0 score = %d, want frozen at %d", g.Score(0), played.Score)
}
if g.Score(2) != 0 {
t.Errorf("survivor seat 2 score = %d, want 0", g.Score(2))
}
}
// TestDropoutTileDisposition proves the per-game setting governs the bag: remove
// leaves it unchanged, return adds the leaver's full rack back.
func TestDropoutTileDisposition(t *testing.T) {
const seed = 7
remove, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutRemove})
if err != nil {
t.Fatalf("new remove game: %v", err)
}
ret, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 3, Seed: seed, DropoutTiles: DropoutReturn})
if err != nil {
t.Fatalf("new return game: %v", err)
}
bagBefore := remove.BagLen()
if ret.BagLen() != bagBefore {
t.Fatalf("identical seeds must start with equal bags: %d vs %d", remove.BagLen(), ret.BagLen())
}
rackSize := remove.rules.RackSize // seat 0 holds a full rack on the opening turn
if _, err := remove.Resign(); err != nil {
t.Fatalf("remove resign: %v", err)
}
if _, err := ret.Resign(); err != nil {
t.Fatalf("return resign: %v", err)
}
if remove.BagLen() != bagBefore {
t.Errorf("remove: bag = %d, want unchanged %d", remove.BagLen(), bagBefore)
}
if ret.BagLen() != bagBefore+rackSize {
t.Errorf("return: bag = %d, want %d (rack returned)", ret.BagLen(), bagBefore+rackSize)
}
}
// TestResignedSeatExcludedFromWinOnScorelessEnd proves a resigned seat never wins
// even when the game ends by the scoreless limit rather than by the resignation.
func TestResignedSeatExcludedFromWinOnScorelessEnd(t *testing.T) {
g := openingGameN(t, 3, DropoutRemove)
hint, ok := g.HintView()
if !ok {
t.Fatal("opening game has no hint")
}
played, err := g.SubmitPlay(hint.Dir, hint.Tiles) // seat 0 leads
if err != nil {
t.Fatalf("seat 0 play: %v", err)
}
if played.Score == 0 {
t.Fatal("opening play scored 0; pick a different seed")
}
if _, err := g.Pass(); err != nil { // seat 1
t.Fatalf("seat 1 pass: %v", err)
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if _, err := g.Resign(); err != nil { // seat 0 drops out while leading
t.Fatalf("seat 0 resign: %v", err)
}
for !g.Over() { // seats 1 and 2 pass until the six-scoreless limit ends it
if _, err := g.Pass(); err != nil {
t.Fatalf("pass: %v", err)
}
}
if g.Reason() != EndScoreless {
t.Fatalf("reason = %v, want scoreless", g.Reason())
}
if res := g.Result(); res.Winner == 0 {
t.Error("winner = 0, but the resigned leader must be excluded")
}
}
// TestFourPlayerDropToTwoContinues proves two drop-outs in a four-player game
// leave the remaining two playing on, skipping both resigned seats.
func TestFourPlayerDropToTwoContinues(t *testing.T) {
g, err := New(testReg, Options{Variant: VariantEnglish, Version: testVersion, Players: 4, Seed: 3, DropoutTiles: DropoutRemove})
if err != nil {
t.Fatalf("new game: %v", err)
}
if _, err := g.Resign(); err != nil { // seat 0
t.Fatalf("seat 0 resign: %v", err)
}
if g.ToMove() != 1 {
t.Fatalf("to move = %d, want 1", g.ToMove())
}
if _, err := g.Resign(); err != nil { // seat 1
t.Fatalf("seat 1 resign: %v", err)
}
if g.Over() {
t.Fatal("game with two active seats must continue")
}
if g.ToMove() != 2 {
t.Errorf("to move = %d, want 2", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 2
t.Fatalf("seat 2 pass: %v", err)
}
if g.ToMove() != 3 {
t.Errorf("to move = %d, want 3", g.ToMove())
}
if _, err := g.Pass(); err != nil { // seat 3
t.Fatalf("seat 3 pass: %v", err)
}
if g.ToMove() != 2 {
t.Errorf("to move = %d, want 2 (seats 0,1 skipped)", g.ToMove())
}
}
+27 -8
View File
@@ -89,10 +89,11 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
seed = svc.rng()
}
g, err := engine.New(svc.registry, engine.Options{
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
Variant: params.Variant,
Version: svc.version,
Players: len(params.Seats),
Seed: seed,
DropoutTiles: params.DropoutTiles,
})
if err != nil {
if errors.Is(err, engine.ErrUnknownVariant) || errors.Is(err, engine.ErrUnknownVersion) {
@@ -114,6 +115,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
turnTimeoutSecs: int(timeout / time.Second),
hintsAllowed: params.HintsAllowed,
hintsPerPlayer: params.HintsPerPlayer,
dropoutTiles: params.DropoutTiles.String(),
}
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
@@ -455,6 +457,22 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
}, nil
}
// Participants returns the seated account IDs in seat order, the seat index whose
// turn it is, and the game status. It is a snapshot read (no engine, no lock) that
// lets the social package gate per-game chat and nudges without importing the
// engine or the game's private state.
func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.UUID, int, string, error) {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
return nil, 0, "", err
}
seats := make([]uuid.UUID, len(g.Seats))
for _, s := range g.Seats {
seats[s.Seat] = s.AccountID
}
return seats, g.ToMove, g.Status, nil
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
@@ -506,10 +524,11 @@ func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error)
return nil, err
}
g, err := engine.New(svc.registry, engine.Options{
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
Variant: pre.Variant,
Version: pre.DictVersion,
Players: pre.Players,
Seed: seed,
DropoutTiles: pre.DropoutTiles,
})
if err != nil {
return nil, err
+8 -1
View File
@@ -37,6 +37,7 @@ type gameInsert struct {
turnTimeoutSecs int
hintsAllowed bool
hintsPerPlayer int
dropoutTiles string
}
// statDelta is one account's contribution to its statistics on a game finish.
@@ -91,7 +92,8 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []uuid.UUI
gi := table.Games.INSERT(
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Players, table.Games.TurnTimeoutSecs, table.Games.HintsAllowed, table.Games.HintsPerPlayer,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer)
table.Games.DropoutTiles,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, ins.players, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles)
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
@@ -367,6 +369,10 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
if err != nil {
return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
}
dropout, err := engine.ParseDropoutTiles(g.DropoutTiles)
if err != nil {
return Game{}, fmt.Errorf("game: %s: %w", g.GameID, err)
}
out := Game{
ID: g.GameID,
Variant: variant,
@@ -378,6 +384,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
TurnTimeout: time.Duration(g.TurnTimeoutSecs) * time.Second,
HintsAllowed: g.HintsAllowed,
HintsPerPlayer: int(g.HintsPerPlayer),
DropoutTiles: dropout,
MoveCount: int(g.MoveCount),
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
+4 -2
View File
@@ -60,8 +60,9 @@ type CreateParams struct {
Seats []uuid.UUID
TurnTimeout time.Duration // one of AllowedTurnTimeouts; zero → DefaultTurnTimeout
HintsAllowed bool
HintsPerPlayer int // starting per-seat hint allowance
Seed int64 // zero → a random seed is chosen
HintsPerPlayer int // starting per-seat hint allowance
DropoutTiles engine.DropoutTiles // disposition of a dropped-out seat's tiles (3+ players); zero → remove
Seed int64 // zero → a random seed is chosen
}
// Game is the persisted state of a match: the games row joined with its seats.
@@ -76,6 +77,7 @@ type Game struct {
TurnTimeout time.Duration
HintsAllowed bool
HintsPerPlayer int
DropoutTiles engine.DropoutTiles
MoveCount int
EndReason string // "" while active
Seats []Seat
+159
View File
@@ -0,0 +1,159 @@
//go:build integration
package inttest
import (
"context"
"errors"
"regexp"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// capturingMailer records the last message instead of sending it, so tests can
// recover the confirm-code from the body.
type capturingMailer struct{ lastBody string }
func (m *capturingMailer) Send(_ context.Context, _, _, body string) error {
m.lastBody = body
return nil
}
var sixDigit = regexp.MustCompile(`\d{6}`)
// TestEmailConfirmFlow covers the happy path: request a code, confirm it, and the
// email becomes a confirmed identity of the account.
func TestEmailConfirmFlow(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "user-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, acc, email); err != nil {
t.Fatalf("request code: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if code == "" {
t.Fatalf("no code in mail body %q", mailer.lastBody)
}
// A wrong code is rejected without confirming.
if _, err := svc.ConfirmCode(ctx, acc, email, "000000"); !errors.Is(err, account.ErrCodeMismatch) && !errors.Is(err, account.ErrTooManyAttempts) {
t.Fatalf("wrong code = %v, want mismatch", err)
}
got, err := svc.ConfirmCode(ctx, acc, email, code)
if err != nil {
t.Fatalf("confirm code: %v", err)
}
if got.ID != acc {
t.Errorf("confirmed account = %s, want %s", got.ID, acc)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("email identity must be confirmed after a correct code")
}
}
// TestEmailAlreadyTakenByAnotherAccount refuses to bind an email confirmed by a
// different account (merge is a later stage).
func TestEmailAlreadyTakenByAnotherAccount(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
owner := provisionAccount(t)
email := "taken-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, owner, email); err != nil {
t.Fatalf("owner request: %v", err)
}
if _, err := svc.ConfirmCode(ctx, owner, email, sixDigit.FindString(mailer.lastBody)); err != nil {
t.Fatalf("owner confirm: %v", err)
}
other := provisionAccount(t)
if err := svc.RequestCode(ctx, other, email); !errors.Is(err, account.ErrEmailTaken) {
t.Fatalf("other request = %v, want ErrEmailTaken", err)
}
}
// TestEmailCodeExpires rejects a code past its TTL (backdated directly).
func TestEmailCodeExpires(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "expire-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, acc, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.email_confirmations SET expires_at = now() - interval '1 minute' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("backdate expiry: %v", err)
}
if _, err := svc.ConfirmCode(ctx, acc, email, code); !errors.Is(err, account.ErrCodeExpired) {
t.Fatalf("confirm expired = %v, want ErrCodeExpired", err)
}
}
// TestEmailTooManyAttempts locks a code after the attempt cap.
func TestEmailTooManyAttempts(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer)
acc := provisionAccount(t)
email := "lock-" + uuid.NewString() + "@example.com"
if err := svc.RequestCode(ctx, acc, email); err != nil {
t.Fatalf("request: %v", err)
}
// Five wrong tries are mismatches; the sixth is locked out.
for i := 0; i < 5; i++ {
if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrCodeMismatch) {
t.Fatalf("attempt %d = %v, want ErrCodeMismatch", i+1, err)
}
}
if _, err := svc.ConfirmCode(ctx, acc, email, "999999"); !errors.Is(err, account.ErrTooManyAttempts) {
t.Fatalf("after cap = %v, want ErrTooManyAttempts", err)
}
}
// TestUpdateProfilePersists writes a full profile and reads it back.
func TestUpdateProfilePersists(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
BlockChat: true,
BlockFriendRequests: true,
})
if err != nil {
t.Fatalf("update profile: %v", err)
}
if updated.DisplayName != "Kaya" || updated.PreferredLanguage != "ru" || updated.TimeZone != "Europe/Moscow" {
t.Errorf("profile not applied: %+v", updated)
}
if !updated.BlockChat || !updated.BlockFriendRequests {
t.Errorf("block toggles not applied: %+v", updated)
}
reloaded, err := store.GetByID(ctx, acc)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
t.Errorf("profile did not persist: %+v", reloaded)
}
}
+167
View File
@@ -0,0 +1,167 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/lobby"
)
// newInvitationService builds an invitation service over the shared pool, starting
// games through a real game service and reading blocks through a social service.
func newInvitationService() *lobby.InvitationService {
return lobby.NewInvitationService(lobby.NewStore(testDB), newGameService(), account.NewStore(testDB), newSocialService())
}
func englishInvite() lobby.InvitationSettings {
return lobby.InvitationSettings{
Variant: engine.VariantEnglish,
TurnTimeout: 24 * time.Hour,
HintsAllowed: true,
HintsPerPlayer: 1,
}
}
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
ctx := context.Background()
mm := lobby.NewMatchmaker(newGameService())
a, b := provisionAccount(t), provisionAccount(t)
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait")
}
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if !r2.Matched {
t.Fatal("second enqueue must match")
}
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != "active" || len(seats) != 2 {
t.Fatalf("matched game state: status %q seats %v", status, seats)
}
}
func TestInvitationAllAcceptStartsGame(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
if inv.Status != "pending" || len(inv.Invitees) != 2 {
t.Fatalf("unexpected invitation: %+v", inv)
}
if got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); err != nil || got.Status != "pending" {
t.Fatalf("first accept: status %q err %v", got.Status, err)
}
final, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true)
if err != nil {
t.Fatalf("second accept: %v", err)
}
if final.Status != "started" || final.GameID == nil {
t.Fatalf("invitation not started: %+v", final)
}
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != "active" || len(seats) != 3 || seats[0] != inviter {
t.Fatalf("started game: status %q seats %v (inviter %s)", status, seats, inviter)
}
}
func TestInvitationDeclineCancels(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], false)
if err != nil {
t.Fatalf("decline: %v", err)
}
if got.Status != "declined" || got.GameID != nil {
t.Fatalf("after decline: %+v", got)
}
// A further response is refused.
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
}
}
func TestInvitationLazyExpiry(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.game_invitations SET expires_at = now() - interval '1 minute' WHERE invitation_id = $1`, inv.ID); err != nil {
t.Fatalf("backdate expiry: %v", err)
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationExpired) {
t.Fatalf("respond expired = %v, want ErrInvitationExpired", err)
}
}
func TestInvitationBlockedInvitee(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
social := newSocialService()
inviter := provisionAccount(t)
invitee := provisionAccount(t)
if err := social.Block(ctx, invitee, inviter); err != nil {
t.Fatalf("block: %v", err)
}
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
}
}
func TestInvitationCancelByInviter(t *testing.T) {
ctx := context.Background()
svc := newInvitationService()
inviter := provisionAccount(t)
invitees := []uuid.UUID{provisionAccount(t)}
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
if err != nil {
t.Fatalf("create: %v", err)
}
// A non-inviter cannot cancel.
if err := svc.CancelInvitation(ctx, inv.ID, invitees[0]); !errors.Is(err, lobby.ErrNotInviter) {
t.Fatalf("stranger cancel = %v, want ErrNotInviter", err)
}
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
t.Fatalf("inviter cancel: %v", err)
}
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
}
}
@@ -0,0 +1,75 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestMultiplayerTimeoutContinues drives a three-player game through the domain:
// the first timeout drops a seat but the game plays on, and the second leaves a
// sole survivor who wins. Empty away windows make the timeouts deterministic.
func TestMultiplayerTimeoutContinues(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t), provisionAccount(t)}
for _, s := range seats {
setAway(t, s, "UTC", "00:00", "00:00") // empty window → no away grace
}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 42,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if g.Players != 3 {
t.Fatalf("players = %d, want 3", g.Players)
}
// Seat 0 (to move) goes overdue. It times out, but two seats remain, so the
// game continues and the turn advances off the dropped seat.
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
t.Fatalf("first sweep: %v", err)
}
h, err := svc.History(ctx, g.ID)
if err != nil {
t.Fatalf("history: %v", err)
}
if h.Game.Status != game.StatusActive {
t.Fatalf("a three-player game must continue after one timeout, status %q", h.Game.Status)
}
if h.Game.ToMove == 0 {
t.Errorf("to-move should advance off the timed-out seat 0, got %d", h.Game.ToMove)
}
// The next seat to move also times out, leaving a single active seat: the game
// finishes and the sole survivor wins.
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
t.Fatalf("second sweep: %v", err)
}
h2, err := svc.History(ctx, g.ID)
if err != nil {
t.Fatalf("history 2: %v", err)
}
if h2.Game.Status != game.StatusFinished || h2.Game.EndReason != "timeout" {
t.Fatalf("game should finish on the second timeout: status %q reason %q", h2.Game.Status, h2.Game.EndReason)
}
winners := 0
for _, s := range h2.Game.Seats {
if s.IsWinner {
winners++
}
}
if winners != 1 {
t.Errorf("want exactly one surviving winner, got %d", winners)
}
}
+199
View File
@@ -0,0 +1,199 @@
//go:build integration
package inttest
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/social"
)
// newSocialService builds a social service over the shared pool, reading game
// state through a real game service.
func newSocialService() *social.Service {
return social.NewService(social.NewStore(testDB), account.NewStore(testDB), newGameService())
}
// newGameWithSeats creates a started game seating n fresh accounts and returns the
// game id and the seated account ids in seat order.
func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
t.Helper()
seats := make([]uuid.UUID, n)
for i := range seats {
seats[i] = provisionAccount(t)
}
g, err := newGameService().Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create game: %v", err)
}
return g.ID, seats
}
func TestFriendRequestLifecycle(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
// A duplicate request in either direction is refused.
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestExists) {
t.Fatalf("duplicate = %v, want ErrRequestExists", err)
}
if got, _ := svc.ListIncomingRequests(ctx, b); len(got) != 1 || got[0] != a {
t.Fatalf("incoming for b = %v, want [a]", got)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
for _, who := range []uuid.UUID{a, b} {
friends, err := svc.ListFriends(ctx, who)
if err != nil {
t.Fatalf("list friends: %v", err)
}
if len(friends) != 1 {
t.Fatalf("friends of %s = %v, want one", who, friends)
}
}
if err := svc.Unfriend(ctx, a, b); err != nil {
t.Fatalf("unfriend: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friends after unfriend = %v, want none", friends)
}
}
func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
store := account.NewStore(testDB)
// Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil {
t.Fatalf("set toggle: %v", err)
}
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("toggle send = %v, want ErrRequestBlocked", err)
}
// Block: the addressee has blocked the requester.
c, d := provisionAccount(t), provisionAccount(t)
if err := svc.Block(ctx, d, c); err != nil {
t.Fatalf("block: %v", err)
}
if err := svc.SendFriendRequest(ctx, c, d); !errors.Is(err, social.ErrRequestBlocked) {
t.Fatalf("blocked send = %v, want ErrRequestBlocked", err)
}
}
func TestBlockSeversFriendship(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
a, b := provisionAccount(t), provisionAccount(t)
if err := svc.SendFriendRequest(ctx, a, b); err != nil {
t.Fatalf("send: %v", err)
}
if err := svc.RespondFriendRequest(ctx, b, a, true); err != nil {
t.Fatalf("accept: %v", err)
}
if err := svc.Block(ctx, a, b); err != nil {
t.Fatalf("block: %v", err)
}
if friends, _ := svc.ListFriends(ctx, a); len(friends) != 0 {
t.Errorf("friendship must be severed by a block, got %v", friends)
}
}
func TestChatPostListAndBlocks(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
store := account.NewStore(testDB)
gameID, seats := newGameWithSeats(t, 2)
if _, err := svc.PostMessage(ctx, gameID, seats[0], "good luck", "203.0.113.7"); err != nil {
t.Fatalf("post: %v", err)
}
msgs, err := svc.Messages(ctx, gameID, seats[1])
if err != nil {
t.Fatalf("messages: %v", err)
}
if len(msgs) != 1 || msgs[0].Body != "good luck" || msgs[0].SenderIP != "203.0.113.7" {
t.Fatalf("unexpected messages: %+v", msgs)
}
// A per-user block hides the blocked sender's messages from the viewer.
if err := svc.Block(ctx, seats[1], seats[0]); err != nil {
t.Fatalf("block: %v", err)
}
if msgs, _ := svc.Messages(ctx, gameID, seats[1]); len(msgs) != 0 {
t.Errorf("blocked sender's messages still visible: %+v", msgs)
}
// A viewer who disabled chat sees no messages.
other, seats2 := newGameWithSeats(t, 2)
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err)
}
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil {
t.Fatalf("set block_chat: %v", err)
}
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
t.Errorf("block_chat viewer should see no messages, got %+v", msgs)
}
}
func TestChatRejectsBadContent(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2)
if _, err := svc.PostMessage(ctx, gameID, seats[0], "join evil.example.com now", ""); !errors.Is(err, social.ErrForbiddenContent) {
t.Fatalf("link post = %v, want ErrForbiddenContent", err)
}
if _, err := svc.PostMessage(ctx, gameID, seats[0], strings.Repeat("a", 61), ""); !errors.Is(err, social.ErrMessageTooLong) {
t.Fatalf("long post = %v, want ErrMessageTooLong", err)
}
// A non-participant cannot post.
if _, err := svc.PostMessage(ctx, gameID, provisionAccount(t), "hi", ""); !errors.Is(err, social.ErrNotParticipant) {
t.Fatalf("stranger post = %v, want ErrNotParticipant", err)
}
}
func TestNudgeRulesAndRateLimit(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
gameID, seats := newGameWithSeats(t, 2) // seat 0 is to move at the start
// The player to move cannot nudge; the waiting opponent can.
if _, err := svc.Nudge(ctx, gameID, seats[0]); !errors.Is(err, social.ErrNudgeOnOwnTurn) {
t.Fatalf("to-move nudge = %v, want ErrNudgeOnOwnTurn", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("opponent nudge: %v", err)
}
// A second nudge within the hour is refused.
if _, err := svc.Nudge(ctx, gameID, seats[1]); !errors.Is(err, social.ErrNudgeTooSoon) {
t.Fatalf("rapid nudge = %v, want ErrNudgeTooSoon", err)
}
// Backdating the last nudge past the window allows another.
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.chat_messages SET created_at = now() - interval '2 hours' WHERE game_id = $1 AND kind = 'nudge'`, gameID); err != nil {
t.Fatalf("backdate nudge: %v", err)
}
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge after window: %v", err)
}
}
+459
View File
@@ -0,0 +1,459 @@
package lobby
import (
"context"
"database/sql"
"errors"
"fmt"
"slices"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// invitationTTL is how long an unanswered invitation stays open before it lazily
// expires.
const invitationTTL = 7 * 24 * time.Hour
// Invitation statuses.
const (
invitationPending = "pending"
invitationDeclined = "declined"
invitationCancelled = "cancelled"
invitationExpired = "expired"
invitationStarted = "started"
)
// Invitee responses.
const (
inviteePending = "pending"
inviteeAccepted = "accepted"
inviteeDeclined = "declined"
)
// InvitationSettings are the game settings an inviter chooses. A zero TurnTimeout
// defaults to game.DefaultTurnTimeout; the zero DropoutTiles removes a leaver's
// tiles from play.
type InvitationSettings struct {
Variant engine.Variant
TurnTimeout time.Duration
HintsAllowed bool
HintsPerPlayer int
DropoutTiles engine.DropoutTiles
}
// Invitee is one invited player's seat and response.
type Invitee struct {
AccountID uuid.UUID
Seat int
Response string
}
// Invitation is a friend-game invitation with its invitees.
type Invitation struct {
ID uuid.UUID
InviterID uuid.UUID
Settings InvitationSettings
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
Invitees []Invitee
}
// InvitationService creates and resolves friend-game invitations, starting the
// game through a GameCreator once every invitee has accepted.
type InvitationService struct {
store *Store
games GameCreator
accounts *account.Store
blocker Blocker
now func() time.Time
}
// NewInvitationService constructs an InvitationService. store owns the invitation
// tables; games starts the accepted game; accounts validates invitees; blocker
// refuses invitations across a block.
func NewInvitationService(store *Store, games GameCreator, accounts *account.Store, blocker Blocker) *InvitationService {
return &InvitationService{
store: store,
games: games,
accounts: accounts,
blocker: blocker,
now: func() time.Time { return time.Now().UTC() },
}
}
// CreateInvitation records a pending invitation from inviterID to inviteeIDs (in
// seat order, 1..N) with the given settings. The total seat count must be 2-4,
// invitees distinct and not the inviter, every invitee an existing account with no
// block standing between them, and the settings acceptable.
func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uuid.UUID, inviteeIDs []uuid.UUID, settings InvitationSettings) (Invitation, error) {
if n := len(inviteeIDs) + 1; n < 2 || n > 4 {
return Invitation{}, fmt.Errorf("%w: need 2-4 players, got %d", ErrInvalidInvitation, n)
}
if settings.HintsPerPlayer < 0 {
return Invitation{}, fmt.Errorf("%w: hints per player must be >= 0", ErrInvalidInvitation)
}
if settings.TurnTimeout == 0 {
settings.TurnTimeout = game.DefaultTurnTimeout
}
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
}
seen := map[uuid.UUID]bool{inviterID: true}
for _, id := range inviteeIDs {
if seen[id] {
return Invitation{}, fmt.Errorf("%w: %s invited twice or is the inviter", ErrInvalidInvitation, id)
}
seen[id] = true
if _, err := svc.accounts.GetByID(ctx, id); err != nil {
if errors.Is(err, account.ErrNotFound) {
return Invitation{}, fmt.Errorf("%w: invitee %s not found", ErrInvalidInvitation, id)
}
return Invitation{}, err
}
blocked, err := svc.blocker.IsBlocked(ctx, inviterID, id)
if err != nil {
return Invitation{}, err
}
if blocked {
return Invitation{}, ErrInvitationBlocked
}
}
id, err := uuid.NewV7()
if err != nil {
return Invitation{}, fmt.Errorf("lobby: new invitation id: %w", err)
}
ins := invitationInsert{
id: id,
inviterID: inviterID,
variant: settings.Variant.String(),
turnTimeoutSecs: int(settings.TurnTimeout / time.Second),
hintsAllowed: settings.HintsAllowed,
hintsPerPlayer: settings.HintsPerPlayer,
dropoutTiles: settings.DropoutTiles.String(),
expiresAt: svc.now().Add(invitationTTL),
}
if err := svc.store.insertInvitation(ctx, ins, inviteeIDs); err != nil {
return Invitation{}, err
}
return svc.store.loadInvitation(ctx, id)
}
// RespondInvitation records accountID's accept or decline of an invitation. A
// decline cancels the whole invitation; the accept that completes the set starts
// the game and marks the invitation started.
func (svc *InvitationService) RespondInvitation(ctx context.Context, invitationID, accountID uuid.UUID, accept bool) (Invitation, error) {
res, err := svc.store.respondTx(ctx, invitationID, accountID, accept, svc.now())
if err != nil {
return Invitation{}, err
}
if accept && res.allAccepted {
if err := svc.startGame(ctx, invitationID); err != nil {
return Invitation{}, err
}
}
return svc.store.loadInvitation(ctx, invitationID)
}
// startGame creates the game for a fully-accepted invitation and marks it started.
func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.UUID) error {
inv, err := svc.store.loadInvitation(ctx, invitationID)
if err != nil {
return err
}
seats := make([]uuid.UUID, len(inv.Invitees)+1)
seats[0] = inv.InviterID
for _, iv := range inv.Invitees {
if iv.Seat < 1 || iv.Seat >= len(seats) {
return fmt.Errorf("lobby: invitation %s has out-of-range seat %d", invitationID, iv.Seat)
}
seats[iv.Seat] = iv.AccountID
}
g, err := svc.games.Create(ctx, game.CreateParams{
Variant: inv.Settings.Variant,
Seats: seats,
TurnTimeout: inv.Settings.TurnTimeout,
HintsAllowed: inv.Settings.HintsAllowed,
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles,
})
if err != nil {
return err
}
if _, err := svc.store.markStarted(ctx, invitationID, g.ID, svc.now()); err != nil {
return err
}
return nil
}
// CancelInvitation lets the inviter withdraw a pending invitation.
func (svc *InvitationService) CancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID) error {
return svc.store.cancelInvitation(ctx, invitationID, inviterID, svc.now())
}
// GetInvitation loads an invitation with its invitees.
func (svc *InvitationService) GetInvitation(ctx context.Context, invitationID uuid.UUID) (Invitation, error) {
return svc.store.loadInvitation(ctx, invitationID)
}
// invitationInsert carries the immutable fields of a new invitation.
type invitationInsert struct {
id uuid.UUID
inviterID uuid.UUID
variant string
turnTimeoutSecs int
hintsAllowed bool
hintsPerPlayer int
dropoutTiles string
expiresAt time.Time
}
// respondResult reports the state after an invitee response.
type respondResult struct {
allAccepted bool
}
// insertInvitation inserts the invitation and one invitee row per id (seats 1..N).
func (s *Store) insertInvitation(ctx context.Context, ins invitationInsert, inviteeIDs []uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
ii := table.GameInvitations.INSERT(
table.GameInvitations.InvitationID, table.GameInvitations.InviterID, table.GameInvitations.Variant,
table.GameInvitations.TurnTimeoutSecs, table.GameInvitations.HintsAllowed, table.GameInvitations.HintsPerPlayer,
table.GameInvitations.DropoutTiles, table.GameInvitations.ExpiresAt,
).VALUES(ins.id, ins.inviterID, ins.variant, ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, ins.dropoutTiles, ins.expiresAt)
if _, err := ii.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert invitation: %w", err)
}
for i, id := range inviteeIDs {
pi := table.GameInvitationInvitees.INSERT(
table.GameInvitationInvitees.InvitationID, table.GameInvitationInvitees.AccountID, table.GameInvitationInvitees.Seat,
).VALUES(ins.id, id, i+1)
if _, err := pi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert invitee %d: %w", i+1, err)
}
}
return nil
})
}
// loadInvitation reads an invitation and its invitees ordered by seat.
func (s *Store) loadInvitation(ctx context.Context, id uuid.UUID) (Invitation, error) {
isel := postgres.SELECT(table.GameInvitations.AllColumns).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.GameInvitations
if err := isel.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Invitation{}, ErrInvitationNotFound
}
return Invitation{}, fmt.Errorf("lobby: load invitation %s: %w", id, err)
}
variant, err := engine.ParseVariant(row.Variant)
if err != nil {
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
}
dropout, err := engine.ParseDropoutTiles(row.DropoutTiles)
if err != nil {
return Invitation{}, fmt.Errorf("lobby: invitation %s: %w", id, err)
}
inv := Invitation{
ID: row.InvitationID,
InviterID: row.InviterID,
Settings: InvitationSettings{
Variant: variant,
TurnTimeout: time.Duration(row.TurnTimeoutSecs) * time.Second,
HintsAllowed: row.HintsAllowed,
HintsPerPlayer: int(row.HintsPerPlayer),
DropoutTiles: dropout,
},
Status: row.Status,
GameID: row.GameID,
ExpiresAt: row.ExpiresAt,
CreatedAt: row.CreatedAt,
}
psel := postgres.SELECT(table.GameInvitationInvitees.AllColumns).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(id))).
ORDER_BY(table.GameInvitationInvitees.Seat.ASC())
var prows []model.GameInvitationInvitees
if err := psel.QueryContext(ctx, s.db, &prows); err != nil {
return Invitation{}, fmt.Errorf("lobby: load invitees %s: %w", id, err)
}
for _, p := range prows {
inv.Invitees = append(inv.Invitees, Invitee{AccountID: p.AccountID, Seat: int(p.Seat), Response: p.Response})
}
return inv, nil
}
// respondTx applies an invitee's response inside a row-locked transaction so
// concurrent responses serialise and exactly one accept can complete the set.
func (s *Store) respondTx(ctx context.Context, invitationID, accountID uuid.UUID, accept bool, now time.Time) (respondResult, error) {
var res respondResult
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
isel := postgres.SELECT(table.GameInvitations.Status, table.GameInvitations.ExpiresAt).
FROM(table.GameInvitations).
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID))).
FOR(postgres.UPDATE())
var inv model.GameInvitations
if err := isel.QueryContext(ctx, tx, &inv); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return ErrInvitationNotFound
}
return fmt.Errorf("lock invitation: %w", err)
}
if inv.Status == invitationPending && now.After(inv.ExpiresAt) {
if err := setInvitationStatus(ctx, tx, invitationID, invitationExpired, now); err != nil {
return err
}
return ErrInvitationExpired
}
if inv.Status != invitationPending {
return ErrInvitationNotPending
}
psel := postgres.SELECT(table.GameInvitationInvitees.Response).
FROM(table.GameInvitationInvitees).
WHERE(
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
).LIMIT(1)
var invitee model.GameInvitationInvitees
if err := psel.QueryContext(ctx, tx, &invitee); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return ErrNotInvited
}
return fmt.Errorf("load invitee: %w", err)
}
if invitee.Response != inviteePending {
return ErrAlreadyResponded
}
if !accept {
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeDeclined, now); err != nil {
return err
}
return setInvitationStatus(ctx, tx, invitationID, invitationDeclined, now)
}
if err := setInviteeResponse(ctx, tx, invitationID, accountID, inviteeAccepted, now); err != nil {
return err
}
remaining, err := unacceptedInvitees(ctx, tx, invitationID)
if err != nil {
return err
}
res.allAccepted = remaining == 0
return nil
})
return res, err
}
// markStarted stamps a fully-accepted invitation as started, only while it is
// still pending, and reports whether it did.
func (s *Store) markStarted(ctx context.Context, invitationID, gameID uuid.UUID, now time.Time) (bool, error) {
stmt := table.GameInvitations.
UPDATE(table.GameInvitations.Status, table.GameInvitations.GameID, table.GameInvitations.UpdatedAt).
SET(postgres.String(invitationStarted), postgres.UUID(gameID), postgres.TimestampzT(now)).
WHERE(
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("lobby: mark started: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("lobby: mark started rows: %w", err)
}
return n > 0, nil
}
// cancelInvitation withdraws a pending invitation on behalf of its inviter.
func (s *Store) cancelInvitation(ctx context.Context, invitationID, inviterID uuid.UUID, now time.Time) error {
stmt := table.GameInvitations.
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
SET(postgres.String(invitationCancelled), postgres.TimestampzT(now)).
WHERE(
table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitations.InviterID.EQ(postgres.UUID(inviterID))).
AND(table.GameInvitations.Status.EQ(postgres.String(invitationPending))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return fmt.Errorf("lobby: cancel invitation: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("lobby: cancel invitation rows: %w", err)
}
if n == 0 {
// Either the invitation is gone, not the caller's, or no longer pending.
inv, err := s.loadInvitation(ctx, invitationID)
if err != nil {
return err
}
if inv.InviterID != inviterID {
return ErrNotInviter
}
return ErrInvitationNotPending
}
return nil
}
// unacceptedInvitees counts the invitees of an invitation not yet accepted.
func unacceptedInvitees(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID) (int, error) {
stmt := postgres.SELECT(table.GameInvitationInvitees.Response).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)))
var rows []model.GameInvitationInvitees
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
return 0, fmt.Errorf("count invitees: %w", err)
}
remaining := 0
for _, r := range rows {
if r.Response != inviteeAccepted {
remaining++
}
}
return remaining, nil
}
// setInvitationStatus updates an invitation's status and updated_at.
func setInvitationStatus(ctx context.Context, tx *sql.Tx, invitationID uuid.UUID, status string, now time.Time) error {
stmt := table.GameInvitations.
UPDATE(table.GameInvitations.Status, table.GameInvitations.UpdatedAt).
SET(postgres.String(status), postgres.TimestampzT(now)).
WHERE(table.GameInvitations.InvitationID.EQ(postgres.UUID(invitationID)))
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("set invitation status: %w", err)
}
return nil
}
// setInviteeResponse updates one invitee's response and responded_at.
func setInviteeResponse(ctx context.Context, tx *sql.Tx, invitationID, accountID uuid.UUID, response string, now time.Time) error {
stmt := table.GameInvitationInvitees.
UPDATE(table.GameInvitationInvitees.Response, table.GameInvitationInvitees.RespondedAt).
SET(postgres.String(response), postgres.TimestampzT(now)).
WHERE(
table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(invitationID)).
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(accountID))),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("set invitee response: %w", err)
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
// Package lobby forms games: an in-memory matchmaking pool that pairs two humans
// for an auto-match, and friend-game invitations (invite -> accept) that start a
// 2-4 player game once every invitee has accepted. Both produce a game through the
// game domain (a GameCreator); neither imports the engine. The matchmaking pool
// is in-memory and lost on restart (players re-queue); the robot that substitutes
// for a missing human after a short wait is added in a later stage.
package lobby
import (
"context"
"errors"
"github.com/google/uuid"
"scrabble/backend/internal/game"
)
// GameCreator is the slice of the game domain the lobby needs: starting a seated
// game. game.Service satisfies it.
type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
}
// Blocker reports whether two accounts have a block between them (either
// direction). social.Service satisfies it; the lobby uses it to refuse
// invitations between blocked accounts.
type Blocker interface {
IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error)
}
// Auto-match defaults: a casual two-player game on the longest move clock with one
// hint per player (docs/ARCHITECTURE.md §6). The drop-out tile disposition is moot
// for two players, so the engine default (remove) applies.
const (
autoMatchHintsAllowed = true
autoMatchHintsPerPlayer = 1
)
// Sentinel errors returned by the lobby.
var (
// ErrAlreadyQueued is returned when an account already waits in a pool.
ErrAlreadyQueued = errors.New("lobby: account already in the matchmaking pool")
// ErrInvalidInvitation is returned for a malformed invitation (bad player
// count, duplicate or self invitee, or unacceptable settings).
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
// ErrInvitationBlocked is returned when a block stands between the inviter and
// an invitee.
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
// ErrInvitationNotFound is returned when no invitation matches the lookup.
ErrInvitationNotFound = errors.New("lobby: invitation not found")
// ErrInvitationNotPending is returned when an invitation is no longer open.
ErrInvitationNotPending = errors.New("lobby: invitation is not pending")
// ErrInvitationExpired is returned when an invitation has passed its deadline.
ErrInvitationExpired = errors.New("lobby: invitation has expired")
// ErrNotInvited is returned when an account is not an invitee of the invitation.
ErrNotInvited = errors.New("lobby: account was not invited")
// ErrAlreadyResponded is returned when an invitee has already accepted or declined.
ErrAlreadyResponded = errors.New("lobby: invitee has already responded")
// ErrNotInviter is returned when a non-inviter tries to cancel an invitation.
ErrNotInviter = errors.New("lobby: only the inviter may cancel")
)
+112
View File
@@ -0,0 +1,112 @@
package lobby
import (
"context"
"math/rand"
"sync"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// Matchmaker is the in-memory auto-match pool: a FIFO queue per variant that pairs
// the next two humans into a two-player game. It holds no database state and is
// lost on restart (players simply re-queue). It is safe for concurrent use.
//
// Auto-match is anonymous, so the pool does not consult per-user blocks (those
// govern friends, chat and invitations between known players). Robot substitution
// for a missing human is added in a later stage.
type Matchmaker struct {
games GameCreator
mu sync.Mutex
queues map[engine.Variant][]uuid.UUID
queued map[uuid.UUID]engine.Variant
rng *rand.Rand
}
// NewMatchmaker constructs a Matchmaker that starts matched games through games.
func NewMatchmaker(games GameCreator) *Matchmaker {
return &Matchmaker{
games: games,
queues: make(map[engine.Variant][]uuid.UUID),
queued: make(map[uuid.UUID]engine.Variant),
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
}
// EnqueueResult reports the outcome of joining the pool: either a started game or a
// queued ticket awaiting an opponent.
type EnqueueResult struct {
Matched bool
Game game.Game
}
// Enqueue joins accountID to the variant pool. If an opponent already waits, the
// two are paired (seat order randomised for first-move fairness) and a game starts
// immediately; otherwise the account waits. An account already waiting in any pool
// gets ErrAlreadyQueued.
func (m *Matchmaker) Enqueue(ctx context.Context, accountID uuid.UUID, variant engine.Variant) (EnqueueResult, error) {
m.mu.Lock()
if _, ok := m.queued[accountID]; ok {
m.mu.Unlock()
return EnqueueResult{}, ErrAlreadyQueued
}
q := m.queues[variant]
if len(q) == 0 {
m.queues[variant] = append(q, accountID)
m.queued[accountID] = variant
m.mu.Unlock()
return EnqueueResult{}, nil
}
opponent := q[0]
m.queues[variant] = q[1:]
delete(m.queued, opponent)
seats := []uuid.UUID{opponent, accountID}
if m.rng.Intn(2) == 0 {
seats[0], seats[1] = seats[1], seats[0]
}
m.mu.Unlock()
g, err := m.games.Create(ctx, game.CreateParams{
Variant: variant,
Seats: seats,
TurnTimeout: game.DefaultTurnTimeout,
HintsAllowed: autoMatchHintsAllowed,
HintsPerPlayer: autoMatchHintsPerPlayer,
})
if err != nil {
return EnqueueResult{}, err
}
return EnqueueResult{Matched: true, Game: g}, nil
}
// Cancel removes accountID from whatever pool it waits in, reporting whether it
// was queued.
func (m *Matchmaker) Cancel(_ context.Context, accountID uuid.UUID) bool {
m.mu.Lock()
defer m.mu.Unlock()
variant, ok := m.queued[accountID]
if !ok {
return false
}
delete(m.queued, accountID)
q := m.queues[variant]
for i, id := range q {
if id == accountID {
m.queues[variant] = append(q[:i], q[i+1:]...)
break
}
}
return true
}
// QueueLen returns the number of accounts waiting in the variant pool.
func (m *Matchmaker) QueueLen(variant engine.Variant) int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.queues[variant])
}
+151
View File
@@ -0,0 +1,151 @@
package lobby
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// fakeCreator records the games a matchmaker asks it to start.
type fakeCreator struct {
created []game.CreateParams
err error
}
func (f *fakeCreator) Create(_ context.Context, p game.CreateParams) (game.Game, error) {
if f.err != nil {
return game.Game{}, f.err
}
f.created = append(f.created, p)
return game.Game{ID: uuid.New(), Players: len(p.Seats)}, nil
}
func seatsContain(seats []uuid.UUID, want ...uuid.UUID) bool {
for _, w := range want {
found := false
for _, s := range seats {
if s == w {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func TestMatchmakerPairsTwoHumans(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator)
ctx := context.Background()
a, b := uuid.New(), uuid.New()
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
if r1.Matched {
t.Fatal("first enqueue must wait, not match")
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Fatalf("queue len = %d, want 1", mm.QueueLen(engine.VariantEnglish))
}
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
if !r2.Matched {
t.Fatal("second enqueue must match")
}
if mm.QueueLen(engine.VariantEnglish) != 0 {
t.Fatalf("queue len = %d, want 0 after match", mm.QueueLen(engine.VariantEnglish))
}
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
p := creator.created[0]
if len(p.Seats) != 2 || !seatsContain(p.Seats, a, b) {
t.Errorf("seats = %v, want both %s and %s", p.Seats, a, b)
}
if p.TurnTimeout != game.DefaultTurnTimeout || !p.HintsAllowed || p.HintsPerPlayer != autoMatchHintsPerPlayer {
t.Errorf("auto-match defaults not applied: %+v", p)
}
}
func TestMatchmakerAlreadyQueued(t *testing.T) {
mm := NewMatchmaker(&fakeCreator{})
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); !errors.Is(err, ErrAlreadyQueued) {
t.Fatalf("second enqueue err = %v, want ErrAlreadyQueued", err)
}
}
func TestMatchmakerCancel(t *testing.T) {
mm := NewMatchmaker(&fakeCreator{})
ctx := context.Background()
a := uuid.New()
if _, err := mm.Enqueue(ctx, a, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue: %v", err)
}
if !mm.Cancel(ctx, a) {
t.Fatal("cancel of a queued account must report true")
}
if mm.QueueLen(engine.VariantEnglish) != 0 {
t.Fatalf("queue len = %d, want 0 after cancel", mm.QueueLen(engine.VariantEnglish))
}
if mm.Cancel(ctx, a) {
t.Fatal("cancel of an unqueued account must report false")
}
}
func TestMatchmakerVariantsAreSeparate(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator)
ctx := context.Background()
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantEnglish); err != nil {
t.Fatalf("enqueue en: %v", err)
}
if _, err := mm.Enqueue(ctx, uuid.New(), engine.VariantRussianScrabble); err != nil {
t.Fatalf("enqueue ru: %v", err)
}
if len(creator.created) != 0 {
t.Fatalf("different variants must not match; created %d", len(creator.created))
}
if mm.QueueLen(engine.VariantEnglish) != 1 || mm.QueueLen(engine.VariantRussianScrabble) != 1 {
t.Errorf("each variant pool should hold one waiter")
}
}
func TestMatchmakerFIFO(t *testing.T) {
creator := &fakeCreator{}
mm := NewMatchmaker(creator)
ctx := context.Background()
a, b, c := uuid.New(), uuid.New(), uuid.New()
for _, id := range []uuid.UUID{a, b, c} {
if _, err := mm.Enqueue(ctx, id, engine.VariantEnglish); err != nil {
t.Fatalf("enqueue %s: %v", id, err)
}
}
// a waited, b matched a (oldest), c waits.
if len(creator.created) != 1 {
t.Fatalf("created %d games, want 1", len(creator.created))
}
if !seatsContain(creator.created[0].Seats, a, b) {
t.Errorf("FIFO match should pair a and b, got %v", creator.created[0].Seats)
}
if mm.QueueLen(engine.VariantEnglish) != 1 {
t.Errorf("c should remain queued; len = %d", mm.QueueLen(engine.VariantEnglish))
}
}
+33
View File
@@ -0,0 +1,33 @@
package lobby
import (
"context"
"database/sql"
"fmt"
)
// Store is the Postgres-backed query surface for friend-game invitations.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
@@ -0,0 +1,19 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Blocks struct {
BlockerID uuid.UUID `sql:"primary_key"`
BlockedID uuid.UUID `sql:"primary_key"`
CreatedAt time.Time
}
@@ -0,0 +1,23 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type ChatMessages struct {
MessageID uuid.UUID `sql:"primary_key"`
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP *string
CreatedAt time.Time
}
@@ -0,0 +1,24 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type EmailConfirmations struct {
ConfirmationID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID
Email string
CodeHash string
ExpiresAt time.Time
Attempts int16
ConsumedAt *time.Time
CreatedAt time.Time
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type Friendships struct {
RequesterID uuid.UUID `sql:"primary_key"`
AddresseeID uuid.UUID `sql:"primary_key"`
Status string
CreatedAt time.Time
RespondedAt *time.Time
}
@@ -0,0 +1,21 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type GameInvitationInvitees struct {
InvitationID uuid.UUID `sql:"primary_key"`
AccountID uuid.UUID `sql:"primary_key"`
Seat int16
Response string
RespondedAt *time.Time
}
@@ -0,0 +1,28 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type GameInvitations struct {
InvitationID uuid.UUID `sql:"primary_key"`
InviterID uuid.UUID
Variant string
TurnTimeoutSecs int32
HintsAllowed bool
HintsPerPlayer int16
DropoutTiles string
Status string
GameID *uuid.UUID
ExpiresAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -29,4 +29,5 @@ type Games struct {
CreatedAt time.Time
UpdatedAt time.Time
FinishedAt *time.Time
DropoutTiles string
}
@@ -0,0 +1,84 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Blocks = newBlocksTable("backend", "blocks", "")
type blocksTable struct {
postgres.Table
// Columns
BlockerID postgres.ColumnString
BlockedID postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type BlocksTable struct {
blocksTable
EXCLUDED blocksTable
}
// AS creates new BlocksTable with assigned alias
func (a BlocksTable) AS(alias string) *BlocksTable {
return newBlocksTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new BlocksTable with assigned schema name
func (a BlocksTable) FromSchema(schemaName string) *BlocksTable {
return newBlocksTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new BlocksTable with assigned table prefix
func (a BlocksTable) WithPrefix(prefix string) *BlocksTable {
return newBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new BlocksTable with assigned table suffix
func (a BlocksTable) WithSuffix(suffix string) *BlocksTable {
return newBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newBlocksTable(schemaName, tableName, alias string) *BlocksTable {
return &BlocksTable{
blocksTable: newBlocksTableImpl(schemaName, tableName, alias),
EXCLUDED: newBlocksTableImpl("", "excluded", ""),
}
}
func newBlocksTableImpl(schemaName, tableName, alias string) blocksTable {
var (
BlockerIDColumn = postgres.StringColumn("blocker_id")
BlockedIDColumn = postgres.StringColumn("blocked_id")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{BlockerIDColumn, BlockedIDColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{CreatedAtColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn}
)
return blocksTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
BlockerID: BlockerIDColumn,
BlockedID: BlockedIDColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,96 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var ChatMessages = newChatMessagesTable("backend", "chat_messages", "")
type chatMessagesTable struct {
postgres.Table
// Columns
MessageID postgres.ColumnString
GameID postgres.ColumnString
SenderID postgres.ColumnString
Kind postgres.ColumnString
Body postgres.ColumnString
SenderIP postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ChatMessagesTable struct {
chatMessagesTable
EXCLUDED chatMessagesTable
}
// AS creates new ChatMessagesTable with assigned alias
func (a ChatMessagesTable) AS(alias string) *ChatMessagesTable {
return newChatMessagesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ChatMessagesTable with assigned schema name
func (a ChatMessagesTable) FromSchema(schemaName string) *ChatMessagesTable {
return newChatMessagesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ChatMessagesTable with assigned table prefix
func (a ChatMessagesTable) WithPrefix(prefix string) *ChatMessagesTable {
return newChatMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ChatMessagesTable with assigned table suffix
func (a ChatMessagesTable) WithSuffix(suffix string) *ChatMessagesTable {
return newChatMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTable {
return &ChatMessagesTable{
chatMessagesTable: newChatMessagesTableImpl(schemaName, tableName, alias),
EXCLUDED: newChatMessagesTableImpl("", "excluded", ""),
}
}
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
var (
MessageIDColumn = postgres.StringColumn("message_id")
GameIDColumn = postgres.StringColumn("game_id")
SenderIDColumn = postgres.StringColumn("sender_id")
KindColumn = postgres.StringColumn("kind")
BodyColumn = postgres.StringColumn("body")
SenderIPColumn = postgres.StringColumn("sender_ip")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
)
return chatMessagesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
MessageID: MessageIDColumn,
GameID: GameIDColumn,
SenderID: SenderIDColumn,
Kind: KindColumn,
Body: BodyColumn,
SenderIP: SenderIPColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,99 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var EmailConfirmations = newEmailConfirmationsTable("backend", "email_confirmations", "")
type emailConfirmationsTable struct {
postgres.Table
// Columns
ConfirmationID postgres.ColumnString
AccountID postgres.ColumnString
Email postgres.ColumnString
CodeHash postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
Attempts postgres.ColumnInteger
ConsumedAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type EmailConfirmationsTable struct {
emailConfirmationsTable
EXCLUDED emailConfirmationsTable
}
// AS creates new EmailConfirmationsTable with assigned alias
func (a EmailConfirmationsTable) AS(alias string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new EmailConfirmationsTable with assigned schema name
func (a EmailConfirmationsTable) FromSchema(schemaName string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new EmailConfirmationsTable with assigned table prefix
func (a EmailConfirmationsTable) WithPrefix(prefix string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new EmailConfirmationsTable with assigned table suffix
func (a EmailConfirmationsTable) WithSuffix(suffix string) *EmailConfirmationsTable {
return newEmailConfirmationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newEmailConfirmationsTable(schemaName, tableName, alias string) *EmailConfirmationsTable {
return &EmailConfirmationsTable{
emailConfirmationsTable: newEmailConfirmationsTableImpl(schemaName, tableName, alias),
EXCLUDED: newEmailConfirmationsTableImpl("", "excluded", ""),
}
}
func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailConfirmationsTable {
var (
ConfirmationIDColumn = postgres.StringColumn("confirmation_id")
AccountIDColumn = postgres.StringColumn("account_id")
EmailColumn = postgres.StringColumn("email")
CodeHashColumn = postgres.StringColumn("code_hash")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
AttemptsColumn = postgres.IntegerColumn("attempts")
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
)
return emailConfirmationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
ConfirmationID: ConfirmationIDColumn,
AccountID: AccountIDColumn,
Email: EmailColumn,
CodeHash: CodeHashColumn,
ExpiresAt: ExpiresAtColumn,
Attempts: AttemptsColumn,
ConsumedAt: ConsumedAtColumn,
CreatedAt: CreatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Friendships = newFriendshipsTable("backend", "friendships", "")
type friendshipsTable struct {
postgres.Table
// Columns
RequesterID postgres.ColumnString
AddresseeID postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
RespondedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type FriendshipsTable struct {
friendshipsTable
EXCLUDED friendshipsTable
}
// AS creates new FriendshipsTable with assigned alias
func (a FriendshipsTable) AS(alias string) *FriendshipsTable {
return newFriendshipsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new FriendshipsTable with assigned schema name
func (a FriendshipsTable) FromSchema(schemaName string) *FriendshipsTable {
return newFriendshipsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new FriendshipsTable with assigned table prefix
func (a FriendshipsTable) WithPrefix(prefix string) *FriendshipsTable {
return newFriendshipsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new FriendshipsTable with assigned table suffix
func (a FriendshipsTable) WithSuffix(suffix string) *FriendshipsTable {
return newFriendshipsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newFriendshipsTable(schemaName, tableName, alias string) *FriendshipsTable {
return &FriendshipsTable{
friendshipsTable: newFriendshipsTableImpl(schemaName, tableName, alias),
EXCLUDED: newFriendshipsTableImpl("", "excluded", ""),
}
}
func newFriendshipsTableImpl(schemaName, tableName, alias string) friendshipsTable {
var (
RequesterIDColumn = postgres.StringColumn("requester_id")
AddresseeIDColumn = postgres.StringColumn("addressee_id")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
allColumns = postgres.ColumnList{RequesterIDColumn, AddresseeIDColumn, StatusColumn, CreatedAtColumn, RespondedAtColumn}
mutableColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, RespondedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
)
return friendshipsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
RequesterID: RequesterIDColumn,
AddresseeID: AddresseeIDColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
RespondedAt: RespondedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,90 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GameInvitationInvitees = newGameInvitationInviteesTable("backend", "game_invitation_invitees", "")
type gameInvitationInviteesTable struct {
postgres.Table
// Columns
InvitationID postgres.ColumnString
AccountID postgres.ColumnString
Seat postgres.ColumnInteger
Response postgres.ColumnString
RespondedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameInvitationInviteesTable struct {
gameInvitationInviteesTable
EXCLUDED gameInvitationInviteesTable
}
// AS creates new GameInvitationInviteesTable with assigned alias
func (a GameInvitationInviteesTable) AS(alias string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameInvitationInviteesTable with assigned schema name
func (a GameInvitationInviteesTable) FromSchema(schemaName string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameInvitationInviteesTable with assigned table prefix
func (a GameInvitationInviteesTable) WithPrefix(prefix string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameInvitationInviteesTable with assigned table suffix
func (a GameInvitationInviteesTable) WithSuffix(suffix string) *GameInvitationInviteesTable {
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameInvitationInviteesTable(schemaName, tableName, alias string) *GameInvitationInviteesTable {
return &GameInvitationInviteesTable{
gameInvitationInviteesTable: newGameInvitationInviteesTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameInvitationInviteesTableImpl("", "excluded", ""),
}
}
func newGameInvitationInviteesTableImpl(schemaName, tableName, alias string) gameInvitationInviteesTable {
var (
InvitationIDColumn = postgres.StringColumn("invitation_id")
AccountIDColumn = postgres.StringColumn("account_id")
SeatColumn = postgres.IntegerColumn("seat")
ResponseColumn = postgres.StringColumn("response")
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
allColumns = postgres.ColumnList{InvitationIDColumn, AccountIDColumn, SeatColumn, ResponseColumn, RespondedAtColumn}
mutableColumns = postgres.ColumnList{SeatColumn, ResponseColumn, RespondedAtColumn}
defaultColumns = postgres.ColumnList{ResponseColumn}
)
return gameInvitationInviteesTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InvitationID: InvitationIDColumn,
AccountID: AccountIDColumn,
Seat: SeatColumn,
Response: ResponseColumn,
RespondedAt: RespondedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -0,0 +1,111 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var GameInvitations = newGameInvitationsTable("backend", "game_invitations", "")
type gameInvitationsTable struct {
postgres.Table
// Columns
InvitationID postgres.ColumnString
InviterID postgres.ColumnString
Variant postgres.ColumnString
TurnTimeoutSecs postgres.ColumnInteger
HintsAllowed postgres.ColumnBool
HintsPerPlayer postgres.ColumnInteger
DropoutTiles postgres.ColumnString
Status postgres.ColumnString
GameID postgres.ColumnString
ExpiresAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type GameInvitationsTable struct {
gameInvitationsTable
EXCLUDED gameInvitationsTable
}
// AS creates new GameInvitationsTable with assigned alias
func (a GameInvitationsTable) AS(alias string) *GameInvitationsTable {
return newGameInvitationsTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new GameInvitationsTable with assigned schema name
func (a GameInvitationsTable) FromSchema(schemaName string) *GameInvitationsTable {
return newGameInvitationsTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new GameInvitationsTable with assigned table prefix
func (a GameInvitationsTable) WithPrefix(prefix string) *GameInvitationsTable {
return newGameInvitationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new GameInvitationsTable with assigned table suffix
func (a GameInvitationsTable) WithSuffix(suffix string) *GameInvitationsTable {
return newGameInvitationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitationsTable {
return &GameInvitationsTable{
gameInvitationsTable: newGameInvitationsTableImpl(schemaName, tableName, alias),
EXCLUDED: newGameInvitationsTableImpl("", "excluded", ""),
}
}
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
var (
InvitationIDColumn = postgres.StringColumn("invitation_id")
InviterIDColumn = postgres.StringColumn("inviter_id")
VariantColumn = postgres.StringColumn("variant")
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
StatusColumn = postgres.StringColumn("status")
GameIDColumn = postgres.StringColumn("game_id")
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
)
return gameInvitationsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
InvitationID: InvitationIDColumn,
InviterID: InviterIDColumn,
Variant: VariantColumn,
TurnTimeoutSecs: TurnTimeoutSecsColumn,
HintsAllowed: HintsAllowedColumn,
HintsPerPlayer: HintsPerPlayerColumn,
DropoutTiles: DropoutTilesColumn,
Status: StatusColumn,
GameID: GameIDColumn,
ExpiresAt: ExpiresAtColumn,
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -33,6 +33,7 @@ type gamesTable struct {
CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz
FinishedAt postgres.ColumnTimestampz
DropoutTiles postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -90,9 +91,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn}
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn}
)
return gamesTable{
@@ -115,6 +117,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn,
FinishedAt: FinishedAtColumn,
DropoutTiles: DropoutTilesColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -12,7 +12,13 @@ package table
func UseSchema(schema string) {
AccountStats = AccountStats.FromSchema(schema)
Accounts = Accounts.FromSchema(schema)
Blocks = Blocks.FromSchema(schema)
ChatMessages = ChatMessages.FromSchema(schema)
Complaints = Complaints.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema)
Friendships = Friendships.FromSchema(schema)
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
GameInvitations = GameInvitations.FromSchema(schema)
GameMoves = GameMoves.FromSchema(schema)
GamePlayers = GamePlayers.FromSchema(schema)
Games = Games.FromSchema(schema)
@@ -0,0 +1,136 @@
-- +goose Up
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
-- nudge folded in as a message kind), email confirm-codes, and friend-game
-- invitations -- plus the per-game drop-out tile disposition the multi-player
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
SET search_path = backend, pg_catalog;
-- The disposition of a dropped-out player's tiles in a game with three or more
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
ALTER TABLE games
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
-- The friend graph. A row is created by the requester as 'pending' and flipped to
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
-- row. Friendship is symmetric: a player's friends are the accepted rows in
-- either direction. A pair has at most one row (guarded in Go against either
-- direction existing).
CREATE TABLE friendships (
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
responded_at timestamptz,
PRIMARY KEY (requester_id, addressee_id),
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')),
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
);
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
-- mutually by the social checks (a block in either direction suppresses chat
-- visibility and prevents requests/invitations between the pair).
CREATE TABLE blocks (
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (blocker_id, blocked_id),
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
);
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty
-- body, so one journal carries both chatter and nudges. body is capped at 60
-- runes (enforced again in Go on input, where the content filter also rejects
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
-- a validated string (text, not inet, to avoid go-jet literal friction; the
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
-- purged; it cascades away only with its game.
CREATE TABLE chat_messages (
message_id uuid PRIMARY KEY,
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
sender_id uuid NOT NULL REFERENCES accounts (account_id),
kind text NOT NULL DEFAULT 'message',
body text NOT NULL DEFAULT '',
sender_ip text,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
);
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
WHERE kind = 'nudge';
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the
-- 6-digit code (the plaintext is never stored, matching the session model);
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
-- for the same (account, lowercased email) and inserts a fresh one.
CREATE TABLE email_confirmations (
confirmation_id uuid PRIMARY KEY,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
email text NOT NULL,
code_hash text NOT NULL,
expires_at timestamptz NOT NULL,
attempts smallint NOT NULL DEFAULT 0,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
);
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to
-- 1..3 invitees; the game starts only when every invitee has accepted, and any
-- decline cancels the whole invitation. Lazily expired after expires_at (no
-- background sweep). game_id is set when the game is started.
CREATE TABLE game_invitations (
invitation_id uuid PRIMARY KEY,
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
variant text NOT NULL,
turn_timeout_secs integer NOT NULL,
hints_allowed boolean NOT NULL DEFAULT true,
hints_per_player smallint NOT NULL DEFAULT 1,
dropout_tiles text NOT NULL DEFAULT 'remove',
status text NOT NULL DEFAULT 'pending',
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
);
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's
-- seat in the started game (1..3, in invitation order). response tracks each
-- invitee's pending/accepted/declined decision.
CREATE TABLE game_invitation_invitees (
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
seat smallint NOT NULL,
response text NOT NULL DEFAULT 'pending',
responded_at timestamptz,
PRIMARY KEY (invitation_id, account_id),
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
);
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
-- +goose Down
DROP TABLE game_invitation_invitees;
DROP TABLE game_invitations;
DROP TABLE email_confirmations;
DROP TABLE chat_messages;
DROP TABLE blocks;
DROP TABLE friendships;
ALTER TABLE games
DROP CONSTRAINT games_dropout_tiles_chk,
DROP COLUMN dropout_tiles;
+32
View File
@@ -17,6 +17,9 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
)
@@ -39,6 +42,14 @@ type Deps struct {
// SessionsReady reports whether the session cache has been warmed. A nil
// func skips the session-readiness check.
SessionsReady func() bool
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services.
// They are held for the REST/stream handlers the gateway adds in Stage 6 (like
// the route groups, this is scaffolding exposed via accessors); the server
// itself does not route to them yet.
Social *social.Service
Matchmaker *lobby.Matchmaker
Invitations *lobby.InvitationService
Emails *account.EmailService
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -50,6 +61,11 @@ type Server struct {
pingTimeout time.Duration
sessionsReady func() bool
social *social.Service
matchmaker *lobby.Matchmaker
invitations *lobby.InvitationService
emails *account.EmailService
public *gin.RouterGroup
user *gin.RouterGroup
internal *gin.RouterGroup
@@ -78,6 +94,10 @@ func New(addr string, deps Deps) *Server {
db: deps.DB,
pingTimeout: pingTimeout,
sessionsReady: deps.SessionsReady,
social: deps.Social,
matchmaker: deps.Matchmaker,
invitations: deps.Invitations,
emails: deps.Emails,
http: &http.Server{Addr: addr, Handler: engine},
}
s.registerProbes(engine)
@@ -136,6 +156,18 @@ func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
// AdminGroup returns the admin route group (authenticated at the gateway).
func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin }
// Social returns the social domain service for the handlers added in Stage 6.
func (s *Server) Social() *social.Service { return s.social }
// Matchmaker returns the in-memory matchmaking pool for the Stage 6 handlers.
func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker }
// Invitations returns the friend-game invitation service for the Stage 6 handlers.
func (s *Server) Invitations() *lobby.InvitationService { return s.invitations }
// Emails returns the email confirm-code service for the Stage 6 handlers.
func (s *Server) Emails() *account.EmailService { return s.emails }
// Handler returns the underlying HTTP handler. It lets tests drive the server
// without binding a socket and lets later stages compose the backend behind
// another listener.
+106
View File
@@ -0,0 +1,106 @@
package social
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Block records that blockerID has blocked blockedID. Blocking severs any
// friendship or pending request between the two and, through the mutual block
// checks, suppresses chat visibility and new requests/invitations in both
// directions. It is idempotent.
func (svc *Service) Block(ctx context.Context, blockerID, blockedID uuid.UUID) error {
if blockerID == blockedID {
return ErrSelfRelation
}
return svc.store.insertBlock(ctx, blockerID, blockedID)
}
// Unblock removes blockerID's block on blockedID. It is idempotent.
func (svc *Service) Unblock(ctx context.Context, blockerID, blockedID uuid.UUID) error {
return svc.store.deleteBlock(ctx, blockerID, blockedID)
}
// ListBlocks returns the account IDs blockerID has blocked.
func (svc *Service) ListBlocks(ctx context.Context, blockerID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listBlocks(ctx, blockerID)
}
// IsBlocked reports whether a block stands between a and b in either direction.
func (svc *Service) IsBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
return svc.store.isBlocked(ctx, a, b)
}
// isBlocked reports whether a block row exists between a and b in either direction.
func (s *Store) isBlocked(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Blocks.BlockerID).
FROM(table.Blocks).
WHERE(
table.Blocks.BlockerID.EQ(postgres.UUID(a)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(b))).
OR(table.Blocks.BlockerID.EQ(postgres.UUID(b)).AND(table.Blocks.BlockedID.EQ(postgres.UUID(a)))),
).LIMIT(1)
var row model.Blocks
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: is blocked: %w", err)
}
return true, nil
}
// insertBlock severs any friendship between the pair and inserts the block, in one
// transaction; a duplicate block is ignored.
func (s *Store) insertBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
del := table.Friendships.DELETE().WHERE(edgeEither(blocker, blocked))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("clear friendship on block: %w", err)
}
ins := table.Blocks.
INSERT(table.Blocks.BlockerID, table.Blocks.BlockedID).
VALUES(blocker, blocked).
ON_CONFLICT(table.Blocks.BlockerID, table.Blocks.BlockedID).DO_NOTHING()
if _, err := ins.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert block: %w", err)
}
return nil
})
}
// deleteBlock removes a block. It is idempotent.
func (s *Store) deleteBlock(ctx context.Context, blocker, blocked uuid.UUID) error {
stmt := table.Blocks.DELETE().WHERE(
table.Blocks.BlockerID.EQ(postgres.UUID(blocker)).
AND(table.Blocks.BlockedID.EQ(postgres.UUID(blocked))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: delete block: %w", err)
}
return nil
}
// listBlocks returns the accounts blocker has blocked.
func (s *Store) listBlocks(ctx context.Context, blocker uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Blocks.BlockedID).
FROM(table.Blocks).
WHERE(table.Blocks.BlockerID.EQ(postgres.UUID(blocker)))
var rows []model.Blocks
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list blocks: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.BlockedID)
}
return out, nil
}
+234
View File
@@ -0,0 +1,234 @@
package social
import (
"context"
"errors"
"fmt"
"net/netip"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
const (
// maxChatRunes caps a chat message's length, keeping it to a quick reaction.
maxChatRunes = 60
// nudgeInterval is the minimum gap between two nudges by the same player in a game.
nudgeInterval = time.Hour
// kindMessage and kindNudge are the chat_messages.kind values.
kindMessage = "message"
kindNudge = "nudge"
// statusActive mirrors game.StatusActive: the status string a live game reports.
statusActive = "active"
)
// Message is one persisted per-game chat entry. A nudge is a Message with Kind
// nudge and an empty Body. SenderIP is the gateway-forwarded client IP (empty when
// unknown), kept for moderation.
type Message struct {
ID uuid.UUID
GameID uuid.UUID
SenderID uuid.UUID
Kind string
Body string
SenderIP string
CreatedAt time.Time
}
// PostMessage stores a chat message from senderID in gameID. The sender must be a
// seated player who has not disabled chat; the body must be non-empty, within the
// rune limit, and free of links/emails/phone numbers (the content filter). The
// gateway-forwarded senderIP is validated and stored for moderation.
func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID, body, senderIP string) (Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
if !slices.Contains(seats, senderID) {
return Message{}, ErrNotParticipant
}
sender, err := svc.accounts.GetByID(ctx, senderID)
if err != nil {
return Message{}, err
}
if sender.BlockChat {
return Message{}, ErrChatBlocked
}
body = strings.TrimSpace(body)
if body == "" {
return Message{}, ErrEmptyMessage
}
if utf8.RuneCountInString(body) > maxChatRunes {
return Message{}, ErrMessageTooLong
}
if err := Clean(body); err != nil {
return Message{}, err
}
return svc.store.insertChatMessage(ctx, gameID, senderID, kindMessage, body, parseIP(senderIP))
}
// Nudge records a nudge from senderID toward the player whose turn is awaited. The
// game must be active, the sender a seated player whose turn it is not, and the
// once-per-hour-per-game limit not yet hit.
func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Message, error) {
seats, toMove, status, err := svc.games.Participants(ctx, gameID)
if err != nil {
return Message{}, err
}
if status != statusActive {
return Message{}, ErrGameNotActive
}
idx := slices.Index(seats, senderID)
if idx < 0 {
return Message{}, ErrNotParticipant
}
if idx == toMove {
return Message{}, ErrNudgeOnOwnTurn
}
last, ok, err := svc.store.lastNudgeAt(ctx, gameID, senderID)
if err != nil {
return Message{}, err
}
if ok && svc.now().Sub(last) < nudgeInterval {
return Message{}, ErrNudgeTooSoon
}
return svc.store.insertChatMessage(ctx, gameID, senderID, kindNudge, "", nil)
}
// Messages returns the per-game chat visible to viewerID: the viewer must be a
// seated player. Messages from a sender the viewer has a block with (either
// direction) are dropped, and if the viewer has disabled chat only nudges remain.
func (svc *Service) Messages(ctx context.Context, gameID, viewerID uuid.UUID) ([]Message, error) {
seats, _, _, err := svc.games.Participants(ctx, gameID)
if err != nil {
return nil, err
}
if !slices.Contains(seats, viewerID) {
return nil, ErrNotParticipant
}
viewer, err := svc.accounts.GetByID(ctx, viewerID)
if err != nil {
return nil, err
}
blocked := make(map[uuid.UUID]bool)
for _, seat := range seats {
if seat == viewerID {
continue
}
yes, err := svc.store.isBlocked(ctx, viewerID, seat)
if err != nil {
return nil, err
}
if yes {
blocked[seat] = true
}
}
all, err := svc.store.listChatMessages(ctx, gameID)
if err != nil {
return nil, err
}
out := make([]Message, 0, len(all))
for _, m := range all {
if blocked[m.SenderID] {
continue
}
if m.Kind == kindMessage && viewer.BlockChat {
continue
}
out = append(out, m)
}
return out, nil
}
// parseIP returns a validated canonical IP string, or nil when raw is empty or
// not a valid address.
func parseIP(raw string) *string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return nil
}
canon := addr.String()
return &canon
}
// insertChatMessage stores one chat row and returns it.
func (s *Store) insertChatMessage(ctx context.Context, gameID, senderID uuid.UUID, kind, body string, ip *string) (Message, error) {
id, err := uuid.NewV7()
if err != nil {
return Message{}, fmt.Errorf("social: new message id: %w", err)
}
var ipVal any = postgres.NULL
if ip != nil {
ipVal = postgres.String(*ip)
}
stmt := table.ChatMessages.INSERT(
table.ChatMessages.MessageID, table.ChatMessages.GameID, table.ChatMessages.SenderID,
table.ChatMessages.Kind, table.ChatMessages.Body, table.ChatMessages.SenderIP,
).VALUES(id, gameID, senderID, kind, body, ipVal).
RETURNING(table.ChatMessages.AllColumns)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return Message{}, fmt.Errorf("social: insert chat message: %w", err)
}
return messageFromRow(row), nil
}
// listChatMessages returns a game's chat in chronological order.
func (s *Store) listChatMessages(ctx context.Context, gameID uuid.UUID) ([]Message, error) {
stmt := postgres.SELECT(table.ChatMessages.AllColumns).
FROM(table.ChatMessages).
WHERE(table.ChatMessages.GameID.EQ(postgres.UUID(gameID))).
ORDER_BY(table.ChatMessages.CreatedAt.ASC(), table.ChatMessages.MessageID.ASC())
var rows []model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list chat: %w", err)
}
out := make([]Message, 0, len(rows))
for _, r := range rows {
out = append(out, messageFromRow(r))
}
return out, nil
}
// lastNudgeAt returns the time of senderID's most recent nudge in gameID, if any.
func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages).
WHERE(
table.ChatMessages.GameID.EQ(postgres.UUID(gameID)).
AND(table.ChatMessages.SenderID.EQ(postgres.UUID(senderID))).
AND(table.ChatMessages.Kind.EQ(postgres.String(kindNudge))),
).ORDER_BY(table.ChatMessages.CreatedAt.DESC()).LIMIT(1)
var row model.ChatMessages
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return time.Time{}, false, nil
}
return time.Time{}, false, fmt.Errorf("social: last nudge: %w", err)
}
return row.CreatedAt, true, nil
}
// messageFromRow projects a generated row into the public Message.
func messageFromRow(r model.ChatMessages) Message {
m := Message{
ID: r.MessageID,
GameID: r.GameID,
SenderID: r.SenderID,
Kind: r.Kind,
Body: r.Body,
CreatedAt: r.CreatedAt,
}
if r.SenderIP != nil {
m.SenderIP = *r.SenderIP
}
return m
}
+88
View File
@@ -0,0 +1,88 @@
package social
import (
"errors"
"fmt"
"regexp"
"strings"
"mvdan.cc/xurls/v2"
)
// ErrForbiddenContent is returned when a chat message contains a web link, email
// address or phone number — including lightly obfuscated forms. The chat is for
// quick in-game reactions, not for exchanging contact details.
var ErrForbiddenContent = errors.New("social: message contains a link, email or phone number")
// phoneDigits is the minimum run of consecutive digits treated as a phone number.
const phoneDigits = 7
// relaxedURL matches URLs, bare domains and email addresses (xurls relaxed mode).
var relaxedURL = xurls.Relaxed()
// spelledSeparators rewrites the words people use to dodge a detector back into
// their symbols, so "gmail dot com" and "user at host" are still caught.
var spelledSeparators = strings.NewReplacer(
" dot ", ".", "(dot)", ".", "[dot]", ".", " punto ", ".", " точка ", ".",
" at ", "@", "(at)", "@", "[at]", "@", " собака ", "@", " собачка ", "@",
)
// leet folds digits and symbols commonly substituted for letters, so "g00gl3.com"
// is recognised. It leaves '.' and '@' untouched, since those carry the link and
// email structure the matcher relies on.
var leet = strings.NewReplacer(
"0", "o", "1", "i", "3", "e", "4", "a", "5", "s", "7", "t", "8", "b",
"$", "s", "!", "i", "|", "l",
)
// spaceAroundPunct collapses whitespace around '.' and '@' so "gmail . com" and
// "user @ host" close up into a detectable address.
var spaceAroundPunct = regexp.MustCompile(`\s*([.@])\s*`)
// phoneSeparators are the characters stripped before counting a digit run, so a
// grouped number like "8 (900) 123-45-67" collapses to a single run.
var phoneSeparators = regexp.MustCompile(`[\s\-.()+]`)
// Clean reports whether body is free of links, emails and phone numbers, returning
// ErrForbiddenContent (naming the category) otherwise. Detection is best-effort
// over a short, rune-limited message: it folds common letter/digit obfuscation
// and spelled-out separators before matching, but does not claim to defeat every
// evasion.
func Clean(body string) error {
lower := strings.ToLower(body)
if hasLinkOrEmail(lower) {
return fmt.Errorf("%w: link or email", ErrForbiddenContent)
}
if hasPhone(lower) {
return fmt.Errorf("%w: phone number", ErrForbiddenContent)
}
return nil
}
// hasLinkOrEmail matches the lower-cased text both as written and after folding
// spelled separators, spacing and leet substitutions.
func hasLinkOrEmail(lower string) bool {
if relaxedURL.MatchString(lower) {
return true
}
deobfuscated := leet.Replace(spaceAroundPunct.ReplaceAllString(spelledSeparators.Replace(lower), "$1"))
return relaxedURL.MatchString(deobfuscated)
}
// hasPhone reports a run of phoneDigits or more digits once phone-style separators
// are removed.
func hasPhone(lower string) bool {
stripped := phoneSeparators.ReplaceAllString(lower, "")
run := 0
for _, r := range stripped {
if r >= '0' && r <= '9' {
run++
if run >= phoneDigits {
return true
}
continue
}
run = 0
}
return false
}
@@ -0,0 +1,53 @@
package social
import (
"errors"
"testing"
)
func TestCleanAllowsOrdinaryChat(t *testing.T) {
clean := []string{
"",
"nice move!",
"gg wp",
"хороший ход, поздравляю",
"unlucky draw this round",
"I scored 42 points",
"only 6 digits here: 123456",
"see you at 5",
"three vowels in my rack",
"well played :)",
}
for _, body := range clean {
if err := Clean(body); err != nil {
t.Errorf("Clean(%q) = %v, want nil", body, err)
}
}
}
func TestCleanRejectsLinksEmailsPhones(t *testing.T) {
forbidden := []string{
// Plain links and bare domains.
"http://evil.example.com",
"visit https://x.io/abc now",
"join discord.gg/xyzqwer",
"check scrabblecheat.com",
// Emails, plain and obfuscated.
"mail me a@b.com",
"john at gmail dot com",
"j0hn at gma1l dot c0m",
// Obfuscated domains.
"g00gle dot com",
"site . com please",
// Phone numbers, plain and grouped.
"call 89001234567",
"+7 900 123 45 67",
"8 (900) 123-45-67",
"my number is 1234567",
}
for _, body := range forbidden {
if err := Clean(body); !errors.Is(err, ErrForbiddenContent) {
t.Errorf("Clean(%q) = %v, want ErrForbiddenContent", body, err)
}
}
}
+234
View File
@@ -0,0 +1,234 @@
package social
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Friendship statuses persisted in friendships.status.
const (
friendPending = "pending"
friendAccepted = "accepted"
)
// SendFriendRequest records a pending friend request from requesterID to
// addresseeID. It refuses a self-request, a request blocked by either a per-user
// block or the addressee's block_friend_requests toggle, and a duplicate of an
// existing request or friendship in either direction.
func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
if requesterID == addresseeID {
return ErrSelfRelation
}
blocked, err := svc.store.isBlocked(ctx, requesterID, addresseeID)
if err != nil {
return err
}
addressee, err := svc.accounts.GetByID(ctx, addresseeID)
if err != nil {
if errors.Is(err, account.ErrNotFound) {
return account.ErrNotFound
}
return err
}
if blocked || addressee.BlockFriendRequests {
return ErrRequestBlocked
}
exists, err := svc.store.friendshipExists(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if exists {
return ErrRequestExists
}
if err := svc.store.insertFriendRequest(ctx, requesterID, addresseeID); err != nil {
if isUniqueViolation(err) {
return ErrRequestExists
}
return err
}
return nil
}
// RespondFriendRequest lets addresseeID accept or decline the pending request
// from requesterID. Accepting flips it to a friendship; declining deletes it.
// Either way ErrRequestNotFound is returned when no pending request matches.
func (svc *Service) RespondFriendRequest(ctx context.Context, addresseeID, requesterID uuid.UUID, accept bool) error {
var ok bool
var err error
if accept {
ok, err = svc.store.acceptFriendRequest(ctx, requesterID, addresseeID, svc.now())
} else {
ok, err = svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
}
if err != nil {
return err
}
if !ok {
return ErrRequestNotFound
}
return nil
}
// CancelFriendRequest withdraws requesterID's own pending request to addresseeID.
func (svc *Service) CancelFriendRequest(ctx context.Context, requesterID, addresseeID uuid.UUID) error {
ok, err := svc.store.deletePendingRequest(ctx, requesterID, addresseeID)
if err != nil {
return err
}
if !ok {
return ErrRequestNotFound
}
return nil
}
// Unfriend removes the friendship between the two accounts, in either direction.
// It is idempotent: removing a non-existent friendship is not an error.
func (svc *Service) Unfriend(ctx context.Context, accountID, otherID uuid.UUID) error {
return svc.store.deleteFriendship(ctx, accountID, otherID)
}
// ListFriends returns the account IDs that are accepted friends of accountID.
func (svc *Service) ListFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listFriends(ctx, accountID)
}
// ListIncomingRequests returns the account IDs that have a pending friend request
// awaiting accountID's response.
func (svc *Service) ListIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
return svc.store.listIncomingRequests(ctx, accountID)
}
// friendshipExists reports whether any friendship row (pending or accepted) exists
// between a and b in either direction.
func (s *Store) friendshipExists(ctx context.Context, a, b uuid.UUID) (bool, error) {
stmt := postgres.SELECT(table.Friendships.Status).
FROM(table.Friendships).
WHERE(edgeEither(a, b)).
LIMIT(1)
var row model.Friendships
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return false, nil
}
return false, fmt.Errorf("social: friendship exists: %w", err)
}
return true, nil
}
// insertFriendRequest inserts a pending request from requester to addressee.
func (s *Store) insertFriendRequest(ctx context.Context, requester, addressee uuid.UUID) error {
stmt := table.Friendships.INSERT(
table.Friendships.RequesterID, table.Friendships.AddresseeID, table.Friendships.Status,
).VALUES(requester, addressee, friendPending)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: insert friend request: %w", err)
}
return nil
}
// acceptFriendRequest flips a pending request to accepted and reports whether a
// row matched.
func (s *Store) acceptFriendRequest(ctx context.Context, requester, addressee uuid.UUID, now time.Time) (bool, error) {
stmt := table.Friendships.
UPDATE(table.Friendships.Status, table.Friendships.RespondedAt).
SET(postgres.String(friendAccepted), postgres.TimestampzT(now)).
WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: accept friend request")
}
// deletePendingRequest removes a pending request and reports whether a row matched.
func (s *Store) deletePendingRequest(ctx context.Context, requester, addressee uuid.UUID) (bool, error) {
stmt := table.Friendships.DELETE().WHERE(
table.Friendships.RequesterID.EQ(postgres.UUID(requester)).
AND(table.Friendships.AddresseeID.EQ(postgres.UUID(addressee))).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
return execAffected(ctx, s.db, stmt, "social: delete friend request")
}
// deleteFriendship removes an accepted friendship in either direction.
func (s *Store) deleteFriendship(ctx context.Context, a, b uuid.UUID) error {
stmt := table.Friendships.DELETE().WHERE(
edgeEither(a, b).AND(table.Friendships.Status.EQ(postgres.String(friendAccepted))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("social: delete friendship: %w", err)
}
return nil
}
// listFriends returns the other side of every accepted edge touching accountID.
func (s *Store) listFriends(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID, table.Friendships.AddresseeID).
FROM(table.Friendships).
WHERE(
table.Friendships.Status.EQ(postgres.String(friendAccepted)).
AND(table.Friendships.RequesterID.EQ(postgres.UUID(accountID)).
OR(table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list friends: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
if r.RequesterID == accountID {
out = append(out, r.AddresseeID)
} else {
out = append(out, r.RequesterID)
}
}
return out, nil
}
// listIncomingRequests returns the requesters of every pending request to accountID.
func (s *Store) listIncomingRequests(ctx context.Context, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.Friendships.RequesterID).
FROM(table.Friendships).
WHERE(
table.Friendships.AddresseeID.EQ(postgres.UUID(accountID)).
AND(table.Friendships.Status.EQ(postgres.String(friendPending))),
)
var rows []model.Friendships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("social: list incoming requests: %w", err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.RequesterID)
}
return out, nil
}
// edgeEither matches a friendship row between a and b in either direction.
func edgeEither(a, b uuid.UUID) postgres.BoolExpression {
return table.Friendships.RequesterID.EQ(postgres.UUID(a)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(b))).
OR(table.Friendships.RequesterID.EQ(postgres.UUID(b)).AND(table.Friendships.AddresseeID.EQ(postgres.UUID(a))))
}
// execAffected runs a mutating statement and reports whether it changed a row.
func execAffected(ctx context.Context, db qrm.Executable, stmt postgres.Statement, what string) (bool, error) {
res, err := stmt.ExecContext(ctx, db)
if err != nil {
return false, fmt.Errorf("%s: %w", what, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("%s rows: %w", what, err)
}
return n > 0, nil
}
+75
View File
@@ -0,0 +1,75 @@
// Package social owns the player-facing social fabric around games: the friend
// graph (request/accept), per-user blocks, and per-game chat with nudges folded
// in as a message kind. It owns the friendships, blocks and chat_messages tables,
// reads the account-level block toggles through account.Store, and gates chat and
// nudge on game state through a GameReader so it never imports the engine. The
// live delivery of chat and nudges (push / in-app stream) belongs to the gateway
// in a later stage; this package only persists and reads them.
package social
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
// GameReader is the slice of the game domain the social package needs: the seated
// accounts in seat order, the seat index whose turn it is, and the game status.
// game.Service satisfies it, so chat and nudge gate on game state without a
// dependency on the engine or the game's private state.
type GameReader interface {
Participants(ctx context.Context, gameID uuid.UUID) (seats []uuid.UUID, toMove int, status string, err error)
}
// Sentinel errors returned by the service.
var (
// ErrSelfRelation is returned when an account targets itself.
ErrSelfRelation = errors.New("social: cannot target yourself")
// ErrRequestExists is returned when a friend request or friendship already
// exists between the two accounts (in either direction).
ErrRequestExists = errors.New("social: a friend request or friendship already exists")
// ErrRequestBlocked is returned when the addressee does not accept friend
// requests (their global toggle) or a block stands between the two accounts.
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
// ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNotParticipant is returned when an account is not seated in the game.
ErrNotParticipant = errors.New("social: account is not a player in this game")
// ErrChatBlocked is returned when the sender has disabled chat for themselves.
ErrChatBlocked = errors.New("social: chat is disabled for this account")
// ErrMessageTooLong is returned when a chat message exceeds the rune limit.
ErrMessageTooLong = errors.New("social: message exceeds the length limit")
// ErrEmptyMessage is returned when a chat message is blank after trimming.
ErrEmptyMessage = errors.New("social: message is empty")
// ErrNudgeOnOwnTurn is returned when a player tries to nudge while it is their
// own turn (there is no awaited opponent to nudge).
ErrNudgeOnOwnTurn = errors.New("social: cannot nudge while it is your turn")
// ErrNudgeTooSoon is returned when the per-game once-per-hour nudge limit is hit.
ErrNudgeTooSoon = errors.New("social: a nudge was already sent in the last hour")
// ErrGameNotActive is returned when a nudge is attempted on a finished game.
ErrGameNotActive = errors.New("social: game is not active")
)
// Service is the social domain. It is the only writer of the friendships, blocks
// and chat_messages tables and is safe for concurrent use.
type Service struct {
store *Store
accounts *account.Store
games GameReader
now func() time.Time
}
// NewService constructs a Service. store owns the social tables; accounts supplies
// the block toggles; games gates chat and nudge on game state.
func NewService(store *Store, accounts *account.Store, games GameReader) *Service {
return &Service{
store: store,
accounts: accounts,
games: games,
now: func() time.Time { return time.Now().UTC() },
}
}
+47
View File
@@ -0,0 +1,47 @@
package social
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
const uniqueViolation = "23505"
// Store is the Postgres-backed query surface for the friend graph, per-user
// blocks and per-game chat.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
// violation, used to collapse a request/insert race into a friendly error.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
+61 -21
View File
@@ -87,8 +87,13 @@ arrive from a platform rather than completing a mandatory registration).
a platform auto-provisions a durable account bound to that platform identity.
Concretely, platform and email identities share one `identities` table keyed by
a unique `(kind, external_id)`; email is an identity with `kind=email` and a
`confirmed` flag (the confirm-code flow lands later). Accounts and identities
use application-generated **UUIDv7** primary keys.
`confirmed` flag. The **email confirm-code flow** (Stage 4) binds an email to the
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. An email already confirmed by **another** account is
refused — adopting it would be a merge, which Stage 10 owns. Accounts and
identities use application-generated **UUIDv7** primary keys.
- **Linking** is initiated from an authenticated profile: choose a platform →
complete that platform's web-auth confirm → attach the identity to the
current account.
@@ -162,10 +167,16 @@ Key points:
timed out while asleep.
- **Players**: auto-match is always 2 players; friend games are 24 players.
`backend` owns turn order and the bag for any player count. A resignation or
timeout in a two-player game ends it with the other player winning; **richer
multi-player drop-out (a leaver's seat skipped while the rest play on, with a
per-game disposition of their tiles) is deferred to Stage 4**, when friend games
are formed.
timeout in a two-player game ends it with the other player winning. In a game
with **three or more seats** a resignation or timeout **drops that seat and the
rest play on** — the engine skips the resigned seat in the turn rotation and
excludes it from the win, finishing the game (the sole survivor wins) only once
one active seat remains, or by the ordinary end conditions among the active
seats. A per-game **drop-out tile disposition**, chosen at creation
(`dropout_tiles`: `remove` from play — the default — or `return` to the bag),
governs the leaver's rack, which is **never revealed** to the remaining players;
it is recorded for deterministic journal replay. (Two-player games end on the
first drop-out, so the disposition does not affect them.)
- **Hint**: governed by two per-game settings — whether hints are allowed and the
starting per-player allowance — plus a per-account hint **wallet**
(`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
@@ -197,17 +208,39 @@ within 10 seconds. Designed to be indistinguishable from a person.
## 8. Lobby & social
- **Matchmaking** *(detail planned)*: a FIFO pool keyed by `(variant,
language)`; 10 s with no human match → substitute the robot.
- **Friends**: add by friend list, internal ID, or platform deep-link.
- **Block** settings independently suppress in-game chat and friend requests.
- **Chat**: per-game, persisted, length-limited, suppressed by the block
setting.
- **Nudge**: a player may nudge the opponent whose turn is awaited once per
hour; the opponent receives a platform-native notification.
- **Profile**: `preferred_language` (en/ru), display name, linked platform
accounts, email (confirm-code binding), **timezone** (drives robot sleep;
default from platform/locale, user-editable), block toggles.
- **Matchmaking**: an **in-memory** FIFO pool keyed by `variant` (the variant
fixes the board language), pairing the next two humans into a two-player
auto-match with the seat order randomised for first-move fairness. The pool is
lost on restart (players re-queue) and is anonymous, so it does not consult
blocks. The 10 s wait and the **robot substitution** for a missing human are
added in Stage 5.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by
friend list or internal ID now, by platform deep-link with Stage 8. Declining or
cancelling removes the pending request; blocking someone severs an existing
friendship.
- **Block**: two independent **global** account toggles (`block_chat`,
`block_friend_requests`) **plus** a **per-user block list**. A per-user block is
applied mutually: it hides the pair's chat from each other and refuses friend
requests and game invitations between them.
- **Friend games**: formed by **invitation → accept** (an `game_invitations`
record with one row per invitee). The 24 player game starts once **every**
invitee accepts; any decline cancels the invitation, and a pending invitation
expires after 7 days (enforced lazily on access).
- **Chat**: per-game, persisted (kept with the game's archive), **≤ 60 runes**,
and **validated on input** — links, email addresses and phone numbers (including
lightly obfuscated forms) are rejected, since the chat is for quick reactions,
not contact exchange. Each message stores the sender's IP (forwarded by the
gateway in Stage 6) for moderation. A sender who has disabled chat cannot post,
and messages from a blocked sender are hidden from the viewer.
- **Nudge**: folded into the chat as a `nudge` message kind. The player awaiting
the opponent may nudge **once per hour per game**; it is not allowed on one's own
turn. The platform-native delivery is wired with the gateway / platform
side-service (Stage 6 / 8).
- **Profile**: `preferred_language` (en/ru), display name, email
(confirm-code binding, see §4), **timezone** (drives the away window and the
robot's sleep; user-editable), the daily **away window** and the block toggles —
all editable through `account.UpdateProfile`. Linked platform accounts and merge
are Stage 10.
## 9. Persistence
@@ -220,9 +253,14 @@ within 10 seconds. Designed to be indistinguishable from a person.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`),
`identities` (platform/email identities, unique `(kind, external_id)`),
`sessions` (revoke-only opaque-token hashes), and the Stage 3 game tables
`games`, `game_players`, `game_moves` (the move journal), `complaints` and
`account_stats`.
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
`games` (Stage 4 added the `dropout_tiles` disposition column), `game_players`,
`game_moves` (the move journal), `complaints` and `account_stats`, and the
Stage 4 social/lobby tables `friendships` (the request/accept graph), `blocks`
(per-user blocks), `chat_messages` (per-game chat and nudges), `email_confirmations`
(pending confirm-codes) and `game_invitations` / `game_invitation_invitees`
(friend-game invitations). The matchmaking pool is **in-memory** and persists
nothing.
- **Active games are event-sourced.** A game is a `games` row (pinned
`variant`/`dict_version`, bag `seed`, the per-game settings, and a denormalised
turn cursor) plus an append-only, decoded move journal (`game_moves`); the live
@@ -263,7 +301,9 @@ does not cover.
Two channels: **platform-native push** (out-of-app, via the platform
side-service — your-turn, nudge) and the **in-app live stream** (chat,
opponent-moved, while the app is open). Backend emits notification intents;
delivery fans out to the appropriate channel.
delivery fans out to the appropriate channel. Stage 4 **persists** the
notification-worthy events (chat messages and nudges) but does not yet deliver
them: the gRPC stream to the gateway and the platform push arrive in Stage 6 / 8.
## 11. Observability
+26 -10
View File
@@ -23,9 +23,13 @@ linking an identity that already has history merges it into the current
account (stats summed, games/friends transferred).
### Lobby & matchmaking *(Stage 4)*
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins
a `(variant, language)` pool; after 10 s with no human, the robot substitutes.
Friend games (24) are formed by friend list, internal ID, or deep-link.
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a
per-variant pool and is paired with the next waiting human; after 10 s with no
human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
formed by inviting players from the friend list or by internal ID (deep-link
invites arrive with the platform integration): the inviter chooses the settings
and the game starts once every invitee has accepted — any decline cancels it, and
an unanswered invitation expires after seven days.
### Playing a game *(Stage 3)*
Place tiles, pass, exchange, or resign. A play is validated against the game's
@@ -37,9 +41,12 @@ personal hint wallet once the per-game allowance is spent. The game ends when th
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
24 hours): a missed turn auto-resigns, except while the player is inside their
daily away window. A resignation or timeout gives the win to the other player and
the leaver keeps their score (two-player games; multi-player drop-out-and-continue
arrives with the lobby in Stage 4).
daily away window. In a two-player game a resignation or timeout gives the win to
the other player and the leaver keeps their score. In a game with three or four
players the leaver's seat is dropped and the others play on, the game ending when a
single active player remains; the disposition of the leaver's tiles (returned to
the bag or removed from play) is chosen when the game is created, and the leaver's
rack is never shown to the others.
### Robot opponent *(Stage 5)*
Indistinguishable-from-human substitute in auto-match. Decides once whether to
@@ -47,12 +54,21 @@ play to win (~40%), targets a small score margin, plays with human-like timing
and a night sleep window, and nudges/answers nudges like a person.
### Social: friends, block, chat, nudge *(Stage 4)*
Add friends; block chat and/or friend requests independently; per-game chat;
nudge the awaited opponent at most once per hour (platform-native push).
Send a friend request and have it accepted (decline or cancel withdraws it,
unfriending removes the friendship). Block globally — switch off incoming chat
and/or friend requests — and block individual players (a per-user block hides that
person's chat and stops requests and game invitations both ways; it also ends any
existing friendship). Per-game chat is for quick reactions: messages are short
(up to 60 characters) and may not contain links, email addresses or phone numbers,
even disguised. Nudge the player whose turn is awaited at most once per hour (the
nudge is part of the game chat); the out-of-app push is delivered via the platform.
### Profile & settings *(Stage 4)*
Language (en/ru), display name, linked accounts, email binding, timezone, block
toggles.
Edit language (en/ru), display name, timezone, the daily away window and the block
toggles, and bind an email by confirm-code: the backend emails a short code that,
once entered, attaches the email to the account (an email already confirmed by
another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 10.
### History & statistics *(Stage 3)*
Finished games are archived in a dictionary-independent form and exportable to
+26 -11
View File
@@ -23,9 +23,12 @@ session-токен; backend сопоставляет его с внутренн
### Лобби и подбор *(Stage 4)*
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
встаёт в пул по `(вариант, язык)`; через 10 с без человека подставляется
робот. Игры с друзьями (2–4) формируются по списку друзей, внутреннему ID
или deep-link.
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей или по внутреннему ID
(приглашения по deep-link появятся с платформенной интеграцией): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой
отказ отменяет приглашение, а без ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)*
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
@@ -37,9 +40,12 @@ session-токен; backend сопоставляет его с внутренн
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
суточного окна отсутствия (away). Сдача или таймаут отдают победу другому игроку,
а вышедший сохраняет свои очки (партии на двоих; выход одного с продолжением для
остальных появится вместе с лобби в Stage 4).
суточного окна отсутствия (away). В партии на двоих сдача или таймаут отдают
победу другому игроку, а вышедший сохраняет свои очки. В партии на троих-четверых
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
убрать из игры) выбирается при создании партии, а его стойка никогда не
показывается остальным.
### Робот-соперник *(Stage 5)*
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
@@ -47,13 +53,22 @@ session-токен; backend сопоставляет его с внутренн
таймингом и ночным сном, делает и принимает nudge как человек.
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
Добавление в друзья; независимая блокировка чата и/или заявок в друзья;
чат в рамках партии; nudge ожидаемого соперника не чаще раза в час
(платформенное уведомление).
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
расторгает дружбу). Глобальная блокировка — отключить входящие чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу.
### Профиль и настройки *(Stage 4)*
Язык (en/ru), отображаемое имя, привязанные аккаунты, привязка email, таймзона,
переключатели блокировок.
Редактирование языка (en/ru), отображаемого имени, таймзоны, суточного окна
отсутствия (away) и переключателей блокировок, а также привязка email по
confirm-коду: backend шлёт на почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 10.
### История и статистика *(Stage 3)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
+14 -1
View File
@@ -33,7 +33,20 @@ tests or touching CI.
end, **journal-replay equivalence**, the turn-timeout sweep with away-window
grace, resign win/loss and statistics, the hint allowance-then-wallet policy,
word-check and complaint capture, and per-game-lock serialisation). The robot
balance/margin regression tests arrive with Stage 5.
balance/margin regression tests arrive with Stage 5. Stage 4 adds the engine's
**multi-player drop-out** cases (continue after one resign, last-survivor win,
the tile-disposition bag effect) and a domain integration test for a 3-player
**timeout that continues**.
- **Social & lobby** *(Stage 4+)*`backend/internal/social` unit-tests the chat
**content filter** (links/emails/phones plus obfuscated forms) and
`backend/internal/lobby` unit-tests the in-memory **matchmaker** (FIFO pairing,
cancel, per-variant pools) with a fake game creator. Postgres-backed `inttest`
covers the friend request/accept lifecycle with the block/toggle guards, the
per-user block (and its severing of friendships), chat post/list with the IP,
content and block-visibility rules, the nudge turn/rate-limit rules, the
invitation flow (all-accept starts the game, decline cancels, lazy expiry,
inviter-only cancel), and the email confirm-code flow (request/confirm, taken
email, expiry and attempt-cap) with a fixture mailer.
## Principles
+2
View File
@@ -71,4 +71,6 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=