diff --git a/PLAN.md b/PLAN.md index 2e80fc4..20d6949 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 1–3: **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, diff --git a/backend/README.md b/backend/README.md index 46abaae..f5c917d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,10 +29,25 @@ that auto-resigns overdue turns (honouring each player's daily away window). Lik Stages 1–2 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 2–4 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 3–4 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 diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index 94f7340..c31ed6b 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -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 diff --git a/backend/go.mod b/backend/go.mod index d92f02b..131119d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go new file mode 100644 index 0000000..975354d --- /dev/null +++ b/backend/internal/account/email.go @@ -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[:]) +} diff --git a/backend/internal/account/email_test.go b/backend/internal/account/email_test.go new file mode 100644 index 0000000..46c3c0a --- /dev/null +++ b/backend/internal/account/email_test.go @@ -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", 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) + } +} diff --git a/backend/internal/account/mailer.go b/backend/internal/account/mailer.go new file mode 100644 index 0000000..b8bfce8 --- /dev/null +++ b/backend/internal/account/mailer.go @@ -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 +} diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go new file mode 100644 index 0000000..1a9fde0 --- /dev/null +++ b/backend/internal/account/profile.go @@ -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 +} diff --git a/backend/internal/account/profile_test.go b/backend/internal/account/profile_test.go new file mode 100644 index 0000000..b3b03e3 --- /dev/null +++ b/backend/internal/account/profile_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ed23bfe..a9f6639 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 diff --git a/backend/internal/engine/engine.go b/backend/internal/engine/engine.go index 65ce22f..2d35976 100644 --- a/backend/internal/engine/engine.go +++ b/backend/internal/engine/engine.go @@ -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") diff --git a/backend/internal/engine/game.go b/backend/internal/engine/game.go index 78609a7..58652f5 100644 --- a/backend/internal/engine/game.go +++ b/backend/internal/engine/game.go @@ -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 { diff --git a/backend/internal/engine/resign_test.go b/backend/internal/engine/resign_test.go index a89f2df..1df334a 100644 --- a/backend/internal/engine/resign_test.go +++ b/backend/internal/engine/resign_test.go @@ -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()) + } +} diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 891fd27..e601a72 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -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 diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 49919f6..555a45e 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -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, diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index 86b7000..25b5ff7 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -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 diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go new file mode 100644 index 0000000..7cdd159 --- /dev/null +++ b/backend/internal/inttest/email_test.go @@ -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) + } +} diff --git a/backend/internal/inttest/lobby_test.go b/backend/internal/inttest/lobby_test.go new file mode 100644 index 0000000..b6c9ed7 --- /dev/null +++ b/backend/internal/inttest/lobby_test.go @@ -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) + } +} diff --git a/backend/internal/inttest/multiplayer_test.go b/backend/internal/inttest/multiplayer_test.go new file mode 100644 index 0000000..d77e126 --- /dev/null +++ b/backend/internal/inttest/multiplayer_test.go @@ -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) + } +} diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go new file mode 100644 index 0000000..2feb792 --- /dev/null +++ b/backend/internal/inttest/social_test.go @@ -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) + } +} diff --git a/backend/internal/lobby/invitations.go b/backend/internal/lobby/invitations.go new file mode 100644 index 0000000..92db720 --- /dev/null +++ b/backend/internal/lobby/invitations.go @@ -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 +} diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go new file mode 100644 index 0000000..5258608 --- /dev/null +++ b/backend/internal/lobby/lobby.go @@ -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") +) diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go new file mode 100644 index 0000000..44dcb4b --- /dev/null +++ b/backend/internal/lobby/matchmaker.go @@ -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]) +} diff --git a/backend/internal/lobby/matchmaker_test.go b/backend/internal/lobby/matchmaker_test.go new file mode 100644 index 0000000..67a0627 --- /dev/null +++ b/backend/internal/lobby/matchmaker_test.go @@ -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)) + } +} diff --git a/backend/internal/lobby/store.go b/backend/internal/lobby/store.go new file mode 100644 index 0000000..f1ee555 --- /dev/null +++ b/backend/internal/lobby/store.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/blocks.go b/backend/internal/postgres/jet/backend/model/blocks.go new file mode 100644 index 0000000..46a49ee --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/blocks.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/chat_messages.go b/backend/internal/postgres/jet/backend/model/chat_messages.go new file mode 100644 index 0000000..b909695 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/chat_messages.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/email_confirmations.go b/backend/internal/postgres/jet/backend/model/email_confirmations.go new file mode 100644 index 0000000..d0bf3c9 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/email_confirmations.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/friendships.go b/backend/internal/postgres/jet/backend/model/friendships.go new file mode 100644 index 0000000..85f3cff --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/friendships.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/game_invitation_invitees.go b/backend/internal/postgres/jet/backend/model/game_invitation_invitees.go new file mode 100644 index 0000000..4b02cab --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_invitation_invitees.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/game_invitations.go b/backend/internal/postgres/jet/backend/model/game_invitations.go new file mode 100644 index 0000000..d031bbe --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/game_invitations.go @@ -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 +} diff --git a/backend/internal/postgres/jet/backend/model/games.go b/backend/internal/postgres/jet/backend/model/games.go index d578fca..cd84aed 100644 --- a/backend/internal/postgres/jet/backend/model/games.go +++ b/backend/internal/postgres/jet/backend/model/games.go @@ -29,4 +29,5 @@ type Games struct { CreatedAt time.Time UpdatedAt time.Time FinishedAt *time.Time + DropoutTiles string } diff --git a/backend/internal/postgres/jet/backend/table/blocks.go b/backend/internal/postgres/jet/backend/table/blocks.go new file mode 100644 index 0000000..3db29ed --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/blocks.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/chat_messages.go b/backend/internal/postgres/jet/backend/table/chat_messages.go new file mode 100644 index 0000000..97ab351 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/chat_messages.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/email_confirmations.go b/backend/internal/postgres/jet/backend/table/email_confirmations.go new file mode 100644 index 0000000..0ddfd0c --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/email_confirmations.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/friendships.go b/backend/internal/postgres/jet/backend/table/friendships.go new file mode 100644 index 0000000..b55cef8 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/friendships.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/game_invitation_invitees.go b/backend/internal/postgres/jet/backend/table/game_invitation_invitees.go new file mode 100644 index 0000000..c4b2b7a --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_invitation_invitees.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/game_invitations.go b/backend/internal/postgres/jet/backend/table/game_invitations.go new file mode 100644 index 0000000..190edbf --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/game_invitations.go @@ -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, + } +} diff --git a/backend/internal/postgres/jet/backend/table/games.go b/backend/internal/postgres/jet/backend/table/games.go index ab90ff5..cbd44ae 100644 --- a/backend/internal/postgres/jet/backend/table/games.go +++ b/backend/internal/postgres/jet/backend/table/games.go @@ -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, diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index 6ab0813..ebac108 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -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) diff --git a/backend/internal/postgres/migrations/00003_social.sql b/backend/internal/postgres/migrations/00003_social.sql new file mode 100644 index 0000000..1a7fa5e --- /dev/null +++ b/backend/internal/postgres/migrations/00003_social.sql @@ -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; diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index bae764d..1637d21 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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. diff --git a/backend/internal/social/blocks.go b/backend/internal/social/blocks.go new file mode 100644 index 0000000..4bd4417 --- /dev/null +++ b/backend/internal/social/blocks.go @@ -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 +} diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go new file mode 100644 index 0000000..9fda59b --- /dev/null +++ b/backend/internal/social/chat.go @@ -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 +} diff --git a/backend/internal/social/chatfilter.go b/backend/internal/social/chatfilter.go new file mode 100644 index 0000000..1783e32 --- /dev/null +++ b/backend/internal/social/chatfilter.go @@ -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 +} diff --git a/backend/internal/social/chatfilter_test.go b/backend/internal/social/chatfilter_test.go new file mode 100644 index 0000000..576b4ba --- /dev/null +++ b/backend/internal/social/chatfilter_test.go @@ -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) + } + } +} diff --git a/backend/internal/social/friends.go b/backend/internal/social/friends.go new file mode 100644 index 0000000..9cdc9c5 --- /dev/null +++ b/backend/internal/social/friends.go @@ -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 +} diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go new file mode 100644 index 0000000..b8d648e --- /dev/null +++ b/backend/internal/social/social.go @@ -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() }, + } +} diff --git a/backend/internal/social/store.go b/backend/internal/social/store.go new file mode 100644 index 0000000..55683ca --- /dev/null +++ b/backend/internal/social/store.go @@ -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 +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cd595d7..f95bebf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 2–4 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 2–4 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 diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 57d98ae..4416e2b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 (2–4) 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 (2–4) 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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 146c0ae..a75b2e5 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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)* Завершённые партии архивируются в независимом от словаря виде и экспортируются diff --git a/docs/TESTING.md b/docs/TESTING.md index 3e6618a..e1de2d9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -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 diff --git a/go.work.sum b/go.work.sum index d7f32a4..4adfc57 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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=