Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged. New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer). Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
This commit is contained in:
@@ -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,
|
||||
|
||||
+23
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"lowercases", "User@Example.COM", "user@example.com", false},
|
||||
{"trims", " a@b.io ", "a@b.io", false},
|
||||
{"strips display name", "Jane Doe <jane@x.org>", "jane@x.org", false},
|
||||
{"empty", "", "", true},
|
||||
{"no at sign", "notanemail", "", true},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := normalizeEmail(tc.in)
|
||||
if tc.wantErr {
|
||||
if !errors.Is(err, ErrInvalidEmail) {
|
||||
t.Fatalf("err = %v, want ErrInvalidEmail", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("got %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateCodeFormat(t *testing.T) {
|
||||
sixDigits := regexp.MustCompile(`^\d{6}$`)
|
||||
for range 50 {
|
||||
code, hash, err := generateCode()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !sixDigits.MatchString(code) {
|
||||
t.Fatalf("code %q is not exactly six digits", code)
|
||||
}
|
||||
if hash != hashCode(code) {
|
||||
t.Errorf("returned hash does not match hashCode(%q)", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashCodeStable(t *testing.T) {
|
||||
if hashCode("123456") != hashCode("123456") {
|
||||
t.Fatal("hashCode is not deterministic")
|
||||
}
|
||||
if hashCode("123456") == hashCode("654321") {
|
||||
t.Fatal("distinct codes must not share a hash")
|
||||
}
|
||||
if got := len(hashCode("000000")); got != 64 {
|
||||
t.Errorf("hex SHA-256 length = %d, want 64", got)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -97,6 +97,9 @@ var (
|
||||
// ErrUnknownVersion is returned when no dictionary is registered for a
|
||||
// (variant, version) pair.
|
||||
ErrUnknownVersion = errors.New("engine: unknown dictionary version")
|
||||
// ErrUnknownDropoutTiles is returned by ParseDropoutTiles for a label that is
|
||||
// neither "remove" nor "return".
|
||||
ErrUnknownDropoutTiles = errors.New("engine: unknown drop-out tile disposition")
|
||||
// ErrIllegalPlay wraps a solver validation failure: off-board geometry, a
|
||||
// word absent from the dictionary, or a play that does not connect.
|
||||
ErrIllegalPlay = errors.New("engine: illegal play")
|
||||
|
||||
+102
-17
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/lobby"
|
||||
)
|
||||
|
||||
// newInvitationService builds an invitation service over the shared pool, starting
|
||||
// games through a real game service and reading blocks through a social service.
|
||||
func newInvitationService() *lobby.InvitationService {
|
||||
return lobby.NewInvitationService(lobby.NewStore(testDB), newGameService(), account.NewStore(testDB), newSocialService())
|
||||
}
|
||||
|
||||
func englishInvite() lobby.InvitationSettings {
|
||||
return lobby.InvitationSettings{
|
||||
Variant: engine.VariantEnglish,
|
||||
TurnTimeout: 24 * time.Hour,
|
||||
HintsAllowed: true,
|
||||
HintsPerPlayer: 1,
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchmakingPairsAndStartsGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mm := lobby.NewMatchmaker(newGameService())
|
||||
a, b := provisionAccount(t), provisionAccount(t)
|
||||
|
||||
r1, err := mm.Enqueue(ctx, a, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue a: %v", err)
|
||||
}
|
||||
if r1.Matched {
|
||||
t.Fatal("first enqueue must wait")
|
||||
}
|
||||
r2, err := mm.Enqueue(ctx, b, engine.VariantEnglish)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue b: %v", err)
|
||||
}
|
||||
if !r2.Matched {
|
||||
t.Fatal("second enqueue must match")
|
||||
}
|
||||
seats, _, status, err := newGameService().Participants(ctx, r2.Game.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != "active" || len(seats) != 2 {
|
||||
t.Fatalf("matched game state: status %q seats %v", status, seats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationAllAcceptStartsGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if inv.Status != "pending" || len(inv.Invitees) != 2 {
|
||||
t.Fatalf("unexpected invitation: %+v", inv)
|
||||
}
|
||||
|
||||
if got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); err != nil || got.Status != "pending" {
|
||||
t.Fatalf("first accept: status %q err %v", got.Status, err)
|
||||
}
|
||||
final, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true)
|
||||
if err != nil {
|
||||
t.Fatalf("second accept: %v", err)
|
||||
}
|
||||
if final.Status != "started" || final.GameID == nil {
|
||||
t.Fatalf("invitation not started: %+v", final)
|
||||
}
|
||||
seats, _, status, err := newGameService().Participants(ctx, *final.GameID)
|
||||
if err != nil {
|
||||
t.Fatalf("participants: %v", err)
|
||||
}
|
||||
if status != "active" || len(seats) != 3 || seats[0] != inviter {
|
||||
t.Fatalf("started game: status %q seats %v (inviter %s)", status, seats, inviter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationDeclineCancels(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
got, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], false)
|
||||
if err != nil {
|
||||
t.Fatalf("decline: %v", err)
|
||||
}
|
||||
if got.Status != "declined" || got.GameID != nil {
|
||||
t.Fatalf("after decline: %+v", got)
|
||||
}
|
||||
// A further response is refused.
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[1], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
|
||||
t.Fatalf("respond after decline = %v, want ErrInvitationNotPending", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationLazyExpiry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`UPDATE backend.game_invitations SET expires_at = now() - interval '1 minute' WHERE invitation_id = $1`, inv.ID); err != nil {
|
||||
t.Fatalf("backdate expiry: %v", err)
|
||||
}
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationExpired) {
|
||||
t.Fatalf("respond expired = %v, want ErrInvitationExpired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationBlockedInvitee(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
social := newSocialService()
|
||||
inviter := provisionAccount(t)
|
||||
invitee := provisionAccount(t)
|
||||
if err := social.Block(ctx, invitee, inviter); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite()); !errors.Is(err, lobby.ErrInvitationBlocked) {
|
||||
t.Fatalf("create blocked = %v, want ErrInvitationBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationCancelByInviter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newInvitationService()
|
||||
inviter := provisionAccount(t)
|
||||
invitees := []uuid.UUID{provisionAccount(t)}
|
||||
inv, err := svc.CreateInvitation(ctx, inviter, invitees, englishInvite())
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
// A non-inviter cannot cancel.
|
||||
if err := svc.CancelInvitation(ctx, inv.ID, invitees[0]); !errors.Is(err, lobby.ErrNotInviter) {
|
||||
t.Fatalf("stranger cancel = %v, want ErrNotInviter", err)
|
||||
}
|
||||
if err := svc.CancelInvitation(ctx, inv.ID, inviter); err != nil {
|
||||
t.Fatalf("inviter cancel: %v", err)
|
||||
}
|
||||
if _, err := svc.RespondInvitation(ctx, inv.ID, invitees[0], true); !errors.Is(err, lobby.ErrInvitationNotPending) {
|
||||
t.Fatalf("respond after cancel = %v, want ErrInvitationNotPending", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/game"
|
||||
)
|
||||
|
||||
// TestMultiplayerTimeoutContinues drives a three-player game through the domain:
|
||||
// the first timeout drops a seat but the game plays on, and the second leaves a
|
||||
// sole survivor who wins. Empty away windows make the timeouts deterministic.
|
||||
func TestMultiplayerTimeoutContinues(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newGameService()
|
||||
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t), provisionAccount(t)}
|
||||
for _, s := range seats {
|
||||
setAway(t, s, "UTC", "00:00", "00:00") // empty window → no away grace
|
||||
}
|
||||
g, err := svc.Create(ctx, game.CreateParams{
|
||||
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 42,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if g.Players != 3 {
|
||||
t.Fatalf("players = %d, want 3", g.Players)
|
||||
}
|
||||
|
||||
// Seat 0 (to move) goes overdue. It times out, but two seats remain, so the
|
||||
// game continues and the turn advances off the dropped seat.
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("first sweep: %v", err)
|
||||
}
|
||||
h, err := svc.History(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if h.Game.Status != game.StatusActive {
|
||||
t.Fatalf("a three-player game must continue after one timeout, status %q", h.Game.Status)
|
||||
}
|
||||
if h.Game.ToMove == 0 {
|
||||
t.Errorf("to-move should advance off the timed-out seat 0, got %d", h.Game.ToMove)
|
||||
}
|
||||
|
||||
// The next seat to move also times out, leaving a single active seat: the game
|
||||
// finishes and the sole survivor wins.
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
if _, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil {
|
||||
t.Fatalf("second sweep: %v", err)
|
||||
}
|
||||
h2, err := svc.History(ctx, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("history 2: %v", err)
|
||||
}
|
||||
if h2.Game.Status != game.StatusFinished || h2.Game.EndReason != "timeout" {
|
||||
t.Fatalf("game should finish on the second timeout: status %q reason %q", h2.Game.Status, h2.Game.EndReason)
|
||||
}
|
||||
winners := 0
|
||||
for _, s := range h2.Game.Seats {
|
||||
if s.IsWinner {
|
||||
winners++
|
||||
}
|
||||
}
|
||||
if winners != 1 {
|
||||
t.Errorf("want exactly one surviving winner, got %d", winners)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package lobby
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for friend-game invitations.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Blocks struct {
|
||||
BlockerID uuid.UUID `sql:"primary_key"`
|
||||
BlockedID uuid.UUID `sql:"primary_key"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ChatMessages struct {
|
||||
MessageID uuid.UUID `sql:"primary_key"`
|
||||
GameID uuid.UUID
|
||||
SenderID uuid.UUID
|
||||
Kind string
|
||||
Body string
|
||||
SenderIP *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type EmailConfirmations struct {
|
||||
ConfirmationID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
Email string
|
||||
CodeHash string
|
||||
ExpiresAt time.Time
|
||||
Attempts int16
|
||||
ConsumedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Friendships struct {
|
||||
RequesterID uuid.UUID `sql:"primary_key"`
|
||||
AddresseeID uuid.UUID `sql:"primary_key"`
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
RespondedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameInvitationInvitees struct {
|
||||
InvitationID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Seat int16
|
||||
Response string
|
||||
RespondedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GameInvitations struct {
|
||||
InvitationID uuid.UUID `sql:"primary_key"`
|
||||
InviterID uuid.UUID
|
||||
Variant string
|
||||
TurnTimeoutSecs int32
|
||||
HintsAllowed bool
|
||||
HintsPerPlayer int16
|
||||
DropoutTiles string
|
||||
Status string
|
||||
GameID *uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -29,4 +29,5 @@ type Games struct {
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
DropoutTiles string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Blocks = newBlocksTable("backend", "blocks", "")
|
||||
|
||||
type blocksTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
BlockerID postgres.ColumnString
|
||||
BlockedID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type BlocksTable struct {
|
||||
blocksTable
|
||||
|
||||
EXCLUDED blocksTable
|
||||
}
|
||||
|
||||
// AS creates new BlocksTable with assigned alias
|
||||
func (a BlocksTable) AS(alias string) *BlocksTable {
|
||||
return newBlocksTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new BlocksTable with assigned schema name
|
||||
func (a BlocksTable) FromSchema(schemaName string) *BlocksTable {
|
||||
return newBlocksTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new BlocksTable with assigned table prefix
|
||||
func (a BlocksTable) WithPrefix(prefix string) *BlocksTable {
|
||||
return newBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new BlocksTable with assigned table suffix
|
||||
func (a BlocksTable) WithSuffix(suffix string) *BlocksTable {
|
||||
return newBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newBlocksTable(schemaName, tableName, alias string) *BlocksTable {
|
||||
return &BlocksTable{
|
||||
blocksTable: newBlocksTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newBlocksTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newBlocksTableImpl(schemaName, tableName, alias string) blocksTable {
|
||||
var (
|
||||
BlockerIDColumn = postgres.StringColumn("blocker_id")
|
||||
BlockedIDColumn = postgres.StringColumn("blocked_id")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{BlockerIDColumn, BlockedIDColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return blocksTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
BlockerID: BlockerIDColumn,
|
||||
BlockedID: BlockedIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var ChatMessages = newChatMessagesTable("backend", "chat_messages", "")
|
||||
|
||||
type chatMessagesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
MessageID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
SenderID postgres.ColumnString
|
||||
Kind postgres.ColumnString
|
||||
Body postgres.ColumnString
|
||||
SenderIP postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ChatMessagesTable struct {
|
||||
chatMessagesTable
|
||||
|
||||
EXCLUDED chatMessagesTable
|
||||
}
|
||||
|
||||
// AS creates new ChatMessagesTable with assigned alias
|
||||
func (a ChatMessagesTable) AS(alias string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ChatMessagesTable with assigned schema name
|
||||
func (a ChatMessagesTable) FromSchema(schemaName string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ChatMessagesTable with assigned table prefix
|
||||
func (a ChatMessagesTable) WithPrefix(prefix string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ChatMessagesTable with assigned table suffix
|
||||
func (a ChatMessagesTable) WithSuffix(suffix string) *ChatMessagesTable {
|
||||
return newChatMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newChatMessagesTable(schemaName, tableName, alias string) *ChatMessagesTable {
|
||||
return &ChatMessagesTable{
|
||||
chatMessagesTable: newChatMessagesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newChatMessagesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newChatMessagesTableImpl(schemaName, tableName, alias string) chatMessagesTable {
|
||||
var (
|
||||
MessageIDColumn = postgres.StringColumn("message_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
SenderIDColumn = postgres.StringColumn("sender_id")
|
||||
KindColumn = postgres.StringColumn("kind")
|
||||
BodyColumn = postgres.StringColumn("body")
|
||||
SenderIPColumn = postgres.StringColumn("sender_ip")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, SenderIDColumn, KindColumn, BodyColumn, SenderIPColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{KindColumn, BodyColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return chatMessagesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
MessageID: MessageIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
SenderID: SenderIDColumn,
|
||||
Kind: KindColumn,
|
||||
Body: BodyColumn,
|
||||
SenderIP: SenderIPColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var EmailConfirmations = newEmailConfirmationsTable("backend", "email_confirmations", "")
|
||||
|
||||
type emailConfirmationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ConfirmationID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Email postgres.ColumnString
|
||||
CodeHash postgres.ColumnString
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
Attempts postgres.ColumnInteger
|
||||
ConsumedAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type EmailConfirmationsTable struct {
|
||||
emailConfirmationsTable
|
||||
|
||||
EXCLUDED emailConfirmationsTable
|
||||
}
|
||||
|
||||
// AS creates new EmailConfirmationsTable with assigned alias
|
||||
func (a EmailConfirmationsTable) AS(alias string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new EmailConfirmationsTable with assigned schema name
|
||||
func (a EmailConfirmationsTable) FromSchema(schemaName string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new EmailConfirmationsTable with assigned table prefix
|
||||
func (a EmailConfirmationsTable) WithPrefix(prefix string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new EmailConfirmationsTable with assigned table suffix
|
||||
func (a EmailConfirmationsTable) WithSuffix(suffix string) *EmailConfirmationsTable {
|
||||
return newEmailConfirmationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newEmailConfirmationsTable(schemaName, tableName, alias string) *EmailConfirmationsTable {
|
||||
return &EmailConfirmationsTable{
|
||||
emailConfirmationsTable: newEmailConfirmationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newEmailConfirmationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newEmailConfirmationsTableImpl(schemaName, tableName, alias string) emailConfirmationsTable {
|
||||
var (
|
||||
ConfirmationIDColumn = postgres.StringColumn("confirmation_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
EmailColumn = postgres.StringColumn("email")
|
||||
CodeHashColumn = postgres.StringColumn("code_hash")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
AttemptsColumn = postgres.IntegerColumn("attempts")
|
||||
ConsumedAtColumn = postgres.TimestampzColumn("consumed_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{ConfirmationIDColumn, AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, EmailColumn, CodeHashColumn, ExpiresAtColumn, AttemptsColumn, ConsumedAtColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{AttemptsColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return emailConfirmationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ConfirmationID: ConfirmationIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Email: EmailColumn,
|
||||
CodeHash: CodeHashColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
Attempts: AttemptsColumn,
|
||||
ConsumedAt: ConsumedAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Friendships = newFriendshipsTable("backend", "friendships", "")
|
||||
|
||||
type friendshipsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
RequesterID postgres.ColumnString
|
||||
AddresseeID postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
RespondedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type FriendshipsTable struct {
|
||||
friendshipsTable
|
||||
|
||||
EXCLUDED friendshipsTable
|
||||
}
|
||||
|
||||
// AS creates new FriendshipsTable with assigned alias
|
||||
func (a FriendshipsTable) AS(alias string) *FriendshipsTable {
|
||||
return newFriendshipsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new FriendshipsTable with assigned schema name
|
||||
func (a FriendshipsTable) FromSchema(schemaName string) *FriendshipsTable {
|
||||
return newFriendshipsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new FriendshipsTable with assigned table prefix
|
||||
func (a FriendshipsTable) WithPrefix(prefix string) *FriendshipsTable {
|
||||
return newFriendshipsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new FriendshipsTable with assigned table suffix
|
||||
func (a FriendshipsTable) WithSuffix(suffix string) *FriendshipsTable {
|
||||
return newFriendshipsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newFriendshipsTable(schemaName, tableName, alias string) *FriendshipsTable {
|
||||
return &FriendshipsTable{
|
||||
friendshipsTable: newFriendshipsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newFriendshipsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newFriendshipsTableImpl(schemaName, tableName, alias string) friendshipsTable {
|
||||
var (
|
||||
RequesterIDColumn = postgres.StringColumn("requester_id")
|
||||
AddresseeIDColumn = postgres.StringColumn("addressee_id")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
|
||||
allColumns = postgres.ColumnList{RequesterIDColumn, AddresseeIDColumn, StatusColumn, CreatedAtColumn, RespondedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, RespondedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return friendshipsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
RequesterID: RequesterIDColumn,
|
||||
AddresseeID: AddresseeIDColumn,
|
||||
Status: StatusColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
RespondedAt: RespondedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GameInvitationInvitees = newGameInvitationInviteesTable("backend", "game_invitation_invitees", "")
|
||||
|
||||
type gameInvitationInviteesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
InvitationID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Seat postgres.ColumnInteger
|
||||
Response postgres.ColumnString
|
||||
RespondedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GameInvitationInviteesTable struct {
|
||||
gameInvitationInviteesTable
|
||||
|
||||
EXCLUDED gameInvitationInviteesTable
|
||||
}
|
||||
|
||||
// AS creates new GameInvitationInviteesTable with assigned alias
|
||||
func (a GameInvitationInviteesTable) AS(alias string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GameInvitationInviteesTable with assigned schema name
|
||||
func (a GameInvitationInviteesTable) FromSchema(schemaName string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GameInvitationInviteesTable with assigned table prefix
|
||||
func (a GameInvitationInviteesTable) WithPrefix(prefix string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GameInvitationInviteesTable with assigned table suffix
|
||||
func (a GameInvitationInviteesTable) WithSuffix(suffix string) *GameInvitationInviteesTable {
|
||||
return newGameInvitationInviteesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGameInvitationInviteesTable(schemaName, tableName, alias string) *GameInvitationInviteesTable {
|
||||
return &GameInvitationInviteesTable{
|
||||
gameInvitationInviteesTable: newGameInvitationInviteesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGameInvitationInviteesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGameInvitationInviteesTableImpl(schemaName, tableName, alias string) gameInvitationInviteesTable {
|
||||
var (
|
||||
InvitationIDColumn = postgres.StringColumn("invitation_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
SeatColumn = postgres.IntegerColumn("seat")
|
||||
ResponseColumn = postgres.StringColumn("response")
|
||||
RespondedAtColumn = postgres.TimestampzColumn("responded_at")
|
||||
allColumns = postgres.ColumnList{InvitationIDColumn, AccountIDColumn, SeatColumn, ResponseColumn, RespondedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{SeatColumn, ResponseColumn, RespondedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ResponseColumn}
|
||||
)
|
||||
|
||||
return gameInvitationInviteesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
InvitationID: InvitationIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Seat: SeatColumn,
|
||||
Response: ResponseColumn,
|
||||
RespondedAt: RespondedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GameInvitations = newGameInvitationsTable("backend", "game_invitations", "")
|
||||
|
||||
type gameInvitationsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
InvitationID postgres.ColumnString
|
||||
InviterID postgres.ColumnString
|
||||
Variant postgres.ColumnString
|
||||
TurnTimeoutSecs postgres.ColumnInteger
|
||||
HintsAllowed postgres.ColumnBool
|
||||
HintsPerPlayer postgres.ColumnInteger
|
||||
DropoutTiles postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
ExpiresAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GameInvitationsTable struct {
|
||||
gameInvitationsTable
|
||||
|
||||
EXCLUDED gameInvitationsTable
|
||||
}
|
||||
|
||||
// AS creates new GameInvitationsTable with assigned alias
|
||||
func (a GameInvitationsTable) AS(alias string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GameInvitationsTable with assigned schema name
|
||||
func (a GameInvitationsTable) FromSchema(schemaName string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GameInvitationsTable with assigned table prefix
|
||||
func (a GameInvitationsTable) WithPrefix(prefix string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GameInvitationsTable with assigned table suffix
|
||||
func (a GameInvitationsTable) WithSuffix(suffix string) *GameInvitationsTable {
|
||||
return newGameInvitationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGameInvitationsTable(schemaName, tableName, alias string) *GameInvitationsTable {
|
||||
return &GameInvitationsTable{
|
||||
gameInvitationsTable: newGameInvitationsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGameInvitationsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGameInvitationsTableImpl(schemaName, tableName, alias string) gameInvitationsTable {
|
||||
var (
|
||||
InvitationIDColumn = postgres.StringColumn("invitation_id")
|
||||
InviterIDColumn = postgres.StringColumn("inviter_id")
|
||||
VariantColumn = postgres.StringColumn("variant")
|
||||
TurnTimeoutSecsColumn = postgres.IntegerColumn("turn_timeout_secs")
|
||||
HintsAllowedColumn = postgres.BoolColumn("hints_allowed")
|
||||
HintsPerPlayerColumn = postgres.IntegerColumn("hints_per_player")
|
||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
ExpiresAtColumn = postgres.TimestampzColumn("expires_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{InvitationIDColumn, InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{InviterIDColumn, VariantColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, GameIDColumn, ExpiresAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{HintsAllowedColumn, HintsPerPlayerColumn, DropoutTilesColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return gameInvitationsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
InvitationID: InvitationIDColumn,
|
||||
InviterID: InviterIDColumn,
|
||||
Variant: VariantColumn,
|
||||
TurnTimeoutSecs: TurnTimeoutSecsColumn,
|
||||
HintsAllowed: HintsAllowedColumn,
|
||||
HintsPerPlayer: HintsPerPlayerColumn,
|
||||
DropoutTiles: DropoutTilesColumn,
|
||||
Status: StatusColumn,
|
||||
GameID: GameIDColumn,
|
||||
ExpiresAt: ExpiresAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type gamesTable struct {
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
DropoutTiles postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -90,9 +91,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
|
||||
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, DropoutTilesColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn}
|
||||
)
|
||||
|
||||
return gamesTable{
|
||||
@@ -115,6 +117,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
FinishedAt: FinishedAtColumn,
|
||||
DropoutTiles: DropoutTilesColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -12,7 +12,13 @@ package table
|
||||
func UseSchema(schema string) {
|
||||
AccountStats = AccountStats.FromSchema(schema)
|
||||
Accounts = Accounts.FromSchema(schema)
|
||||
Blocks = Blocks.FromSchema(schema)
|
||||
ChatMessages = ChatMessages.FromSchema(schema)
|
||||
Complaints = Complaints.FromSchema(schema)
|
||||
EmailConfirmations = EmailConfirmations.FromSchema(schema)
|
||||
Friendships = Friendships.FromSchema(schema)
|
||||
GameInvitationInvitees = GameInvitationInvitees.FromSchema(schema)
|
||||
GameInvitations = GameInvitations.FromSchema(schema)
|
||||
GameMoves = GameMoves.FromSchema(schema)
|
||||
GamePlayers = GamePlayers.FromSchema(schema)
|
||||
Games = Games.FromSchema(schema)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
-- +goose Up
|
||||
-- Stage 4 lobby & social: the friend graph, per-user blocks, per-game chat (with
|
||||
-- nudge folded in as a message kind), email confirm-codes, and friend-game
|
||||
-- invitations -- plus the per-game drop-out tile disposition the multi-player
|
||||
-- engine needs. Matchmaking is an in-memory pool and persists nothing.
|
||||
SET search_path = backend, pg_catalog;
|
||||
|
||||
-- The disposition of a dropped-out player's tiles in a game with three or more
|
||||
-- seats (docs/ARCHITECTURE.md §6), chosen at creation: 'remove' burns them
|
||||
-- (default), 'return' puts them back in the bag. Moot for a two-player game,
|
||||
-- which ends on the first drop-out. engine.DropoutTiles owns the stable labels.
|
||||
ALTER TABLE games
|
||||
ADD COLUMN dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||
ADD CONSTRAINT games_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return'));
|
||||
|
||||
-- The friend graph. A row is created by the requester as 'pending' and flipped to
|
||||
-- 'accepted' by the addressee; declining, cancelling or unfriending deletes the
|
||||
-- row. Friendship is symmetric: a player's friends are the accepted rows in
|
||||
-- either direction. A pair has at most one row (guarded in Go against either
|
||||
-- direction existing).
|
||||
CREATE TABLE friendships (
|
||||
requester_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
addressee_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
responded_at timestamptz,
|
||||
PRIMARY KEY (requester_id, addressee_id),
|
||||
CONSTRAINT friendships_status_chk CHECK (status IN ('pending', 'accepted')),
|
||||
CONSTRAINT friendships_distinct_chk CHECK (requester_id <> addressee_id)
|
||||
);
|
||||
CREATE INDEX friendships_addressee_idx ON friendships (addressee_id);
|
||||
|
||||
-- Per-user blocks. blocker_id has blocked blocked_id; the effect is applied
|
||||
-- mutually by the social checks (a block in either direction suppresses chat
|
||||
-- visibility and prevents requests/invitations between the pair).
|
||||
CREATE TABLE blocks (
|
||||
blocker_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
blocked_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (blocker_id, blocked_id),
|
||||
CONSTRAINT blocks_distinct_chk CHECK (blocker_id <> blocked_id)
|
||||
);
|
||||
CREATE INDEX blocks_blocked_idx ON blocks (blocked_id);
|
||||
|
||||
-- Per-game chat. A nudge ("it's your move") is a kind='nudge' row with an empty
|
||||
-- body, so one journal carries both chatter and nudges. body is capped at 60
|
||||
-- runes (enforced again in Go on input, where the content filter also rejects
|
||||
-- links/emails/phone numbers). sender_ip holds the gateway-forwarded client IP as
|
||||
-- a validated string (text, not inet, to avoid go-jet literal friction; the
|
||||
-- gateway populates it in Stage 6). Chat is part of the game archive and is never
|
||||
-- purged; it cascades away only with its game.
|
||||
CREATE TABLE chat_messages (
|
||||
message_id uuid PRIMARY KEY,
|
||||
game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE,
|
||||
sender_id uuid NOT NULL REFERENCES accounts (account_id),
|
||||
kind text NOT NULL DEFAULT 'message',
|
||||
body text NOT NULL DEFAULT '',
|
||||
sender_ip text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT chat_messages_kind_chk CHECK (kind IN ('message', 'nudge')),
|
||||
CONSTRAINT chat_messages_body_len_chk CHECK (char_length(body) <= 60),
|
||||
CONSTRAINT chat_messages_nudge_empty_chk CHECK (kind <> 'nudge' OR body = '')
|
||||
);
|
||||
CREATE INDEX chat_messages_game_idx ON chat_messages (game_id, created_at);
|
||||
-- Backs the once-per-hour nudge rate-limit lookup (latest nudge by a sender).
|
||||
CREATE INDEX chat_messages_nudge_idx ON chat_messages (game_id, sender_id, created_at)
|
||||
WHERE kind = 'nudge';
|
||||
|
||||
-- Pending email confirm-codes. code_hash is the hex-encoded SHA-256 of the
|
||||
-- 6-digit code (the plaintext is never stored, matching the session model);
|
||||
-- expires_at bounds the TTL and attempts caps brute force. A row is consumed
|
||||
-- (consumed_at stamped) on success. A re-request deletes the prior pending row
|
||||
-- for the same (account, lowercased email) and inserts a fresh one.
|
||||
CREATE TABLE email_confirmations (
|
||||
confirmation_id uuid PRIMARY KEY,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
email text NOT NULL,
|
||||
code_hash text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
attempts smallint NOT NULL DEFAULT 0,
|
||||
consumed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT email_confirmations_attempts_chk CHECK (attempts >= 0)
|
||||
);
|
||||
CREATE INDEX email_confirmations_account_idx ON email_confirmations (account_id);
|
||||
|
||||
-- A friend-game invitation. The inviter (seat 0) proposes the game settings to
|
||||
-- 1..3 invitees; the game starts only when every invitee has accepted, and any
|
||||
-- decline cancels the whole invitation. Lazily expired after expires_at (no
|
||||
-- background sweep). game_id is set when the game is started.
|
||||
CREATE TABLE game_invitations (
|
||||
invitation_id uuid PRIMARY KEY,
|
||||
inviter_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
variant text NOT NULL,
|
||||
turn_timeout_secs integer NOT NULL,
|
||||
hints_allowed boolean NOT NULL DEFAULT true,
|
||||
hints_per_player smallint NOT NULL DEFAULT 1,
|
||||
dropout_tiles text NOT NULL DEFAULT 'remove',
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
game_id uuid REFERENCES games (game_id) ON DELETE SET NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT game_invitations_variant_chk CHECK (variant IN ('english', 'russian_scrabble', 'erudit')),
|
||||
CONSTRAINT game_invitations_dropout_tiles_chk CHECK (dropout_tiles IN ('remove', 'return')),
|
||||
CONSTRAINT game_invitations_status_chk CHECK (status IN ('pending', 'declined', 'cancelled', 'expired', 'started')),
|
||||
CONSTRAINT game_invitations_turn_timeout_chk CHECK (turn_timeout_secs > 0),
|
||||
CONSTRAINT game_invitations_hints_per_player_chk CHECK (hints_per_player >= 0)
|
||||
);
|
||||
CREATE INDEX game_invitations_inviter_idx ON game_invitations (inviter_id);
|
||||
|
||||
-- One row per invitee (the inviter is implicit seat 0). seat is the invitee's
|
||||
-- seat in the started game (1..3, in invitation order). response tracks each
|
||||
-- invitee's pending/accepted/declined decision.
|
||||
CREATE TABLE game_invitation_invitees (
|
||||
invitation_id uuid NOT NULL REFERENCES game_invitations (invitation_id) ON DELETE CASCADE,
|
||||
account_id uuid NOT NULL REFERENCES accounts (account_id) ON DELETE CASCADE,
|
||||
seat smallint NOT NULL,
|
||||
response text NOT NULL DEFAULT 'pending',
|
||||
responded_at timestamptz,
|
||||
PRIMARY KEY (invitation_id, account_id),
|
||||
CONSTRAINT game_invitation_invitees_response_chk CHECK (response IN ('pending', 'accepted', 'declined')),
|
||||
CONSTRAINT game_invitation_invitees_seat_chk CHECK (seat BETWEEN 1 AND 3)
|
||||
);
|
||||
CREATE INDEX game_invitation_invitees_account_idx ON game_invitation_invitees (account_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE game_invitation_invitees;
|
||||
DROP TABLE game_invitations;
|
||||
DROP TABLE email_confirmations;
|
||||
DROP TABLE chat_messages;
|
||||
DROP TABLE blocks;
|
||||
DROP TABLE friendships;
|
||||
ALTER TABLE games
|
||||
DROP CONSTRAINT games_dropout_tiles_chk,
|
||||
DROP COLUMN dropout_tiles;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mvdan.cc/xurls/v2"
|
||||
)
|
||||
|
||||
// ErrForbiddenContent is returned when a chat message contains a web link, email
|
||||
// address or phone number — including lightly obfuscated forms. The chat is for
|
||||
// quick in-game reactions, not for exchanging contact details.
|
||||
var ErrForbiddenContent = errors.New("social: message contains a link, email or phone number")
|
||||
|
||||
// phoneDigits is the minimum run of consecutive digits treated as a phone number.
|
||||
const phoneDigits = 7
|
||||
|
||||
// relaxedURL matches URLs, bare domains and email addresses (xurls relaxed mode).
|
||||
var relaxedURL = xurls.Relaxed()
|
||||
|
||||
// spelledSeparators rewrites the words people use to dodge a detector back into
|
||||
// their symbols, so "gmail dot com" and "user at host" are still caught.
|
||||
var spelledSeparators = strings.NewReplacer(
|
||||
" dot ", ".", "(dot)", ".", "[dot]", ".", " punto ", ".", " точка ", ".",
|
||||
" at ", "@", "(at)", "@", "[at]", "@", " собака ", "@", " собачка ", "@",
|
||||
)
|
||||
|
||||
// leet folds digits and symbols commonly substituted for letters, so "g00gl3.com"
|
||||
// is recognised. It leaves '.' and '@' untouched, since those carry the link and
|
||||
// email structure the matcher relies on.
|
||||
var leet = strings.NewReplacer(
|
||||
"0", "o", "1", "i", "3", "e", "4", "a", "5", "s", "7", "t", "8", "b",
|
||||
"$", "s", "!", "i", "|", "l",
|
||||
)
|
||||
|
||||
// spaceAroundPunct collapses whitespace around '.' and '@' so "gmail . com" and
|
||||
// "user @ host" close up into a detectable address.
|
||||
var spaceAroundPunct = regexp.MustCompile(`\s*([.@])\s*`)
|
||||
|
||||
// phoneSeparators are the characters stripped before counting a digit run, so a
|
||||
// grouped number like "8 (900) 123-45-67" collapses to a single run.
|
||||
var phoneSeparators = regexp.MustCompile(`[\s\-.()+]`)
|
||||
|
||||
// Clean reports whether body is free of links, emails and phone numbers, returning
|
||||
// ErrForbiddenContent (naming the category) otherwise. Detection is best-effort
|
||||
// over a short, rune-limited message: it folds common letter/digit obfuscation
|
||||
// and spelled-out separators before matching, but does not claim to defeat every
|
||||
// evasion.
|
||||
func Clean(body string) error {
|
||||
lower := strings.ToLower(body)
|
||||
if hasLinkOrEmail(lower) {
|
||||
return fmt.Errorf("%w: link or email", ErrForbiddenContent)
|
||||
}
|
||||
if hasPhone(lower) {
|
||||
return fmt.Errorf("%w: phone number", ErrForbiddenContent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasLinkOrEmail matches the lower-cased text both as written and after folding
|
||||
// spelled separators, spacing and leet substitutions.
|
||||
func hasLinkOrEmail(lower string) bool {
|
||||
if relaxedURL.MatchString(lower) {
|
||||
return true
|
||||
}
|
||||
deobfuscated := leet.Replace(spaceAroundPunct.ReplaceAllString(spelledSeparators.Replace(lower), "$1"))
|
||||
return relaxedURL.MatchString(deobfuscated)
|
||||
}
|
||||
|
||||
// hasPhone reports a run of phoneDigits or more digits once phone-style separators
|
||||
// are removed.
|
||||
func hasPhone(lower string) bool {
|
||||
stripped := phoneSeparators.ReplaceAllString(lower, "")
|
||||
run := 0
|
||||
for _, r := range stripped {
|
||||
if r >= '0' && r <= '9' {
|
||||
run++
|
||||
if run >= phoneDigits {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
run = 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCleanAllowsOrdinaryChat(t *testing.T) {
|
||||
clean := []string{
|
||||
"",
|
||||
"nice move!",
|
||||
"gg wp",
|
||||
"хороший ход, поздравляю",
|
||||
"unlucky draw this round",
|
||||
"I scored 42 points",
|
||||
"only 6 digits here: 123456",
|
||||
"see you at 5",
|
||||
"three vowels in my rack",
|
||||
"well played :)",
|
||||
}
|
||||
for _, body := range clean {
|
||||
if err := Clean(body); err != nil {
|
||||
t.Errorf("Clean(%q) = %v, want nil", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanRejectsLinksEmailsPhones(t *testing.T) {
|
||||
forbidden := []string{
|
||||
// Plain links and bare domains.
|
||||
"http://evil.example.com",
|
||||
"visit https://x.io/abc now",
|
||||
"join discord.gg/xyzqwer",
|
||||
"check scrabblecheat.com",
|
||||
// Emails, plain and obfuscated.
|
||||
"mail me a@b.com",
|
||||
"john at gmail dot com",
|
||||
"j0hn at gma1l dot c0m",
|
||||
// Obfuscated domains.
|
||||
"g00gle dot com",
|
||||
"site . com please",
|
||||
// Phone numbers, plain and grouped.
|
||||
"call 89001234567",
|
||||
"+7 900 123 45 67",
|
||||
"8 (900) 123-45-67",
|
||||
"my number is 1234567",
|
||||
}
|
||||
for _, body := range forbidden {
|
||||
if err := Clean(body); !errors.Is(err, ErrForbiddenContent) {
|
||||
t.Errorf("Clean(%q) = %v, want ErrForbiddenContent", body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package social
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// uniqueViolation is the PostgreSQL SQLSTATE for a unique-constraint violation.
|
||||
const uniqueViolation = "23505"
|
||||
|
||||
// Store is the Postgres-backed query surface for the friend graph, per-user
|
||||
// blocks and per-game chat.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint
|
||||
// violation, used to collapse a request/insert race into a friendly error.
|
||||
func isUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == uniqueViolation
|
||||
}
|
||||
|
||||
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
|
||||
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+61
-21
@@ -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
|
||||
|
||||
|
||||
+26
-10
@@ -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
|
||||
|
||||
+26
-11
@@ -23,9 +23,12 @@ session-токен; backend сопоставляет его с внутренн
|
||||
|
||||
### Лобби и подбор *(Stage 4)*
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
|
||||
встаёт в пул по `(вариант, язык)`; через 10 с без человека подставляется
|
||||
робот. Игры с друзьями (2–4) формируются по списку друзей, внутреннему ID
|
||||
или deep-link.
|
||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей или по внутреннему ID
|
||||
(приглашения по deep-link появятся с платформенной интеграцией): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой
|
||||
отказ отменяет приглашение, а без ответа приглашение протухает через семь дней.
|
||||
|
||||
### Игровой процесс *(Stage 3)*
|
||||
Выкладывание фишек, пас, обмен или сдача. Ход проверяется по словарю партии при
|
||||
@@ -37,9 +40,12 @@ session-токен; backend сопоставляет его с внутренн
|
||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||
суточного окна отсутствия (away). Сдача или таймаут отдают победу другому игроку,
|
||||
а вышедший сохраняет свои очки (партии на двоих; выход одного с продолжением для
|
||||
остальных появится вместе с лобби в Stage 4).
|
||||
суточного окна отсутствия (away). В партии на двоих сдача или таймаут отдают
|
||||
победу другому игроку, а вышедший сохраняет свои очки. В партии на троих-четверых
|
||||
место вышедшего убирается, остальные играют дальше, и партия завершается, когда
|
||||
остаётся один активный игрок; что делать с фишками вышедшего (вернуть в мешок или
|
||||
убрать из игры) выбирается при создании партии, а его стойка никогда не
|
||||
показывается остальным.
|
||||
|
||||
### Робот-соперник *(Stage 5)*
|
||||
Неотличимый от человека дублёр в авто-подборе. Один раз решает, играть ли на
|
||||
@@ -47,13 +53,22 @@ session-токен; backend сопоставляет его с внутренн
|
||||
таймингом и ночным сном, делает и принимает nudge как человек.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge *(Stage 4)*
|
||||
Добавление в друзья; независимая блокировка чата и/или заявок в друзья;
|
||||
чат в рамках партии; nudge ожидаемого соперника не чаще раза в час
|
||||
(платформенное уведомление).
|
||||
Заявка в друзья и её принятие (отклонение или отмена снимают заявку, удаление —
|
||||
расторгает дружбу). Глобальная блокировка — отключить входящие чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
партии — для быстрых реакций: сообщения короткие (до 60 символов) и не должны
|
||||
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
|
||||
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
|
||||
push доставляется через платформу.
|
||||
|
||||
### Профиль и настройки *(Stage 4)*
|
||||
Язык (en/ru), отображаемое имя, привязанные аккаунты, привязка email, таймзона,
|
||||
переключатели блокировок.
|
||||
Редактирование языка (en/ru), отображаемого имени, таймзоны, суточного окна
|
||||
отсутствия (away) и переключателей блокировок, а также привязка email по
|
||||
confirm-коду: backend шлёт на почту короткий код, и после ввода email
|
||||
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
|
||||
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
|
||||
слияние появятся в Stage 10.
|
||||
|
||||
### История и статистика *(Stage 3)*
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
|
||||
+14
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user