Stage 7: UI playable slice + remaining edge ops #7

Merged
developer merged 6 commits from feature/stage-7-ui into master 2026-06-03 10:20:32 +00:00
130 changed files with 11610 additions and 64 deletions
+64
View File
@@ -0,0 +1,64 @@
name: Tests · UI
# Hermetic UI checks: type-check, Vitest unit tests, production build with a
# bundle-size budget, and a Playwright smoke against the in-memory mock transport
# (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not
# regenerated (the same model as the Go committed jet/fbs output).
on:
push:
paths:
- 'ui/**'
- '.gitea/workflows/ui-test.yaml'
pull_request:
paths:
- 'ui/**'
- '.gitea/workflows/ui-test.yaml'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Type-check
run: pnpm run check
- name: Unit tests
run: pnpm run test:unit
- name: Build
run: pnpm run build
- name: Bundle-size budget
run: node scripts/bundle-size.mjs
# The Playwright system libraries are provisioned once on the runner host
# (`sudo npx playwright@<version> install-deps chromium`), so the job needs no
# apt and no sudo: it only downloads the browser binary into the runner cache
# (persisted by the host executor) and runs the smoke. The timeouts guard
# against a future hang. Keep this in lockstep with @playwright/test in
# package.json — re-run install-deps on the host after a major bump.
- name: Install Playwright browser
run: pnpm exec playwright install chromium
timeout-minutes: 5
- name: E2E smoke (mock)
run: pnpm run test:e2e
timeout-minutes: 5
+8
View File
@@ -121,4 +121,12 @@ go vet ./backend/...
gofmt -l . # must print nothing gofmt -l . # must print nothing
go test -count=1 ./backend/... go test -count=1 ./backend/...
go run ./backend/cmd/backend # /healthz, /readyz on :8080 go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
pnpm start # UI mock mode: lobby -> game, no backend
``` ```
The `ui` module is a Node project (pnpm), **not** in `go.work`; its CI is
`.gitea/workflows/ui-test.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
+129 -19
View File
@@ -40,11 +40,12 @@ independent (see ARCHITECTURE §9.1).
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | **done** | | 5 | Robot opponent | **done** |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo | | 7 | UI playable slice (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | todo |
| 8 | Telegram integration (bot side-service, deep-link, push) | todo | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo |
| 9 | Admin & dictionary ops (complaint review, version reload) | todo | | 9 | Telegram integration (bot side-service, deep-link, push) | todo |
| 10 | Account linking & merge | todo | | 10 | Admin & dictionary ops (complaint review, version reload) | todo |
| 11 | Polish (observability, perf with evidence, deploy) | todo | | 11 | Account linking & merge | todo |
| 12 | Polish (observability, perf with evidence, deploy) | todo |
Scaffolding is incremental: `go.work` lists only existing modules; each stage Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs. adds the modules it needs.
@@ -70,7 +71,7 @@ platform identities.
Open details: Postgres version + DSN/`search_path` convention; jet vs Open details: Postgres version + DSN/`search_path` convention; jet vs
sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque
random length, TTL, revocation); account/identity table shape; whether the random length, TTL, revocation); account/identity table shape; whether the
admin bootstrap lands here or in Stage 9. admin bootstrap lands here or in Stage 10.
### Stage 2 — Engine package ### Stage 2 — Engine package
Scope: `backend/internal/engine` over scrabble-solver — versioned DAWG Scope: `backend/internal/engine` over scrabble-solver — versioned DAWG
@@ -120,25 +121,90 @@ available); Capacitor-ready structure.
Open details: detailed game-board UX (deferred by the owner to this stage); Open details: detailed game-board UX (deferred by the owner to this stage);
client routing; offline/refresh behaviour; design system / theming. client routing; offline/refresh behaviour; design system / theming.
### Stage 8 — Telegram integration #### Suggested layouts (lobby + game screen)
User note:
> Detailed interview about UI/UX is **strongly** required.
> Too much to discuss.
```text
┌────────────────────┐
│ Display_Name =│- Profile
├────────────────────┤- Settings
│ Invitations │- About
│ - list │
├────────────────────┤
│ Active games │
│ - list │
├────────────────────┤
│ Finished games │
│ - list │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
├────────────────────┤
│ ┌───┐ ┌───┐ ┌───┐│
│ New │ Stats Tourn│
│ └───┘ └───┘ └───┘│
└────────────────────┘
┌────────────────────┐
Lobby│◄ ==│- History
├────────────────────┤- Chat
│You Ann Kaya Rick│- Check word
│136 700 179 39│- Drop game
├────────────────────┤
│ │
│ │
│ │
│ c │
│ words │
│ o │
│ s │
│ s │
│ │
│ │
├──┬──┬──┬──┬──┬──┬──┤ ┌──┐
│A │Q │Z │* │N │I │W │◄│ │MakeMove/Reset
├──┴──┴──┴──┴──┴──┴──┤ └──┘
│ ┌───┐ ┌───┐ ┌───┐ │
│ Draw│ Skip│ Shfl│ │
│ └───┘ └───┘ └───┘ │
└────────────────────┘
```
### Stage 8 — UI: social, account & history surfaces
Scope: the UI surfaces deferred from Stage 7's playable slice, wiring the matching
backend/gateway operations as each screen needs them (the Stage 6 vertical-slice
pattern): friends (request/accept/decline/list), per-user blocks, friend-game
invitations (create 24 player, accept/decline, invitations list), profile **editing**
(`account.UpdateProfile` + the email confirm-code binding UI), the statistics screen,
and the history viewer with GCG export/download.
Open details: friends/invitations UX; stats presentation; history/GCG viewer + download
mechanics; any new validation the profile-editing forms need.
### Stage 9 — Telegram integration
Scope: bot side-service, deep-link invites, platform push (your-turn / nudge), Scope: bot side-service, deep-link invites, platform push (your-turn / nudge),
Mini App launch/auth; backend↔platform internal API. Mini App launch/auth; backend↔platform internal API.
Open details: bot framework/library; deep-link scheme; push message templates; Open details: bot framework/library; deep-link scheme; push message templates;
internal API contract; Mini App hosting/origin. internal API contract; Mini App hosting/origin.
### Stage 9 — Admin & dictionary ops ### Stage 10 — Admin & dictionary ops
Scope: admin endpoints (users, games, complaint review queue, dictionary Scope: admin endpoints (users, games, complaint review queue, dictionary
versions + reload), complaint→dictionary update pipeline. versions + reload), complaint→dictionary update pipeline.
Open details: whether a server-rendered console is wanted or JSON-only; the Open details: whether a server-rendered console is wanted or JSON-only; the
dictionary rebuild/deploy pipeline; complaint resolution workflow. dictionary rebuild/deploy pipeline; complaint resolution workflow.
### Stage 10 — Account linking & merge ### Stage 11 — Account linking & merge
Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends, Scope: link-via-confirm; merge-into-A (stats sum, transfer games/friends,
dedupe). High blast-radius — focused regression tests. dedupe). High blast-radius — focused regression tests.
Open details: conflict resolution (active games on both, duplicate friends, Open details: conflict resolution (active games on both, duplicate friends,
display-name collisions); irreversibility/audit; confirm-flow per platform. display-name collisions); irreversibility/audit; confirm-flow per platform.
### Stage 11 — Polish ### Stage 12 — Polish
Scope: observability dashboards, evidence-based performance work, prod Scope: observability dashboards, evidence-based performance work, prod
build/deploy. build/deploy.
Open details: deployment target/host; dashboards; load expectations. Open details: deployment target/host; dashboards; load expectations.
@@ -164,9 +230,9 @@ Open details: deployment target/host; dashboards; load expectations.
- HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user, - HTTP surface: **service/store/cache layer only**. `/api/v1/{public,user,
internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via
`Server` group accessors); the session/account REST handlers land with the `Server` group accessors); the session/account REST handlers land with the
gateway in **Stage 6**. Admin bootstrap deferred to **Stage 9**. gateway in **Stage 6**. Admin bootstrap deferred to **Stage 10**.
- Telemetry: providers + request-timing middleware + otelsql; exporters - Telemetry: providers + request-timing middleware + otelsql; exporters
`none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 11**. `none` (default) / `stdout`; OTLP + dashboards deferred to **Stage 12**.
- Tests/CI: integration tests behind the `integration` build tag in - Tests/CI: integration tests behind the `integration` build tag in
`backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk `backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk
off, serial), firing on push and PR. Backend now **hard-depends on Postgres off, serial), firing on push and PR. Backend now **hard-depends on Postgres
@@ -247,7 +313,7 @@ Open details: deployment target/host; dashboards; load expectations.
wins/losses; `max_word_points` = best single **move** score; ties draw, wins/losses; `max_word_points` = best single **move** score; ties draw,
resign/timeout is a loss, guests get no stats. resign/timeout is a loss, guests get no stats.
- **Complaint** (interview): full payload with `game_id`; word-check is scoped - **Complaint** (interview): full payload with `game_id`; word-check is scoped
to the game's pinned `(variant, dict_version)`. Stage 9 owns the resolution to the game's pinned `(variant, dict_version)`. Stage 10 owns the resolution
lifecycle, so the `status` column carries no value CHECK yet. lifecycle, so the `status` column carries no value CHECK yet.
- **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon` - **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon`
pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES` pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
@@ -295,7 +361,7 @@ Open details: deployment target/host; dashboards; load expectations.
stored as a **SHA-256 hash**; a `Mailer` seam with an SMTP relay 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 (`BACKEND_SMTP_*`) and a default **log mailer**. It binds an email to the
current account; an email already confirmed by another account → `ErrEmailTaken` current account; an email already confirmed by another account → `ErrEmailTaken`
(**merge is Stage 10**); email-as-login is Stage 6 and reuses this mechanism. (**merge is Stage 11**); email-as-login is Stage 6 and reuses this mechanism.
- **Multi-player drop-out** (interview; discharges the Stage 3 deferral): the - **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 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 active, finishing (last-survivor wins) when one remains; `winner` excludes all
@@ -362,9 +428,11 @@ Open details: deployment target/host; dashboards; load expectations.
end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/ end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/
`auth.email.login`), `profile.get`, `game.submit_play`/`game.state`, `auth.email.login`), `profile.get`, `game.submit_play`/`game.state`,
`lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin `lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin
passthrough. The remaining domain operations (friends, blocks, invitations, passthrough. The remaining domain operations reuse the identical transcode
hint, word-check, pass/exchange/resign, history/GCG, profile editing) reuse the pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/
identical transcode pattern and are wired in **Stage 7** as the UI needs them. resign, hint, evaluate, word-check/complaint, history, my-games list, chat
list/nudge) in **Stage 7**; the social/account ops (friends, blocks,
invitations, profile editing, stats, GCG export) in **Stage 8**.
- **Wire contracts in a new shared `scrabble/pkg` module** (interview): the - **Wire contracts in a new shared `scrabble/pkg` module** (interview): the
backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads
(`pkg/fbs`, one `scrabblefb` namespace) live here with **committed** generated (`pkg/fbs`, one `scrabblefb` namespace) live here with **committed** generated
@@ -402,7 +470,7 @@ Open details: deployment target/host; dashboards; load expectations.
(the galaxy donor's crypto stack was dropped, per §3). (the galaxy donor's crypto stack was dropped, per §3).
- **Admin = gateway validates Basic-Auth** (interview): the gateway checks - **Admin = gateway validates Basic-Auth** (interview): the gateway checks
`GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend `GATEWAY_ADMIN_USER`/`_PASSWORD` and reverse-proxies to backend
`/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 9. `/api/v1/admin/*`; the backend admin surface is a single `ping` until Stage 10.
- **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min, - **Rate-limit = 2 dimensions, 3 classes** (interview): public per-IP (30/min,
burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min, burst 10), authenticated per-user (120/min, burst 40), admin per-IP (60/min,
burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket burst 20), plus an email-code per-IP sub-limit (5/10 min); token bucket
@@ -416,6 +484,48 @@ Open details: deployment target/host; dashboards; load expectations.
(unit) — integration stays `./backend/...` (the only module with tagged tests). (unit) — integration stays `./backend/...` (the only module with tagged tests).
The solver clone + `BACKEND_DICT_DIR` steps are unchanged. The solver clone + `BACKEND_DICT_DIR` steps are unchanged.
- **Stage 7** (interview + implementation):
- **Scope = playable slice** (interview): the *whole* UI shell plus the core play
loop end-to-end; the social/account/history surfaces were split out into a new
**Stage 8** and the later stages shifted +1 (Telegram→9, Admin→10, Linking→11,
Polish→12). Stage 7 wires only the operations the slice needs (the Stage 6
"as the UI needs them" pattern): the new gateway/transcode + backend-REST ops
`games.list`, `game.{pass,exchange,resign,hint,evaluate,check_word,complaint,
history}`, `chat.{list,nudge}`. The only new domain code is `game.ListForAccount`
(the "my games" query) and seat **`display_name`** resolution (server DTO layer);
`SeatView` gained a trailing `display_name`. Friends/blocks/invitations,
profile-editing, stats and the history/GCG viewer are Stage 8.
- **Stack** (interview): plain **Svelte 5 (runes) + TypeScript + Vite**, no
SvelteKit; `@connectrpc/connect-web` + the `flatbuffers` runtime, with the edge
TS bindings generated from the **same** `edge.proto` (`protoc-gen-es`) and
`scrabble.fbs` (`flatc --ts`) and **committed** under `ui/src/gen/` (dev-time
codegen, like `cmd/jetgen` / `pkg/Makefile`; CI builds the committed output).
- **No board on the wire** (discovered): `StateView` carries no grid, so the client
**replays the decoded move journal** (`game.history`, newly wired) onto an empty
board; premium squares + tile values are a client-side map **ported from
`scrabble-solver/rules/rules.go`** with a Vitest parity test.
- **Board UX** (interview): full-width, borderless; tiles placed by **Pointer-Events
drag or tap** (no HTML5 DnD — it has no touch support); a contextual **MakeMove**
control (short tap → make/reset popup, ~1 s press-and-hold → commit); per-tile
recall by tapping a pending tile; a **two-state zoom** (15↔9 cells) on touch only
(auto-zoom-in on placement, double-tap / pinch manual); a blank-letter chooser.
All board/tiles/effects are **pure HTML5/CSS + Unicode** — no image/font/SVG asset.
- **Theming** (interview): own **CSS custom-property tokens**, light/dark via
`prefers-color-scheme`, **Telegram-themeParams-ready** (a runtime hook can override
the tokens; the SDK is wired in the Telegram stage). **Navigation** (interview):
dependency-free **hash router**; session token in memory + **IndexedDB**, re-resolved
on reload (reopen Subscribe, refetch the open game); stream reconnect on focus.
**i18n** en/ru is a hand-rolled typed catalog (compile-time key parity + a test).
- **Mock transport** (owner request): a build-flagged in-memory fake (`VITE_MOCK`,
`pnpm start`) drives lobby → active game → board with no backend, tree-shaken out
of production; it is the same fixture the Playwright smoke uses.
- **Tests/CI** (interview): **Vitest** units (board replay, placement machine,
premium parity, i18n parity, FlatBuffers codec) + a **Playwright** smoke against
the mock; a new **`ui-test.yaml`** workflow (type-check, unit, build with a
**bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go
workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount`
integration test and gateway transcode tests for the new ops were added.
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
@@ -433,7 +543,7 @@ Open details: deployment target/host; dashboards; load expectations.
git submodule (the ~0.50.7 MB DAWGs are regenerated wholesale and bloat git git submodule (the ~0.50.7 MB DAWGs are regenerated wholesale and bloat git
history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull
is a **deploy-time** way to populate the directory, **not** the runtime is a **deploy-time** way to populate the directory, **not** the runtime
dynamic-reload mechanism (Stage 9) — keep the `BACKEND_DICT_DIR` directory as dynamic-reload mechanism (Stage 10) — keep the `BACKEND_DICT_DIR` directory as
the runtime contract: a new `.dawg` appears in it and is loaded with the runtime contract: a new `.dawg` appears in it and is loaded with
`dawg.Load`. `dawg.Load`.
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a - **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
+15 -2
View File
@@ -11,8 +11,9 @@ supports English Scrabble, Russian Scrabble and Эрудит.
admin surface behind Basic Auth. *(added in a later stage)* admin surface behind Basic Auth. *(added in a later stage)*
- **`backend`** — internal-only service that owns every domain concern and - **`backend`** — internal-only service that owns every domain concern and
embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process. embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process.
- **`ui`** — pure-HTML5 client (plain Svelte + Vite), embeddable in platform - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC
webviews and packageable to native via Capacitor. *(added in a later stage)* + FlatBuffers, embeddable in platform webviews and packageable to native via
Capacitor. See [`ui/README.md`](ui/README.md).
- **`platform/*`** — per-platform side-services (e.g. the Telegram bot). - **`platform/*`** — per-platform side-services (e.g. the Telegram bot).
*(added in a later stage)* *(added in a later stage)*
@@ -67,3 +68,15 @@ Key environment: `BACKEND_HTTP_ADDR` (default `:8080`), `BACKEND_LOG_LEVEL`
(`debug|info|warn|error`, default `info`), `BACKEND_POSTGRES_DSN` (**required**). (`debug|info|warn|error`, default `info`), `BACKEND_POSTGRES_DSN` (**required**).
The full configuration surface and the go-jet regeneration step live in The full configuration surface and the go-jet regeneration step live in
[`backend/README.md`](backend/README.md). [`backend/README.md`](backend/README.md).
## Run the UI locally
```sh
cd ui && pnpm install
pnpm start # mock mode: lobby -> game with no backend, on http://localhost:5173
pnpm dev # against a running gateway (Vite proxies the RPC path to :8081)
```
`pnpm check` (type-check), `pnpm test:unit` (Vitest), `pnpm test:e2e` (Playwright
smoke vs the mock), `pnpm build` (static bundle). Details — including the committed
edge codegen (`pnpm codegen`) — are in [`ui/README.md`](ui/README.md).
+2 -2
View File
@@ -33,7 +33,7 @@ var (
// ErrInvalidEmail is returned for an unparseable email address. // ErrInvalidEmail is returned for an unparseable email address.
ErrInvalidEmail = errors.New("account: invalid email address") ErrInvalidEmail = errors.New("account: invalid email address")
// ErrEmailTaken is returned when the email is already confirmed by another // ErrEmailTaken is returned when the email is already confirmed by another
// account; binding it would be a merge, which Stage 10 owns. // account; binding it would be a merge, which Stage 11 owns.
ErrEmailTaken = errors.New("account: email already confirmed by another account") ErrEmailTaken = errors.New("account: email already confirmed by another account")
// ErrAlreadyConfirmed is returned when the email is already confirmed by the // ErrAlreadyConfirmed is returned when the email is already confirmed by the
// requesting account. // requesting account.
@@ -52,7 +52,7 @@ var (
// Mailer and verifies it, binding a confirmed email identity to the requesting // 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), // 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 // matching the session model. Binding an email already confirmed by a different
// account is refused (ErrEmailTaken) — merging two accounts is Stage 10 — and // account is refused (ErrEmailTaken) — merging two accounts is Stage 11 — and
// using an email as a login is Stage 6, which reuses this mechanism. // using an email as a login is Stage 6, which reuses this mechanism.
type EmailService struct { type EmailService struct {
store *Store store *Store
+7
View File
@@ -566,6 +566,13 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid.
return seats, g.ToMove, g.Status, nil return seats, g.ToMove, g.Status, nil
} }
// ListForAccount returns every game the account is seated in, newest first, for the
// lobby's active/finished lists. The live position is not loaded — the summaries come
// straight from the durable rows.
func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
return svc.store.ListGamesForAccount(ctx, accountID)
}
// History returns a game's full, dictionary-independent move journal. // History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) { func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID) g, err := svc.store.GetGame(ctx, gameID)
+44
View File
@@ -135,6 +135,50 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
return projectGame(grow, srows) return projectGame(grow, srows)
} }
// ListGamesForAccount loads every game the account is seated in (active and
// finished), newest first, each joined with its ordered seats. It backs the lobby's
// "my games" lists.
func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([]Game, error) {
gstmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games.INNER_JOIN(table.GamePlayers, table.GamePlayers.GameID.EQ(table.Games.GameID))).
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.Games.UpdatedAt.DESC())
var grows []model.Games
if err := gstmt.QueryContext(ctx, s.db, &grows); err != nil {
return nil, fmt.Errorf("game: list for account: %w", err)
}
if len(grows) == 0 {
return nil, nil
}
ids := make([]postgres.Expression, len(grows))
for i, g := range grows {
ids[i] = postgres.UUID(g.GameID)
}
sstmt := postgres.SELECT(table.GamePlayers.AllColumns).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.GameID.IN(ids...)).
ORDER_BY(table.GamePlayers.GameID.ASC(), table.GamePlayers.Seat.ASC())
var srows []model.GamePlayers
if err := sstmt.QueryContext(ctx, s.db, &srows); err != nil {
return nil, fmt.Errorf("game: list seats for account: %w", err)
}
byGame := make(map[uuid.UUID][]model.GamePlayers, len(grows))
for _, r := range srows {
byGame[r.GameID] = append(byGame[r.GameID], r)
}
out := make([]Game, 0, len(grows))
for _, g := range grows {
pg, err := projectGame(g, byGame[g.GameID])
if err != nil {
return nil, err
}
out = append(out, pg)
}
return out, nil
}
// GetJournal loads the ordered, decoded move journal for a game. // GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) { func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
stmt := postgres.SELECT(table.GameMoves.AllColumns). stmt := postgres.SELECT(table.GameMoves.AllColumns).
+2 -2
View File
@@ -15,7 +15,7 @@ const (
StatusFinished = "finished" StatusFinished = "finished"
) )
// ComplaintStatus values; Stage 9 owns the resolution lifecycle, Stage 3 only // ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
// ever writes StatusComplaintOpen. // ever writes StatusComplaintOpen.
const StatusComplaintOpen = "open" const StatusComplaintOpen = "open"
@@ -176,7 +176,7 @@ type RobotTurn struct {
Seed int64 Seed int64
} }
// Complaint is a word-check complaint awaiting admin review (Stage 9). // Complaint is a word-check complaint awaiting admin review (Stage 10).
type Complaint struct { type Complaint struct {
ID uuid.UUID ID uuid.UUID
ComplainantID uuid.UUID ComplainantID uuid.UUID
+57
View File
@@ -86,6 +86,63 @@ func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWor
return wins, losses, draws, maxGame, maxWord, true return wins, losses, draws, maxGame, maxWord, true
} }
// TestListForAccount checks the lobby "my games" query: it returns exactly the
// games the account is seated in (each with its seats), and nothing for an outsider.
func TestListForAccount(t *testing.T) {
ctx := context.Background()
svc := newGameService()
me, opp := provisionAccount(t), provisionAccount(t)
g1, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{me, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
})
if err != nil {
t.Fatalf("create g1: %v", err)
}
g2, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{opp, me}, TurnTimeout: 24 * time.Hour, Seed: 2,
})
if err != nil {
t.Fatalf("create g2: %v", err)
}
games, err := svc.ListForAccount(ctx, me)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(games) != 2 {
t.Fatalf("got %d games, want 2", len(games))
}
seen := map[uuid.UUID]bool{}
for _, g := range games {
seen[g.ID] = true
if len(g.Seats) != 2 {
t.Errorf("game %s has %d seats, want 2", g.ID, len(g.Seats))
}
seated := false
for _, s := range g.Seats {
if s.AccountID == me {
seated = true
}
}
if !seated {
t.Errorf("account not found among seats of returned game %s", g.ID)
}
}
if !seen[g1.ID] || !seen[g2.ID] {
t.Errorf("returned games %v missing g1=%s or g2=%s", seen, g1.ID, g2.ID)
}
other := provisionAccount(t)
og, err := svc.ListForAccount(ctx, other)
if err != nil {
t.Fatalf("list other: %v", err)
}
if len(og) != 0 {
t.Errorf("outsider sees %d games, want 0", len(og))
}
}
// TestGameLifecycleAndStats drives a greedy two-player game to its natural end // TestGameLifecycleAndStats drives a greedy two-player game to its natural end
// through the service and checks the finish state and statistics. // through the service and checks the finish state and statistics.
func TestGameLifecycleAndStats(t *testing.T) { func TestGameLifecycleAndStats(t *testing.T) {
+8 -6
View File
@@ -66,13 +66,15 @@ type moveRecordDTO struct {
Total int `json:"total"` Total int `json:"total"`
} }
// seatDTO is one seat's public standing. // seatDTO is one seat's public standing. DisplayName is resolved from the account
// store by the handler (the game domain keys seats by account id only).
type seatDTO struct { type seatDTO struct {
Seat int `json:"seat"` Seat int `json:"seat"`
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
Score int `json:"score"` DisplayName string `json:"display_name"`
HintsUsed int `json:"hints_used"` Score int `json:"score"`
IsWinner bool `json:"is_winner"` HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
} }
// gameDTO is the shared game summary. // gameDTO is the shared game summary.
+11
View File
@@ -37,8 +37,17 @@ func (s *Server) registerRoutes() {
u.GET("/profile", s.handleProfile) u.GET("/profile", s.handleProfile)
} }
if s.games != nil { if s.games != nil {
u.GET("/games", s.handleListGames)
u.POST("/games/:id/play", s.handleSubmitPlay) u.POST("/games/:id/play", s.handleSubmitPlay)
u.GET("/games/:id/state", s.handleGameState) u.GET("/games/:id/state", s.handleGameState)
u.POST("/games/:id/pass", s.handlePass)
u.POST("/games/:id/exchange", s.handleExchange)
u.POST("/games/:id/resign", s.handleResign)
u.POST("/games/:id/hint", s.handleHint)
u.POST("/games/:id/evaluate", s.handleEvaluate)
u.GET("/games/:id/check_word", s.handleCheckWord)
u.POST("/games/:id/complaint", s.handleComplaint)
u.GET("/games/:id/history", s.handleHistory)
} }
if s.matchmaker != nil { if s.matchmaker != nil {
u.POST("/lobby/enqueue", s.handleEnqueue) u.POST("/lobby/enqueue", s.handleEnqueue)
@@ -46,6 +55,8 @@ func (s *Server) registerRoutes() {
} }
if s.social != nil { if s.social != nil {
u.POST("/games/:id/chat", s.handleChatPost) u.POST("/games/:id/chat", s.handleChatPost)
u.GET("/games/:id/chat", s.handleChatList)
u.POST("/games/:id/nudge", s.handleNudge)
} }
s.admin.GET("/ping", s.handleAdminPing) s.admin.GET("/ping", s.handleAdminPing)
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth // The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth
// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to // reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to
// have authenticated the operator; the admin surface itself (complaint review, // have authenticated the operator; the admin surface itself (complaint review,
// dictionary versions) lands in Stage 9. handleAdminPing is the proxy target that // dictionary versions) lands in Stage 10. handleAdminPing is the proxy target that
// proves the path end to end until then. // proves the path end to end until then.
func (s *Server) handleAdminPing(c *gin.Context) { func (s *Server) handleAdminPing(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
+321
View File
@@ -0,0 +1,321 @@
package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"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.
// hintResultDTO is the top-ranked move plus the remaining hint budget.
type hintResultDTO struct {
Move moveRecordDTO `json:"move"`
HintsRemaining int `json:"hints_remaining"`
}
// evalResultDTO is an unlimited move preview: legality, score and the words formed.
type evalResultDTO struct {
Legal bool `json:"legal"`
Score int `json:"score"`
Words []string `json:"words"`
}
// wordCheckDTO is the result of the unlimited dictionary lookup tool.
type wordCheckDTO struct {
Word string `json:"word"`
Legal bool `json:"legal"`
}
// historyDTO is a game's decoded move journal, the source for client board replay.
type historyDTO struct {
GameID string `json:"game_id"`
Moves []moveRecordDTO `json:"moves"`
}
// gameListDTO is the caller's games (active and finished) for the lobby.
type gameListDTO struct {
Games []gameDTO `json:"games"`
}
// chatListDTO is a game's chat history.
type chatListDTO struct {
Messages []chatDTO `json:"messages"`
}
// exchangeRequest swaps the given rack tiles back into the bag.
type exchangeRequest struct {
Tiles []string `json:"tiles"`
}
// complaintRequest disputes a word-check result.
type complaintRequest struct {
Word string `json:"word"`
Note string `json:"note"`
}
// fillSeatNames resolves each seat's display name from the account store, memoising
// across seats and games within one request.
func (s *Server) fillSeatNames(ctx context.Context, g *gameDTO, memo map[string]string) {
for i := range g.Seats {
id := g.Seats[i].AccountID
name, ok := memo[id]
if !ok {
if uid, err := uuid.Parse(id); err == nil {
if acc, err := s.accounts.GetByID(ctx, uid); err == nil {
name = acc.DisplayName
}
}
memo[id] = name
}
g.Seats[i].DisplayName = name
}
}
// moveRecordDTOFromHistory projects a journal move into the shared move DTO.
func moveRecordDTOFromHistory(m game.HistoryMove) moveRecordDTO {
tiles := make([]tileDTO, 0, len(m.Tiles))
for _, t := range m.Tiles {
tiles = append(tiles, tileDTO{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
return moveRecordDTO{
Player: m.Seat,
Action: m.Action,
Dir: m.Dir,
MainRow: m.MainRow,
MainCol: m.MainCol,
Tiles: tiles,
Words: m.Words,
Count: len(m.Words),
Score: m.Score,
Total: m.RunningTotal,
}
}
// handlePass forfeits the player's turn.
func (s *Server) handlePass(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
res, err := s.games.Pass(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleExchange swaps the chosen rack tiles back into the bag.
func (s *Server) handleExchange(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req exchangeRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, req.Tiles)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleResign resigns the player from the game.
func (s *Server) handleResign(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
res, err := s.games.Resign(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
s.writeMoveResult(c, res)
}
// handleHint reveals the top-ranked move and spends a hint.
func (s *Server) handleHint(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
h, err := s.games.Hint(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, hintResultDTO{
Move: moveRecordDTOFrom(h.Move),
HintsRemaining: h.HintsRemaining,
})
}
// handleEvaluate previews a tentative play's legality and score.
func (s *Server) handleEvaluate(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req submitPlayRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
dir, ok := parseDirection(req.Dir)
if !ok {
abortBadRequest(c, "dir must be H or V")
return
}
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
for _, t := range req.Tiles {
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
}
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words})
}
// handleCheckWord looks a word up in the game's pinned dictionary.
func (s *Server) handleCheckWord(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
word := c.Query("word")
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
}
// handleComplaint files a word-check complaint into the admin review queue.
func (s *Server) handleComplaint(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
var req complaintRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if _, err := s.games.FileComplaint(c.Request.Context(), gameID, uid, req.Word, req.Note); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleHistory returns a game's decoded move journal for board replay / history.
func (s *Server) handleHistory(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
view, err := s.games.History(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
moves := make([]moveRecordDTO, 0, len(view.Moves))
for _, m := range view.Moves {
moves = append(moves, moveRecordDTOFromHistory(m))
}
c.JSON(http.StatusOK, historyDTO{GameID: gameID.String(), Moves: moves})
}
// handleListGames returns the caller's active and finished games for the lobby.
func (s *Server) handleListGames(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
games, err := s.games.ListForAccount(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
memo := map[string]string{}
out := make([]gameDTO, 0, len(games))
for _, g := range games {
dto := gameDTOFromGame(g)
s.fillSeatNames(c.Request.Context(), &dto, memo)
out = append(out, dto)
}
c.JSON(http.StatusOK, gameListDTO{Games: out})
}
// handleChatList returns a game's chat history for the viewer.
func (s *Server) handleChatList(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
msgs, err := s.social.Messages(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
out := make([]chatDTO, 0, len(msgs))
for _, m := range msgs {
out = append(out, chatDTOFrom(m))
}
c.JSON(http.StatusOK, chatListDTO{Messages: out})
}
// handleNudge posts a nudge to the player whose turn is awaited.
func (s *Server) handleNudge(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
if !ok {
return
}
msg, err := s.social.Nudge(c.Request.Context(), gameID, uid)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, chatDTOFrom(msg))
}
// userGame reads the authenticated account and the :id game param, aborting with the
// right status when either is missing. ok is false when the request was aborted.
func (s *Server) userGame(c *gin.Context) (uuid.UUID, uuid.UUID, bool) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return uuid.UUID{}, uuid.UUID{}, false
}
gameID, ok := gameIDParam(c)
if !ok {
abortBadRequest(c, "invalid game id")
return uuid.UUID{}, uuid.UUID{}, false
}
return uid, gameID, true
}
// writeMoveResult emits a committed move with seat display names filled in.
func (s *Server) writeMoveResult(c *gin.Context, res game.MoveResult) {
dto := moveResultDTOFrom(res)
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
c.JSON(http.StatusOK, dto)
}
+14 -4
View File
@@ -68,7 +68,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, moveResultDTOFrom(res)) s.writeMoveResult(c, res)
} }
// handleGameState returns the player's view of a game. // handleGameState returns the player's view of a game.
@@ -88,7 +88,9 @@ func (s *Server) handleGameState(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, stateDTOFrom(view)) dto := stateDTOFrom(view)
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
c.JSON(http.StatusOK, dto)
} }
// enqueueRequest joins the per-variant auto-match pool. // enqueueRequest joins the per-variant auto-match pool.
@@ -118,7 +120,11 @@ func (s *Server) handleEnqueue(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, matchDTOFrom(res)) dto := matchDTOFrom(res)
if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
}
c.JSON(http.StatusOK, dto)
} }
// handlePoll reports whether the caller has been paired since queueing. // handlePoll reports whether the caller has been paired since queueing.
@@ -133,7 +139,11 @@ func (s *Server) handlePoll(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
c.JSON(http.StatusOK, matchDTOFrom(res)) dto := matchDTOFrom(res)
if dto.Game != nil {
s.fillSeatNames(c.Request.Context(), dto.Game, map[string]string{})
}
c.JSON(http.StatusOK, dto)
} }
// chatPostRequest posts a per-game chat message. // chatPostRequest posts a per-game chat message.
+21 -10
View File
@@ -24,9 +24,20 @@ Three executables plus per-platform side-services:
administration. Embeds the **`scrabble-solver`** engine **as a library, administration. Embeds the **`scrabble-solver`** engine **as a library,
in-process** — there is no per-game container. The only network consumer of in-process** — there is no per-game container. The only network consumer of
`backend` is `gateway` (plus platform side-services over an internal API). `backend` is `gateway` (plus platform side-services over an internal API).
- **`ui`** *(planned)* — pure-HTML5 client (plain Svelte + Vite, static build). - **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build;
Talks to `backend` only through `gateway`. Embeddable in platform webviews; no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC +
packageable to native (iOS/Android) via Capacitor. FlatBuffers, with the edge TS bindings generated from the **same** `edge.proto`
and `scrabble.fbs` and committed under `ui/src/gen/`. The **playable slice**
(Stage 7) covers auth, "my games", auto-match, the board (play/pass/exchange/
resign), hint, word-check, chat/nudge, the live stream, i18n (en/ru) and a profile
view; the social/account/history surfaces follow in Stage 8. There is no board on
the wire — the client **reconstructs the 15×15 board by replaying the move
journal** (§9.1) and renders board, tiles, premium squares and effects as pure
CSS + Unicode (no image/font/SVG assets). Tiles are placed by Pointer-Events drag
or tap; a CSS-token theme is light/dark and Telegram-themeParams-ready; navigation
is a hash router and the session token is held in memory + IndexedDB. A build-flagged
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
- **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot - **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot
first): deep-link invites and platform-native push notifications. They talk first): deep-link invites and platform-native push notifications. They talk
to `backend` over an internal API. to `backend` over an internal API.
@@ -108,7 +119,7 @@ arrive from a platform rather than completing a mandatory registration).
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a 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 development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. An email already confirmed by **another** account is confirmed email identity. An email already confirmed by **another** account is
refused — adopting it would be a merge, which Stage 10 owns. Accounts and refused — adopting it would be a merge, which Stage 11 owns. Accounts and
identities use application-generated **UUIDv7** primary keys. identities use application-generated **UUIDv7** primary keys.
- **Linking** is initiated from an authenticated profile: choose a platform → - **Linking** is initiated from an authenticated profile: choose a platform →
complete that platform's web-auth confirm → attach the identity to the complete that platform's web-auth confirm → attach the identity to the
@@ -140,7 +151,7 @@ Key points:
word-check tool through `Registry.Lookup`. word-check tool through `Registry.Lookup`.
- **Dictionary versioning — pin per game.** A game records the `dict_version` it - **Dictionary versioning — pin per game.** A game records the `dict_version` it
started on and finishes on that version; new games use the latest. Multiple started on and finishes on that version; new games use the latest. Multiple
versions may be resident at once. An admin reload *(planned, Stage 9)* versions may be resident at once. An admin reload *(planned, Stage 10)*
registers a new version through `Registry.Load`; delivery is the DAWG file in registers a new version through `Registry.Load`; delivery is the DAWG file in
the image / a volume mounted at the dictionary directory. (A future split of the image / a volume mounted at the dictionary directory. (A future split of
the solver into engine + dictionary generator with versioned artifacts is the solver into engine + dictionary generator with versioned artifacts is
@@ -202,7 +213,7 @@ Key points:
- **Word-check tool**: unlimited dictionary lookups against the game's pinned - **Word-check tool**: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a **complaint** (complainant, game, variant, dictionary; each result offers a **complaint** (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in an dict_version, word, the disputed result, an optional note) that lands in an
admin review queue *(admin side planned, Stage 9)*. admin review queue *(admin side planned, Stage 10)*.
## 7. Robot opponent ## 7. Robot opponent
@@ -250,7 +261,7 @@ requires (there is no DM surface; chat is per-game).
emits a **match-found** notification (§10), delivered over the live stream; emits a **match-found** notification (§10), delivered over the live stream;
`Poll` remains as a fallback for a client that is not currently streaming. `Poll` remains as a fallback for a client that is not currently streaming.
- **Friends**: a **request → accept** graph (one `friendships` table) — add by - **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 friend list or internal ID now, by platform deep-link with Stage 9. Declining or
cancelling removes the pending request; blocking someone severs an existing cancelling removes the pending request; blocking someone severs an existing
friendship. friendship.
- **Block**: two independent **global** account toggles (`block_chat`, - **Block**: two independent **global** account toggles (`block_chat`,
@@ -275,7 +286,7 @@ requires (there is no DM surface; chat is per-game).
(confirm-code binding, see §4), **timezone** (drives the away window and the (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 — robot's sleep; user-editable), the daily **away window** and the block toggles —
all editable through `account.UpdateProfile`. Linked platform accounts and merge all editable through `account.UpdateProfile`. Linked platform accounts and merge
are Stage 10. are Stage 11.
## 9. Persistence ## 9. Persistence
@@ -337,7 +348,7 @@ does not cover.
## 10. Notifications ## 10. Notifications
Two channels: the **in-app live stream** (delivered from Stage 6) and Two channels: the **in-app live stream** (delivered from Stage 6) and
**platform-native push** (out-of-app, via the platform side-service — Stage 8). **platform-native push** (out-of-app, via the platform side-service — Stage 9).
The backend emits notification intents through an in-process hub The backend emits notification intents through an in-process hub
(`internal/notify`, a `Publisher` seam installed on the game, social and lobby (`internal/notify`, a `Publisher` seam installed on the game, social and lobby
services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`, services); a single backend→gateway **gRPC server-stream** (`Push.Subscribe`,
@@ -348,7 +359,7 @@ robot-driver and timeout-sweeper moves emit too), **chat-message** and **nudge**
(from the social service), and **match-found** (from the matchmaker, §8). Event (from the social service), and **match-found** (from the matchmaker, §8). Event
payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client payloads are FlatBuffers-encoded by the backend and forwarded verbatim. A client
that is not currently streaming falls back to the matchmaker's `Poll` for that is not currently streaming falls back to the matchmaker's `Poll` for
match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 8; match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 9;
session-revocation events and cursor-based stream resume are deferred session-revocation events and cursor-based stream resume are deferred
(single-instance MVP). (single-instance MVP).
+13 -3
View File
@@ -9,6 +9,16 @@ the detail is authored.
## Domains ## Domains
### Client app *(Stage 7 / 8)*
The web/app client (Svelte + Vite) realizes these stories. The **playable slice**
(Stage 7) covers signing in (guest or email), the "my games" lobby, starting an
auto-match, playing the board (place tiles by drag or tap, pass, exchange, resign),
the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge,
real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. Managing friends and blocks, creating friend games (invitations),
editing the profile, the statistics screen and the history/GCG viewer arrive in
Stage 8.
### Identity & sessions *(Stage 1 / 6)* ### Identity & sessions *(Stage 1 / 6)*
A player arrives from a platform (Telegram first), via email login, or as an A player arrives from a platform (Telegram first), via email login, or as an
ephemeral guest. The gateway validates the credential once and mints a thin ephemeral guest. The gateway validates the credential once and mints a thin
@@ -17,7 +27,7 @@ session-only with restricted features (auto-match only; no friends, stats or
history). While the app is open the client keeps a live stream and receives history). While the app is open the client keeps a live stream and receives
in-app updates in real time — the opponent's move, your turn, chat, nudges and a in-app updates in real time — the opponent's move, your turn, chat, nudges and a
found match; out-of-app push (your turn, nudge) is delivered by the platform found match; out-of-app push (your turn, nudge) is delivered by the platform
later (Stage 8). later (Stage 9).
### Accounts, linking & merge *(Stage 1 / 10)* ### Accounts, linking & merge *(Stage 1 / 10)*
First platform contact auto-provisions a durable account. From the profile a First platform contact auto-provisions a durable account. From the profile a
@@ -76,7 +86,7 @@ Edit language (en/ru), display name, timezone, the daily away window and the blo
toggles, and bind an email by confirm-code: the backend emails a short code that, 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 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 another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 10. accounts and merge arrive in Stage 11.
### History & statistics *(Stage 3)* ### History & statistics *(Stage 3)*
Finished games are archived in a dictionary-independent form and exportable to Finished games are archived in a dictionary-independent form and exportable to
@@ -84,6 +94,6 @@ GCG. Statistics (durable accounts only): wins, losses, draws, max points in a
game, and max points for a single move (the best play, which already includes game, and max points for a single move (the best play, which already includes
every word it formed plus the all-tiles bonus). every word it formed plus the all-tiles bonus).
### Administration *(Stage 9)* ### Administration *(Stage 10)*
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
versions, and inspects users/games. versions, and inspects users/games.
+13 -3
View File
@@ -8,6 +8,16 @@
## Домены ## Домены
### Клиентское приложение *(Stage 7 / 8)*
Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез**
(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора,
игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача),
top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии,
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. Управление друзьями и блоками, создание дружеских игр
(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG
появятся в Stage 8.
### Личность и сессии *(Stage 1 / 6)* ### Личность и сессии *(Stage 1 / 6)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
@@ -16,7 +26,7 @@ session-токен; backend сопоставляет его с внутренн
статистики и истории). Пока приложение открыто, клиент держит живой стрим и статистики и истории). Пока приложение открыто, клиент держит живой стрим и
получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и
найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит
позже (Stage 8). позже (Stage 9).
### Аккаунты, привязка и слияние *(Stage 1 / 10)* ### Аккаунты, привязка и слияние *(Stage 1 / 10)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
@@ -76,7 +86,7 @@ push доставляется через платформу.
confirm-коду: backend шлёт на почту короткий код, и после ввода email confirm-коду: backend шлёт на почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 10. слияние появятся в Stage 11.
### История и статистика *(Stage 3)* ### История и статистика *(Stage 3)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются Завершённые партии архивируются в независимом от словаря виде и экспортируются
@@ -84,6 +94,6 @@ confirm-коду: backend шлёт на почту короткий код, и
макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все
образованные им слова и бонус за все фишки). образованные им слова и бонус за все фишки).
### Администрирование *(Stage 9)* ### Администрирование *(Stage 10)*
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
словаря, смотрит пользователей/игры. словаря, смотрит пользователей/игры.
+1 -1
View File
@@ -2,7 +2,7 @@
// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway // reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway
// validates the operator credential and forwards authenticated requests to // validates the operator credential and forwards authenticated requests to
// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The // backend /api/v1/admin/*; the backend trusts the gateway on this segment. The
// admin API itself is filled in Stage 9. // admin API itself is filled in Stage 10.
package admin package admin
import ( import (
+123 -5
View File
@@ -54,11 +54,12 @@ type MoveRecordResp struct {
// SeatResp is one seat's public standing. // SeatResp is one seat's public standing.
type SeatResp struct { type SeatResp struct {
Seat int `json:"seat"` Seat int `json:"seat"`
AccountID string `json:"account_id"` AccountID string `json:"account_id"`
Score int `json:"score"` DisplayName string `json:"display_name"`
HintsUsed int `json:"hints_used"` Score int `json:"score"`
IsWinner bool `json:"is_winner"` HintsUsed int `json:"hints_used"`
IsWinner bool `json:"is_winner"`
} }
// GameResp is the shared game summary. // GameResp is the shared game summary.
@@ -189,3 +190,120 @@ func (c *Client) ChatPost(ctx context.Context, userID, gameID, body, clientIP st
map[string]string{"body": body}, &out) map[string]string{"body": body}, &out)
return out, err return out, err
} }
// HintResultResp is the top-ranked move plus the remaining hint budget.
type HintResultResp struct {
Move MoveRecordResp `json:"move"`
HintsRemaining int `json:"hints_remaining"`
}
// EvalResultResp is an unlimited move preview.
type EvalResultResp struct {
Legal bool `json:"legal"`
Score int `json:"score"`
Words []string `json:"words"`
}
// WordCheckResp is a dictionary lookup outcome.
type WordCheckResp struct {
Word string `json:"word"`
Legal bool `json:"legal"`
}
// HistoryResp is a game's decoded move journal.
type HistoryResp struct {
GameID string `json:"game_id"`
Moves []MoveRecordResp `json:"moves"`
}
// GameListResp is the caller's games for the lobby.
type GameListResp struct {
Games []GameResp `json:"games"`
}
// ChatListResp is a game's chat history.
type ChatListResp struct {
Messages []ChatResp `json:"messages"`
}
func (c *Client) gamePath(gameID, suffix string) string {
return "/api/v1/user/games/" + url.PathEscape(gameID) + suffix
}
// Pass forfeits the player's turn.
func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/pass"), userID, "", struct{}{}, &out)
return out, err
}
// Exchange swaps the chosen rack tiles back into the bag.
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []string) (MoveResultResp, error) {
var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
map[string]any{"tiles": tiles}, &out)
return out, err
}
// Resign resigns the player from the game.
func (c *Client) Resign(ctx context.Context, userID, gameID string) (MoveResultResp, error) {
var out MoveResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/resign"), userID, "", struct{}{}, &out)
return out, err
}
// Hint reveals the top-ranked move and spends a hint.
func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultResp, error) {
var out HintResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/hint"), userID, "", struct{}{}, &out)
return out, err
}
// Evaluate previews a tentative play's legality and score.
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (EvalResultResp, error) {
var out EvalResultResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
map[string]any{"dir": dir, "tiles": tiles}, &out)
return out, err
}
// CheckWord looks a word up in the game's pinned dictionary.
func (c *Client) CheckWord(ctx context.Context, userID, gameID, word string) (WordCheckResp, error) {
var out WordCheckResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?word="+url.QueryEscape(word), userID, "", nil, &out)
return out, err
}
// Complaint disputes a word-check result.
func (c *Client) Complaint(ctx context.Context, userID, gameID, word, note string) error {
return c.do(ctx, http.MethodPost, c.gamePath(gameID, "/complaint"), userID, "",
map[string]string{"word": word, "note": note}, nil)
}
// History returns a game's decoded move journal.
func (c *Client) History(ctx context.Context, userID, gameID string) (HistoryResp, error) {
var out HistoryResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/history"), userID, "", nil, &out)
return out, err
}
// ChatList returns a game's chat history.
func (c *Client) ChatList(ctx context.Context, userID, gameID string) (ChatListResp, error) {
var out ChatListResp
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/chat"), userID, "", nil, &out)
return out, err
}
// Nudge posts a nudge to the player whose turn is awaited.
func (c *Client) Nudge(ctx context.Context, userID, gameID string) (ChatResp, error) {
var out ChatResp
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/nudge"), userID, "", struct{}{}, &out)
return out, err
}
// GamesList returns the caller's active and finished games.
func (c *Client) GamesList(ctx context.Context, userID string) (GameListResp, error) {
var out GameListResp
err := c.do(ctx, http.MethodGet, "/api/v1/user/games", userID, "", nil, &out)
return out, err
}
+101 -4
View File
@@ -100,9 +100,8 @@ func encodeMatch(m backendclient.MatchResp) []byte {
return b.FinishedBytes() return b.FinishedBytes()
} }
// encodeChat builds a ChatMessage payload. // buildChatMessage builds a ChatMessage table and returns its offset.
func encodeChat(c backendclient.ChatResp) []byte { func buildChatMessage(b *flatbuffers.Builder, c backendclient.ChatResp) flatbuffers.UOffsetT {
b := flatbuffers.NewBuilder(192)
id := b.CreateString(c.ID) id := b.CreateString(c.ID)
gid := b.CreateString(c.GameID) gid := b.CreateString(c.GameID)
sid := b.CreateString(c.SenderID) sid := b.CreateString(c.SenderID)
@@ -115,7 +114,103 @@ func encodeChat(c backendclient.ChatResp) []byte {
fb.ChatMessageAddKind(b, kind) fb.ChatMessageAddKind(b, kind)
fb.ChatMessageAddBody(b, body) fb.ChatMessageAddBody(b, body)
fb.ChatMessageAddCreatedAtUnix(b, c.CreatedAtUnix) fb.ChatMessageAddCreatedAtUnix(b, c.CreatedAtUnix)
b.Finish(fb.ChatMessageEnd(b)) return fb.ChatMessageEnd(b)
}
// encodeChat builds a ChatMessage payload.
func encodeChat(c backendclient.ChatResp) []byte {
b := flatbuffers.NewBuilder(192)
b.Finish(buildChatMessage(b, c))
return b.FinishedBytes()
}
// encodeHintResult builds a HintResult payload.
func encodeHintResult(r backendclient.HintResultResp) []byte {
b := flatbuffers.NewBuilder(512)
move := buildMoveRecord(b, r.Move)
fb.HintResultStart(b)
fb.HintResultAddMove(b, move)
fb.HintResultAddHintsRemaining(b, int32(r.HintsRemaining))
b.Finish(fb.HintResultEnd(b))
return b.FinishedBytes()
}
// encodeEvalResult builds an EvalResult payload.
func encodeEvalResult(r backendclient.EvalResultResp) []byte {
b := flatbuffers.NewBuilder(256)
words := buildStringVector(b, r.Words, fb.EvalResultStartWordsVector)
fb.EvalResultStart(b)
fb.EvalResultAddLegal(b, r.Legal)
fb.EvalResultAddScore(b, int32(r.Score))
fb.EvalResultAddWords(b, words)
b.Finish(fb.EvalResultEnd(b))
return b.FinishedBytes()
}
// encodeWordCheck builds a WordCheckResult payload.
func encodeWordCheck(r backendclient.WordCheckResp) []byte {
b := flatbuffers.NewBuilder(64)
word := b.CreateString(r.Word)
fb.WordCheckResultStart(b)
fb.WordCheckResultAddWord(b, word)
fb.WordCheckResultAddLegal(b, r.Legal)
b.Finish(fb.WordCheckResultEnd(b))
return b.FinishedBytes()
}
// encodeHistory builds a History payload (the decoded move journal).
func encodeHistory(r backendclient.HistoryResp) []byte {
b := flatbuffers.NewBuilder(1024)
moveOffs := make([]flatbuffers.UOffsetT, len(r.Moves))
for i, m := range r.Moves {
moveOffs[i] = buildMoveRecord(b, m)
}
fb.HistoryStartMovesVector(b, len(moveOffs))
for i := len(moveOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(moveOffs[i])
}
moves := b.EndVector(len(moveOffs))
gid := b.CreateString(r.GameID)
fb.HistoryStart(b)
fb.HistoryAddGameId(b, gid)
fb.HistoryAddMoves(b, moves)
b.Finish(fb.HistoryEnd(b))
return b.FinishedBytes()
}
// encodeGameList builds a GameList payload (the caller's games).
func encodeGameList(r backendclient.GameListResp) []byte {
b := flatbuffers.NewBuilder(1024)
gameOffs := make([]flatbuffers.UOffsetT, len(r.Games))
for i, g := range r.Games {
gameOffs[i] = buildGameView(b, g)
}
fb.GameListStartGamesVector(b, len(gameOffs))
for i := len(gameOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(gameOffs[i])
}
games := b.EndVector(len(gameOffs))
fb.GameListStart(b)
fb.GameListAddGames(b, games)
b.Finish(fb.GameListEnd(b))
return b.FinishedBytes()
}
// encodeChatList builds a ChatList payload (a game's chat history).
func encodeChatList(r backendclient.ChatListResp) []byte {
b := flatbuffers.NewBuilder(512)
msgOffs := make([]flatbuffers.UOffsetT, len(r.Messages))
for i, m := range r.Messages {
msgOffs[i] = buildChatMessage(b, m)
}
fb.ChatListStartMessagesVector(b, len(msgOffs))
for i := len(msgOffs) - 1; i >= 0; i-- {
b.PrependUOffsetT(msgOffs[i])
}
msgs := b.EndVector(len(msgOffs))
fb.ChatListStart(b)
fb.ChatListAddMessages(b, msgs)
b.Finish(fb.ChatListEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -124,12 +219,14 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers
seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats))
for i, s := range g.Seats { for i, s := range g.Seats {
aid := b.CreateString(s.AccountID) aid := b.CreateString(s.AccountID)
dname := b.CreateString(s.DisplayName)
fb.SeatViewStart(b) fb.SeatViewStart(b)
fb.SeatViewAddSeat(b, int32(s.Seat)) fb.SeatViewAddSeat(b, int32(s.Seat))
fb.SeatViewAddAccountId(b, aid) fb.SeatViewAddAccountId(b, aid)
fb.SeatViewAddScore(b, int32(s.Score)) fb.SeatViewAddScore(b, int32(s.Score))
fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed))
fb.SeatViewAddIsWinner(b, s.IsWinner) fb.SeatViewAddIsWinner(b, s.IsWinner)
fb.SeatViewAddDisplayName(b, dname)
seatOffs[i] = fb.SeatViewEnd(b) seatOffs[i] = fb.SeatViewEnd(b)
} }
fb.GameViewStartSeatsVector(b, len(seatOffs)) fb.GameViewStartSeatsVector(b, len(seatOffs))
+169
View File
@@ -26,6 +26,17 @@ const (
MsgLobbyEnqueue = "lobby.enqueue" MsgLobbyEnqueue = "lobby.enqueue"
MsgLobbyPoll = "lobby.poll" MsgLobbyPoll = "lobby.poll"
MsgChatPost = "chat.post" MsgChatPost = "chat.post"
MsgGamesList = "games.list"
MsgGamePass = "game.pass"
MsgGameExchange = "game.exchange"
MsgGameResign = "game.resign"
MsgGameHint = "game.hint"
MsgGameEvaluate = "game.evaluate"
MsgGameCheckWord = "game.check_word"
MsgGameComplaint = "game.complaint"
MsgGameHistory = "game.history"
MsgChatList = "chat.list"
MsgChatNudge = "chat.nudge"
) )
// Request is one decoded Execute call. // Request is one decoded Execute call.
@@ -69,6 +80,17 @@ func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Regi
r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true}
r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true} r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(backend), Auth: true}
r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true} r.ops[MsgChatPost] = Op{Handler: chatPostHandler(backend), Auth: true}
r.ops[MsgGamesList] = Op{Handler: gamesListHandler(backend), Auth: true}
r.ops[MsgGamePass] = Op{Handler: passHandler(backend), Auth: true}
r.ops[MsgGameExchange] = Op{Handler: exchangeHandler(backend), Auth: true}
r.ops[MsgGameResign] = Op{Handler: resignHandler(backend), Auth: true}
r.ops[MsgGameHint] = Op{Handler: hintHandler(backend), Auth: true}
r.ops[MsgGameEvaluate] = Op{Handler: evaluateHandler(backend), Auth: true}
r.ops[MsgGameCheckWord] = Op{Handler: checkWordHandler(backend), Auth: true}
r.ops[MsgGameComplaint] = Op{Handler: complaintHandler(backend), Auth: true}
r.ops[MsgGameHistory] = Op{Handler: historyHandler(backend), Auth: true}
r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true}
r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true}
return r return r
} }
@@ -219,3 +241,150 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON {
} }
return tiles return tiles
} }
// decodeEvalTiles reads the tentative tiles from an EvalRequest.
func decodeEvalTiles(in *fb.EvalRequest) []backendclient.TileJSON {
n := in.TilesLength()
tiles := make([]backendclient.TileJSON, 0, n)
var t fb.TileRecord
for i := 0; i < n; i++ {
if in.Tiles(&t, i) {
tiles = append(tiles, backendclient.TileJSON{
Row: int(t.Row()),
Col: int(t.Col()),
Letter: string(t.Letter()),
Blank: t.Blank(),
})
}
}
return tiles
}
// decodeStringVector reads the exchange tiles from an ExchangeRequest.
func decodeStringVector(in *fb.ExchangeRequest) []string {
n := in.TilesLength()
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, string(in.Tiles(i)))
}
return out
}
func gamesListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.GamesList(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeGameList(res), nil
}
}
func passHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Pass(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func resignHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Resign(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func exchangeHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsExchangeRequest(req.Payload, 0)
res, err := backend.Exchange(ctx, req.UserID, string(in.GameId()), decodeStringVector(in))
if err != nil {
return nil, err
}
return encodeMoveResult(res), nil
}
}
func hintHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Hint(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeHintResult(res), nil
}
}
func evaluateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEvalRequest(req.Payload, 0)
res, err := backend.Evaluate(ctx, req.UserID, string(in.GameId()), string(in.Dir()), decodeEvalTiles(in))
if err != nil {
return nil, err
}
return encodeEvalResult(res), nil
}
}
func checkWordHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsCheckWordRequest(req.Payload, 0)
res, err := backend.CheckWord(ctx, req.UserID, string(in.GameId()), string(in.Word()))
if err != nil {
return nil, err
}
return encodeWordCheck(res), nil
}
}
func complaintHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsComplaintRequest(req.Payload, 0)
if err := backend.Complaint(ctx, req.UserID, string(in.GameId()), string(in.Word()), string(in.Note())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func historyHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.History(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeHistory(res), nil
}
}
func chatListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.ChatList(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeChatList(res), nil
}
}
func nudgeHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.Nudge(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeChat(res), nil
}
}
@@ -139,3 +139,96 @@ func TestDomainErrorSurfacesBackendCode(t *testing.T) {
t.Fatalf("DomainCode = (%q, %v), want (not_your_turn, true)", code, ok) t.Fatalf("DomainCode = (%q, %v), want (not_your_turn, true)", code, ok)
} }
} }
// gameActionPayload builds a GameActionRequest payload (pass / resign / hint / etc.).
func gameActionPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(32)
gid := b.CreateString(gameID)
fb.GameActionRequestStart(b)
fb.GameActionRequestAddGameId(b, gid)
b.Finish(fb.GameActionRequestEnd(b))
return b.FinishedBytes()
}
func TestGamesListRoundTripDecodesSeatNames(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("X-User-ID"); got != "u-9" {
t.Errorf("X-User-ID = %q, want u-9", got)
}
if r.URL.Path != "/api/v1/user/games" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"games":[{"id":"g-1","variant":"english","status":"active","players":2,"to_move":0,"seats":[{"seat":0,"account_id":"u-9","display_name":"You","score":10},{"seat":1,"account_id":"a-1","display_name":"Ann","score":7}]}]}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgGamesList)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-9"})
if err != nil {
t.Fatalf("handler: %v", err)
}
gl := fb.GetRootAsGameList(payload, 0)
if gl.GamesLength() != 1 {
t.Fatalf("games length = %d, want 1", gl.GamesLength())
}
var g fb.GameView
gl.Games(&g, 0)
if string(g.Id()) != "g-1" {
t.Errorf("game id = %q, want g-1", g.Id())
}
var seat fb.SeatView
g.Seats(&seat, 1)
if string(seat.DisplayName()) != "Ann" {
t.Errorf("seat display name = %q, want Ann", seat.DisplayName())
}
}
func TestPassRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/games/g-2/pass" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"move":{"player":0,"action":"pass"},"game":{"id":"g-2","status":"active","seats":[{"seat":0,"account_id":"u-1","display_name":"You"}]}}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgGamePass)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-2")})
if err != nil {
t.Fatalf("handler: %v", err)
}
mr := fb.GetRootAsMoveResult(payload, 0)
var move fb.MoveRecord
mr.Move(&move)
if string(move.Action()) != "pass" {
t.Errorf("action = %q, want pass", move.Action())
}
}
func TestHintRoundTrip(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/user/games/g-3/hint" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_, _ = w.Write([]byte(`{"move":{"player":0,"action":"play","words":["CAT"],"score":9},"hints_remaining":2}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
op, _ := reg.Lookup(transcode.MsgGameHint)
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: gameActionPayload("g-3")})
if err != nil {
t.Fatalf("handler: %v", err)
}
hr := fb.GetRootAsHintResult(payload, 0)
if hr.HintsRemaining() != 2 {
t.Errorf("hints remaining = %d, want 2", hr.HintsRemaining())
}
var move fb.MoveRecord
hr.Move(&move)
if move.Score() != 9 {
t.Errorf("hint move score = %d, want 9", move.Score())
}
}
+69 -1
View File
@@ -23,13 +23,15 @@ table TileRecord {
blank:bool; blank:bool;
} }
// SeatView is one seat's public standing in a game. // SeatView is one seat's public standing in a game. display_name is resolved by the
// backend from the account store (added trailing — backward-compatible).
table SeatView { table SeatView {
seat:int; seat:int;
account_id:string; account_id:string;
score:int; score:int;
hints_used:int; hints_used:int;
is_winner:bool; is_winner:bool;
display_name:string;
} }
// GameView is the shared (non-private) game summary. // GameView is the shared (non-private) game summary.
@@ -143,6 +145,67 @@ table StateView {
hints_remaining:int; hints_remaining:int;
} }
// GameActionRequest carries just a game id (pass / resign / hint / history).
table GameActionRequest {
game_id:string;
}
// ExchangeRequest swaps the listed rack tiles back into the bag.
table ExchangeRequest {
game_id:string;
tiles:[string];
}
// EvalRequest previews a tentative play without committing it.
table EvalRequest {
game_id:string;
dir:string;
tiles:[TileRecord];
}
// EvalResult is an unlimited move preview: legality, score and the words formed.
table EvalResult {
legal:bool;
score:int;
words:[string];
}
// CheckWordRequest looks a word up in the game's pinned dictionary.
table CheckWordRequest {
game_id:string;
word:string;
}
// WordCheckResult is the dictionary lookup outcome.
table WordCheckResult {
word:string;
legal:bool;
}
// ComplaintRequest disputes a word-check result.
table ComplaintRequest {
game_id:string;
word:string;
note:string;
}
// HintResult is the top-ranked move plus the remaining hint budget.
table HintResult {
move:MoveRecord;
hints_remaining:int;
}
// History is a game's decoded move journal — the source for client board replay.
table History {
game_id:string;
moves:[MoveRecord];
}
// GameList is the caller's games (active and finished) for the lobby.
table GameList {
games:[GameView];
}
// --- lobby (authenticated) --- // --- lobby (authenticated) ---
// EnqueueRequest joins the per-variant auto-match pool. // EnqueueRequest joins the per-variant auto-match pool.
@@ -174,6 +237,11 @@ table ChatMessage {
created_at_unix:long; created_at_unix:long;
} }
// ChatList is a game's chat history.
table ChatList {
messages:[ChatMessage];
}
// --- push event payloads --- // --- push event payloads ---
// YourTurnEvent signals that it is now the recipient's turn. // YourTurnEvent signals that it is now the recipient's turn.
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ChatList struct {
_tab flatbuffers.Table
}
func GetRootAsChatList(buf []byte, offset flatbuffers.UOffsetT) *ChatList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ChatList{}
x.Init(buf, n+offset)
return x
}
func FinishChatListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsChatList(buf []byte, offset flatbuffers.UOffsetT) *ChatList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ChatList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedChatListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ChatList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ChatList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ChatList) Messages(obj *ChatMessage, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *ChatList) MessagesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func ChatListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func ChatListAddMessages(builder *flatbuffers.Builder, messages flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(messages), 0)
}
func ChatListStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ChatListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+71
View File
@@ -0,0 +1,71 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type CheckWordRequest struct {
_tab flatbuffers.Table
}
func GetRootAsCheckWordRequest(buf []byte, offset flatbuffers.UOffsetT) *CheckWordRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &CheckWordRequest{}
x.Init(buf, n+offset)
return x
}
func FinishCheckWordRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsCheckWordRequest(buf []byte, offset flatbuffers.UOffsetT) *CheckWordRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &CheckWordRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedCheckWordRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *CheckWordRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *CheckWordRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *CheckWordRequest) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *CheckWordRequest) Word() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func CheckWordRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func CheckWordRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func CheckWordRequestAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(word), 0)
}
func CheckWordRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+82
View File
@@ -0,0 +1,82 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ComplaintRequest struct {
_tab flatbuffers.Table
}
func GetRootAsComplaintRequest(buf []byte, offset flatbuffers.UOffsetT) *ComplaintRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ComplaintRequest{}
x.Init(buf, n+offset)
return x
}
func FinishComplaintRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsComplaintRequest(buf []byte, offset flatbuffers.UOffsetT) *ComplaintRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ComplaintRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedComplaintRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ComplaintRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ComplaintRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ComplaintRequest) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ComplaintRequest) Word() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ComplaintRequest) Note() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func ComplaintRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func ComplaintRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func ComplaintRequestAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(word), 0)
}
func ComplaintRequestAddNote(builder *flatbuffers.Builder, note flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(note), 0)
}
func ComplaintRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+97
View File
@@ -0,0 +1,97 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EvalRequest struct {
_tab flatbuffers.Table
}
func GetRootAsEvalRequest(buf []byte, offset flatbuffers.UOffsetT) *EvalRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EvalRequest{}
x.Init(buf, n+offset)
return x
}
func FinishEvalRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEvalRequest(buf []byte, offset flatbuffers.UOffsetT) *EvalRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EvalRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEvalRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EvalRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EvalRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EvalRequest) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EvalRequest) Dir() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *EvalRequest) Tiles(obj *TileRecord, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *EvalRequest) TilesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func EvalRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func EvalRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func EvalRequestAddDir(builder *flatbuffers.Builder, dir flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(dir), 0)
}
func EvalRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(tiles), 0)
}
func EvalRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func EvalRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+102
View File
@@ -0,0 +1,102 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type EvalResult struct {
_tab flatbuffers.Table
}
func GetRootAsEvalResult(buf []byte, offset flatbuffers.UOffsetT) *EvalResult {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &EvalResult{}
x.Init(buf, n+offset)
return x
}
func FinishEvalResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsEvalResult(buf []byte, offset flatbuffers.UOffsetT) *EvalResult {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &EvalResult{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedEvalResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *EvalResult) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *EvalResult) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *EvalResult) Legal() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *EvalResult) MutateLegal(n bool) bool {
return rcv._tab.MutateBoolSlot(4, n)
}
func (rcv *EvalResult) Score() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *EvalResult) MutateScore(n int32) bool {
return rcv._tab.MutateInt32Slot(6, n)
}
func (rcv *EvalResult) Words(j int) []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
}
return nil
}
func (rcv *EvalResult) WordsLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func EvalResultStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func EvalResultAddLegal(builder *flatbuffers.Builder, legal bool) {
builder.PrependBoolSlot(0, legal, false)
}
func EvalResultAddScore(builder *flatbuffers.Builder, score int32) {
builder.PrependInt32Slot(1, score, 0)
}
func EvalResultAddWords(builder *flatbuffers.Builder, words flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(words), 0)
}
func EvalResultStartWordsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func EvalResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+83
View File
@@ -0,0 +1,83 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type ExchangeRequest struct {
_tab flatbuffers.Table
}
func GetRootAsExchangeRequest(buf []byte, offset flatbuffers.UOffsetT) *ExchangeRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &ExchangeRequest{}
x.Init(buf, n+offset)
return x
}
func FinishExchangeRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsExchangeRequest(buf []byte, offset flatbuffers.UOffsetT) *ExchangeRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &ExchangeRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedExchangeRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *ExchangeRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *ExchangeRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *ExchangeRequest) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *ExchangeRequest) Tiles(j int) []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
}
return nil
}
func (rcv *ExchangeRequest) TilesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func ExchangeRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func ExchangeRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func ExchangeRequestAddTiles(builder *flatbuffers.Builder, tiles flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(tiles), 0)
}
func ExchangeRequestStartTilesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ExchangeRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+60
View File
@@ -0,0 +1,60 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type GameActionRequest struct {
_tab flatbuffers.Table
}
func GetRootAsGameActionRequest(buf []byte, offset flatbuffers.UOffsetT) *GameActionRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &GameActionRequest{}
x.Init(buf, n+offset)
return x
}
func FinishGameActionRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsGameActionRequest(buf []byte, offset flatbuffers.UOffsetT) *GameActionRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &GameActionRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedGameActionRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *GameActionRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *GameActionRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *GameActionRequest) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func GameActionRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func GameActionRequestAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func GameActionRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type GameList struct {
_tab flatbuffers.Table
}
func GetRootAsGameList(buf []byte, offset flatbuffers.UOffsetT) *GameList {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &GameList{}
x.Init(buf, n+offset)
return x
}
func FinishGameListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsGameList(buf []byte, offset flatbuffers.UOffsetT) *GameList {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &GameList{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedGameListBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *GameList) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *GameList) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *GameList) Games(obj *GameView, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *GameList) GamesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func GameListStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func GameListAddGames(builder *flatbuffers.Builder, games flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(games), 0)
}
func GameListStartGamesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func GameListEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+80
View File
@@ -0,0 +1,80 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type HintResult struct {
_tab flatbuffers.Table
}
func GetRootAsHintResult(buf []byte, offset flatbuffers.UOffsetT) *HintResult {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &HintResult{}
x.Init(buf, n+offset)
return x
}
func FinishHintResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsHintResult(buf []byte, offset flatbuffers.UOffsetT) *HintResult {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &HintResult{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedHintResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *HintResult) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *HintResult) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *HintResult) Move(obj *MoveRecord) *MoveRecord {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
x := rcv._tab.Indirect(o + rcv._tab.Pos)
if obj == nil {
obj = new(MoveRecord)
}
obj.Init(rcv._tab.Bytes, x)
return obj
}
return nil
}
func (rcv *HintResult) HintsRemaining() int32 {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetInt32(o + rcv._tab.Pos)
}
return 0
}
func (rcv *HintResult) MutateHintsRemaining(n int32) bool {
return rcv._tab.MutateInt32Slot(6, n)
}
func HintResultStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func HintResultAddMove(builder *flatbuffers.Builder, move flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(move), 0)
}
func HintResultAddHintsRemaining(builder *flatbuffers.Builder, hintsRemaining int32) {
builder.PrependInt32Slot(1, hintsRemaining, 0)
}
func HintResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+86
View File
@@ -0,0 +1,86 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type History struct {
_tab flatbuffers.Table
}
func GetRootAsHistory(buf []byte, offset flatbuffers.UOffsetT) *History {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &History{}
x.Init(buf, n+offset)
return x
}
func FinishHistoryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsHistory(buf []byte, offset flatbuffers.UOffsetT) *History {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &History{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedHistoryBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *History) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *History) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *History) GameId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *History) Moves(obj *MoveRecord, j int) bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
x := rcv._tab.Vector(o)
x += flatbuffers.UOffsetT(j) * 4
x = rcv._tab.Indirect(x)
obj.Init(rcv._tab.Bytes, x)
return true
}
return false
}
func (rcv *History) MovesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func HistoryStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func HistoryAddGameId(builder *flatbuffers.Builder, gameId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(gameId), 0)
}
func HistoryAddMoves(builder *flatbuffers.Builder, moves flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(moves), 0)
}
func HistoryStartMovesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func HistoryEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -97,8 +97,16 @@ func (rcv *SeatView) MutateIsWinner(n bool) bool {
return rcv._tab.MutateBoolSlot(12, n) return rcv._tab.MutateBoolSlot(12, n)
} }
func (rcv *SeatView) DisplayName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func SeatViewStart(builder *flatbuffers.Builder) { func SeatViewStart(builder *flatbuffers.Builder) {
builder.StartObject(5) builder.StartObject(6)
} }
func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) { func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) {
builder.PrependInt32Slot(0, seat, 0) builder.PrependInt32Slot(0, seat, 0)
@@ -115,6 +123,9 @@ func SeatViewAddHintsUsed(builder *flatbuffers.Builder, hintsUsed int32) {
func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) { func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) {
builder.PrependBoolSlot(4, isWinner, false) builder.PrependBoolSlot(4, isWinner, false)
} }
func SeatViewAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(displayName), 0)
}
func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func SeatViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+75
View File
@@ -0,0 +1,75 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WordCheckResult struct {
_tab flatbuffers.Table
}
func GetRootAsWordCheckResult(buf []byte, offset flatbuffers.UOffsetT) *WordCheckResult {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WordCheckResult{}
x.Init(buf, n+offset)
return x
}
func FinishWordCheckResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWordCheckResult(buf []byte, offset flatbuffers.UOffsetT) *WordCheckResult {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WordCheckResult{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWordCheckResultBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WordCheckResult) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WordCheckResult) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WordCheckResult) Word() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *WordCheckResult) Legal() bool {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.GetBool(o + rcv._tab.Pos)
}
return false
}
func (rcv *WordCheckResult) MutateLegal(n bool) bool {
return rcv._tab.MutateBoolSlot(6, n)
}
func WordCheckResultStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func WordCheckResultAddWord(builder *flatbuffers.Builder, word flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(word), 0)
}
func WordCheckResultAddLegal(builder *flatbuffers.Builder, legal bool) {
builder.PrependBoolSlot(1, legal, false)
}
func WordCheckResultEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.svelte-kit/
*.tsbuildinfo
test-results/
playwright-report/
playwright/.cache/
.DS_Store
+5
View File
@@ -0,0 +1,5 @@
# Do not run an implicit install before `pnpm run <script>` — CI and dev install
# explicitly. esbuild's platform binary ships as an optional dependency, so its
# build script is not required for Vite to work; it is allow-listed in package.json
# (pnpm.onlyBuiltDependencies) for a clean fresh install.
verify-deps-before-run=false
+69
View File
@@ -0,0 +1,69 @@
# scrabble-ui
Pure-HTML5 game client — **plain Svelte 5 (runes) + TypeScript + Vite**, no
SvelteKit. Talks to the `gateway` over **Connect-RPC + FlatBuffers**; embeddable in
platform webviews and packageable to native via Capacitor.
Stage 7 ships the **playable slice**: sign in (guest / email), the "my games" lobby,
auto-match, the board (place tiles by drag or tap, pass, exchange, resign), hint,
word-check + complaint, per-game chat and nudge, the live in-app stream, i18n (en/ru),
theme, and a read-only profile. Friends/blocks, friend-game invitations, profile
editing, the stats screen and the history/GCG viewer are Stage 8.
## Scripts
```sh
pnpm install
pnpm start # mock mode (VITE_MOCK): lobby -> game with no backend, :5173
pnpm dev # against a running gateway (Vite proxies /scrabble.edge.v1.Gateway -> :8081)
pnpm check # svelte-check / tsc
pnpm test:unit # Vitest (pure logic + FlatBuffers codec)
pnpm test:e2e # Playwright smoke against the mock
pnpm build # static bundle into dist/ (prod ~67 KB gzip JS)
pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
```
`GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
gateway origin for a packaged (non-proxied) build.
## How it talks to the gateway
A single Connect `Execute(message_type, payload)` carries every unary op; the request
and response bodies are **FlatBuffers** tables (`pkg/fbs/scrabble.fbs`) in `payload`.
The session token rides in `Authorization: Bearer`; a domain failure comes back in
`result_code`. `Subscribe` is the live event stream. `lib/transport.ts` is the real
client; `lib/mock/` is an in-memory fake selected by `MODE === 'mock'` (and tree-shaken
out of production). Both speak the plain `lib/model.ts` types via `lib/codec.ts`.
**No board on the wire:** `StateView` is a summary + rack only, so the client
reconstructs the 15×15 board by replaying the decoded move journal (`game.history`).
Premium squares and tile values (`lib/premiums.ts`) are a client-side map **ported from
`scrabble-solver/rules/rules.go`** (pinned by a Vitest parity test). Board, tiles and
effects are pure CSS + Unicode — no image/font/SVG assets.
## Codegen
`src/gen/` is **committed**; CI builds it, it is not regenerated there (the same model
as the Go committed jet/fbs output). `pnpm codegen` runs `flatc --ts` on
`../pkg/fbs/scrabble.fbs` and `buf generate` (`protoc-gen-es`) on the edge proto. Needs
`flatc` 23.5.26 and `buf` on PATH.
## Theming
Design tokens are CSS custom properties (`src/app.css`); light/dark follows
`prefers-color-scheme` or an explicit choice in Settings. The token system is
**Telegram-themeParams-ready** (`lib/theme.ts`) — a Mini App can override the tokens at
runtime; the Telegram SDK itself is wired in the Telegram stage.
## Layout
```
src/
lib/ model, client facade, transport (+ mock), codec, board replay,
placement state machine, premiums, i18n, theme, session, router, app store
components/ Header, Modal, Toast
screens/ Login, Lobby, NewGame, Profile, Settings, About
game/ Game, Board, Rack, Controls, MakeMove, Chat
gen/ committed edge codegen (FlatBuffers + Connect)
e2e/ Playwright smoke (mock)
```
+9
View File
@@ -0,0 +1,9 @@
# Generates the TypeScript Connect client for the edge envelope service from the
# same gateway/proto/edge/v1/edge.proto the Go gateway uses. The committed output
# lives under src/gen/edge/; CI only builds it (dev-time codegen, like pkg/Makefile).
version: v2
plugins:
- local: ./node_modules/.bin/protoc-gen-es
out: src/gen
opt:
- target=ts
+29
View File
@@ -0,0 +1,29 @@
import { expect, test } from '@playwright/test';
// The playable-slice smoke against the mock transport: guest login -> lobby shows the
// seeded active game -> open it -> the board renders committed tiles -> place a rack
// tile (tap) and see the score preview.
test('guest reaches a board and previews a placement', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Active games')).toBeVisible();
const activeRow = page.getByRole('button', { name: /Ann/ });
await expect(activeRow).toBeVisible();
await activeRow.click();
// Board renders, including a committed tile from the seeded HELLO play.
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('[data-cell] .letter', { hasText: 'H' }).first()).toBeVisible();
// Tap a rack tile, then an empty board cell -> a pending tile + score preview.
const rackTile = page.locator('.rack .tile').first();
await rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await expect(page.locator('.preview')).toContainText(/\d/);
// The contextual MakeMove control appears once a tile is pending.
await expect(page.getByRole('button', { name: /make move/i })).toBeVisible();
});
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>Scrabble</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "scrabble-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Scrabble game client (plain Svelte 5 + Vite). Talks to the gateway over Connect-RPC + FlatBuffers.",
"scripts": {
"dev": "vite",
"start": "vite --mode mock",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"codegen": "rm -rf src/gen && flatc --ts -o src/gen/fbs ../pkg/fbs/scrabble.fbs && buf generate ../gateway --template buf.gen.yaml",
"test:unit": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@bufbuild/protobuf": "^2.12.0",
"@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0",
"flatbuffers": "^25.9.23"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.12.0",
"@playwright/test": "^1.49.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig, devices } from '@playwright/test';
// Hermetic e2e: Playwright boots the Vite dev server in `mock` mode (the in-memory
// fake transport), so the smoke needs no backend/gateway/Postgres.
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: {
baseURL: 'http://localhost:4173',
trace: 'on-first-retry',
},
webServer: {
command: 'pnpm exec vite --mode mock --port 4173 --strictPort',
url: 'http://localhost:4173',
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
+1410
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
# pnpm 11 records build-script approval here. esbuild's postinstall materialises
# its CLI shim; the platform binary itself ships as an optional dependency.
allowBuilds:
esbuild: true
+21
View File
@@ -0,0 +1,21 @@
// Bundle-size budget gate. Sums the gzipped size of the built app JS and fails if it
// exceeds the budget — a guard against an accidental heavy dependency. The real
// transport build is ~69 KB gzip today; the budget leaves headroom.
import { readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
const BUDGET = 100 * 1024; // gzip bytes for app JS
const dir = 'dist/assets';
let total = 0;
for (const f of readdirSync(dir)) {
if (!f.endsWith('.js')) continue;
const gz = gzipSync(readFileSync(`${dir}/${f}`)).length;
total += gz;
console.log(`${f}: ${(gz / 1024).toFixed(1)} KB gzip`);
}
console.log(`total app JS: ${(total / 1024).toFixed(1)} KB gzip (budget ${BUDGET / 1024} KB)`);
if (total > BUDGET) {
console.error('bundle exceeds size budget');
process.exit(1);
}
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import { app, bootstrap } from './lib/app.svelte';
import { router } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import Toast from './components/Toast.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte';
import Profile from './screens/Profile.svelte';
import Settings from './screens/Settings.svelte';
import About from './screens/About.svelte';
import Game from './game/Game.svelte';
onMount(() => {
void bootstrap();
});
</script>
{#if !app.ready}
<div class="splash">{t('common.loading')}</div>
{:else if router.route.name === 'login'}
<Login />
{:else if router.route.name === 'new'}
<NewGame />
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'profile'}
<Profile />
{:else if router.route.name === 'settings'}
<Settings />
{:else if router.route.name === 'about'}
<About />
{:else}
<Lobby />
{/if}
<Toast />
<style>
.splash {
height: 100%;
display: grid;
place-items: center;
color: var(--text-muted);
}
</style>
+144
View File
@@ -0,0 +1,144 @@
/*
* Design tokens — pure CSS custom properties, no framework, no image/font/SVG
* assets. Light is the default; dark is applied either by the OS
* (prefers-color-scheme) or by an explicit [data-theme] set from Settings. A
* Telegram Mini App can override these same variables at runtime from
* WebApp.themeParams (see lib/theme — SDK wiring lands in the Telegram stage), so
* the whole UI re-themes without touching components.
*/
:root {
--bg: #f3f4f6;
--bg-elev: #ffffff;
--surface: #ffffff;
--surface-2: #eef0f3;
--text: #14181f;
--text-muted: #6b7280;
--border: #d8dce2;
--accent: #2f6df6;
--accent-text: #ffffff;
--danger: #d6453d;
--ok: #1f9d57;
--warn: #c9881b;
/* board + tiles (all drawn with CSS primitives) */
--board-bg: #cdd6cf;
--cell-bg: #e7ece8;
--cell-line: #b6c0b8;
--tile-bg: #f4e2b8;
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #ffe7a3;
--tile-recent: #fff6d8;
--prem-tw: #e06a5b; /* triple word */
--prem-dw: #efa6a0; /* double word + centre */
--prem-tl: #4f8fd6; /* triple letter */
--prem-dl: #a8cdec; /* double letter */
--prem-text: #2a2113;
/* shape + type */
--radius: 10px;
--radius-sm: 6px;
--gap: 8px;
--pad: 12px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0f1115;
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
--accent: #5b8cff;
--accent-text: #0b0e13;
--danger: #f0635a;
--ok: #44c87f;
--warn: #e0a93a;
--board-bg: #2a3330;
--cell-bg: #222a27;
--cell-line: #38433d;
--tile-bg: #d9c79a;
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-recent: #4a4636;
--prem-tw: #b1493d;
--prem-dw: #8c5450;
--prem-tl: #34608f;
--prem-dl: #3b5a72;
--prem-text: #e7eaf0;
}
}
/* Explicit dark chosen in Settings (overrides OS preference). */
:root[data-theme="dark"] {
--bg: #0f1115;
--bg-elev: #171a21;
--surface: #171a21;
--surface-2: #1f242d;
--text: #e7eaf0;
--text-muted: #9aa3b2;
--border: #2a313c;
--accent: #5b8cff;
--accent-text: #0b0e13;
--danger: #f0635a;
--ok: #44c87f;
--warn: #e0a93a;
--board-bg: #2a3330;
--cell-bg: #222a27;
--cell-line: #38433d;
--tile-bg: #d9c79a;
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-recent: #4a4636;
--prem-tw: #b1493d;
--prem-dw: #8c5450;
--prem-tl: #34608f;
--prem-dl: #3b5a72;
--prem-text: #e7eaf0;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
font-size: 16px;
line-height: 1.4;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* never let the page scroll/zoom out from under the board */
overscroll-behavior: none;
touch-action: manipulation;
}
#app {
height: 100%;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
}
.reduce-motion * {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
</script>
<header class="topbar">
{#if back}
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back"></button>
{:else}
<span class="spacer"></span>
{/if}
<h1>{title}</h1>
<div class="end">{#if menu}{@render menu()}{/if}</div>
</header>
<style>
.topbar {
display: flex;
align-items: center;
gap: var(--gap);
padding: 10px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
}
h1 {
font-size: 1.05rem;
margin: 0;
flex: 1;
text-align: center;
font-weight: 600;
}
.icon,
.spacer,
.end {
width: 40px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.end {
width: auto;
min-width: 40px;
}
.icon {
background: none;
border: none;
font-size: 1.1rem;
color: var(--text);
border-radius: var(--radius-sm);
}
.icon:hover {
background: var(--surface-2);
}
</style>
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title = '',
onclose,
children,
}: { title?: string; onclose?: () => void; children?: Snippet } = $props();
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => onclose?.()}>
<div class="sheet" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
{#if title}<h2>{title}</h2>{/if}
{@render children?.()}
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 40;
}
.sheet {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: var(--pad);
width: min(94vw, 420px);
max-height: 86vh;
overflow: auto;
}
h2 {
margin: 0 0 10px;
font-size: 1.05rem;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { app } from '../lib/app.svelte';
</script>
{#if app.toast}
<div class="toast {app.toast.kind}" role="status" aria-live="polite">{app.toast.text}</div>
{/if}
<style>
.toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%);
max-width: min(92vw, 420px);
padding: 10px 16px;
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
box-shadow: var(--shadow);
z-index: 50;
text-align: center;
}
.error {
border-color: var(--danger);
color: var(--danger);
}
</style>
+213
View File
@@ -0,0 +1,213 @@
<script lang="ts">
import type { BoardCell } from '../lib/board';
import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
let {
board,
premium,
pending,
recent,
centre,
zoomed,
variant,
oncell,
ontogglezoom,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>;
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
} = $props();
const premClass: Record<Premium, string> = {
'': '',
TW: 'tw',
DW: 'dw',
TL: 'tl',
DL: 'dl',
};
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
// Double-tap toggles zoom.
let lastTap = 0;
function onTap(row: number, col: number) {
const now = Date.now();
if (now - lastTap < 300) {
ontogglezoom();
lastTap = 0;
return;
}
lastTap = now;
oncell(row, col);
}
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
const pts = new Map<number, { x: number; y: number }>();
let startDist = 0;
function dist(): number {
const p = [...pts.values()];
if (p.length < 2) return 0;
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
}
function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) startDist = dist();
}
function onPointerMove(e: PointerEvent) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && startDist > 0) {
const d = dist();
if (!zoomed && d > startDist * 1.25) {
ontogglezoom();
startDist = 0;
} else if (zoomed && d < startDist * 0.8) {
ontogglezoom();
startDist = 0;
}
}
}
function onPointerUp(e: PointerEvent) {
pts.delete(e.pointerId);
if (pts.size < 2) startDist = 0;
}
function key(r: number, c: number): string {
return `${r},${c}`;
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="viewport"
class:zoomed
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="grid" class:zoomed>
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:recent={recent.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if premLabel[premium[r][c]]}
<span class="plabel">{premLabel[premium[r][c]]}</span>
{/if}
</button>
{/each}
{/each}
</div>
</div>
<style>
.viewport {
width: 100%;
background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm);
touch-action: none;
}
.viewport.zoomed {
overflow: auto;
max-height: 70vh;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 2px;
width: 100%;
}
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 2px;
background: var(--cell-bg);
color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0;
overflow: hidden;
}
.cell.tw {
background: var(--prem-tw);
}
.cell.dw {
background: var(--prem-dw);
}
.cell.tl {
background: var(--prem-tl);
}
.cell.dl {
background: var(--prem-dl);
}
.cell.filled,
.cell.pending {
background: var(--tile-bg);
color: var(--tile-text);
box-shadow: inset 0 -2px 0 var(--tile-edge);
}
.cell.pending {
background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.cell.recent {
box-shadow:
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
}
.letter {
font-size: 1.05em;
font-weight: 700;
line-height: 1;
}
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val {
position: absolute;
right: 1px;
bottom: 0;
font-size: 0.55em;
font-weight: 600;
}
.plabel {
opacity: 0.85;
font-weight: 600;
}
.star {
font-size: 1.1em;
opacity: 0.7;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import type { ChatMessage } from '../lib/model';
import { t } from '../lib/i18n/index.svelte';
let {
messages,
myId,
busy,
onsend,
onnudge,
}: {
messages: ChatMessage[];
myId: string;
busy: boolean;
onsend: (text: string) => void;
onnudge: () => void;
} = $props();
let text = $state('');
function send() {
const v = text.trim();
if (!v) return;
onsend(v);
text = '';
}
</script>
<div class="chat">
<div class="list">
{#if messages.length === 0}
<p class="empty">{t('chat.empty')}</p>
{/if}
{#each messages as m (m.id)}
{#if m.kind === 'nudge'}
<div class="note">{t('chat.nudge')}</div>
{:else}
<div class="msg" class:mine={m.senderId === myId}>{m.body}</div>
{/if}
{/each}
</div>
<div class="input">
<input
maxlength="60"
placeholder={t('chat.placeholder')}
bind:value={text}
onkeydown={(e) => e.key === 'Enter' && send()}
/>
<button onclick={send} disabled={busy}>{t('chat.send')}</button>
<button class="nudge" onclick={onnudge} disabled={busy}>{t('chat.nudge')}</button>
</div>
</div>
<style>
.chat {
display: flex;
flex-direction: column;
gap: 10px;
height: 56vh;
}
.list {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding: 4px;
}
.empty {
color: var(--text-muted);
text-align: center;
margin: auto;
}
.msg {
align-self: flex-start;
max-width: 80%;
padding: 7px 11px;
border-radius: 12px;
background: var(--surface-2);
}
.msg.mine {
align-self: flex-end;
background: var(--accent);
color: var(--accent-text);
}
.note {
align-self: center;
font-size: 0.82rem;
color: var(--text-muted);
font-style: italic;
}
.input {
display: flex;
gap: 6px;
}
.input input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
}
.input button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<script lang="ts">
import type { EvalResult } from '../lib/model';
import { t } from '../lib/i18n/index.svelte';
let {
preview,
hints,
busy,
ambiguous,
dir,
ondraw,
onskip,
onshuffle,
onhint,
ondir,
}: {
preview: EvalResult | null;
hints: number;
busy: boolean;
ambiguous: boolean;
dir: 'H' | 'V';
ondraw: () => void;
onskip: () => void;
onshuffle: () => void;
onhint: () => void;
ondir: () => void;
} = $props();
</script>
<div class="controls">
<div class="preview">
{#if preview}
{#if preview.legal}
<span class="ok">{t('game.preview', { n: preview.score })}</span>
{:else}
<span class="bad">{t('game.previewIllegal')}</span>
{/if}
{/if}
{#if ambiguous}
<button class="dir" onclick={ondir} title="direction">{dir === 'H' ? '↔' : '↕'}</button>
{/if}
</div>
<div class="row">
<button onclick={ondraw} disabled={busy}>{t('game.draw')}</button>
<button onclick={onskip} disabled={busy}>{t('game.skip')}</button>
<button onclick={onshuffle} disabled={busy}>{t('game.shuffle')}</button>
<button class="hint" onclick={onhint} disabled={busy || hints <= 0}>
{t('game.hint')}{hints > 0 ? ` (${hints})` : ''}
</button>
</div>
</div>
<style>
.controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.preview {
min-height: 22px;
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.dir {
margin-left: auto;
width: 34px;
height: 28px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 1rem;
}
.row {
display: flex;
gap: 6px;
}
.row button {
flex: 1;
padding: 11px 6px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.row button:disabled {
opacity: 0.45;
}
.hint {
color: var(--accent);
}
</style>
+741
View File
@@ -0,0 +1,741 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Header from '../components/Header.svelte';
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
import MakeMove from './MakeMove.svelte';
import Controls from './Controls.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { lastPlayTiles, replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import {
BLANK,
direction,
newPlacement,
place,
rackView,
recallAt,
reset,
toSubmit,
type Placement,
} from '../lib/placement';
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
let moves = $state<MoveRecord[]>([]);
let placement = $state<Placement>(newPlacement([]));
let preview = $state<EvalResult | null>(null);
let dirOverride = $state<Direction | undefined>(undefined);
let busy = $state(false);
let zoomed = $state(false);
let selected = $state<number | null>(null);
let panel = $state<'none' | 'chat' | 'history'>('none');
let menuOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
let checkOpen = $state(false);
let checkWord = $state('');
let checkResult = $state<{ word: string; legal: boolean } | null>(null);
let resignOpen = $state(false);
let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
const ctr = $derived(centre(variant));
const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
);
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
const slots = $derived(rackView(placement));
const isMyTurn = $derived(
!!view && view.game.status === 'active' && view.game.toMove === view.seat,
);
const gameOver = $derived(!!view && view.game.status !== 'active');
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
const ambiguous = $derived(placement.pending.length === 1);
async function load() {
try {
const [st, hist] = await Promise.all([gateway.gameState(id), gateway.gameHistory(id)]);
view = st;
moves = hist.moves;
placement = newPlacement(st.rack);
preview = null;
selected = null;
dirOverride = undefined;
} catch (e) {
handleError(e);
}
}
async function loadChat() {
try {
messages = await gateway.chatList(id);
} catch (e) {
handleError(e);
}
}
onMount(load);
$effect(() => {
const e = app.lastEvent;
if (!e) return;
if ((e.kind === 'opponent_moved' || e.kind === 'your_turn') && e.gameId === id) void load();
else if (e.kind === 'chat_message' && e.message.gameId === id && panel === 'chat') void loadChat();
else if (e.kind === 'nudge' && e.gameId === id && panel === 'chat') void loadChat();
});
function isCoarse(): boolean {
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
}
// --- tile placement: pointer drag + tap, both feeding the placement model ---
let downInfo: { index: number; x0: number; y0: number } | null = null;
let dragMoved = false;
let swallowClick = false;
function onRackDown(e: PointerEvent, index: number) {
if (!isMyTurn || busy) return;
downInfo = { index, x0: e.clientX, y0: e.clientY };
dragMoved = false;
window.addEventListener('pointermove', onWinMove);
window.addEventListener('pointerup', onWinUp);
}
function onWinMove(e: PointerEvent) {
if (!downInfo) return;
if (!dragMoved && Math.hypot(e.clientX - downInfo.x0, e.clientY - downInfo.y0) > 6) {
dragMoved = true;
const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement
}
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
}
function onWinUp(e: PointerEvent) {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
const di = downInfo;
downInfo = null;
if (drag && dragMoved && di) {
const el = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest(
'[data-cell]',
) as HTMLElement | null;
drag = null;
if (el?.dataset.row && el.dataset.col) {
attemptPlace(di.index, Number(el.dataset.row), Number(el.dataset.col));
}
swallowClick = true;
setTimeout(() => (swallowClick = false), 60);
} else if (di) {
selected = selected === di.index ? null : di.index;
drag = null;
}
}
onDestroy(() => {
window.removeEventListener('pointermove', onWinMove);
window.removeEventListener('pointerup', onWinUp);
});
function onCell(row: number, col: number) {
if (swallowClick) return;
if (pendingMap.has(`${row},${col}`)) {
placement = recallAt(placement, row, col);
recompute();
return;
}
if (selected != null) {
attemptPlace(selected, row, col);
selected = null;
}
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
}
placement = place(placement, index, row, col);
recompute();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
blankPrompt = null;
recompute();
}
let previewTimer: ReturnType<typeof setTimeout> | null = null;
function recompute() {
preview = null;
if (previewTimer) clearTimeout(previewTimer);
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
previewTimer = setTimeout(async () => {
try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
} catch {
/* preview is best-effort */
}
}, 250);
}
async function commit() {
const sub = toSubmit(placement, dirOverride);
if (!sub) return;
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function resetPlacement() {
placement = reset(placement);
preview = null;
selected = null;
dirOverride = undefined;
}
async function doPass() {
busy = true;
try {
await gateway.pass(id);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doResign() {
resignOpen = false;
busy = true;
try {
await gateway.resign(id);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
async function doHint() {
try {
const h = await gateway.hint(id);
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join('');
showToast(t('game.hintShown', { word, n: h.move.score }));
if (view) view = { ...view, hintsRemaining: h.hintsRemaining };
} catch (e) {
handleError(e);
}
}
function shuffle() {
if (placement.pending.length > 0) return;
const r = [...placement.rack];
for (let i = r.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[r[i], r[j]] = [r[j], r[i]];
}
placement = newPlacement(r);
}
function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H';
recompute();
}
function openExchange() {
menuOpen = false;
resetPlacement();
exchangeSel = [];
exchangeOpen = true;
}
function toggleExch(i: number) {
exchangeSel = exchangeSel.includes(i) ? exchangeSel.filter((x) => x !== i) : [...exchangeSel, i];
}
async function doExchange() {
if (!view || exchangeSel.length === 0) return;
const tiles = exchangeSel.map((i) => view!.rack[i]);
exchangeOpen = false;
busy = true;
try {
await gateway.exchange(id, tiles);
await load();
} catch (e) {
handleError(e);
} finally {
busy = false;
}
}
function openCheck() {
menuOpen = false;
checkWord = '';
checkResult = null;
checkOpen = true;
}
async function runCheck() {
const w = checkWord.trim();
if (!w) return;
try {
checkResult = await gateway.checkWord(id, w);
} catch (e) {
handleError(e);
}
}
async function complain() {
if (!checkResult) return;
try {
await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent'));
} catch (e) {
handleError(e);
}
}
function openChat() {
menuOpen = false;
panel = 'chat';
void loadChat();
}
async function sendChat(text: string) {
try {
const m = await gateway.chatPost(id, text);
messages = [...messages, m];
} catch (e) {
handleError(e);
}
}
async function nudge() {
try {
const m = await gateway.nudge(id);
messages = [...messages, m];
} catch (e) {
handleError(e);
}
}
function resultText(): string {
if (!view) return '';
const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
</script>
<Header title={t('app.title')} back="/">
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => { menuOpen = false; panel = 'history'; }}>{t('game.history')}</button>
<button onclick={openChat}>{t('game.chat')}</button>
<button onclick={openCheck}>{t('game.checkWord')}</button>
<button onclick={() => { menuOpen = false; resignOpen = true; }}>{t('game.dropGame')}</button>
</div>
{/if}
{/snippet}
</Header>
{#if view}
<div class="scoreboard">
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div>
</div>
{/each}
</div>
<div class="boardwrap">
<Board
{board}
{premium}
pending={pendingMap}
{recent}
centre={ctr}
{zoomed}
{variant}
oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)}
/>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
{/if}
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
</div>
{#if !gameOver}
<div class="rack-row">
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if placement.pending.length > 0}
<MakeMove
label={t('game.makeMove')}
resetLabel={t('game.reset')}
onmake={commit}
onreset={resetPlacement}
/>
{/if}
</div>
<Controls
{preview}
hints={view.hintsRemaining}
busy={busy || !isMyTurn}
{ambiguous}
{dir}
ondraw={openExchange}
onskip={doPass}
onshuffle={shuffle}
onhint={doHint}
ondir={toggleDir}
/>
{/if}
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
{#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span>
</div>
{/if}
{#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha">
{#each alphabet(variant) as ch (ch)}
<button onclick={() => chooseBlank(ch)}>{ch}</button>
{/each}
</div>
</Modal>
{/if}
{#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} onclick={() => toggleExch(i)}>
{letter === BLANK ? '?' : letter}
</button>
{/each}
</div>
<button class="confirm" disabled={exchangeSel.length === 0} onclick={doExchange}>
{t('game.exchangeConfirm', { n: exchangeSel.length })}
</button>
</Modal>
{/if}
{#if checkOpen}
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
<div class="check">
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} />
<button onclick={runCheck}>{t('game.checkWord')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
{checkResult.legal
? t('game.wordLegal', { word: checkResult.word })
: t('game.wordIllegal', { word: checkResult.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</Modal>
{/if}
{#if resignOpen}
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
<div class="confirm-row">
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
<button class="danger" onclick={doResign}>{t('game.dropGame')}</button>
</div>
</Modal>
{/if}
{#if panel === 'chat'}
<Modal title={t('game.chat')} onclose={() => (panel = 'none')}>
<Chat {messages} myId={app.session?.userId ?? ''} {busy} onsend={sendChat} onnudge={nudge} />
</Modal>
{/if}
{#if panel === 'history' && view}
<Modal title={t('game.history')} onclose={() => (panel = 'none')}>
<ol class="history">
{#each moves as m, i (i)}
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
</li>
{/each}
</ol>
</Modal>
{/if}
<style>
.scoreboard {
display: flex;
gap: 2px;
padding: 6px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.seat {
flex: 1;
text-align: center;
padding: 4px;
border-radius: var(--radius-sm);
}
.seat.turn {
background: var(--surface-2);
outline: 1px solid var(--accent);
}
.seat.win .sc {
color: var(--ok);
}
.nm {
font-size: 0.8rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sc {
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.boardwrap {
padding: 8px;
}
.status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--pad) 8px;
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-ind {
font-weight: 600;
color: var(--text);
}
.over {
color: var(--accent);
}
.rack-row {
display: flex;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad);
}
.rack-wrap {
flex: 1;
min-width: 0;
}
:global(.rack-row .wrap) {
display: flex;
}
.loading {
text-align: center;
color: var(--text-muted);
padding: 40px;
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
}
.ghost {
position: fixed;
width: 40px;
height: 40px;
transform: translate(-50%, -50%);
background: var(--tile-pending);
color: var(--tile-text);
border-radius: 5px;
display: grid;
place-items: center;
font-weight: 700;
font-size: 1.3rem;
box-shadow: var(--shadow);
pointer-events: none;
z-index: 60;
}
.alpha {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
}
.alpha button {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 700;
}
.exch {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
margin-bottom: 12px;
}
.etile {
aspect-ratio: 1;
border: 1px solid var(--border);
background: var(--tile-bg);
color: var(--tile-text);
border-radius: 5px;
font-weight: 700;
}
.etile.sel {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.confirm {
width: 100%;
padding: 11px;
background: var(--accent);
color: var(--accent-text);
border: none;
border-radius: var(--radius-sm);
font-weight: 700;
}
.confirm:disabled {
opacity: 0.5;
}
.check {
display: flex;
gap: 6px;
}
.check input {
flex: 1;
padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
}
.check button {
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
}
.ok {
color: var(--ok);
}
.bad {
color: var(--danger);
}
.complain {
background: none;
border: none;
color: var(--accent);
padding: 4px 0;
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.danger {
background: var(--danger) !important;
color: #fff !important;
border-color: var(--danger) !important;
}
.history {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
</style>
+122
View File
@@ -0,0 +1,122 @@
<script lang="ts">
// The contextual commit control: appears when tiles are pending. A short tap opens a
// minimalist popup (Make move / Reset); a press-and-hold (~1s) commits immediately.
let {
label,
resetLabel,
onmake,
onreset,
}: { label: string; resetLabel: string; onmake: () => void; onreset: () => void } = $props();
let popup = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let held = false;
function clear() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function down() {
held = false;
clear();
timer = setTimeout(() => {
held = true;
popup = false;
onmake();
}, 1000);
}
function up() {
clear();
if (!held) popup = true;
}
function leave() {
clear();
}
</script>
<div class="wrap">
<button
class="make"
onpointerdown={down}
onpointerup={up}
onpointerleave={leave}
onpointercancel={leave}
>
{label}
</button>
{#if popup}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (popup = false)}></div>
<div class="popup">
<button
class="go"
onclick={() => {
popup = false;
onmake();
}}>{label}</button
>
<button
class="rs"
onclick={() => {
popup = false;
onreset();
}}>{resetLabel}</button
>
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
}
.make {
height: 100%;
min-width: 64px;
padding: 0 14px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
font-weight: 700;
touch-action: none;
user-select: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 18;
}
.popup {
position: absolute;
right: 0;
bottom: calc(100% + 6px);
z-index: 19;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px;
min-width: 140px;
}
.popup button {
padding: 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.popup .go {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<script lang="ts">
import type { RackSlot } from '../lib/placement';
import { BLANK } from '../lib/placement';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
let {
slots,
variant,
selected,
ondown,
}: {
slots: RackSlot[];
variant: Variant;
selected: number | null;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
</script>
<div class="rack">
{#each slots as slot (slot.index)}
{#if slot.used}
<span class="slot empty"></span>
{:else}
<button
class="slot tile"
class:selected={selected === slot.index}
data-rack-index={slot.index}
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
</button>
{/if}
{/each}
</div>
<style>
.rack {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
}
.slot {
aspect-ratio: 1;
border-radius: 5px;
}
.empty {
background: var(--surface-2);
border: 1px dashed var(--border);
}
.tile {
position: relative;
background: var(--tile-bg);
color: var(--tile-text);
border: none;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
font-size: 1.4rem;
touch-action: none;
user-select: none;
}
.tile.selected {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.val {
position: absolute;
right: 3px;
bottom: 1px;
font-size: 0.7rem;
font-weight: 600;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts"
// @generated from file edge/v1/edge.proto (package scrabble.edge.v1, syntax proto3)
/* eslint-disable */
// Package scrabble.edge.v1 is the client <-> gateway Connect-RPC contract. It is
// deliberately minimal (ARCHITECTURE.md §2): a single unary Execute that routes
// by message_type, and a server-streaming Subscribe for the in-app live channel.
// The actual request/response and event bodies travel as FlatBuffers bytes in the
// payload fields (pkg/fbs). The session token rides in the Authorization header,
// not the envelope (no per-request signing — ARCHITECTURE.md §3).
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file edge/v1/edge.proto.
*/
export const file_edge_v1_edge: GenFile = /*@__PURE__*/
fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJLCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMIhIKEFN1YnNjcmliZVJlcXVlc3QiOAoFRXZlbnQSDAoEa2luZBgBIAEoCRIPCgdwYXlsb2FkGAIgASgMEhAKCGV2ZW50X2lkGAMgASgJMqUBCgdHYXRld2F5Ek4KB0V4ZWN1dGUSIC5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXF1ZXN0GiEuc2NyYWJibGUuZWRnZS52MS5FeGVjdXRlUmVzcG9uc2USSgoJU3Vic2NyaWJlEiIuc2NyYWJibGUuZWRnZS52MS5TdWJzY3JpYmVSZXF1ZXN0Ghcuc2NyYWJibGUuZWRnZS52MS5FdmVudDABQidaJXNjcmFiYmxlL2dhdGV3YXkvcHJvdG8vZWRnZS92MTtlZGdldjFiBnByb3RvMw");
/**
* ExecuteRequest is the unary envelope. message_type selects the operation;
* payload is its FlatBuffers-encoded request body; request_id is an optional
* client correlation id echoed back.
*
* @generated from message scrabble.edge.v1.ExecuteRequest
*/
export type ExecuteRequest = Message<"scrabble.edge.v1.ExecuteRequest"> & {
/**
* @generated from field: string message_type = 1;
*/
messageType: string;
/**
* @generated from field: bytes payload = 2;
*/
payload: Uint8Array;
/**
* @generated from field: string request_id = 3;
*/
requestId: string;
};
/**
* Describes the message scrabble.edge.v1.ExecuteRequest.
* Use `create(ExecuteRequestSchema)` to create a new message.
*/
export const ExecuteRequestSchema: GenMessage<ExecuteRequest> = /*@__PURE__*/
messageDesc(file_edge_v1_edge, 0);
/**
* ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
* error code; payload is the FlatBuffers-encoded response body (empty on error).
*
* @generated from message scrabble.edge.v1.ExecuteResponse
*/
export type ExecuteResponse = Message<"scrabble.edge.v1.ExecuteResponse"> & {
/**
* @generated from field: string request_id = 1;
*/
requestId: string;
/**
* @generated from field: string result_code = 2;
*/
resultCode: string;
/**
* @generated from field: bytes payload = 3;
*/
payload: Uint8Array;
};
/**
* Describes the message scrabble.edge.v1.ExecuteResponse.
* Use `create(ExecuteResponseSchema)` to create a new message.
*/
export const ExecuteResponseSchema: GenMessage<ExecuteResponse> = /*@__PURE__*/
messageDesc(file_edge_v1_edge, 1);
/**
* SubscribeRequest opens the live stream. It is empty: the session is taken from
* the Authorization header.
*
* @generated from message scrabble.edge.v1.SubscribeRequest
*/
export type SubscribeRequest = Message<"scrabble.edge.v1.SubscribeRequest"> & {
};
/**
* Describes the message scrabble.edge.v1.SubscribeRequest.
* Use `create(SubscribeRequestSchema)` to create a new message.
*/
export const SubscribeRequestSchema: GenMessage<SubscribeRequest> = /*@__PURE__*/
messageDesc(file_edge_v1_edge, 2);
/**
* Event is one live event. kind is the notification catalog kind; payload is its
* FlatBuffers-encoded body; event_id is a correlation id.
*
* @generated from message scrabble.edge.v1.Event
*/
export type Event = Message<"scrabble.edge.v1.Event"> & {
/**
* @generated from field: string kind = 1;
*/
kind: string;
/**
* @generated from field: bytes payload = 2;
*/
payload: Uint8Array;
/**
* @generated from field: string event_id = 3;
*/
eventId: string;
};
/**
* Describes the message scrabble.edge.v1.Event.
* Use `create(EventSchema)` to create a new message.
*/
export const EventSchema: GenMessage<Event> = /*@__PURE__*/
messageDesc(file_edge_v1_edge, 3);
/**
* Gateway is the public edge service.
*
* @generated from service scrabble.edge.v1.Gateway
*/
export const Gateway: GenService<{
/**
* Execute runs one unary operation identified by message_type. Auth operations
* (auth.*) are unauthenticated and return a minted session; all others require
* a valid session token in the Authorization header.
*
* @generated from rpc scrabble.edge.v1.Gateway.Execute
*/
execute: {
methodKind: "unary";
input: typeof ExecuteRequestSchema;
output: typeof ExecuteResponseSchema;
},
/**
* Subscribe opens the in-app live-event stream for the authenticated session.
*
* @generated from rpc scrabble.edge.v1.Gateway.Subscribe
*/
subscribe: {
methodKind: "server_streaming";
input: typeof SubscribeRequestSchema;
output: typeof EventSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_edge_v1_edge, 0);
+3
View File
@@ -0,0 +1,3 @@
// automatically generated by the FlatBuffers compiler, do not modify
export * as scrabblefb from './scrabblefb.js';
+36
View File
@@ -0,0 +1,36 @@
// automatically generated by the FlatBuffers compiler, do not modify
export { Ack } from './scrabblefb/ack.js';
export { ChatList } from './scrabblefb/chat-list.js';
export { ChatMessage } from './scrabblefb/chat-message.js';
export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
export { EmailLoginRequest } from './scrabblefb/email-login-request.js';
export { EmailRequestRequest } from './scrabblefb/email-request-request.js';
export { EnqueueRequest } from './scrabblefb/enqueue-request.js';
export { EvalRequest } from './scrabblefb/eval-request.js';
export { EvalResult } from './scrabblefb/eval-result.js';
export { ExchangeRequest } from './scrabblefb/exchange-request.js';
export { GameActionRequest } from './scrabblefb/game-action-request.js';
export { GameList } from './scrabblefb/game-list.js';
export { GameView } from './scrabblefb/game-view.js';
export { GuestLoginRequest } from './scrabblefb/guest-login-request.js';
export { HintResult } from './scrabblefb/hint-result.js';
export { History } from './scrabblefb/history.js';
export { MatchFoundEvent } from './scrabblefb/match-found-event.js';
export { MatchResult } from './scrabblefb/match-result.js';
export { MoveRecord } from './scrabblefb/move-record.js';
export { MoveResult } from './scrabblefb/move-result.js';
export { NudgeEvent } from './scrabblefb/nudge-event.js';
export { OpponentMovedEvent } from './scrabblefb/opponent-moved-event.js';
export { Profile } from './scrabblefb/profile.js';
export { SeatView } from './scrabblefb/seat-view.js';
export { Session } from './scrabblefb/session.js';
export { StateRequest } from './scrabblefb/state-request.js';
export { StateView } from './scrabblefb/state-view.js';
export { SubmitPlayRequest } from './scrabblefb/submit-play-request.js';
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
export { TileRecord } from './scrabblefb/tile-record.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
+46
View File
@@ -0,0 +1,46 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class Ack {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):Ack {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsAck(bb:flatbuffers.ByteBuffer, obj?:Ack):Ack {
return (obj || new Ack()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsAck(bb:flatbuffers.ByteBuffer, obj?:Ack):Ack {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new Ack()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
ok():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startAck(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addOk(builder:flatbuffers.Builder, ok:boolean) {
builder.addFieldInt8(0, +ok, +false);
}
static endAck(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createAck(builder:flatbuffers.Builder, ok:boolean):flatbuffers.Offset {
Ack.startAck(builder);
Ack.addOk(builder, ok);
return Ack.endAck(builder);
}
}
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { ChatMessage } from '../scrabblefb/chat-message.js';
export class ChatList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):ChatList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsChatList(bb:flatbuffers.ByteBuffer, obj?:ChatList):ChatList {
return (obj || new ChatList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsChatList(bb:flatbuffers.ByteBuffer, obj?:ChatList):ChatList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new ChatList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
messages(index: number, obj?:ChatMessage):ChatMessage|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new ChatMessage()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
messagesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startChatList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addMessages(builder:flatbuffers.Builder, messagesOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, messagesOffset, 0);
}
static createMessagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startMessagesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endChatList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createChatList(builder:flatbuffers.Builder, messagesOffset:flatbuffers.Offset):flatbuffers.Offset {
ChatList.startChatList(builder);
ChatList.addMessages(builder, messagesOffset);
return ChatList.endChatList(builder);
}
}
+106
View File
@@ -0,0 +1,106 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class ChatMessage {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):ChatMessage {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsChatMessage(bb:flatbuffers.ByteBuffer, obj?:ChatMessage):ChatMessage {
return (obj || new ChatMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsChatMessage(bb:flatbuffers.ByteBuffer, obj?:ChatMessage):ChatMessage {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new ChatMessage()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
id():string|null
id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
id(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
senderId():string|null
senderId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
senderId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
kind():string|null
kind(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
kind(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
body():string|null
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
body(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
createdAtUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startChatMessage(builder:flatbuffers.Builder) {
builder.startObject(6);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, idOffset, 0);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, gameIdOffset, 0);
}
static addSenderId(builder:flatbuffers.Builder, senderIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, senderIdOffset, 0);
}
static addKind(builder:flatbuffers.Builder, kindOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, kindOffset, 0);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, bodyOffset, 0);
}
static addCreatedAtUnix(builder:flatbuffers.Builder, createdAtUnix:bigint) {
builder.addFieldInt64(5, createdAtUnix, BigInt('0'));
}
static endChatMessage(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createChatMessage(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, gameIdOffset:flatbuffers.Offset, senderIdOffset:flatbuffers.Offset, kindOffset:flatbuffers.Offset, bodyOffset:flatbuffers.Offset, createdAtUnix:bigint):flatbuffers.Offset {
ChatMessage.startChatMessage(builder);
ChatMessage.addId(builder, idOffset);
ChatMessage.addGameId(builder, gameIdOffset);
ChatMessage.addSenderId(builder, senderIdOffset);
ChatMessage.addKind(builder, kindOffset);
ChatMessage.addBody(builder, bodyOffset);
ChatMessage.addCreatedAtUnix(builder, createdAtUnix);
return ChatMessage.endChatMessage(builder);
}
}
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class ChatPostRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):ChatPostRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsChatPostRequest(bb:flatbuffers.ByteBuffer, obj?:ChatPostRequest):ChatPostRequest {
return (obj || new ChatPostRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsChatPostRequest(bb:flatbuffers.ByteBuffer, obj?:ChatPostRequest):ChatPostRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new ChatPostRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
body():string|null
body(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
body(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startChatPostRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, bodyOffset, 0);
}
static endChatPostRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createChatPostRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, bodyOffset:flatbuffers.Offset):flatbuffers.Offset {
ChatPostRequest.startChatPostRequest(builder);
ChatPostRequest.addGameId(builder, gameIdOffset);
ChatPostRequest.addBody(builder, bodyOffset);
return ChatPostRequest.endChatPostRequest(builder);
}
}
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class CheckWordRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):CheckWordRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsCheckWordRequest(bb:flatbuffers.ByteBuffer, obj?:CheckWordRequest):CheckWordRequest {
return (obj || new CheckWordRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsCheckWordRequest(bb:flatbuffers.ByteBuffer, obj?:CheckWordRequest):CheckWordRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new CheckWordRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
word():string|null
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
word(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startCheckWordRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, wordOffset, 0);
}
static endCheckWordRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createCheckWordRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, wordOffset:flatbuffers.Offset):flatbuffers.Offset {
CheckWordRequest.startCheckWordRequest(builder);
CheckWordRequest.addGameId(builder, gameIdOffset);
CheckWordRequest.addWord(builder, wordOffset);
return CheckWordRequest.endCheckWordRequest(builder);
}
}
@@ -0,0 +1,72 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class ComplaintRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):ComplaintRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsComplaintRequest(bb:flatbuffers.ByteBuffer, obj?:ComplaintRequest):ComplaintRequest {
return (obj || new ComplaintRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsComplaintRequest(bb:flatbuffers.ByteBuffer, obj?:ComplaintRequest):ComplaintRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new ComplaintRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
word():string|null
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
word(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
note():string|null
note(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
note(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startComplaintRequest(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, wordOffset, 0);
}
static addNote(builder:flatbuffers.Builder, noteOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, noteOffset, 0);
}
static endComplaintRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createComplaintRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, wordOffset:flatbuffers.Offset, noteOffset:flatbuffers.Offset):flatbuffers.Offset {
ComplaintRequest.startComplaintRequest(builder);
ComplaintRequest.addGameId(builder, gameIdOffset);
ComplaintRequest.addWord(builder, wordOffset);
ComplaintRequest.addNote(builder, noteOffset);
return ComplaintRequest.endComplaintRequest(builder);
}
}
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailLoginRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailLoginRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailLoginRequest(bb:flatbuffers.ByteBuffer, obj?:EmailLoginRequest):EmailLoginRequest {
return (obj || new EmailLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailLoginRequest(bb:flatbuffers.ByteBuffer, obj?:EmailLoginRequest):EmailLoginRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
code():string|null
code(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
code(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static addCode(builder:flatbuffers.Builder, codeOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, codeOffset, 0);
}
static endEmailLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailLoginRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, codeOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailLoginRequest.startEmailLoginRequest(builder);
EmailLoginRequest.addEmail(builder, emailOffset);
EmailLoginRequest.addCode(builder, codeOffset);
return EmailLoginRequest.endEmailLoginRequest(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EmailRequestRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EmailRequestRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEmailRequestRequest(bb:flatbuffers.ByteBuffer, obj?:EmailRequestRequest):EmailRequestRequest {
return (obj || new EmailRequestRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEmailRequestRequest(bb:flatbuffers.ByteBuffer, obj?:EmailRequestRequest):EmailRequestRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EmailRequestRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
email():string|null
email(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
email(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailRequestRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0);
}
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset);
return EmailRequestRequest.endEmailRequestRequest(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EnqueueRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EnqueueRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEnqueueRequest(bb:flatbuffers.ByteBuffer, obj?:EnqueueRequest):EnqueueRequest {
return (obj || new EnqueueRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEnqueueRequest(bb:flatbuffers.ByteBuffer, obj?:EnqueueRequest):EnqueueRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EnqueueRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEnqueueRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, variantOffset, 0);
}
static endEnqueueRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEnqueueRequest(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset):flatbuffers.Offset {
EnqueueRequest.startEnqueueRequest(builder);
EnqueueRequest.addVariant(builder, variantOffset);
return EnqueueRequest.endEnqueueRequest(builder);
}
}
+90
View File
@@ -0,0 +1,90 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { TileRecord } from '../scrabblefb/tile-record.js';
export class EvalRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EvalRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEvalRequest(bb:flatbuffers.ByteBuffer, obj?:EvalRequest):EvalRequest {
return (obj || new EvalRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEvalRequest(bb:flatbuffers.ByteBuffer, obj?:EvalRequest):EvalRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EvalRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
dir():string|null
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dir(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number, obj?:TileRecord):TileRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
tilesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startEvalRequest(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, dirOffset, 0);
}
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, tilesOffset, 0);
}
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endEvalRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEvalRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
EvalRequest.startEvalRequest(builder);
EvalRequest.addGameId(builder, gameIdOffset);
EvalRequest.addDir(builder, dirOffset);
EvalRequest.addTiles(builder, tilesOffset);
return EvalRequest.endEvalRequest(builder);
}
}
+85
View File
@@ -0,0 +1,85 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class EvalResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):EvalResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsEvalResult(bb:flatbuffers.ByteBuffer, obj?:EvalResult):EvalResult {
return (obj || new EvalResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsEvalResult(bb:flatbuffers.ByteBuffer, obj?:EvalResult):EvalResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new EvalResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
legal():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
score():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
words(index: number):string
words(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
words(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
wordsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startEvalResult(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addLegal(builder:flatbuffers.Builder, legal:boolean) {
builder.addFieldInt8(0, +legal, +false);
}
static addScore(builder:flatbuffers.Builder, score:number) {
builder.addFieldInt32(1, score, 0);
}
static addWords(builder:flatbuffers.Builder, wordsOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, wordsOffset, 0);
}
static createWordsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startWordsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endEvalResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createEvalResult(builder:flatbuffers.Builder, legal:boolean, score:number, wordsOffset:flatbuffers.Offset):flatbuffers.Offset {
EvalResult.startEvalResult(builder);
EvalResult.addLegal(builder, legal);
EvalResult.addScore(builder, score);
EvalResult.addWords(builder, wordsOffset);
return EvalResult.endEvalResult(builder);
}
}
@@ -0,0 +1,77 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class ExchangeRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):ExchangeRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsExchangeRequest(bb:flatbuffers.ByteBuffer, obj?:ExchangeRequest):ExchangeRequest {
return (obj || new ExchangeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsExchangeRequest(bb:flatbuffers.ByteBuffer, obj?:ExchangeRequest):ExchangeRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new ExchangeRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number):string
tiles(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
tiles(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
tilesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startExchangeRequest(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, tilesOffset, 0);
}
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endExchangeRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createExchangeRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
ExchangeRequest.startExchangeRequest(builder);
ExchangeRequest.addGameId(builder, gameIdOffset);
ExchangeRequest.addTiles(builder, tilesOffset);
return ExchangeRequest.endExchangeRequest(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class GameActionRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GameActionRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGameActionRequest(bb:flatbuffers.ByteBuffer, obj?:GameActionRequest):GameActionRequest {
return (obj || new GameActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGameActionRequest(bb:flatbuffers.ByteBuffer, obj?:GameActionRequest):GameActionRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GameActionRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startGameActionRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static endGameActionRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameActionRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
GameActionRequest.startGameActionRequest(builder);
GameActionRequest.addGameId(builder, gameIdOffset);
return GameActionRequest.endGameActionRequest(builder);
}
}
+66
View File
@@ -0,0 +1,66 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { GameView } from '../scrabblefb/game-view.js';
export class GameList {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GameList {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGameList(bb:flatbuffers.ByteBuffer, obj?:GameList):GameList {
return (obj || new GameList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGameList(bb:flatbuffers.ByteBuffer, obj?:GameList):GameList {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GameList()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
games(index: number, obj?:GameView):GameView|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
gamesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startGameList(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addGames(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gamesOffset, 0);
}
static createGamesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startGamesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endGameList(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameList(builder:flatbuffers.Builder, gamesOffset:flatbuffers.Offset):flatbuffers.Offset {
GameList.startGameList(builder);
GameList.addGames(builder, gamesOffset);
return GameList.endGameList(builder);
}
}
+166
View File
@@ -0,0 +1,166 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { SeatView } from '../scrabblefb/seat-view.js';
export class GameView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GameView {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGameView(bb:flatbuffers.ByteBuffer, obj?:GameView):GameView {
return (obj || new GameView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGameView(bb:flatbuffers.ByteBuffer, obj?:GameView):GameView {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GameView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
id():string|null
id(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
id(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
variant():string|null
variant(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
variant(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
dictVersion():string|null
dictVersion(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dictVersion(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
status():string|null
status(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
status(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
players():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
toMove():number {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
turnTimeoutSecs():number {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
moveCount():number {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
endReason():string|null
endReason(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
endReason(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
seats(index: number, obj?:SeatView):SeatView|null {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? (obj || new SeatView()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
seatsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startGameView(builder:flatbuffers.Builder) {
builder.startObject(10);
}
static addId(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, idOffset, 0);
}
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, variantOffset, 0);
}
static addDictVersion(builder:flatbuffers.Builder, dictVersionOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, dictVersionOffset, 0);
}
static addStatus(builder:flatbuffers.Builder, statusOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, statusOffset, 0);
}
static addPlayers(builder:flatbuffers.Builder, players:number) {
builder.addFieldInt32(4, players, 0);
}
static addToMove(builder:flatbuffers.Builder, toMove:number) {
builder.addFieldInt32(5, toMove, 0);
}
static addTurnTimeoutSecs(builder:flatbuffers.Builder, turnTimeoutSecs:number) {
builder.addFieldInt32(6, turnTimeoutSecs, 0);
}
static addMoveCount(builder:flatbuffers.Builder, moveCount:number) {
builder.addFieldInt32(7, moveCount, 0);
}
static addEndReason(builder:flatbuffers.Builder, endReasonOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, endReasonOffset, 0);
}
static addSeats(builder:flatbuffers.Builder, seatsOffset:flatbuffers.Offset) {
builder.addFieldOffset(9, seatsOffset, 0);
}
static createSeatsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startSeatsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endGameView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGameView(builder:flatbuffers.Builder, idOffset:flatbuffers.Offset, variantOffset:flatbuffers.Offset, dictVersionOffset:flatbuffers.Offset, statusOffset:flatbuffers.Offset, players:number, toMove:number, turnTimeoutSecs:number, moveCount:number, endReasonOffset:flatbuffers.Offset, seatsOffset:flatbuffers.Offset):flatbuffers.Offset {
GameView.startGameView(builder);
GameView.addId(builder, idOffset);
GameView.addVariant(builder, variantOffset);
GameView.addDictVersion(builder, dictVersionOffset);
GameView.addStatus(builder, statusOffset);
GameView.addPlayers(builder, players);
GameView.addToMove(builder, toMove);
GameView.addTurnTimeoutSecs(builder, turnTimeoutSecs);
GameView.addMoveCount(builder, moveCount);
GameView.addEndReason(builder, endReasonOffset);
GameView.addSeats(builder, seatsOffset);
return GameView.endGameView(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class GuestLoginRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):GuestLoginRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsGuestLoginRequest(bb:flatbuffers.ByteBuffer, obj?:GuestLoginRequest):GuestLoginRequest {
return (obj || new GuestLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsGuestLoginRequest(bb:flatbuffers.ByteBuffer, obj?:GuestLoginRequest):GuestLoginRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new GuestLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
locale():string|null
locale(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
locale(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startGuestLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, localeOffset, 0);
}
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset):flatbuffers.Offset {
GuestLoginRequest.startGuestLoginRequest(builder);
GuestLoginRequest.addLocale(builder, localeOffset);
return GuestLoginRequest.endGuestLoginRequest(builder);
}
}
+59
View File
@@ -0,0 +1,59 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { MoveRecord } from '../scrabblefb/move-record.js';
export class HintResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):HintResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsHintResult(bb:flatbuffers.ByteBuffer, obj?:HintResult):HintResult {
return (obj || new HintResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsHintResult(bb:flatbuffers.ByteBuffer, obj?:HintResult):HintResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new HintResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
move(obj?:MoveRecord):MoveRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
hintsRemaining():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startHintResult(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, moveOffset, 0);
}
static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
builder.addFieldInt32(1, hintsRemaining, 0);
}
static endHintResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createHintResult(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset, hintsRemaining:number):flatbuffers.Offset {
HintResult.startHintResult(builder);
HintResult.addMove(builder, moveOffset);
HintResult.addHintsRemaining(builder, hintsRemaining);
return HintResult.endHintResult(builder);
}
}
+78
View File
@@ -0,0 +1,78 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { MoveRecord } from '../scrabblefb/move-record.js';
export class History {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):History {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsHistory(bb:flatbuffers.ByteBuffer, obj?:History):History {
return (obj || new History()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsHistory(bb:flatbuffers.ByteBuffer, obj?:History):History {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new History()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
moves(index: number, obj?:MoveRecord):MoveRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
movesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startHistory(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addMoves(builder:flatbuffers.Builder, movesOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, movesOffset, 0);
}
static createMovesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startMovesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endHistory(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createHistory(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, movesOffset:flatbuffers.Offset):flatbuffers.Offset {
History.startHistory(builder);
History.addGameId(builder, gameIdOffset);
History.addMoves(builder, movesOffset);
return History.endHistory(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class MatchFoundEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):MatchFoundEvent {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsMatchFoundEvent(bb:flatbuffers.ByteBuffer, obj?:MatchFoundEvent):MatchFoundEvent {
return (obj || new MatchFoundEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsMatchFoundEvent(bb:flatbuffers.ByteBuffer, obj?:MatchFoundEvent):MatchFoundEvent {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new MatchFoundEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startMatchFoundEvent(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static endMatchFoundEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createMatchFoundEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
MatchFoundEvent.startMatchFoundEvent(builder);
MatchFoundEvent.addGameId(builder, gameIdOffset);
return MatchFoundEvent.endMatchFoundEvent(builder);
}
}
+53
View File
@@ -0,0 +1,53 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { GameView } from '../scrabblefb/game-view.js';
export class MatchResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):MatchResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsMatchResult(bb:flatbuffers.ByteBuffer, obj?:MatchResult):MatchResult {
return (obj || new MatchResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsMatchResult(bb:flatbuffers.ByteBuffer, obj?:MatchResult):MatchResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new MatchResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
matched():boolean {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
game(obj?:GameView):GameView|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startMatchResult(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addMatched(builder:flatbuffers.Builder, matched:boolean) {
builder.addFieldInt8(0, +matched, +false);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, gameOffset, 0);
}
static endMatchResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
+179
View File
@@ -0,0 +1,179 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { TileRecord } from '../scrabblefb/tile-record.js';
export class MoveRecord {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):MoveRecord {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsMoveRecord(bb:flatbuffers.ByteBuffer, obj?:MoveRecord):MoveRecord {
return (obj || new MoveRecord()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsMoveRecord(bb:flatbuffers.ByteBuffer, obj?:MoveRecord):MoveRecord {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new MoveRecord()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
player():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
action():string|null
action(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
action(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
dir():string|null
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dir(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
mainRow():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
mainCol():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
tiles(index: number, obj?:TileRecord):TileRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
tilesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
words(index: number):string
words(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
words(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
wordsLength():number {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
count():number {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
score():number {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
total():number {
const offset = this.bb!.__offset(this.bb_pos, 22);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startMoveRecord(builder:flatbuffers.Builder) {
builder.startObject(10);
}
static addPlayer(builder:flatbuffers.Builder, player:number) {
builder.addFieldInt32(0, player, 0);
}
static addAction(builder:flatbuffers.Builder, actionOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, actionOffset, 0);
}
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, dirOffset, 0);
}
static addMainRow(builder:flatbuffers.Builder, mainRow:number) {
builder.addFieldInt32(3, mainRow, 0);
}
static addMainCol(builder:flatbuffers.Builder, mainCol:number) {
builder.addFieldInt32(4, mainCol, 0);
}
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, tilesOffset, 0);
}
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addWords(builder:flatbuffers.Builder, wordsOffset:flatbuffers.Offset) {
builder.addFieldOffset(6, wordsOffset, 0);
}
static createWordsVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startWordsVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addCount(builder:flatbuffers.Builder, count:number) {
builder.addFieldInt32(7, count, 0);
}
static addScore(builder:flatbuffers.Builder, score:number) {
builder.addFieldInt32(8, score, 0);
}
static addTotal(builder:flatbuffers.Builder, total:number) {
builder.addFieldInt32(9, total, 0);
}
static endMoveRecord(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createMoveRecord(builder:flatbuffers.Builder, player:number, actionOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, mainRow:number, mainCol:number, tilesOffset:flatbuffers.Offset, wordsOffset:flatbuffers.Offset, count:number, score:number, total:number):flatbuffers.Offset {
MoveRecord.startMoveRecord(builder);
MoveRecord.addPlayer(builder, player);
MoveRecord.addAction(builder, actionOffset);
MoveRecord.addDir(builder, dirOffset);
MoveRecord.addMainRow(builder, mainRow);
MoveRecord.addMainCol(builder, mainCol);
MoveRecord.addTiles(builder, tilesOffset);
MoveRecord.addWords(builder, wordsOffset);
MoveRecord.addCount(builder, count);
MoveRecord.addScore(builder, score);
MoveRecord.addTotal(builder, total);
return MoveRecord.endMoveRecord(builder);
}
}
+54
View File
@@ -0,0 +1,54 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { GameView } from '../scrabblefb/game-view.js';
import { MoveRecord } from '../scrabblefb/move-record.js';
export class MoveResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):MoveResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsMoveResult(bb:flatbuffers.ByteBuffer, obj?:MoveResult):MoveResult {
return (obj || new MoveResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsMoveResult(bb:flatbuffers.ByteBuffer, obj?:MoveResult):MoveResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new MoveResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
move(obj?:MoveRecord):MoveRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new MoveRecord()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
game(obj?:GameView):GameView|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
static startMoveResult(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addMove(builder:flatbuffers.Builder, moveOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, moveOffset, 0);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, gameOffset, 0);
}
static endMoveResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
}
+60
View File
@@ -0,0 +1,60 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class NudgeEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):NudgeEvent {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsNudgeEvent(bb:flatbuffers.ByteBuffer, obj?:NudgeEvent):NudgeEvent {
return (obj || new NudgeEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsNudgeEvent(bb:flatbuffers.ByteBuffer, obj?:NudgeEvent):NudgeEvent {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new NudgeEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
fromUserId():string|null
fromUserId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
fromUserId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startNudgeEvent(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addFromUserId(builder:flatbuffers.Builder, fromUserIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, fromUserIdOffset, 0);
}
static endNudgeEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createNudgeEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, fromUserIdOffset:flatbuffers.Offset):flatbuffers.Offset {
NudgeEvent.startNudgeEvent(builder);
NudgeEvent.addGameId(builder, gameIdOffset);
NudgeEvent.addFromUserId(builder, fromUserIdOffset);
return NudgeEvent.endNudgeEvent(builder);
}
}
@@ -0,0 +1,90 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class OpponentMovedEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):OpponentMovedEvent {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsOpponentMovedEvent(bb:flatbuffers.ByteBuffer, obj?:OpponentMovedEvent):OpponentMovedEvent {
return (obj || new OpponentMovedEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsOpponentMovedEvent(bb:flatbuffers.ByteBuffer, obj?:OpponentMovedEvent):OpponentMovedEvent {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new OpponentMovedEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
seat():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
action():string|null
action(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
action(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
score():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
total():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startOpponentMovedEvent(builder:flatbuffers.Builder) {
builder.startObject(5);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
builder.addFieldInt32(1, seat, 0);
}
static addAction(builder:flatbuffers.Builder, actionOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, actionOffset, 0);
}
static addScore(builder:flatbuffers.Builder, score:number) {
builder.addFieldInt32(3, score, 0);
}
static addTotal(builder:flatbuffers.Builder, total:number) {
builder.addFieldInt32(4, total, 0);
}
static endOpponentMovedEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createOpponentMovedEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, seat:number, actionOffset:flatbuffers.Offset, score:number, total:number):flatbuffers.Offset {
OpponentMovedEvent.startOpponentMovedEvent(builder);
OpponentMovedEvent.addGameId(builder, gameIdOffset);
OpponentMovedEvent.addSeat(builder, seat);
OpponentMovedEvent.addAction(builder, actionOffset);
OpponentMovedEvent.addScore(builder, score);
OpponentMovedEvent.addTotal(builder, total);
return OpponentMovedEvent.endOpponentMovedEvent(builder);
}
}
+124
View File
@@ -0,0 +1,124 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class Profile {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):Profile {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsProfile(bb:flatbuffers.ByteBuffer, obj?:Profile):Profile {
return (obj || new Profile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsProfile(bb:flatbuffers.ByteBuffer, obj?:Profile):Profile {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new Profile()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
userId():string|null
userId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
userId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
preferredLanguage():string|null
preferredLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
preferredLanguage(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
timeZone():string|null
timeZone(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
timeZone(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
hintBalance():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
blockChat():boolean {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
blockFriendRequests():boolean {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
isGuest():boolean {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startProfile(builder:flatbuffers.Builder) {
builder.startObject(8);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, userIdOffset, 0);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, displayNameOffset, 0);
}
static addPreferredLanguage(builder:flatbuffers.Builder, preferredLanguageOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, preferredLanguageOffset, 0);
}
static addTimeZone(builder:flatbuffers.Builder, timeZoneOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, timeZoneOffset, 0);
}
static addHintBalance(builder:flatbuffers.Builder, hintBalance:number) {
builder.addFieldInt32(4, hintBalance, 0);
}
static addBlockChat(builder:flatbuffers.Builder, blockChat:boolean) {
builder.addFieldInt8(5, +blockChat, +false);
}
static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:boolean) {
builder.addFieldInt8(6, +blockFriendRequests, +false);
}
static addIsGuest(builder:flatbuffers.Builder, isGuest:boolean) {
builder.addFieldInt8(7, +isGuest, +false);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean):flatbuffers.Offset {
Profile.startProfile(builder);
Profile.addUserId(builder, userIdOffset);
Profile.addDisplayName(builder, displayNameOffset);
Profile.addPreferredLanguage(builder, preferredLanguageOffset);
Profile.addTimeZone(builder, timeZoneOffset);
Profile.addHintBalance(builder, hintBalance);
Profile.addBlockChat(builder, blockChat);
Profile.addBlockFriendRequests(builder, blockFriendRequests);
Profile.addIsGuest(builder, isGuest);
return Profile.endProfile(builder);
}
}
+100
View File
@@ -0,0 +1,100 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class SeatView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):SeatView {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsSeatView(bb:flatbuffers.ByteBuffer, obj?:SeatView):SeatView {
return (obj || new SeatView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsSeatView(bb:flatbuffers.ByteBuffer, obj?:SeatView):SeatView {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new SeatView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
seat():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
accountId():string|null
accountId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
accountId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
score():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintsUsed():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
isWinner():boolean {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startSeatView(builder:flatbuffers.Builder) {
builder.startObject(6);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
builder.addFieldInt32(0, seat, 0);
}
static addAccountId(builder:flatbuffers.Builder, accountIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, accountIdOffset, 0);
}
static addScore(builder:flatbuffers.Builder, score:number) {
builder.addFieldInt32(2, score, 0);
}
static addHintsUsed(builder:flatbuffers.Builder, hintsUsed:number) {
builder.addFieldInt32(3, hintsUsed, 0);
}
static addIsWinner(builder:flatbuffers.Builder, isWinner:boolean) {
builder.addFieldInt8(4, +isWinner, +false);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, displayNameOffset, 0);
}
static endSeatView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createSeatView(builder:flatbuffers.Builder, seat:number, accountIdOffset:flatbuffers.Offset, score:number, hintsUsed:number, isWinner:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
SeatView.startSeatView(builder);
SeatView.addSeat(builder, seat);
SeatView.addAccountId(builder, accountIdOffset);
SeatView.addScore(builder, score);
SeatView.addHintsUsed(builder, hintsUsed);
SeatView.addIsWinner(builder, isWinner);
SeatView.addDisplayName(builder, displayNameOffset);
return SeatView.endSeatView(builder);
}
}
+82
View File
@@ -0,0 +1,82 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class Session {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):Session {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsSession(bb:flatbuffers.ByteBuffer, obj?:Session):Session {
return (obj || new Session()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsSession(bb:flatbuffers.ByteBuffer, obj?:Session):Session {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new Session()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
token():string|null
token(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
token(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
userId():string|null
userId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
userId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
isGuest():boolean {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
displayName():string|null
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
displayName(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startSession(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, tokenOffset, 0);
}
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, userIdOffset, 0);
}
static addIsGuest(builder:flatbuffers.Builder, isGuest:boolean) {
builder.addFieldInt8(2, +isGuest, +false);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, displayNameOffset, 0);
}
static endSession(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
Session.startSession(builder);
Session.addToken(builder, tokenOffset);
Session.addUserId(builder, userIdOffset);
Session.addIsGuest(builder, isGuest);
Session.addDisplayName(builder, displayNameOffset);
return Session.endSession(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class StateRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):StateRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsStateRequest(bb:flatbuffers.ByteBuffer, obj?:StateRequest):StateRequest {
return (obj || new StateRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsStateRequest(bb:flatbuffers.ByteBuffer, obj?:StateRequest):StateRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new StateRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startStateRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static endStateRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStateRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset):flatbuffers.Offset {
StateRequest.startStateRequest(builder);
StateRequest.addGameId(builder, gameIdOffset);
return StateRequest.endStateRequest(builder);
}
}
+108
View File
@@ -0,0 +1,108 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { GameView } from '../scrabblefb/game-view.js';
export class StateView {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):StateView {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsStateView(bb:flatbuffers.ByteBuffer, obj?:StateView):StateView {
return (obj || new StateView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsStateView(bb:flatbuffers.ByteBuffer, obj?:StateView):StateView {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new StateView()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
game(obj?:GameView):GameView|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? (obj || new GameView()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
}
seat():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
rack(index: number):string
rack(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
rack(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
rackLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
bagLen():number {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
hintsRemaining():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
static startStateView(builder:flatbuffers.Builder) {
builder.startObject(5);
}
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameOffset, 0);
}
static addSeat(builder:flatbuffers.Builder, seat:number) {
builder.addFieldInt32(1, seat, 0);
}
static addRack(builder:flatbuffers.Builder, rackOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, rackOffset, 0);
}
static createRackVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startRackVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addBagLen(builder:flatbuffers.Builder, bagLen:number) {
builder.addFieldInt32(3, bagLen, 0);
}
static addHintsRemaining(builder:flatbuffers.Builder, hintsRemaining:number) {
builder.addFieldInt32(4, hintsRemaining, 0);
}
static endStateView(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number):flatbuffers.Offset {
StateView.startStateView(builder);
StateView.addGame(builder, gameOffset);
StateView.addSeat(builder, seat);
StateView.addRack(builder, rackOffset);
StateView.addBagLen(builder, bagLen);
StateView.addHintsRemaining(builder, hintsRemaining);
return StateView.endStateView(builder);
}
}
@@ -0,0 +1,90 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
import { TileRecord } from '../scrabblefb/tile-record.js';
export class SubmitPlayRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):SubmitPlayRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsSubmitPlayRequest(bb:flatbuffers.ByteBuffer, obj?:SubmitPlayRequest):SubmitPlayRequest {
return (obj || new SubmitPlayRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsSubmitPlayRequest(bb:flatbuffers.ByteBuffer, obj?:SubmitPlayRequest):SubmitPlayRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new SubmitPlayRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
dir():string|null
dir(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
dir(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
tiles(index: number, obj?:TileRecord):TileRecord|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? (obj || new TileRecord()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
}
tilesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startSubmitPlayRequest(builder:flatbuffers.Builder) {
builder.startObject(3);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addDir(builder:flatbuffers.Builder, dirOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, dirOffset, 0);
}
static addTiles(builder:flatbuffers.Builder, tilesOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, tilesOffset, 0);
}
static createTilesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startTilesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endSubmitPlayRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createSubmitPlayRequest(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, dirOffset:flatbuffers.Offset, tilesOffset:flatbuffers.Offset):flatbuffers.Offset {
SubmitPlayRequest.startSubmitPlayRequest(builder);
SubmitPlayRequest.addGameId(builder, gameIdOffset);
SubmitPlayRequest.addDir(builder, dirOffset);
SubmitPlayRequest.addTiles(builder, tilesOffset);
return SubmitPlayRequest.endSubmitPlayRequest(builder);
}
}
@@ -0,0 +1,48 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class TelegramLoginRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):TelegramLoginRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsTelegramLoginRequest(bb:flatbuffers.ByteBuffer, obj?:TelegramLoginRequest):TelegramLoginRequest {
return (obj || new TelegramLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsTelegramLoginRequest(bb:flatbuffers.ByteBuffer, obj?:TelegramLoginRequest):TelegramLoginRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new TelegramLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
initData():string|null
initData(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
initData(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startTelegramLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, initDataOffset, 0);
}
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset):flatbuffers.Offset {
TelegramLoginRequest.startTelegramLoginRequest(builder);
TelegramLoginRequest.addInitData(builder, initDataOffset);
return TelegramLoginRequest.endTelegramLoginRequest(builder);
}
}
+78
View File
@@ -0,0 +1,78 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class TileRecord {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):TileRecord {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsTileRecord(bb:flatbuffers.ByteBuffer, obj?:TileRecord):TileRecord {
return (obj || new TileRecord()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsTileRecord(bb:flatbuffers.ByteBuffer, obj?:TileRecord):TileRecord {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new TileRecord()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
row():number {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
col():number {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
}
letter():string|null
letter(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
letter(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
blank():boolean {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startTileRecord(builder:flatbuffers.Builder) {
builder.startObject(4);
}
static addRow(builder:flatbuffers.Builder, row:number) {
builder.addFieldInt32(0, row, 0);
}
static addCol(builder:flatbuffers.Builder, col:number) {
builder.addFieldInt32(1, col, 0);
}
static addLetter(builder:flatbuffers.Builder, letterOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, letterOffset, 0);
}
static addBlank(builder:flatbuffers.Builder, blank:boolean) {
builder.addFieldInt8(3, +blank, +false);
}
static endTileRecord(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createTileRecord(builder:flatbuffers.Builder, row:number, col:number, letterOffset:flatbuffers.Offset, blank:boolean):flatbuffers.Offset {
TileRecord.startTileRecord(builder);
TileRecord.addRow(builder, row);
TileRecord.addCol(builder, col);
TileRecord.addLetter(builder, letterOffset);
TileRecord.addBlank(builder, blank);
return TileRecord.endTileRecord(builder);
}
}
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WordCheckResult {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WordCheckResult {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWordCheckResult(bb:flatbuffers.ByteBuffer, obj?:WordCheckResult):WordCheckResult {
return (obj || new WordCheckResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWordCheckResult(bb:flatbuffers.ByteBuffer, obj?:WordCheckResult):WordCheckResult {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WordCheckResult()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
word():string|null
word(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
word(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
legal():boolean {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
}
static startWordCheckResult(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addWord(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, wordOffset, 0);
}
static addLegal(builder:flatbuffers.Builder, legal:boolean) {
builder.addFieldInt8(1, +legal, +false);
}
static endWordCheckResult(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWordCheckResult(builder:flatbuffers.Builder, wordOffset:flatbuffers.Offset, legal:boolean):flatbuffers.Offset {
WordCheckResult.startWordCheckResult(builder);
WordCheckResult.addWord(builder, wordOffset);
WordCheckResult.addLegal(builder, legal);
return WordCheckResult.endWordCheckResult(builder);
}
}
@@ -0,0 +1,58 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class YourTurnEvent {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):YourTurnEvent {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsYourTurnEvent(bb:flatbuffers.ByteBuffer, obj?:YourTurnEvent):YourTurnEvent {
return (obj || new YourTurnEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsYourTurnEvent(bb:flatbuffers.ByteBuffer, obj?:YourTurnEvent):YourTurnEvent {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new YourTurnEvent()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
gameId():string|null
gameId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
gameId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
deadlineUnix():bigint {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.readInt64(this.bb_pos + offset) : BigInt('0');
}
static startYourTurnEvent(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addGameId(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, gameIdOffset, 0);
}
static addDeadlineUnix(builder:flatbuffers.Builder, deadlineUnix:bigint) {
builder.addFieldInt64(1, deadlineUnix, BigInt('0'));
}
static endYourTurnEvent(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createYourTurnEvent(builder:flatbuffers.Builder, gameIdOffset:flatbuffers.Offset, deadlineUnix:bigint):flatbuffers.Offset {
YourTurnEvent.startYourTurnEvent(builder);
YourTurnEvent.addGameId(builder, gameIdOffset);
YourTurnEvent.addDeadlineUnix(builder, deadlineUnix);
return YourTurnEvent.endYourTurnEvent(builder);
}
}
+186
View File
@@ -0,0 +1,186 @@
// Central app state + actions. Holds the session/profile, client preferences, a
// transient toast, and the latest live event (screens react to it via $effect). All
// gateway calls funnel through here so errors map to one user-facing toast and an
// expired session logs out.
import type { Profile, PushEvent, Session } from './model';
import { gateway } from './gateway';
import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
export interface Toast {
kind: 'error' | 'info';
text: string;
}
export const app = $state<{
ready: boolean;
session: Session | null;
profile: Profile | null;
toast: Toast | null;
lastEvent: PushEvent | null;
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
localeLocked: boolean;
}>({
ready: false,
session: null,
profile: null,
toast: null,
lastEvent: null,
theme: 'auto',
locale: 'en',
reduceMotion: false,
localeLocked: false,
});
let unsubscribeStream: (() => void) | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => (app.toast = null), 4000);
}
/** handleError maps a GatewayError to a toast; an invalid session logs out. */
export function handleError(err: unknown): void {
if (err instanceof GatewayError) {
if (err.code === 'session_invalid' || err.code === 'unauthenticated') {
void logout();
return;
}
showToast(t(errorKey(err.code)), 'error');
return;
}
showToast(t('error.generic'), 'error');
}
function openStream(): void {
closeStream();
unsubscribeStream = gateway.subscribe(
(e) => {
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
showToast(e.message.kind === 'nudge' ? t('chat.nudge') : e.message.body, 'info');
} else if (e.kind === 'nudge') {
showToast(t('chat.nudge'), 'info');
} else if (e.kind === 'your_turn') {
showToast(t('game.yourTurn'), 'info');
} else if (e.kind === 'match_found') {
navigate(`/game/${e.gameId}`);
}
},
() => showToast(t('error.unavailable'), 'error'),
);
}
function closeStream(): void {
unsubscribeStream?.();
unsubscribeStream = null;
}
async function adoptSession(s: Session): Promise<void> {
gateway.setToken(s.token);
app.session = s;
await saveSession(s);
try {
app.profile = await gateway.profileGet();
if (!app.localeLocked) setLocale(localeFrom(app.profile.preferredLanguage, app.locale));
} catch (err) {
handleError(err);
}
openStream();
}
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
app.locale = prefs.locale;
app.localeLocked = true;
setLocale(prefs.locale);
} else {
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
app.locale = guess;
setLocale(guess);
}
const saved = await loadSession();
if (saved) {
await adoptSession(saved);
if (router.route.name === 'login') navigate('/');
} else if (router.route.name !== 'login') {
navigate('/login');
}
app.ready = true;
}
export async function loginGuest(): Promise<void> {
try {
const s = await gateway.authGuest(app.locale);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function requestEmailCode(email: string): Promise<boolean> {
try {
await gateway.authEmailRequest(email);
return true;
} catch (err) {
handleError(err);
return false;
}
}
export async function loginEmail(email: string, code: string): Promise<void> {
try {
const s = await gateway.authEmailLogin(email, code);
await adoptSession(s);
navigate('/');
} catch (err) {
handleError(err);
}
}
export async function logout(): Promise<void> {
closeStream();
gateway.setToken(null);
await clearSession();
app.session = null;
app.profile = null;
navigate('/login');
}
function persistPrefs(): void {
void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion });
}
export function setTheme(theme: ThemePref): void {
app.theme = theme;
applyTheme(theme);
persistPrefs();
}
export function setLocalePref(locale: Locale): void {
app.locale = locale;
app.localeLocked = true;
setLocale(locale);
persistPrefs();
}
export function setReduceMotion(on: boolean): void {
app.reduceMotion = on;
applyReduceMotion(on);
persistPrefs();
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { lastPlayTiles, replay } from './board';
import type { MoveRecord } from './model';
function play(tiles: { row: number; col: number; letter: string; blank: boolean }[]): MoveRecord {
return {
player: 0,
action: 'play',
dir: 'H',
mainRow: tiles[0].row,
mainCol: tiles[0].col,
tiles,
words: [],
count: 0,
score: 0,
total: 0,
};
}
const pass: MoveRecord = {
player: 1,
action: 'pass',
dir: '',
mainRow: 0,
mainCol: 0,
tiles: [],
words: [],
count: 0,
score: 0,
total: 0,
};
describe('board replay', () => {
it('places play tiles and ignores non-play moves', () => {
const moves = [
play([
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
]),
pass,
play([{ row: 8, col: 7, letter: 'C', blank: false }]),
];
const b = replay(moves);
expect(b[7][7]?.letter).toBe('A');
expect(b[7][8]?.blank).toBe(true);
expect(b[8][7]?.letter).toBe('C');
expect(b[0][0]).toBeNull();
expect(b.length).toBe(15);
expect(b[0].length).toBe(15);
});
it('lastPlayTiles returns the most recent play, skipping passes', () => {
const moves = [play([{ row: 7, col: 7, letter: 'A', blank: false }]), pass];
expect(lastPlayTiles(moves)).toHaveLength(1);
expect(lastPlayTiles([pass])).toHaveLength(0);
});
});
+45
View File
@@ -0,0 +1,45 @@
// Pure board reconstruction. The wire carries no board (StateView is summary + rack
// only), so the live grid is rebuilt by replaying the decoded move journal — exactly
// the dictionary-independent history invariant (ARCHITECTURE §9.1): apply each play's
// placed tiles onto an empty grid.
import type { MoveRecord, Tile } from './model';
import { BOARD_SIZE } from './premiums';
export interface BoardCell {
letter: string;
blank: boolean;
}
export type Board = (BoardCell | null)[][];
export function emptyBoard(): Board {
return Array.from({ length: BOARD_SIZE }, () =>
Array.from({ length: BOARD_SIZE }, () => null as BoardCell | null),
);
}
function inBounds(r: number, c: number): boolean {
return r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE;
}
/** replay folds every play move's tiles onto an empty board (pass/exchange/resign
* change no squares). */
export function replay(moves: MoveRecord[]): Board {
const b = emptyBoard();
for (const m of moves) {
if (m.action !== 'play') continue;
for (const t of m.tiles) {
if (inBounds(t.row, t.col)) b[t.row][t.col] = { letter: t.letter, blank: t.blank };
}
}
return b;
}
/** lastPlayTiles returns the tiles of the most recent play (for highlighting). */
export function lastPlayTiles(moves: MoveRecord[]): Tile[] {
for (let i = moves.length - 1; i >= 0; i--) {
if (moves[i].action === 'play') return moves[i].tiles;
}
return [];
}
+84
View File
@@ -0,0 +1,84 @@
// GatewayClient — the typed facade the screens call. Both the real Connect/
// FlatBuffers transport and the in-memory mock implement it. Domain failures (the
// gateway's result_code) and edge failures (Connect error codes) are normalised
// into a thrown GatewayError carrying a stable `code` the UI maps to an i18n
// message.
import type {
ChatMessage,
EvalResult,
GameList,
GameView,
History,
HintResult,
MatchResult,
MoveResult,
Profile,
PushEvent,
Session,
StateView,
Tile,
Variant,
WordCheckResult,
} from './model';
/** GatewayError carries a stable code (the gateway result_code, or an edge code). */
export class GatewayError extends Error {
readonly code: string;
constructor(code: string, message?: string) {
super(message ?? code);
this.name = 'GatewayError';
this.code = code;
}
}
/** A tile the player is submitting (rack/blank already resolved to a letter). */
export interface PlacedTile {
row: number;
col: number;
letter: string;
blank: boolean;
}
/** Unsubscribe handle for the live stream. */
export type Unsubscribe = () => void;
export interface GatewayClient {
// --- auth (unauthenticated) ---
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
// --- profile / lists ---
profileGet(): Promise<Profile>;
gamesList(): Promise<GameList>;
// --- lobby ---
lobbyEnqueue(variant: Variant): Promise<MatchResult>;
lobbyPoll(): Promise<MatchResult>;
// --- game ---
gameState(gameId: string): Promise<StateView>;
gameHistory(gameId: string): Promise<History>;
submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<MoveResult>;
pass(gameId: string): Promise<MoveResult>;
exchange(gameId: string, tiles: string[]): Promise<MoveResult>;
resign(gameId: string): Promise<MoveResult>;
hint(gameId: string): Promise<HintResult>;
evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise<EvalResult>;
checkWord(gameId: string, word: string): Promise<WordCheckResult>;
complaint(gameId: string, word: string, note: string): Promise<void>;
// --- chat ---
chatPost(gameId: string, body: string): Promise<ChatMessage>;
chatList(gameId: string): Promise<ChatMessage[]>;
nudge(gameId: string): Promise<ChatMessage>;
// --- live stream ---
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
/** Set or clear the bearer token used for authenticated calls and the stream. */
setToken(token: string | null): void;
}
export type { GameView, Tile };
+80
View File
@@ -0,0 +1,80 @@
import { Builder, ByteBuffer } from 'flatbuffers';
import { describe, expect, it } from 'vitest';
import * as fb from '../gen/fbs/scrabblefb';
import { decodeGameList, decodeSession, encodeSubmitPlay } from './codec';
describe('codec', () => {
it('encodes a SubmitPlayRequest the gateway can read', () => {
const buf = encodeSubmitPlay('g1', 'H', [
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: true },
]);
const r = fb.SubmitPlayRequest.getRootAsSubmitPlayRequest(new ByteBuffer(buf));
expect(r.gameId()).toBe('g1');
expect(r.dir()).toBe('H');
expect(r.tilesLength()).toBe(2);
expect(r.tiles(0)?.letter()).toBe('A');
expect(r.tiles(1)?.blank()).toBe(true);
});
it('decodes a Session', () => {
const b = new Builder(64);
const token = b.createString('tok');
const uid = b.createString('u1');
const name = b.createString('Me');
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, true);
fb.Session.addDisplayName(b, name);
b.finish(fb.Session.endSession(b));
expect(decodeSession(b.asUint8Array())).toEqual({
token: 'tok',
userId: 'u1',
isGuest: true,
displayName: 'Me',
});
});
it('decodes a GameList including nested seat display names', () => {
const b = new Builder(256);
const aid = b.createString('a1');
const dn = b.createString('Ann');
fb.SeatView.startSeatView(b);
fb.SeatView.addSeat(b, 1);
fb.SeatView.addAccountId(b, aid);
fb.SeatView.addScore(b, 13);
fb.SeatView.addHintsUsed(b, 0);
fb.SeatView.addIsWinner(b, false);
fb.SeatView.addDisplayName(b, dn);
const seat = fb.SeatView.endSeatView(b);
const seats = fb.GameView.createSeatsVector(b, [seat]);
const id = b.createString('g1');
const variant = b.createString('english');
const dv = b.createString('v1');
const status = b.createString('active');
const er = b.createString('');
fb.GameView.startGameView(b);
fb.GameView.addId(b, id);
fb.GameView.addVariant(b, variant);
fb.GameView.addDictVersion(b, dv);
fb.GameView.addStatus(b, status);
fb.GameView.addPlayers(b, 2);
fb.GameView.addToMove(b, 0);
fb.GameView.addTurnTimeoutSecs(b, 86400);
fb.GameView.addMoveCount(b, 4);
fb.GameView.addEndReason(b, er);
fb.GameView.addSeats(b, seats);
const game = fb.GameView.endGameView(b);
const games = fb.GameList.createGamesVector(b, [game]);
fb.GameList.startGameList(b);
fb.GameList.addGames(b, games);
b.finish(fb.GameList.endGameList(b));
const gl = decodeGameList(b.asUint8Array());
expect(gl.games).toHaveLength(1);
expect(gl.games[0].id).toBe('g1');
expect(gl.games[0].seats[0].displayName).toBe('Ann');
expect(gl.games[0].seats[0].score).toBe(13);
});
});
+384
View File
@@ -0,0 +1,384 @@
// FlatBuffers <-> domain-model codec. The real transport encodes each request table
// and decodes each response/event table here, mirroring the gateway's Go encoders in
// reverse. The screens only ever see the plain model (lib/model), never these wire
// types.
import { Builder, ByteBuffer, type Offset } from 'flatbuffers';
import * as fb from '../gen/fbs/scrabblefb';
import type { PlacedTile } from './client';
import type {
ChatMessage,
EvalResult,
GameList,
GameView,
History,
HintResult,
MatchResult,
MoveRecord,
MoveResult,
Profile,
PushEvent,
Seat,
Session,
StateView,
Tile,
Variant,
WordCheckResult,
} from './model';
// --- request encoders ---
function buildTile(b: Builder, t: PlacedTile): Offset {
const letter = b.createString(t.letter);
fb.TileRecord.startTileRecord(b);
fb.TileRecord.addRow(b, t.row);
fb.TileRecord.addCol(b, t.col);
fb.TileRecord.addLetter(b, letter);
fb.TileRecord.addBlank(b, t.blank);
return fb.TileRecord.endTileRecord(b);
}
function finish(b: Builder, root: Offset): Uint8Array {
b.finish(root);
return b.asUint8Array();
}
export const empty = (): Uint8Array => new Uint8Array();
export function encodeGameAction(gameId: string): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.GameActionRequest.startGameActionRequest(b);
fb.GameActionRequest.addGameId(b, gid);
return finish(b, fb.GameActionRequest.endGameActionRequest(b));
}
export function encodeStateRequest(gameId: string): Uint8Array {
const b = new Builder(64);
const gid = b.createString(gameId);
fb.StateRequest.startStateRequest(b);
fb.StateRequest.addGameId(b, gid);
return finish(b, fb.StateRequest.endStateRequest(b));
}
export function encodeSubmitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const vec = fb.SubmitPlayRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
fb.SubmitPlayRequest.startSubmitPlayRequest(b);
fb.SubmitPlayRequest.addGameId(b, gid);
fb.SubmitPlayRequest.addDir(b, d);
fb.SubmitPlayRequest.addTiles(b, vec);
return finish(b, fb.SubmitPlayRequest.endSubmitPlayRequest(b));
}
export function encodeEval(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Uint8Array {
const b = new Builder(256);
const tileOffs = tiles.map((t) => buildTile(b, t));
const vec = fb.EvalRequest.createTilesVector(b, tileOffs);
const gid = b.createString(gameId);
const d = b.createString(dir);
fb.EvalRequest.startEvalRequest(b);
fb.EvalRequest.addGameId(b, gid);
fb.EvalRequest.addDir(b, d);
fb.EvalRequest.addTiles(b, vec);
return finish(b, fb.EvalRequest.endEvalRequest(b));
}
export function encodeExchange(gameId: string, tiles: string[]): Uint8Array {
const b = new Builder(128);
const offs = tiles.map((s) => b.createString(s));
const vec = fb.ExchangeRequest.createTilesVector(b, offs);
const gid = b.createString(gameId);
fb.ExchangeRequest.startExchangeRequest(b);
fb.ExchangeRequest.addGameId(b, gid);
fb.ExchangeRequest.addTiles(b, vec);
return finish(b, fb.ExchangeRequest.endExchangeRequest(b));
}
export function encodeCheckWord(gameId: string, word: string): Uint8Array {
const b = new Builder(128);
const gid = b.createString(gameId);
const w = b.createString(word);
fb.CheckWordRequest.startCheckWordRequest(b);
fb.CheckWordRequest.addGameId(b, gid);
fb.CheckWordRequest.addWord(b, w);
return finish(b, fb.CheckWordRequest.endCheckWordRequest(b));
}
export function encodeComplaint(gameId: string, word: string, note: string): Uint8Array {
const b = new Builder(256);
const gid = b.createString(gameId);
const w = b.createString(word);
const n = b.createString(note);
fb.ComplaintRequest.startComplaintRequest(b);
fb.ComplaintRequest.addGameId(b, gid);
fb.ComplaintRequest.addWord(b, w);
fb.ComplaintRequest.addNote(b, n);
return finish(b, fb.ComplaintRequest.endComplaintRequest(b));
}
export function encodeEnqueue(variant: Variant): Uint8Array {
const b = new Builder(64);
const v = b.createString(variant);
fb.EnqueueRequest.startEnqueueRequest(b);
fb.EnqueueRequest.addVariant(b, v);
return finish(b, fb.EnqueueRequest.endEnqueueRequest(b));
}
export function encodeChatPost(gameId: string, body: string): Uint8Array {
const b = new Builder(128);
const gid = b.createString(gameId);
const bd = b.createString(body);
fb.ChatPostRequest.startChatPostRequest(b);
fb.ChatPostRequest.addGameId(b, gid);
fb.ChatPostRequest.addBody(b, bd);
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
}
export function encodeGuestLogin(locale: string): Uint8Array {
const b = new Builder(64);
const l = b.createString(locale);
fb.GuestLoginRequest.startGuestLoginRequest(b);
fb.GuestLoginRequest.addLocale(b, l);
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
}
export function encodeEmailRequest(email: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
}
export function encodeEmailLogin(email: string, code: string): Uint8Array {
const b = new Builder(128);
const e = b.createString(email);
const c = b.createString(code);
fb.EmailLoginRequest.startEmailLoginRequest(b);
fb.EmailLoginRequest.addEmail(b, e);
fb.EmailLoginRequest.addCode(b, c);
return finish(b, fb.EmailLoginRequest.endEmailLoginRequest(b));
}
// --- response decoders ---
function s(v: string | null): string {
return v ?? '';
}
function decodeTile(t: fb.TileRecord): Tile {
return { row: t.row(), col: t.col(), letter: s(t.letter()), blank: t.blank() };
}
function decodeSeat(v: fb.SeatView): Seat {
return {
seat: v.seat(),
accountId: s(v.accountId()),
displayName: s(v.displayName()),
score: v.score(),
hintsUsed: v.hintsUsed(),
isWinner: v.isWinner(),
};
}
function decodeGameView(g: fb.GameView): GameView {
const seats: Seat[] = [];
for (let i = 0; i < g.seatsLength(); i++) {
const sv = g.seats(i);
if (sv) seats.push(decodeSeat(sv));
}
return {
id: s(g.id()),
variant: s(g.variant()) as Variant,
dictVersion: s(g.dictVersion()),
status: s(g.status()),
players: g.players(),
toMove: g.toMove(),
turnTimeoutSecs: g.turnTimeoutSecs(),
moveCount: g.moveCount(),
endReason: s(g.endReason()),
seats,
};
}
function decodeMove(m: fb.MoveRecord): MoveRecord {
const tiles: Tile[] = [];
for (let i = 0; i < m.tilesLength(); i++) {
const t = m.tiles(i);
if (t) tiles.push(decodeTile(t));
}
const words: string[] = [];
for (let i = 0; i < m.wordsLength(); i++) words.push(s(m.words(i)));
return {
player: m.player(),
action: s(m.action()),
dir: s(m.dir()),
mainRow: m.mainRow(),
mainCol: m.mainCol(),
tiles,
words,
count: m.count(),
score: m.score(),
total: m.total(),
};
}
function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
return {
id: s(m.id()),
gameId: s(m.gameId()),
senderId: s(m.senderId()),
kind: s(m.kind()),
body: s(m.body()),
createdAtUnix: Number(m.createdAtUnix()),
};
}
export function decodeSession(buf: Uint8Array): Session {
const t = fb.Session.getRootAsSession(new ByteBuffer(buf));
return { token: s(t.token()), userId: s(t.userId()), isGuest: t.isGuest(), displayName: s(t.displayName()) };
}
export function decodeProfile(buf: Uint8Array): Profile {
const p = fb.Profile.getRootAsProfile(new ByteBuffer(buf));
return {
userId: s(p.userId()),
displayName: s(p.displayName()),
preferredLanguage: s(p.preferredLanguage()),
timeZone: s(p.timeZone()),
hintBalance: p.hintBalance(),
blockChat: p.blockChat(),
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
};
}
export function decodeStateView(buf: Uint8Array): StateView {
const v = fb.StateView.getRootAsStateView(new ByteBuffer(buf));
const g = v.game();
const rack: string[] = [];
for (let i = 0; i < v.rackLength(); i++) rack.push(s(v.rack(i)));
return {
game: g ? decodeGameView(g) : emptyGame(),
seat: v.seat(),
rack,
bagLen: v.bagLen(),
hintsRemaining: v.hintsRemaining(),
};
}
export function decodeMoveResult(buf: Uint8Array): MoveResult {
const r = fb.MoveResult.getRootAsMoveResult(new ByteBuffer(buf));
const m = r.move();
const g = r.game();
return { move: m ? decodeMove(m) : emptyMove(), game: g ? decodeGameView(g) : emptyGame() };
}
export function decodeHintResult(buf: Uint8Array): HintResult {
const r = fb.HintResult.getRootAsHintResult(new ByteBuffer(buf));
const m = r.move();
return { move: m ? decodeMove(m) : emptyMove(), hintsRemaining: r.hintsRemaining() };
}
export function decodeEvalResult(buf: Uint8Array): EvalResult {
const r = fb.EvalResult.getRootAsEvalResult(new ByteBuffer(buf));
const words: string[] = [];
for (let i = 0; i < r.wordsLength(); i++) words.push(s(r.words(i)));
return { legal: r.legal(), score: r.score(), words };
}
export function decodeWordCheck(buf: Uint8Array): WordCheckResult {
const r = fb.WordCheckResult.getRootAsWordCheckResult(new ByteBuffer(buf));
return { word: s(r.word()), legal: r.legal() };
}
export function decodeHistory(buf: Uint8Array): History {
const h = fb.History.getRootAsHistory(new ByteBuffer(buf));
const moves: MoveRecord[] = [];
for (let i = 0; i < h.movesLength(); i++) {
const m = h.moves(i);
if (m) moves.push(decodeMove(m));
}
return { gameId: s(h.gameId()), moves };
}
export function decodeGameList(buf: Uint8Array): GameList {
const gl = fb.GameList.getRootAsGameList(new ByteBuffer(buf));
const games: GameView[] = [];
for (let i = 0; i < gl.gamesLength(); i++) {
const g = gl.games(i);
if (g) games.push(decodeGameView(g));
}
return { games };
}
export function decodeMatchResult(buf: Uint8Array): MatchResult {
const m = fb.MatchResult.getRootAsMatchResult(new ByteBuffer(buf));
const g = m.game();
return { matched: m.matched(), game: m.matched() && g ? decodeGameView(g) : undefined };
}
export function decodeChatMessage(buf: Uint8Array): ChatMessage {
return decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(new ByteBuffer(buf)));
}
export function decodeChatList(buf: Uint8Array): ChatMessage[] {
const cl = fb.ChatList.getRootAsChatList(new ByteBuffer(buf));
const out: ChatMessage[] = [];
for (let i = 0; i < cl.messagesLength(); i++) {
const m = cl.messages(i);
if (m) out.push(decodeChatMsg(m));
}
return out;
}
export function decodeEvent(kind: string, payload: Uint8Array): PushEvent | null {
const bb = new ByteBuffer(payload);
switch (kind) {
case 'your_turn': {
const e = fb.YourTurnEvent.getRootAsYourTurnEvent(bb);
return { kind: 'your_turn', gameId: s(e.gameId()), deadlineUnix: Number(e.deadlineUnix()) };
}
case 'opponent_moved': {
const e = fb.OpponentMovedEvent.getRootAsOpponentMovedEvent(bb);
return { kind: 'opponent_moved', gameId: s(e.gameId()), seat: e.seat(), action: s(e.action()), score: e.score(), total: e.total() };
}
case 'chat_message':
return { kind: 'chat_message', message: decodeChatMsg(fb.ChatMessage.getRootAsChatMessage(bb)) };
case 'nudge': {
const e = fb.NudgeEvent.getRootAsNudgeEvent(bb);
return { kind: 'nudge', gameId: s(e.gameId()), fromUserId: s(e.fromUserId()) };
}
case 'match_found': {
const e = fb.MatchFoundEvent.getRootAsMatchFoundEvent(bb);
return { kind: 'match_found', gameId: s(e.gameId()) };
}
case 'heartbeat':
return { kind: 'heartbeat' };
default:
return null;
}
}
function emptyGame(): GameView {
return {
id: '',
variant: 'english',
dictVersion: '',
status: '',
players: 0,
toMove: 0,
turnTimeoutSecs: 0,
moveCount: 0,
endReason: '',
seats: [],
};
}
function emptyMove(): MoveRecord {
return { player: 0, action: '', dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total: 0 };
}

Some files were not shown because too many files have changed in this diff Show More