R6(a): de-stage code, docs, READMEs; split stage6_test

Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
# a golang-alpine builder yields a static binary shipped on distroless nonroot.
#
# The dictionary DAWGs are baked in from the scrabble-dictionary release artifact
# (Stage 14) — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
# — the same set the Go CI downloads — and BACKEND_DICT_DIR points the
# binary at them. The published solver module is fetched directly from Gitea
# (GOPRIVATE), so the build stage needs git and network.
#
+39 -41
View File
@@ -1,24 +1,24 @@
# backend
Internal-only domain service for the Scrabble platform (module `scrabble/backend`).
It owns identity/sessions, accounts, and — in later stages — the lobby, game
runtime, robot, chat, history and administration. Its only network consumers are
the `gateway` and the platform side-services; it is never exposed publicly.
It owns identity/sessions, accounts, the lobby, game runtime, robot, chat, history
and administration. Its only network consumers are the `gateway` and the platform
side-services; it is never exposed publicly.
As of Stage 1 the backend provides the foundation: configuration, the HTTP
listener with the `/api/v1` route-group skeleton and probes, the Postgres pool
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
and the durable accounts / identities / sessions data model. The session and
account REST endpoints are added with the `gateway` (Stage 6); Stage 1 ships the
store/service layer they will call.
The backend provides the foundation: configuration, the HTTP listener with the
`/api/v1` route-group skeleton and probes, the Postgres pool with embedded goose
migrations, OpenTelemetry wiring, an in-memory session cache, and the durable
accounts / identities / sessions data model. The session and account REST
endpoints live in the `gateway`; the backend ships the store/service layer they
call.
Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
`internal/engine` is the in-process bridge to the `scrabble-solver`
library: a versioned dictionary registry, a deterministic tile bag, and a pure
rules `Game` (legal plays, passes, exchanges, resignations and end-condition
detection) that emits dictionary-independent move records. It is a library only;
the game domain wires it into the process in Stage 3.
the game domain wires it into the process.
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
`internal/game` is the game domain over the engine. Active games are
event-sourced: a `games` row plus an append-only decoded move journal, with the
live `engine.Game` kept warm in a cache and rebuilt by replay on a miss. It
provides create, the play/pass/exchange/resign transitions, an unlimited
@@ -26,10 +26,9 @@ score/legality preview, the hint (per-game allowance plus a profile wallet), the
word-check tool with complaint capture, per-player game state, history and GCG
export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like
Stages 12 it is a service/store layer; the HTTP surface lands with the
`gateway` (Stage 6).
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
The lobby and social fabric. `internal/lobby` holds an in-memory
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
@@ -41,10 +40,10 @@ development log mailer). The engine now also handles **multi-player drop-out**:
a 34 player game a resignation or timeout drops that seat and the rest play on
(the tile disposition is a per-game setting), the game ending when one active seat
remains. As before this is a service/store layer — chat and nudges are persisted
but their live delivery, and all REST endpoints, arrive with the `gateway`
(Stage 6); the services are exposed via `Server` accessors for those handlers.
but their live delivery, and all REST endpoints, live in the `gateway`; the
services are exposed via `Server` accessors for those handlers.
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
The robot opponent (`internal/robot`). A pool of durable accounts —
each a `kind='robot'` identity, provisioned at startup with chat and friend
requests blocked — backs human-like, per-language composed names. A background driver plays the
robot's moves through the public game API as an ordinary seated player (so only
@@ -53,28 +52,28 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num
right-skewed delay (quick openings, long endgames), a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot (matching the game's language) after a 10-second wait and
exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
initial game state) drives it instead.
Stage 6 opens the backend to the edge. The route groups gain their first
The backend opens to the edge. The route groups gain their first
handlers (`internal/server/handlers_*.go`): gateway-only session endpoints under
`/api/v1/internal` (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated `/api/v1/user` operations (profile, submit play, game
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
operations under `/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
state, lobby enqueue/poll, chat). The social/account/history operations under
`/api/v1/user`: `friends/*` (request/respond/cancel/unfriend,
list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
(create/accept/decline/cancel/list), `PUT profile`, `email/{request,confirm}`,
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
`stats`, and `games/:id/gcg` (finished-only). The `internal/notify` hub feeds a
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
seed a new account's language and display name from the launch fields, and adds
the `accounts.notifications_in_app_only` flag (default true).
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
route out-of-app push to the Telegram connector; the Telegram login
seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true).
`accounts.is_guest` marks an ephemeral guest — a durable row
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
with no identity, excluded from statistics. The server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
**complaint resolution** lifecycle (the `complaints` `disposition`/`resolution_note`/
@@ -82,13 +81,13 @@ the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
`accounts.service_language`: the language tag of the bot a Telegram
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
holds the language tag of the bot a Telegram
user last signed in through, written on every login and returned by
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link`
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
@@ -98,9 +97,9 @@ shared finished game's foreign keys hold); a shared **active** game blocks the m
The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
**R3** adds rate-limit observability: the gateway posts its periodic rejection
Rate-limit observability: the gateway posts its periodic rejection
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
bounded in-memory episode window for the console's **Throttled** page and applies the
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
@@ -119,8 +118,8 @@ internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
migrations/ # embedded *.sql (goose), schema `backend`
jet/ # generated go-jet models + table builders (committed)
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11)
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11)
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
internal/link/ # link/merge orchestrator over account + accountmerge + session
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
@@ -130,7 +129,7 @@ internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + frien
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag (R3)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
```
## Configuration (environment)
@@ -163,7 +162,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged (R3). |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
## Run
@@ -209,9 +208,8 @@ local solver co-development you may add a temporary replace — see `go.work`).
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
(TODO-1/TODO-2).
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot).
## Tests
+7 -8
View File
@@ -108,7 +108,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.String("dir", cfg.Game.DictDir),
zap.String("version", cfg.Game.DictVersion))
// Stage 10 admin console: an optional backend client to the Telegram connector
// Admin console: an optional backend client to the Telegram connector
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
// leaves broadcasts disabled — the console shows a "not configured" notice.
var conn *connector.Client
@@ -141,7 +141,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
// Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past
// Reap abandoned guest accounts (no game seat, account age past
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
go guestReaper.Run(ctx, cfg.GuestReapInterval)
@@ -149,19 +149,18 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Stage 4 lobby & social domains. 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.
// Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer)
// Stage 11 account linking & merge: the orchestrator over the account, merge and
// Account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// Stage 5 robot opponent: provision its durable account pool (a hard startup
// Robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
// substitutes a pooled robot for a missing human after the wait window.
robots := robot.NewService(games, accounts, socialSvc, tel.MeterProvider().Meter("scrabble/backend/robot"), logger)
@@ -178,7 +177,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
invitations.SetNotifier(hub)
logger.Info("lobby and social domains ready", zap.Duration("robot_wait", cfg.Lobby.RobotWait))
// R3 rate-limit observability: ingest the gateway's rejection reports for the
// Rate-limit observability: ingest the gateway's rejection reports for the
// admin throttled view and the conservative high-rate auto-flag.
rateWatch := ratewatch.New(cfg.RateWatch, accounts, logger)
logger.Info("rate watch ready",
+7 -7
View File
@@ -24,8 +24,8 @@ import (
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow in a later stage. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity (Stage 5).
// confirm-code flow. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity.
const (
KindTelegram = "telegram"
KindEmail = "email"
@@ -66,19 +66,19 @@ type Account struct {
IsGuest bool
// NotificationsInAppOnly confines notifications to the in-app live stream when
// true (the default): the platform side-service skips out-of-app push for the
// account (Stage 9).
// account.
NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated (Stage 11).
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid (Stage 11).
// foreign keys of a shared finished game stay valid.
MergedInto uuid.UUID
// FlaggedHighRateAt is the soft, reversible "suspected high-rate" marker: the
// zero time for an unflagged account, otherwise when the gateway-reported
// rate-limiter rejections first crossed the sustained threshold (R3). An
// rate-limiter rejections first crossed the sustained threshold. An
// operator clears it in the admin console; it never gates any request.
FlaggedHighRateAt time.Time
CreatedAt time.Time
@@ -430,7 +430,7 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
// a profile edit, so updated_at is untouched; it never gates any request (R3).
// a profile edit, so updated_at is untouched; it never gates any request.
// It reports whether the flag was newly set.
func (s *Store) FlagHighRate(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) {
stmt := table.Accounts.
+4 -4
View File
@@ -33,7 +33,7 @@ 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 11 owns.
// account; binding it would be a merge, which the link/merge flow owns.
ErrEmailTaken = errors.New("account: email already confirmed by another account")
// ErrAlreadyConfirmed is returned when the email is already confirmed by the
// requesting account.
@@ -52,8 +52,8 @@ var (
// 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 11 — and
// using an email as a login is Stage 6, which reuses this mechanism.
// account is refused (ErrEmailTaken) — merging two accounts is the link/merge flow —
// and using an email as a login reuses this mechanism.
type EmailService struct {
store *Store
mailer Mailer
@@ -128,7 +128,7 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// RequestLoginCode issues a login confirm-code to the account that owns email,
// provisioning a fresh (unconfirmed) durable account when the email is new. It is
// the unauthenticated email-login entry point (Stage 6) and, unlike RequestCode,
// the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user
// login. The code is mailed to the address, so only its real owner can complete
// the login. It returns the target account id for the subsequent LoginWithCode.
+5 -5
View File
@@ -13,14 +13,14 @@ import (
)
// ErrIdentityTaken is returned when a platform identity being linked already
// belongs to another account; the caller turns it into a merge (Stage 11).
// belongs to another account; the caller turns it into a merge.
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
// a later link or merge, and the merge is only revealed once the code is verified,
// so a probe cannot learn whether an address is registered (Stage 11).
// so a probe cannot learn whether an address is registered.
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
@@ -94,7 +94,7 @@ func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUI
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
// flow (Stage 11).
// flow.
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) {
@@ -109,7 +109,7 @@ func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string
// AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
// to the current account during linking (Stage 11).
// to the current account during linking.
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7()
if err != nil {
@@ -129,7 +129,7 @@ func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, e
}
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity (Stage 11). It is a no-op
// to a durable account once it gains its first identity. It is a no-op
// for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
+3 -3
View File
@@ -25,16 +25,16 @@ const maxDisplayName = 32
// maxDisplayNameSpecials caps the total special characters (the "." / "_" separators —
// every name rune that is neither a letter nor a space) an editable display name may
// carry, so a still-well-formed name cannot be made of mostly punctuation (Stage 17).
// carry, so a still-well-formed name cannot be made of mostly punctuation.
const maxDisplayNameSpecials = 5
// maxAwayWindow bounds the daily away window's duration (midnight-wrap aware).
const maxAwayWindow = 12 * time.Hour
// displayNameRe enforces the editable display-name format (Stage 8): Unicode letters
// displayNameRe enforces the editable display-name format: Unicode letters
// joined by single space / "." / "_" separators, where a "." or "_" may be followed
// by a single space. No leading separator and no two adjacent separators (except
// "<dot|underscore> <space>"); a single trailing "." is allowed (Stage 17), so
// "<dot|underscore> <space>"); a single trailing "." is allowed, so
// "Name_P. Last" and "Anna B." are valid, "Name P._Last" is not.
var displayNameRe = regexp.MustCompile(`^\p{L}+(?:(?:[._] ?| )\p{L}+)*\.?$`)
+1 -1
View File
@@ -12,7 +12,7 @@ import (
// TestUpdateProfileValidation checks that bad fields are rejected before any
// database access, so a nil-backed Store is enough to exercise the guards. It also
// confirms UpdateProfile wires the Stage 8 validators (name format, away window,
// confirms UpdateProfile wires the validators (name format, away window,
// offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) {
s := &Store{}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
)
// offsetZoneRe matches a fixed UTC offset like "+03:00" or "-05:30" — the form the
// Stage 8 profile editor stores (an offset dropdown rather than an IANA name).
// profile editor stores (an offset dropdown rather than an IANA name).
var offsetZoneRe = regexp.MustCompile(`^([+-])(\d{2}):(\d{2})$`)
// parseOffsetZone parses a "±HH:MM" offset into a fixed-offset location, reporting
+2 -2
View File
@@ -20,7 +20,7 @@ type UserListItem struct {
IsGuest bool
IsRobot bool
// FlaggedHighRateAt is the soft high-rate marker (zero when unflagged), shown
// as a badge in the console list (R3).
// as a badge in the console list.
FlaggedHighRateAt time.Time
CreatedAt time.Time
}
@@ -105,7 +105,7 @@ type FlaggedAccount struct {
const flaggedListCap = 200
// ListFlaggedHighRate returns the accounts carrying the high-rate flag, most
// recently flagged first (R3).
// recently flagged first.
func (s *Store) ListFlaggedHighRate(ctx context.Context) ([]FlaggedAccount, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT account_id, display_name, flagged_high_rate_at
+1 -1
View File
@@ -2,7 +2,7 @@
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
// (accounts.merged_into). It is the data core of account linking & merge
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
// one layer up (the link service), since the in-memory session cache lives there.
package accountmerge
+3 -3
View File
@@ -60,7 +60,7 @@ type UsersView struct {
// UserRow is one account row in the list. MoveMin/Avg/Max are the account's
// pre-formatted move-duration summary (empty when it has no timed move);
// FlaggedHighRate marks the soft high-rate badge (R3).
// FlaggedHighRate marks the soft high-rate badge.
type UserRow struct {
ID string
DisplayName string
@@ -111,10 +111,10 @@ type UserDetailView struct {
NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a
// merge (Stage 11), or empty for a live account.
// merge, or empty for a live account.
MergedInto string
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action (R3).
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
HintBalance int
CreatedAt string
+1 -1
View File
@@ -37,7 +37,7 @@ type Config struct {
// Robot configures the robot opponent driver (scan cadence).
Robot robot.Config
// RateWatch tunes the conservative high-rate auto-flag applied to the
// gateway's rate-limiter rejection reports (R3).
// gateway's rate-limiter rejection reports.
RateWatch ratewatch.Config
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
+1 -1
View File
@@ -7,7 +7,7 @@ import (
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
// concrete character and its tile point value. It is the dictionary-independent display
// table the edge sends to the client (Stage 13), produced from the variant's solver
// table the edge sends to the client, produced from the variant's solver
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
// dictionary.
type AlphabetEntry struct {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
// contiguous indices, the concrete lower-case characters the solver emits and the standard
// tile values. This is the real parity check the UI no longer carries (Stage 13).
// tile values. This is the real parity check the UI no longer carries.
func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish)
if err != nil {
+1 -1
View File
@@ -49,7 +49,7 @@ func (v Variant) String() string {
// Language returns the variant's interface/bot language tag: "en" for English Scrabble, "ru" for
// the Russian variants (Russian Scrabble and Erudite). It routes a game's out-of-app push to the
// matching per-language Telegram bot — by the game, not the recipient's last-login bot (Stage 17).
// matching per-language Telegram bot — by the game, not the recipient's last-login bot.
func (v Variant) Language() string {
if v == VariantEnglish {
return "en"
+1 -1
View File
@@ -4,7 +4,7 @@ import "testing"
// TestVariantLanguage checks the variant -> bot-language mapping that routes a game's out-of-app
// push by the game itself (English -> en, the Russian variants -> ru), rather than the recipient's
// last-login bot (Stage 17).
// last-login bot.
func TestVariantLanguage(t *testing.T) {
cases := map[Variant]string{
VariantEnglish: "en",
+2 -2
View File
@@ -21,7 +21,7 @@ type DraftTile struct {
Blank bool `json:"blank"`
}
// Draft is a player's persisted client-side composition for a game (Stage 17): the
// Draft is a player's persisted client-side composition for a game: the
// preferred rack tile order and the board tiles laid but not yet submitted. The server
// keeps it so a reload or a second device resumes the same arrangement.
type Draft struct {
@@ -101,7 +101,7 @@ func (s *Store) clearDraft(ctx context.Context, gameID, accountID uuid.UUID) err
// resetConflictingBoardDrafts clears the board_tiles of every OTHER player's draft that has
// a tile on one of the just-committed cells, since that draft can no longer be placed; the
// rack order is kept (Stage 17 #6).
// rack order is kept.
func (s *Store) resetConflictingBoardDrafts(ctx context.Context, gameID, actorID uuid.UUID, cells []DraftTile) error {
if len(cells) == 0 {
return nil
+1 -1
View File
@@ -6,7 +6,7 @@ import (
)
// The mappers below project the game domain into the wire-agnostic notify.* input
// structs the enriched live events carry (R4). They keep the wire schema out of the
// structs the enriched live events carry. They keep the wire schema out of the
// game package: notify owns the FlatBuffers encoding, this file only resolves the
// values (seat display names, last-activity sort key) into its input shapes.
+7 -8
View File
@@ -217,14 +217,13 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
// indices to concrete letters before delegating to the letter-based play, exchange and
// word-check methods (Stage 13), keeping a single domain path shared with the robot.
// word-check methods, keeping a single domain path shared with the robot.
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
return svc.store.GetGameVariant(ctx, gameID)
}
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot
// (Stage 17).
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
v, err := svc.GameVariant(ctx, gameID)
if err != nil {
@@ -241,7 +240,7 @@ func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed i
// LastMoveAt returns the time of an account's most recent move in a game (and whether it
// has moved). The social service uses it to reset the nudge cooldown once a player has
// taken a turn (Stage 17).
// taken a turn.
func (svc *Service) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
return svc.store.LastMoveAt(ctx, gameID, accountID)
}
@@ -294,7 +293,7 @@ func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID,
return MoveResult{Move: rec, Game: post, Rack: g.Hand(seat), BagLen: g.BagLen()}, nil
}
// afterCommitDrafts maintains the Stage 17 drafts after a committed move: the actor's own
// afterCommitDrafts maintains the drafts after a committed move: the actor's own
// composition is consumed, so clear it; a play's tiles may overlap an opponent's board
// draft, which is then reset. Best-effort — the move is already committed, so a draft
// cleanup failure is logged rather than failing the move.
@@ -382,7 +381,7 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
}
// Game pushes are routed out-of-app by the game's own language, not the recipient's
// last-login bot (Stage 17).
// last-login bot.
lang := post.Variant.Language()
switch post.Status {
case StatusActive:
@@ -789,7 +788,7 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
}
// InitialState returns accountID's full initial view of game gameID as the notify
// PlayerState carried by the match_found / game_started events (R4), so a client can
// PlayerState carried by the match_found / game_started events, so a client can
// render a freshly started game from the event without a follow-up fetch. The variant
// alphabet table is always embedded (the recipient may be seeing the variant for the
// first time). It satisfies lobby.GameCreator.
@@ -837,7 +836,7 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op (Stage 17).
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
func (svc *Service) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
g, err := svc.store.GetGame(ctx, gameID)
if err != nil {
+4 -4
View File
@@ -136,7 +136,7 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
}
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
// to map wire alphabet indices to concrete letters (Stage 13) without loading the whole
// to map wire alphabet indices to concrete letters without loading the whole
// game and its seats.
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
stmt := postgres.SELECT(table.Games.Variant).
@@ -186,7 +186,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
if len(grows) == 0 {
return nil, nil
}
// Drop games the account has hidden from its own lobby (Stage 17).
// Drop games the account has hidden from its own lobby.
hidden, err := s.hiddenGameIDs(ctx, accountID)
if err != nil {
return nil, err
@@ -233,7 +233,7 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
}
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player (Stage 17).
// game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO backend.game_hidden (account_id, game_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
@@ -700,7 +700,7 @@ func (s *Store) GameSeed(ctx context.Context, id uuid.UUID) (int64, error) {
// LastMoveAt returns the time of the account's most recent move in the game and true, or
// the zero time and false when it has not moved. The social service uses it to reset the
// nudge cooldown once the player has taken a turn (Stage 17).
// nudge cooldown once the player has taken a turn.
func (s *Store) LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error) {
var at sql.NullTime
err := s.db.QueryRowContext(ctx,
+3 -3
View File
@@ -15,8 +15,8 @@ const (
StatusFinished = "finished"
)
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen
// and closed StatusComplaintResolved by the admin review queue with a
// Disposition. The CHECK constraints live in migration 00008.
const (
StatusComplaintOpen = "open"
@@ -125,7 +125,7 @@ func (g Game) seatOf(accountID uuid.UUID) (int, bool) {
// MoveResult is the outcome of a committed transition: the decoded move and the
// post-move game, plus the actor's own refilled rack and the bag size after the draw
// (Rack/BagLen, R4), so the mover renders the next state from the response without a
// (Rack/BagLen), so the mover renders the next state from the response without a
// follow-up game.state.
type MoveResult struct {
Move engine.MoveRecord
+75 -4
View File
@@ -11,6 +11,8 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestAccountProvisionByIdentity covers find-or-create semantics, distinct
@@ -78,7 +80,7 @@ func TestAccountProvisionByIdentity(t *testing.T) {
}
// TestGetStatsZeroForFreshAccount checks that an account with no finished games
// reads back the zero statistics rather than an error (the Stage 8 stats screen).
// reads back the zero statistics rather than an error (the stats screen).
func TestGetStatsZeroForFreshAccount(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -109,7 +111,7 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields,
// defaults the in-app-only flag on, and never overwrites an existing account on a
// later login (Stage 9 language seeding).
// later login (language seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -196,7 +198,7 @@ func TestServiceLanguageRoundTrip(t *testing.T) {
}
}
// TestHighRateFlagRoundTrip covers the R3 soft high-rate marker: a fresh account
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
// the operator clear takes a fresh timestamp.
@@ -279,7 +281,7 @@ func TestIdentityExternalID(t *testing.T) {
}
}
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
// TestNotificationsInAppOnlyRoundTrip checks the profile flag persists
// through UpdateProfile and reads back through GetByID.
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
@@ -311,3 +313,72 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
t.Error("GetByID still reports in-app-only after clearing")
}
}
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if !acc.IsGuest {
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
}
return acc.ID
}
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
// against a robot to a natural end and checks the guest holds a seat (the
// game_players foreign key is satisfied) yet accrues no statistics, while the
// durable robot opponent does.
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
guest := provisionGuest(t)
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
const robotSeat = 1 // seats = [guest, robot]
finished := false
for i := 0; i < 400 && !finished; i++ {
_, toMove, status, err := svc.Participants(ctx, g.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != game.StatusActive {
finished = true
break
}
if toMove == robotSeat {
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
robots.Drive(ctx, daytime)
continue
}
playHuman(t, ctx, svc, g.ID, guest)
}
if !finished {
t.Fatal("guest game did not finish within the move budget")
}
if _, _, _, _, _, ok := readStats(t, guest); ok {
t.Error("a guest must not accrue a statistics row")
}
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
t.Error("the durable robot opponent should have a statistics row")
}
}
+2 -2
View File
@@ -169,7 +169,7 @@ func TestConsoleServesAndGuardsCSRF(t *testing.T) {
}
// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
// play-to-win intent and, while it is the robot's turn, its next-move ETA (Stage 17).
// play-to-win intent and, while it is the robot's turn, its next-move ETA.
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
ctx := context.Background()
svc := newGameService()
@@ -207,7 +207,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
}
}
// TestConsoleThrottledViewAndFlagClear drives the R3 rate-limit surface end to
// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
// end against real stores: a gateway report past the threshold auto-flags the
// account, the throttled view shows the episode and the flagged account, the
// user card carries the marker, and the operator clear (a same-origin POST)
+1 -1
View File
@@ -34,7 +34,7 @@ func newDraftGame(t *testing.T) (*game.Service, uuid.UUID, []uuid.UUID, engine.M
return svc, g.ID, seats, hint
}
// TestDraftPersistAndConflictReset covers Stage 17 draft persistence: a round-trip of the
// TestDraftPersistAndConflictReset covers draft persistence: a round-trip of the
// rack order + board tiles, the actor's own draft cleared on their move, and an opponent's
// board draft reset when a committed play overlaps one of its cells (the rack order kept).
func TestDraftPersistAndConflictReset(t *testing.T) {
+47 -1
View File
@@ -159,7 +159,7 @@ func TestUpdateProfilePersists(t *testing.T) {
}
}
// TestUpdateProfileOffsetTimezone checks the Stage 8 UTC-offset timezone: it is
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
// accepted, persisted verbatim, and resolved to the right fixed offset.
func TestUpdateProfileOffsetTimezone(t *testing.T) {
ctx := context.Background()
@@ -181,3 +181,49 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
t.Fatalf("ResolveZone offset = %d, want 10800", off)
}
}
// TestEmailLoginFlow covers the email-as-login path: a code is mailed to
// a new address, verifying it provisions and returns the owning account, and a
// second login for the same address resolves to that same account (a returning
// user), with the identity confirmed.
func TestEmailLoginFlow(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer)
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email)
if err != nil {
t.Fatalf("request login code: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if code == "" {
t.Fatalf("no code in mail body %q", mailer.lastBody)
}
acc, err := svc.LoginWithCode(ctx, email, code)
if err != nil {
t.Fatalf("login with code: %v", err)
}
if acc.ID != accountID {
t.Errorf("login account = %s, want %s", acc.ID, accountID)
}
if acc.IsGuest {
t.Error("an email account must be durable, not a guest")
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login")
}
// A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
if err != nil {
t.Fatalf("second login: %v", err)
}
if acc2.ID != accountID {
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
}
}
+3 -3
View File
@@ -299,7 +299,7 @@ func TestResignWinnerAndStats(t *testing.T) {
}
}
// TestResignOnOpponentTurn checks the Stage 17 fix: a player can forfeit on the
// TestResignOnOpponentTurn checks a player can forfeit on the
// opponent's turn. Seat 0 plays (so it is seat 1's turn), then seat 0 resigns its own
// seat while it is not its turn — no ErrNotYourTurn, the game ends, and seat 0 loses
// despite leading on score.
@@ -464,7 +464,7 @@ func TestHintPolicy(t *testing.T) {
}
}
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
// TestGameVariant covers the edge's lightweight variant lookup: it returns the
// created game's variant and ErrNotFound for an unknown id.
func TestGameVariant(t *testing.T) {
ctx := context.Background()
@@ -619,7 +619,7 @@ func equalStrings(a, b []string) bool {
return true
}
// TestExportGCGRefusesActiveGame checks the Stage 8 finished-only gate: a GCG export
// TestExportGCGRefusesActiveGame checks the finished-only gate: a GCG export
// is allowed only once the game is over, so an active game leaks nothing mid-play.
func TestExportGCGRefusesActiveGame(t *testing.T) {
ctx := context.Background()
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"scrabble/backend/internal/game"
)
// TestHideFinishedGame covers Stage 17 per-account game hiding: an active game cannot be
// TestHideFinishedGame covers per-account game hiding: an active game cannot be
// hidden, a finished game is removed from the hider's own list while staying visible to the
// other player, an outsider cannot hide it, and the action is idempotent.
func TestHideFinishedGame(t *testing.T) {
+1 -1
View File
@@ -97,7 +97,7 @@ func TestRobotPoolProvisionsRobotAccounts(t *testing.T) {
t.Fatalf("get robot account: %v", err)
}
// A robot blocks chat but NOT friend requests: a request to a robot stays pending and
// expires, mirroring a human who ignores it (Stage 17).
// expires, mirroring a human who ignores it.
if acc.DisplayName == "" || !acc.BlockChat || acc.BlockFriendRequests {
t.Errorf("robot profile wrong: name=%q chat-blocked=%v friends-blocked=%v (want chat blocked, friends open)",
acc.DisplayName, acc.BlockChat, acc.BlockFriendRequests)
+7 -7
View File
@@ -70,7 +70,7 @@ func newGameWithSeats(t *testing.T, n int) (uuid.UUID, []uuid.UUID) {
// TestFriendRequestToRobotStaysPending checks a friend request to a robot is accepted as
// pending rather than blocked: robots no longer block friend requests, so the request
// just sits unanswered and later expires — mirroring a human who ignores it (Stage 17).
// just sits unanswered and later expires — mirroring a human who ignores it.
func TestFriendRequestToRobotStaysPending(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -342,7 +342,7 @@ func TestChatRejectsBadContent(t *testing.T) {
}
}
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn (Stage 17):
// TestChatOnlyOnYourTurn checks chat is allowed only on the sender's own turn:
// the player to move can post, the waiting player gets ErrChatNotYourTurn.
func TestChatOnlyOnYourTurn(t *testing.T) {
ctx := context.Background()
@@ -383,7 +383,7 @@ func TestNudgeRulesAndRateLimit(t *testing.T) {
}
// TestNudgeCooldownResetsOnAction checks the nudge cooldown clears once the player has
// acted (moved or chatted) since their last nudge, even within the hour (Stage 17).
// acted (moved or chatted) since their last nudge, even within the hour.
func TestNudgeCooldownResetsOnAction(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -413,7 +413,7 @@ func TestNudgeCooldownResetsOnAction(t *testing.T) {
}
// TestListOutgoingRequests checks the requester-side list that backs the in-game "add to
// friends" item (Stage 17): a pending request shows for the requester only; an accepted one
// friends" item: a pending request shows for the requester only; an accepted one
// clears (it is a friendship now); a declined one stays (cannot be re-sent, so it reads as
// still "sent"); a lazily expired pending one drops (it may be re-sent).
func TestListOutgoingRequests(t *testing.T) {
@@ -469,7 +469,7 @@ func TestListOutgoingRequests(t *testing.T) {
}
// TestRespondPublishesToRequester checks that answering a request notifies the original
// requester over the live channel (Stage 17): accept -> friend_added, decline ->
// requester over the live channel: accept -> friend_added, decline ->
// friend_declined, so a game screen watching that opponent re-derives its friend state.
func TestRespondPublishesToRequester(t *testing.T) {
ctx := context.Background()
@@ -503,7 +503,7 @@ func TestRespondPublishesToRequester(t *testing.T) {
}
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
// it is delivered by the game's bot rather than the recipient's last-login bot (Stage 17).
// it is delivered by the game's bot rather than the recipient's last-login bot.
func TestNudgeRoutedByGameLanguage(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
@@ -528,7 +528,7 @@ func TestNudgeRoutedByGameLanguage(t *testing.T) {
}
}
// TestAdminListMessages checks the admin moderation list (Stage 17): real messages only
// TestAdminListMessages checks the admin moderation list: real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) {
ctx := context.Background()
-130
View File
@@ -1,130 +0,0 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if !acc.IsGuest {
t.Fatalf("provisioned account %s is not flagged guest", acc.ID)
}
return acc.ID
}
// TestGuestAutoMatchLeavesNoStats drives a guest through a full auto-match game
// against a robot to a natural end and checks the guest holds a seat (the
// game_players foreign key is satisfied) yet accrues no statistics, while the
// durable robot opponent does.
func TestGuestAutoMatchLeavesNoStats(t *testing.T) {
ctx := context.Background()
svc := newGameService()
robots := newRobotService(t, svc)
if err := robots.EnsurePool(ctx); err != nil {
t.Fatalf("ensure pool: %v", err)
}
robotID, err := robots.Pick(engine.VariantEnglish)
if err != nil {
t.Fatalf("pick: %v", err)
}
guest := provisionGuest(t)
seed := openingSeed(t)
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{guest, robotID},
TurnTimeout: 24 * time.Hour, Seed: seed,
})
if err != nil {
t.Fatalf("create: %v", err)
}
const robotSeat = 1 // seats = [guest, robot]
finished := false
for i := 0; i < 400 && !finished; i++ {
_, toMove, status, err := svc.Participants(ctx, g.ID)
if err != nil {
t.Fatalf("participants: %v", err)
}
if status != game.StatusActive {
finished = true
break
}
if toMove == robotSeat {
setTurnStarted(t, g.ID, daytime.Add(-2*time.Hour))
robots.Drive(ctx, daytime)
continue
}
playHuman(t, ctx, svc, g.ID, guest)
}
if !finished {
t.Fatal("guest game did not finish within the move budget")
}
if _, _, _, _, _, ok := readStats(t, guest); ok {
t.Error("a guest must not accrue a statistics row")
}
if _, _, _, _, _, ok := readStats(t, robotID); !ok {
t.Error("the durable robot opponent should have a statistics row")
}
}
// TestEmailLoginFlow covers the Stage 6 email-as-login path: a code is mailed to
// a new address, verifying it provisions and returns the owning account, and a
// second login for the same address resolves to that same account (a returning
// user), with the identity confirmed.
func TestEmailLoginFlow(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
svc := account.NewEmailService(account.NewStore(testDB), mailer)
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email)
if err != nil {
t.Fatalf("request login code: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if code == "" {
t.Fatalf("no code in mail body %q", mailer.lastBody)
}
acc, err := svc.LoginWithCode(ctx, email, code)
if err != nil {
t.Fatalf("login with code: %v", err)
}
if acc.ID != accountID {
t.Errorf("login account = %s, want %s", acc.ID, accountID)
}
if acc.IsGuest {
t.Error("an email account must be durable, not a guest")
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login")
}
// A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
if err != nil {
t.Fatalf("second login: %v", err)
}
if acc2.ID != accountID {
t.Errorf("returning login account = %s, want %s", acc2.ID, accountID)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4).
// Package link orchestrates account linking & merge (ARCHITECTURE.md §4).
// It sits above the account, accountmerge and session layers: it verifies the
// caller's control of an identity (an email confirm-code or a gateway-validated
// platform identity), binds a free identity to the current account, and — when the
+2 -2
View File
@@ -106,7 +106,7 @@ func (svc *InvitationService) SetNotifier(p notify.Publisher) {
}
// emitInvitation publishes the invitation notification to each invitee, carrying the invitation
// itself so the client adds it to its lobby list without a refetch (R4).
// itself so the client adds it to its lobby list without a refetch.
func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation, inviteeIDs []uuid.UUID) {
if len(inviteeIDs) == 0 {
return
@@ -120,7 +120,7 @@ func (svc *InvitationService) emitInvitation(ctx context.Context, inv Invitation
}
// emitGameStarted publishes the game_started notification to each seated player, carrying their
// initial view of the started game so the client seeds its game cache without a refetch (R4). A
// initial view of the started game so the client seeds its game cache without a refetch. A
// seat whose state cannot be read is skipped (it still sees the game on the next lobby load).
func (svc *InvitationService) emitGameStarted(ctx context.Context, g game.Game, seats []uuid.UUID) {
intents := make([]notify.Intent, 0, len(seats))
+1 -1
View File
@@ -23,7 +23,7 @@ type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used
// to enrich the match_found / game_started events so the client renders the new game
// without a follow-up fetch (R4).
// without a follow-up fetch.
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
}
+1 -1
View File
@@ -76,7 +76,7 @@ func (m *Matchmaker) SetNotifier(p notify.Publisher) {
// emitMatchFound pushes match_found to every seat of a freshly started game.
// Emitting to a robot seat is harmless (no client subscription exists for it).
func (m *Matchmaker) emitMatchFound(ctx context.Context, g game.Game) {
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot (Stage 17)
lang := g.Variant.Language() // route the push by the game's language, not the recipient's bot
intents := make([]notify.Intent, 0, len(g.Seats))
for _, s := range g.Seats {
state, err := m.games.InitialState(ctx, g.ID, s.AccountID)
+1 -1
View File
@@ -249,7 +249,7 @@ func TestMatchmakerReaperSkipsCancelled(t *testing.T) {
// TestMatchmakerCancelClearsPendingResult covers the race where the reaper substitutes a
// robot just before the player cancels: Cancel must drop the pending result so the
// abandoned game never surfaces through Poll (Stage 17).
// abandoned game never surfaces through Poll.
func TestMatchmakerCancelClearsPendingResult(t *testing.T) {
creator := &fakeCreator{}
mm := newTestMatchmaker(creator, uuid.New())
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
// The builders below encode the nested wire tables embedded in enriched event
// payloads (R4). They mirror the gateway's transcode encoders, but read the domain's
// payloads. They mirror the gateway's transcode encoders, but read the domain's
// already-resolved values (notify.* input structs and the decoded engine.MoveRecord)
// rather than the gateway's REST DTOs. Each returns the offset of the table it built;
// callers must build every nested table before opening the parent event table.
+12 -12
View File
@@ -15,11 +15,11 @@ import (
// the game/social/lobby services emit events without importing the wire schema.
// YourTurn announces to userID that it is their turn in game gameID, with the turn's nominal
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push (Stage
// 17): the player who just moved, their move kind, the main word of a scoring play (empty
// deadline. opponentName, lastAction, lastWord and scoreLine enrich the out-of-app push:
// the player who just moved, their move kind, the main word of a scoring play (empty
// otherwise) and the recipient-first running score line. Empty strings render the plain "your
// turn" text. moveCount is the post-move count, which the client compares against its cached
// game to detect a missed in-app move and fall back to a refetch (R4).
// game to detect a missed in-app move and fall back to a refetch.
func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAction, lastWord, scoreLine string, moveCount int) Intent {
b := flatbuffers.NewBuilder(128)
gid := b.CreateString(gameID.String())
@@ -41,9 +41,9 @@ func YourTurn(userID, gameID uuid.UUID, deadline time.Time, opponentName, lastAc
// GameOver announces to userID that game gameID finished. result is the outcome from userID's
// own perspective ("won"/"lost"/"draw") and scoreLine is the recipient-first final score; both
// feed the out-of-app "game over" push (Stage 17). game is the final post-game summary (the
// feed the out-of-app "game over" push. game is the final post-game summary (the
// adjusted scores after rack penalties and the winner flag), so an in-app client settles the
// finished game from the event without a refetch (R4).
// finished game from the event without a refetch.
func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSummary) Intent {
b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String())
@@ -60,10 +60,10 @@ func GameOver(userID, gameID uuid.UUID, result, scoreLine string, game GameSumma
}
// OpponentMoved tells userID that move was just committed in game gameID, carrying it as a delta
// the client applies to its cached game without a refetch (R4): move is the decoded play/pass/
// the client applies to its cached game without a refetch: move is the decoded play/pass/
// exchange, game is the post-move summary (per-seat scores, to_move, move_count, status) and
// bagLen is the bag size after the draw. The seat/action/score/total scalars repeat the move's
// summary for pre-R4 wire back-compat.
// summary for wire back-compat.
func OpponentMoved(userID, gameID uuid.UUID, move engine.MoveRecord, game GameSummary, bagLen int) Intent {
b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String())
@@ -116,7 +116,7 @@ func Nudge(userID, gameID, fromUserID uuid.UUID) Intent {
// MatchFound tells userID that game gameID, which they are seated in, has started (an auto-match
// pairing or a robot substitution). state is the recipient's full initial view of the new game,
// so the client navigates straight in from the event with no follow-up fetch (R4).
// so the client navigates straight in from the event with no follow-up fetch.
func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512)
gid := b.CreateString(gameID.String())
@@ -131,7 +131,7 @@ func MatchFound(userID, gameID uuid.UUID, state PlayerState) Intent {
// Notification is a lightweight "re-poll" signal to userID that something in their lobby
// changed. kind is a sub-discriminator (NotifyFriendRequest, NotifyFriendAdded,
// NotifyFriendDeclined, NotifyInvitation, NotifyGameStarted). It carries no payload; prefer the
// enriched constructors below, which let the client update its lobby without a refetch (R4).
// enriched constructors below, which let the client update its lobby without a refetch.
func Notification(userID uuid.UUID, kind string) Intent {
b := flatbuffers.NewBuilder(32)
k := b.CreateString(kind)
@@ -143,7 +143,7 @@ func Notification(userID uuid.UUID, kind string) Intent {
// NotificationAccount builds a lobby notification of one of the friend_* kinds carrying the
// account it concerns (the requester, the new friend or the decliner), so the client updates its
// requests/friends lists and the in-game "add friend" state without a refetch (R4).
// requests/friends lists and the in-game "add friend" state without a refetch.
func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
b := flatbuffers.NewBuilder(128)
k := b.CreateString(kind)
@@ -157,7 +157,7 @@ func NotificationAccount(userID uuid.UUID, kind string, acc AccountRef) Intent {
// NotificationGameStarted builds the NotifyGameStarted notification carrying the recipient's
// initial view of the just-started invited game, so the client seeds its game cache and the
// lobby list without a refetch (R4).
// lobby list without a refetch.
func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyGameStarted)
@@ -170,7 +170,7 @@ func NotificationGameStarted(userID uuid.UUID, state PlayerState) Intent {
}
// NotificationInvitation builds the NotifyInvitation notification carrying the new invitation,
// so the client adds it to its lobby invitations list without a refetch (R4).
// so the client adds it to its lobby invitations list without a refetch.
func NotificationInvitation(userID uuid.UUID, inv InvitationSummary) Intent {
b := flatbuffers.NewBuilder(512)
k := b.CreateString(NotifyInvitation)
+2 -2
View File
@@ -28,7 +28,7 @@ const (
// (incoming friend requests, invitations) that drives the lobby badge.
KindNotification = "notify"
// KindGameOver announces a finished game to each seated player, driving the
// out-of-app "game over" push (Stage 17).
// out-of-app "game over" push.
KindGameOver = "game_over"
)
@@ -52,7 +52,7 @@ type Intent struct {
Kind string
Payload []byte
EventID string
// Language routes an out-of-app push to a specific per-language bot (Stage 17): for a
// Language routes an out-of-app push to a specific per-language bot: for a
// game event it is the game's language ("en"/"ru"), so the notification comes from the
// game's bot rather than the recipient's last-login bot. Empty falls back to the
// recipient's service language at the gateway.
+2 -2
View File
@@ -109,12 +109,12 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
t.Fatalf("kind = %q", in.Kind)
}
ev := fb.GetRootAsOpponentMovedEvent(in.Payload, 0)
// The pre-R4 summary scalars repeat the move.
// The summary scalars repeat the move.
if string(ev.GameId()) != gid.String() || ev.Seat() != 1 || string(ev.Action()) != "play" || ev.Score() != 24 || ev.Total() != 130 {
t.Fatalf("scalars wrong: game=%q seat=%d action=%q score=%d total=%d",
ev.GameId(), ev.Seat(), ev.Action(), ev.Score(), ev.Total())
}
// The R4 delta: the move, the post-move summary and the bag size.
// The delta: the move, the post-move summary and the bag size.
if ev.BagLen() != 42 {
t.Fatalf("bag_len = %d, want 42", ev.BagLen())
}
@@ -35,7 +35,7 @@ CREATE TABLE accounts (
merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
merged_at timestamptz,
service_language text CHECK (service_language IN ('en', 'ru')),
-- Soft, reversible "suspected high-rate" marker (R3): set once when the gateway
-- Soft, reversible "suspected high-rate" marker: set once when the gateway
-- reports sustained rate-limiter rejections past the threshold; an operator
-- clears it in the admin console. Never an automatic ban.
flagged_high_rate_at timestamptz,
+1 -1
View File
@@ -1,5 +1,5 @@
// Package ratewatch ingests the gateway's periodic rate-limiter rejection
// reports (R3). It keeps an in-memory window of recent throttle episodes for
// reports. It keeps an in-memory window of recent throttle episodes for
// the admin console's view and applies the conservative high-rate auto-flag:
// when one account's rejections within the rolling window cross the threshold,
// the account store stamps the soft, reversible flagged_high_rate_at marker
+6 -7
View File
@@ -93,13 +93,13 @@ type gameDTO struct {
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active
// game, the finish time once finished (Stage 17).
// game, the finish time once finished.
LastActivityUnix int64 `json:"last_activity_unix"`
Seats []seatDTO `json:"seats"`
}
// moveResultDTO is the outcome of a committed move. Rack carries the actor's refilled rack as
// wire alphabet indices and BagLen the bag size after the draw (R4), so the mover renders the
// wire alphabet indices and BagLen the bag size after the draw, so the mover renders the
// next state from the response without a follow-up state fetch.
type moveResultDTO struct {
Move moveRecordDTO `json:"move"`
@@ -109,15 +109,14 @@ type moveResultDTO struct {
}
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
// tile value), embedded in the state view for display only when the client requests it
// (Stage 13).
// tile value), embedded in the state view for display only when the client requests it.
type alphabetEntryDTO struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (Stage 13; a
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
type stateDTO struct {
Game gameDTO `json:"game"`
@@ -236,7 +235,7 @@ func moveRecordDTOFrom(m engine.MoveRecord) moveRecordDTO {
}
// moveResultDTOFrom projects a committed move result into its DTO, encoding the actor's rack as
// wire alphabet indices (Stage 13; R4).
// wire alphabet indices.
func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
rack, err := engine.EncodeRack(r.Game.Variant, r.Rack)
if err != nil {
@@ -251,7 +250,7 @@ func moveResultDTOFrom(r game.MoveResult) (moveResultDTO, error) {
}
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's
// alphabet indices. When includeAlphabet is set it also embeds the variant's
// display table, which the client caches per variant and renders the rack with.
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
+5 -6
View File
@@ -18,11 +18,10 @@ import (
"scrabble/backend/internal/social"
)
// registerRoutes wires the Stage 6 REST handlers onto the /api/v1 groups. The
// registerRoutes wires the REST handlers onto the /api/v1 groups. The
// internal group is gateway-only (the gateway authenticates and forwards); the
// user group requires X-User-ID; the admin group is reached through the gateway's
// Basic-Auth proxy. This is the representative vertical slice — further domain
// operations follow the same pattern (PLAN.md Stage 6).
// Basic-Auth proxy.
func (s *Server) registerRoutes() {
if s.sessions != nil && s.accounts != nil {
in := s.internal
@@ -32,13 +31,13 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
// Out-of-app push routing for the platform side-service (Stage 9): the
// Out-of-app push routing for the platform side-service: the
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
// before delivering an out-of-app notification.
in.POST("/push-target", s.handlePushTarget)
}
if s.ratewatch != nil {
// The gateway's periodic rate-limiter rejection summary (R3): feeds the
// The gateway's periodic rate-limiter rejection summary: feeds the
// admin console's throttled view and the high-rate auto-flag.
s.internal.POST("/ratelimit/report", s.handleRateLimitReport)
}
@@ -49,7 +48,7 @@ func (s *Server) registerRoutes() {
u.GET("/stats", s.handleStats)
}
if s.links != nil {
// Account linking & merge (Stage 11). The request step always mails a code;
// Account linking & merge. The request step always mails a code;
// a required merge is revealed only after the code is verified, and the
// irreversible merge is an explicit second step.
u.POST("/link/email/request", s.handleLinkEmailRequest)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
)
// The /api/v1/user account handlers wire profile editing, email binding and the
// statistics read (Stage 8). They follow handlers_user.go: X-User-ID identity, a
// statistics read. They follow handlers_user.go: X-User-ID identity, a
// domain call, a JSON DTO. Profile editing overwrites the full editable set, so the
// client sends the complete desired profile.
@@ -560,7 +560,7 @@ func (s *Server) consolePostBroadcast(c *gin.Context) {
// consoleThrottled renders the rate-limit observability page: the recent
// gateway-reported throttle episodes (in-memory, reset on a backend restart)
// and the accounts currently carrying the soft high-rate flag (R3).
// and the accounts currently carrying the soft high-rate flag.
func (s *Server) consoleThrottled(c *gin.Context) {
ctx := c.Request.Context()
var view adminconsole.ThrottledView
@@ -595,7 +595,7 @@ func (s *Server) consoleThrottled(c *gin.Context) {
}
// consoleClearHighRateFlag clears the soft high-rate marker — the operator's
// reversible review action (R3).
// reversible review action.
func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/google/uuid"
)
// The /api/v1/user/blocks/* handlers wire the per-user block list (Stage 8). A block
// The /api/v1/user/blocks/* handlers wire the per-user block list. A block
// is mutual in effect (the social checks apply it both ways) and severs any
// friendship between the pair. They reuse the friend handlers' targetIDRequest and
// account-ref resolution.
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/google/uuid"
)
// The /api/v1/user/friends/* handlers wire the social friend graph (Stage 8): the
// The /api/v1/user/friends/* handlers wire the social friend graph: the
// befriend-an-opponent request flow, the one-time friend-code path, and the
// friends/incoming lists. They follow handlers_user.go: X-User-ID identity, a domain
// call, a JSON DTO. Account ids are projected to {id, display_name} refs resolved
+9 -10
View File
@@ -12,10 +12,9 @@ import (
"scrabble/backend/internal/game"
)
// The handlers below extend the Stage 6 vertical slice with the remaining game and
// chat operations the UI needs (PLAN.md Stage 7). They follow the same pattern as
// handlers_user.go: X-User-ID identity, the domain service call, a JSON DTO mapped
// from the result.
// The handlers below cover the game and chat operations the UI needs. They follow
// the same pattern as handlers_user.go: X-User-ID identity, the domain service
// call, a JSON DTO mapped from the result.
// hintResultDTO is the top-ranked move plus the remaining hint budget.
type hintResultDTO struct {
@@ -53,7 +52,7 @@ type chatListDTO struct {
}
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
// indices (Stage 13); a blank is engine.BlankIndex.
// indices; a blank is engine.BlankIndex.
type exchangeRequest struct {
Tiles []int `json:"tiles"`
}
@@ -211,7 +210,7 @@ func (s *Server) handleEvaluate(c *gin.Context) {
}
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
// repeated ?idx= alphabet indices (Stage 13); the backend decodes them to the concrete
// repeated ?idx= alphabet indices; the backend decodes them to the concrete
// word for the lookup and echoes that concrete word back for the client's result cache.
func (s *Server) handleCheckWord(c *gin.Context) {
_, gameID, ok := s.userGame(c)
@@ -242,7 +241,7 @@ func (s *Server) handleCheckWord(c *gin.Context) {
}
// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice.
// It carries a word-check query as alphabet indices on a GET (Stage 13).
// It carries a word-check query as alphabet indices on a GET.
func queryIndexes(c *gin.Context, key string) ([]int, error) {
raw := c.QueryArray(key)
out := make([]int, 0, len(raw))
@@ -326,7 +325,7 @@ type draftTileDTO struct {
Blank bool `json:"blank"`
}
// draftDTO is a player's persisted client-side composition for a game (Stage 17): the
// draftDTO is a player's persisted client-side composition for a game: the
// preferred rack tile order (an opaque client string) and the board tiles laid but not yet
// submitted. The gateway forwards the JSON verbatim; this layer owns its shape.
type draftDTO struct {
@@ -352,7 +351,7 @@ func (d draftDTO) toDomain() game.Draft {
return game.Draft{RackOrder: d.RackOrder, BoardTiles: tiles}
}
// handleGetDraft returns the player's saved composition for a game (Stage 17), or an empty
// handleGetDraft returns the player's saved composition for a game, or an empty
// draft when none is stored.
func (s *Server) handleGetDraft(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
@@ -367,7 +366,7 @@ func (s *Server) handleGetDraft(c *gin.Context) {
c.JSON(http.StatusOK, draftDTOFrom(d))
}
// handleSaveDraft upserts the player's composition for a game (Stage 17). The service
// handleSaveDraft upserts the player's composition for a game. The service
// rejects a non-player with ErrNotAPlayer.
func (s *Server) handleSaveDraft(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
@@ -12,7 +12,7 @@ import (
"scrabble/backend/internal/lobby"
)
// The /api/v1/user/invitations/* handlers wire friend-game invitations (Stage 8):
// The /api/v1/user/invitations/* handlers wire friend-game invitations:
// create a 2-4 player invitation, accept/decline as an invitee, cancel as the
// inviter, and list the open invitations touching the caller. Display names for the
// inviter and invitees are resolved from the account store.
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"scrabble/backend/internal/link"
)
// The /api/v1/user/link handlers drive account linking & merge (Stage 11). The
// The /api/v1/user/link handlers drive account linking & merge. The
// request step always mails a code (no pre-send "taken" signal, so a probe cannot
// enumerate registered emails); confirm reveals a required merge only after the
// code is verified; merge performs the irreversible consolidation behind an
@@ -23,7 +23,7 @@ type rateLimitReportEntry struct {
}
// handleRateLimitReport ingests one gateway rejection report into the rate
// watch — the admin console's throttled view and the high-rate auto-flag (R3).
// watch — the admin console's throttled view and the high-rate auto-flag.
// Internal, gateway-only: like sessions/resolve it trusts the network segment.
// Malformed individual entries are skipped by the watch itself.
func (s *Server) handleRateLimitReport(c *gin.Context) {
+1 -1
View File
@@ -58,7 +58,7 @@ func TestResolveSessionRejectsEmptyToken(t *testing.T) {
}
}
// TestRateLimitReportEndpoint covers the internal R3 report route: a malformed
// TestRateLimitReportEndpoint covers the internal report route: a malformed
// body is a 400, a valid report lands in the rate watch with 204.
func TestRateLimitReportEndpoint(t *testing.T) {
watch := ratewatch.New(ratewatch.DefaultConfig(), nil, nil)
+3 -3
View File
@@ -27,7 +27,7 @@ func (s *Server) handleProfile(c *gin.Context) {
}
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter
// is a wire alphabet index (Stage 13); for a blank it is the designated letter's index.
// is a wire alphabet index; for a blank it is the designated letter's index.
type submitPlayRequest struct {
Dir string `json:"dir"`
Tiles []struct {
@@ -39,7 +39,7 @@ type submitPlayRequest struct {
}
// tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile
// records for the game's variant (Stage 13: a placed blank carries its designated letter's
// records for the game's variant (a placed blank carries its designated letter's
// index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400).
func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) {
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
@@ -94,7 +94,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
}
// handleGameState returns the player's view of a game.
// handleHideGame hides a finished game from the caller's own lobby list (Stage 17).
// handleHideGame hides a finished game from the caller's own lobby list.
func (s *Server) handleHideGame(c *gin.Context) {
uid, ok := userID(c)
if !ok {
+10 -10
View File
@@ -50,20 +50,20 @@ type Deps struct {
// func skips the session-readiness check.
SessionsReady func() bool
// Sessions, Accounts and Games are the identity, account and game-domain
// services the Stage 6 REST handlers route to.
// services the REST handlers route to.
Sessions *session.Service
Accounts *account.Store
Games *game.Service
// Social, Matchmaker, Invitations and Emails are the Stage 4 domain services
// the Stage 6 REST handlers route to.
// Social, Matchmaker, Invitations and Emails are the domain services
// the REST handlers route to.
Social *social.Service
Matchmaker *lobby.Matchmaker
Invitations *lobby.InvitationService
Emails *account.EmailService
// Links drives account linking & merge (Stage 11): the /api/v1/user/link
// Links drives account linking & merge: the /api/v1/user/link
// endpoints. A nil Links disables them.
Links *link.Service
// Registry holds the resident dictionaries; the admin console (Stage 10) reads
// Registry holds the resident dictionaries; the admin console reads
// its versions and hot-reloads new ones. DictDir is the dictionary directory a
// reload reads a version subdirectory from. A nil Registry disables the console.
Registry *engine.Registry
@@ -72,7 +72,7 @@ type Deps struct {
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
// notice).
Connector *connector.Client
// RateWatch ingests the gateway's rate-limiter rejection reports (R3): the
// RateWatch ingests the gateway's rate-limiter rejection reports: the
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
// disables the internal report endpoint and the console view.
RateWatch *ratewatch.Watch
@@ -196,16 +196,16 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
// InternalGroup returns the gateway-facing internal route group.
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
// Social returns the social domain service for the handlers added in Stage 6.
// Social returns the social domain service for the handlers.
func (s *Server) Social() *social.Service { return s.social }
// Matchmaker returns the in-memory matchmaking pool for the Stage 6 handlers.
// Matchmaker returns the in-memory matchmaking pool for the handlers.
func (s *Server) Matchmaker() *lobby.Matchmaker { return s.matchmaker }
// Invitations returns the friend-game invitation service for the Stage 6 handlers.
// Invitations returns the friend-game invitation service for the handlers.
func (s *Server) Invitations() *lobby.InvitationService { return s.invitations }
// Emails returns the email confirm-code service for the Stage 6 handlers.
// Emails returns the email confirm-code service for the handlers.
func (s *Server) Emails() *account.EmailService { return s.emails }
// Handler returns the underlying HTTP handler. It lets tests drive the server
+2 -2
View File
@@ -97,8 +97,8 @@ func (c *Cache) Remove(tokenHash string) {
}
// RemoveByAccount evicts every cached session belonging to accountID. The
// account-merge flow uses it to drop a retired secondary account's sessions
// (Stage 11); a linear scan is adequate at the cache's size.
// account-merge flow uses it to drop a retired secondary account's sessions;
// a linear scan is adequate at the cache's size.
func (c *Cache) RemoveByAccount(accountID uuid.UUID) {
if c == nil {
return
+2 -2
View File
@@ -73,8 +73,8 @@ func (svc *Service) Revoke(ctx context.Context, token string) error {
}
// RevokeAllForAccount revokes every active session of accountID and evicts them
// from the cache. The account-merge flow calls it to retire a secondary account
// (Stage 11). It is idempotent.
// from the cache. The account-merge flow calls it to retire a secondary account.
// It is idempotent.
func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error {
if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil {
return err
+2 -2
View File
@@ -112,8 +112,8 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time
// RevokeAllForAccount transitions every active session of accountID to revoked
// and returns the post-update rows (so the caller can evict them from the cache).
// It backs the account-merge flow, which retires a secondary account's sessions
// (Stage 11). No matching rows is not an error.
// It backs the account-merge flow, which retires a secondary account's sessions.
// No matching rows is not an error.
func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) {
stmt := table.Sessions.
UPDATE(table.Sessions.Status, table.Sessions.RevokedAt).
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"scrabble/backend/internal/account"
)
// AdminMessage is one chat message in the admin moderation list (Stage 17): the message
// AdminMessage is one chat message in the admin moderation list: the message
// plus its sender's resolved display name and source, for the operator console.
type AdminMessage struct {
ID uuid.UUID
+5 -5
View File
@@ -58,7 +58,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
return Message{}, ErrNotParticipant
}
// Chat is allowed only on the sender's own turn in an active game; the opponent's-turn
// control is the nudge (Stage 17).
// control is the nudge.
if status != statusActive {
return Message{}, ErrGameNotActive
}
@@ -115,7 +115,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
}
if ok && svc.now().Sub(last) < nudgeInterval {
// The cooldown resets once the sender has acted (moved or chatted) since the last
// nudge — engagement clears the "don't spam" limit (Stage 17).
// nudge — engagement clears the "don't spam" limit.
acted, err := svc.actedSince(ctx, gameID, senderID, last)
if err != nil {
return Message{}, err
@@ -132,7 +132,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
if toMove >= 0 && toMove < len(seats) {
nudge := notify.Nudge(seats[toMove], gameID, senderID)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one (Stage 17)
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
}
svc.pub.Publish(nudge)
}
@@ -140,7 +140,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
}
// actedSince reports whether senderID made a move or posted a chat message in the game
// after t — the events that reset the nudge cooldown (Stage 17).
// after t — the events that reset the nudge cooldown.
func (svc *Service) actedSince(ctx context.Context, gameID, senderID uuid.UUID, t time.Time) (bool, error) {
if mv, ok, err := svc.games.LastMoveAt(ctx, gameID, senderID); err != nil {
return false, err
@@ -291,7 +291,7 @@ func (s *Store) lastNudgeAt(ctx context.Context, gameID, senderID uuid.UUID) (ti
// lastMessageAt returns the time of senderID's most recent non-nudge chat message in
// gameID, if any. The nudge cooldown resets when the player chats (or moves), so a stale
// nudge no longer blocks a new one (Stage 17).
// nudge no longer blocks a new one.
func (s *Store) lastMessageAt(ctx context.Context, gameID, senderID uuid.UUID) (time.Time, bool, error) {
stmt := postgres.SELECT(table.ChatMessages.CreatedAt).
FROM(table.ChatMessages).
+1 -1
View File
@@ -31,7 +31,7 @@ const friendRequestTTL = 30 * 24 * time.Hour
// accountRef resolves accountID into a notify.AccountRef (the display name from the account
// store, empty on a lookup failure), for enriching the friend_* live events so the client
// updates its requests/friends state without a refetch (R4).
// updates its requests/friends state without a refetch.
func (svc *Service) accountRef(ctx context.Context, accountID uuid.UUID) notify.AccountRef {
ref := notify.AccountRef{AccountID: accountID.String()}
if acc, err := svc.accounts.GetByID(ctx, accountID); err == nil {
+2 -2
View File
@@ -32,7 +32,7 @@ type GameReader interface {
// has moved); the nudge cooldown resets once the player has taken a turn.
LastMoveAt(ctx context.Context, gameID, accountID uuid.UUID) (time.Time, bool, error)
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
// to the game's bot rather than the recipient's last-login bot (Stage 17).
// to the game's bot rather than the recipient's last-login bot.
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
}
@@ -75,7 +75,7 @@ var (
ErrGameNotActive = errors.New("social: game is not active")
// ErrChatNotYourTurn is returned when a chat message is sent while it is not the
// sender's turn — chat is allowed only on your own turn (the opponent's-turn control
// is the nudge, Stage 17).
// is the nudge).
ErrChatNotYourTurn = errors.New("social: cannot chat while it is not your turn")
)