diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml new file mode 100644 index 0000000..fc3657a --- /dev/null +++ b/.gitea/workflows/ui-test.yaml @@ -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@ 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 diff --git a/CLAUDE.md b/CLAUDE.md index 89f2068..9cad34b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,4 +121,12 @@ go vet ./backend/... gofmt -l . # must print nothing go test -count=1 ./backend/... 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`). diff --git a/PLAN.md b/PLAN.md index ec1d610..dcd43a5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -40,11 +40,12 @@ independent (see ARCHITECTURE §9.1). | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 5 | Robot opponent | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | -| 7 | UI (plain Svelte + Vite, board, lobby, chat, i18n) | todo | -| 8 | Telegram integration (bot side-service, deep-link, push) | todo | -| 9 | Admin & dictionary ops (complaint review, version reload) | todo | -| 10 | Account linking & merge | todo | -| 11 | Polish (observability, perf with evidence, deploy) | todo | +| 7 | UI — playable slice (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | todo | +| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo | +| 9 | Telegram integration (bot side-service, deep-link, push) | todo | +| 10 | Admin & dictionary ops (complaint review, version reload) | 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 adds the modules it needs. @@ -70,7 +71,7 @@ platform identities. Open details: Postgres version + DSN/`search_path` convention; jet vs sqlc/sqlx (default jet); migration naming; exact session-token shape (opaque 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 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); 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 2–4 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), Mini App launch/auth; backend↔platform internal API. Open details: bot framework/library; deep-link scheme; push message templates; 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 versions + reload), complaint→dictionary update pipeline. Open details: whether a server-rendered console is wanted or JSON-only; the 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, dedupe). High blast-radius — focused regression tests. Open details: conflict resolution (active games on both, duplicate friends, display-name collisions); irreversibility/audit; confirm-flow per platform. -### Stage 11 — Polish +### Stage 12 — Polish Scope: observability dashboards, evidence-based performance work, prod build/deploy. 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, internal,admin}` groups + `X-User-ID` middleware are scaffolding (exposed via `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 - `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 `backend/internal/inttest` + new `integration.yaml` (testcontainers, Ryuk 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, resign/timeout is a loss, guests get no stats. - **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. - **GCG** (interview): standard Poslfit dialect (UTF-8, `#player`/`#lexicon` 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 (`BACKEND_SMTP_*`) and a default **log mailer**. It binds an email to the current account; an email already confirmed by another account → `ErrEmailTaken` - (**merge is Stage 10**); email-as-login is Stage 6 and reuses this mechanism. + (**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 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 @@ -362,9 +428,11 @@ Open details: deployment target/host; dashboards; load expectations. end-to-end — auth (`auth.telegram`/`auth.guest`/`auth.email.request`/ `auth.email.login`), `profile.get`, `game.submit_play`/`game.state`, `lobby.enqueue`/`lobby.poll`, `chat.post`, all five push events, and the admin - passthrough. The remaining domain operations (friends, blocks, invitations, - hint, word-check, pass/exchange/resign, history/GCG, profile editing) reuse the - identical transcode pattern and are wired in **Stage 7** as the UI needs them. + passthrough. The remaining domain operations reuse the identical transcode + pattern and are wired as the UI needs them: the play-loop ops (pass/exchange/ + 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 backend push proto (`pkg/proto/push/v1`) and the FlatBuffers edge payloads (`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). - **Admin = gateway validates Basic-Auth** (interview): the gateway checks `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, 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 @@ -416,6 +484,48 @@ Open details: deployment target/host; dashboards; load expectations. (unit) — integration stays `./backend/...` (the only module with tagged tests). 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) - **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.5–0.7 MB DAWGs are regenerated wholesale and bloat git 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 - 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 `dawg.Load`. - **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a diff --git a/README.md b/README.md index c8dc1ca..12f6ac4 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ supports English Scrabble, Russian Scrabble and Эрудит. admin surface behind Basic Auth. *(added in a later stage)* - **`backend`** — internal-only service that owns every domain concern and embeds the [`scrabble-solver`](../scrabble-solver) engine library in-process. -- **`ui`** — pure-HTML5 client (plain Svelte + Vite), embeddable in platform - webviews and packageable to native via Capacitor. *(added in a later stage)* +- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite) over Connect-RPC + + 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). *(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**). The full configuration surface and the go-jet regeneration step live in [`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). diff --git a/backend/internal/account/email.go b/backend/internal/account/email.go index f113d2c..ebbb775 100644 --- a/backend/internal/account/email.go +++ b/backend/internal/account/email.go @@ -33,7 +33,7 @@ var ( // ErrInvalidEmail is returned for an unparseable email address. ErrInvalidEmail = errors.New("account: invalid email address") // ErrEmailTaken is returned when the email is already confirmed by another - // account; binding it would be a merge, which Stage 10 owns. + // account; binding it would be a merge, which Stage 11 owns. ErrEmailTaken = errors.New("account: email already confirmed by another account") // ErrAlreadyConfirmed is returned when the email is already confirmed by the // requesting account. @@ -52,7 +52,7 @@ var ( // Mailer and verifies it, binding a confirmed email identity to the requesting // account. Only the SHA-256 hash of a code is stored (never the plaintext), // matching the session model. Binding an email already confirmed by a different -// account is refused (ErrEmailTaken) — merging two accounts is Stage 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. type EmailService struct { store *Store diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go index 62fd3a4..9d0793d 100644 --- a/backend/internal/game/service.go +++ b/backend/internal/game/service.go @@ -566,6 +566,13 @@ func (svc *Service) Participants(ctx context.Context, gameID uuid.UUID) ([]uuid. 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. func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) { g, err := svc.store.GetGame(ctx, gameID) diff --git a/backend/internal/game/store.go b/backend/internal/game/store.go index 48687de..7969f2e 100644 --- a/backend/internal/game/store.go +++ b/backend/internal/game/store.go @@ -135,6 +135,50 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) { 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. func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) { stmt := postgres.SELECT(table.GameMoves.AllColumns). diff --git a/backend/internal/game/types.go b/backend/internal/game/types.go index a466229..4642e1a 100644 --- a/backend/internal/game/types.go +++ b/backend/internal/game/types.go @@ -15,7 +15,7 @@ const ( 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. const StatusComplaintOpen = "open" @@ -176,7 +176,7 @@ type RobotTurn struct { 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 { ID uuid.UUID ComplainantID uuid.UUID diff --git a/backend/internal/inttest/game_test.go b/backend/internal/inttest/game_test.go index 0f9a22a..00ba2af 100644 --- a/backend/internal/inttest/game_test.go +++ b/backend/internal/inttest/game_test.go @@ -86,6 +86,63 @@ func readStats(t *testing.T, id uuid.UUID) (wins, losses, draws, maxGame, maxWor 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 // through the service and checks the finish state and statistics. func TestGameLifecycleAndStats(t *testing.T) { diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index 2fc4990..7e39b4f 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -66,13 +66,15 @@ type moveRecordDTO struct { 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 { - Seat int `json:"seat"` - AccountID string `json:"account_id"` - Score int `json:"score"` - HintsUsed int `json:"hints_used"` - IsWinner bool `json:"is_winner"` + Seat int `json:"seat"` + AccountID string `json:"account_id"` + DisplayName string `json:"display_name"` + Score int `json:"score"` + HintsUsed int `json:"hints_used"` + IsWinner bool `json:"is_winner"` } // gameDTO is the shared game summary. diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index de3a62d..dc822aa 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -37,8 +37,17 @@ func (s *Server) registerRoutes() { u.GET("/profile", s.handleProfile) } if s.games != nil { + u.GET("/games", s.handleListGames) u.POST("/games/:id/play", s.handleSubmitPlay) 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 { u.POST("/lobby/enqueue", s.handleEnqueue) @@ -46,6 +55,8 @@ func (s *Server) registerRoutes() { } if s.social != nil { 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) } diff --git a/backend/internal/server/handlers_admin.go b/backend/internal/server/handlers_admin.go index 7bf5b23..81b5adf 100644 --- a/backend/internal/server/handlers_admin.go +++ b/backend/internal/server/handlers_admin.go @@ -9,7 +9,7 @@ import ( // 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 // 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. func (s *Server) handleAdminPing(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) diff --git a/backend/internal/server/handlers_game.go b/backend/internal/server/handlers_game.go new file mode 100644 index 0000000..4a5ad11 --- /dev/null +++ b/backend/internal/server/handlers_game.go @@ -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) +} diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go index 6f297d6..60d6a1e 100644 --- a/backend/internal/server/handlers_user.go +++ b/backend/internal/server/handlers_user.go @@ -68,7 +68,7 @@ func (s *Server) handleSubmitPlay(c *gin.Context) { s.abortErr(c, err) return } - c.JSON(http.StatusOK, moveResultDTOFrom(res)) + s.writeMoveResult(c, res) } // handleGameState returns the player's view of a game. @@ -88,7 +88,9 @@ func (s *Server) handleGameState(c *gin.Context) { s.abortErr(c, err) 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. @@ -118,7 +120,11 @@ func (s *Server) handleEnqueue(c *gin.Context) { s.abortErr(c, err) 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. @@ -133,7 +139,11 @@ func (s *Server) handlePoll(c *gin.Context) { s.abortErr(c, err) 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7955482..81ae482 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -24,9 +24,20 @@ Three executables plus per-platform side-services: administration. Embeds the **`scrabble-solver`** engine **as a library, in-process** — there is no per-game container. The only network consumer of `backend` is `gateway` (plus platform side-services over an internal API). -- **`ui`** *(planned)* — pure-HTML5 client (plain Svelte + Vite, static build). - Talks to `backend` only through `gateway`. Embeddable in platform webviews; - packageable to native (iOS/Android) via Capacitor. +- **`ui`** — pure-HTML5 client (plain Svelte 5 + TypeScript + Vite, static build; + no SvelteKit). Talks to `backend` only through `gateway` over Connect-RPC + + 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/`** *(planned)* — per-platform side-services (Telegram bot first): deep-link invites and platform-native push notifications. They talk 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 development log mailer when none is configured) and, once verified, attaches a confirmed email identity. An email already confirmed by **another** account is - refused — adopting it would be a merge, which Stage 10 owns. Accounts and + refused — adopting it would be a merge, which Stage 11 owns. Accounts and identities use application-generated **UUIDv7** primary keys. - **Linking** is initiated from an authenticated profile: choose a platform → complete that platform's web-auth confirm → attach the identity to the @@ -140,7 +151,7 @@ Key points: word-check tool through `Registry.Lookup`. - **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 - 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 the image / a volume mounted at the dictionary directory. (A future split of 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 dictionary; each result offers a **complaint** (complainant, game, variant, 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 @@ -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; `Poll` remains as a fallback for a client that is not currently streaming. - **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 friendship. - **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 robot's sleep; user-editable), the daily **away window** and the block toggles — all editable through `account.UpdateProfile`. Linked platform accounts and merge - are Stage 10. + are Stage 11. ## 9. Persistence @@ -337,7 +348,7 @@ does not cover. ## 10. Notifications 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 (`internal/notify`, a `Publisher` seam installed on the game, social and lobby 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 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 -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 (single-instance MVP). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0fc268f..261e473 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -9,6 +9,16 @@ the detail is authored. ## 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)* 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 @@ -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 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 -later (Stage 8). +later (Stage 9). ### Accounts, linking & merge *(Stage 1 / 10)* 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, once entered, attaches the email to the account (an email already confirmed by another account cannot be taken — that is a merge, a later stage). Linked platform -accounts and merge arrive in Stage 10. +accounts and merge arrive in Stage 11. ### History & statistics *(Stage 3)* 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 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 versions, and inspects users/games. diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index b29ea58..ee1cd09 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -8,6 +8,16 @@ ## Домены +### Клиентское приложение *(Stage 7 / 8)* +Веб/приложение-клиент (Svelte + Vite) воплощает эти истории. **Играбельный срез** +(Stage 7) покрывает вход (гость или email), лобби «мои игры», старт авто-подбора, +игру на доске (постановка фишек перетаскиванием или тапом, пас, обмен, сдача), +top-1 подсказку, безлимитную проверку слова с жалобой, чат и nudge в партии, +обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и +профиль только для чтения. Управление друзьями и блоками, создание дружеских игр +(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG +появятся в Stage 8. + ### Личность и сессии *(Stage 1 / 6)* Игрок приходит с платформы (сначала Telegram), через email-вход или как эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий @@ -16,7 +26,7 @@ session-токен; backend сопоставляет его с внутренн статистики и истории). Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит -позже (Stage 8). +позже (Stage 9). ### Аккаунты, привязка и слияние *(Stage 1 / 10)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок @@ -76,7 +86,7 @@ push доставляется через платформу. confirm-коду: backend шлёт на почту короткий код, и после ввода email привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и -слияние появятся в Stage 10. +слияние появятся в Stage 11. ### История и статистика *(Stage 3)* Завершённые партии архивируются в независимом от словаря виде и экспортируются @@ -84,6 +94,6 @@ confirm-коду: backend шлёт на почту короткий код, и макс. очков за партию и макс. очков за один ход (лучший ход, уже включающий все образованные им слова и бонус за все фишки). -### Администрирование *(Stage 9)* +### Администрирование *(Stage 10)* Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями словаря, смотрит пользователей/игры. diff --git a/gateway/internal/admin/admin.go b/gateway/internal/admin/admin.go index e006c21..ced600c 100644 --- a/gateway/internal/admin/admin.go +++ b/gateway/internal/admin/admin.go @@ -2,7 +2,7 @@ // reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway // validates the operator credential and forwards authenticated requests to // 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 import ( diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 7554296..5c2afad 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -54,11 +54,12 @@ type MoveRecordResp struct { // SeatResp is one seat's public standing. type SeatResp struct { - Seat int `json:"seat"` - AccountID string `json:"account_id"` - Score int `json:"score"` - HintsUsed int `json:"hints_used"` - IsWinner bool `json:"is_winner"` + Seat int `json:"seat"` + AccountID string `json:"account_id"` + DisplayName string `json:"display_name"` + Score int `json:"score"` + HintsUsed int `json:"hints_used"` + IsWinner bool `json:"is_winner"` } // 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) 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 +} diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 08b529c..12fa03d 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -100,9 +100,8 @@ func encodeMatch(m backendclient.MatchResp) []byte { return b.FinishedBytes() } -// encodeChat builds a ChatMessage payload. -func encodeChat(c backendclient.ChatResp) []byte { - b := flatbuffers.NewBuilder(192) +// buildChatMessage builds a ChatMessage table and returns its offset. +func buildChatMessage(b *flatbuffers.Builder, c backendclient.ChatResp) flatbuffers.UOffsetT { id := b.CreateString(c.ID) gid := b.CreateString(c.GameID) sid := b.CreateString(c.SenderID) @@ -115,7 +114,103 @@ func encodeChat(c backendclient.ChatResp) []byte { fb.ChatMessageAddKind(b, kind) fb.ChatMessageAddBody(b, body) 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() } @@ -124,12 +219,14 @@ func buildGameView(b *flatbuffers.Builder, g backendclient.GameResp) flatbuffers seatOffs := make([]flatbuffers.UOffsetT, len(g.Seats)) for i, s := range g.Seats { aid := b.CreateString(s.AccountID) + dname := b.CreateString(s.DisplayName) fb.SeatViewStart(b) fb.SeatViewAddSeat(b, int32(s.Seat)) fb.SeatViewAddAccountId(b, aid) fb.SeatViewAddScore(b, int32(s.Score)) fb.SeatViewAddHintsUsed(b, int32(s.HintsUsed)) fb.SeatViewAddIsWinner(b, s.IsWinner) + fb.SeatViewAddDisplayName(b, dname) seatOffs[i] = fb.SeatViewEnd(b) } fb.GameViewStartSeatsVector(b, len(seatOffs)) diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index e6910cd..a6bdb9a 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -26,6 +26,17 @@ const ( MsgLobbyEnqueue = "lobby.enqueue" MsgLobbyPoll = "lobby.poll" 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. @@ -69,6 +80,17 @@ func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Regi r.ops[MsgLobbyEnqueue] = Op{Handler: enqueueHandler(backend), Auth: true} r.ops[MsgLobbyPoll] = Op{Handler: pollHandler(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 } @@ -219,3 +241,150 @@ func decodeTiles(in *fb.SubmitPlayRequest) []backendclient.TileJSON { } 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 + } +} diff --git a/gateway/internal/transcode/transcode_test.go b/gateway/internal/transcode/transcode_test.go index cb644b7..2af5a4b 100644 --- a/gateway/internal/transcode/transcode_test.go +++ b/gateway/internal/transcode/transcode_test.go @@ -139,3 +139,96 @@ func TestDomainErrorSurfacesBackendCode(t *testing.T) { 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()) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index f333587..ef8fda4 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -23,13 +23,15 @@ table TileRecord { 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 { seat:int; account_id:string; score:int; hints_used:int; is_winner:bool; + display_name:string; } // GameView is the shared (non-private) game summary. @@ -143,6 +145,67 @@ table StateView { 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) --- // EnqueueRequest joins the per-variant auto-match pool. @@ -174,6 +237,11 @@ table ChatMessage { created_at_unix:long; } +// ChatList is a game's chat history. +table ChatList { + messages:[ChatMessage]; +} + // --- push event payloads --- // YourTurnEvent signals that it is now the recipient's turn. diff --git a/pkg/fbs/scrabblefb/ChatList.go b/pkg/fbs/scrabblefb/ChatList.go new file mode 100644 index 0000000..f07bca6 --- /dev/null +++ b/pkg/fbs/scrabblefb/ChatList.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/CheckWordRequest.go b/pkg/fbs/scrabblefb/CheckWordRequest.go new file mode 100644 index 0000000..1d5759d --- /dev/null +++ b/pkg/fbs/scrabblefb/CheckWordRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/ComplaintRequest.go b/pkg/fbs/scrabblefb/ComplaintRequest.go new file mode 100644 index 0000000..a5cdea2 --- /dev/null +++ b/pkg/fbs/scrabblefb/ComplaintRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/EvalRequest.go b/pkg/fbs/scrabblefb/EvalRequest.go new file mode 100644 index 0000000..7eeebae --- /dev/null +++ b/pkg/fbs/scrabblefb/EvalRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/EvalResult.go b/pkg/fbs/scrabblefb/EvalResult.go new file mode 100644 index 0000000..0255618 --- /dev/null +++ b/pkg/fbs/scrabblefb/EvalResult.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/ExchangeRequest.go b/pkg/fbs/scrabblefb/ExchangeRequest.go new file mode 100644 index 0000000..0226c5e --- /dev/null +++ b/pkg/fbs/scrabblefb/ExchangeRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/GameActionRequest.go b/pkg/fbs/scrabblefb/GameActionRequest.go new file mode 100644 index 0000000..f654d10 --- /dev/null +++ b/pkg/fbs/scrabblefb/GameActionRequest.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/GameList.go b/pkg/fbs/scrabblefb/GameList.go new file mode 100644 index 0000000..039e712 --- /dev/null +++ b/pkg/fbs/scrabblefb/GameList.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/HintResult.go b/pkg/fbs/scrabblefb/HintResult.go new file mode 100644 index 0000000..bad7b75 --- /dev/null +++ b/pkg/fbs/scrabblefb/HintResult.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/History.go b/pkg/fbs/scrabblefb/History.go new file mode 100644 index 0000000..681b480 --- /dev/null +++ b/pkg/fbs/scrabblefb/History.go @@ -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() +} diff --git a/pkg/fbs/scrabblefb/SeatView.go b/pkg/fbs/scrabblefb/SeatView.go index 699fa73..f9d78e2 100644 --- a/pkg/fbs/scrabblefb/SeatView.go +++ b/pkg/fbs/scrabblefb/SeatView.go @@ -97,8 +97,16 @@ func (rcv *SeatView) MutateIsWinner(n bool) bool { 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) { - builder.StartObject(5) + builder.StartObject(6) } func SeatViewAddSeat(builder *flatbuffers.Builder, seat int32) { builder.PrependInt32Slot(0, seat, 0) @@ -115,6 +123,9 @@ func SeatViewAddHintsUsed(builder *flatbuffers.Builder, hintsUsed int32) { func SeatViewAddIsWinner(builder *flatbuffers.Builder, isWinner bool) { 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 { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/WordCheckResult.go b/pkg/fbs/scrabblefb/WordCheckResult.go new file mode 100644 index 0000000..da258ed --- /dev/null +++ b/pkg/fbs/scrabblefb/WordCheckResult.go @@ -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() +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..612bbcc --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.svelte-kit/ +*.tsbuildinfo +test-results/ +playwright-report/ +playwright/.cache/ +.DS_Store diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 0000000..1271ab4 --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1,5 @@ +# Do not run an implicit install before `pnpm run + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..a00f5cd --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000..bac1679 --- /dev/null +++ b/ui/playwright.config.ts @@ -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'] } }], +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml new file mode 100644 index 0000000..9532542 --- /dev/null +++ b/ui/pnpm-lock.yaml @@ -0,0 +1,1410 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@bufbuild/protobuf': + specifier: ^2.12.0 + version: 2.12.0 + '@connectrpc/connect': + specifier: ^2.1.0 + version: 2.1.1(@bufbuild/protobuf@2.12.0) + '@connectrpc/connect-web': + specifier: ^2.1.0 + version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0)) + flatbuffers: + specifier: ^25.9.23 + version: 25.9.23 + devDependencies: + '@bufbuild/protoc-gen-es': + specifier: ^2.12.0 + version: 2.12.0(@bufbuild/protobuf@2.12.0) + '@playwright/test': + specifier: ^1.49.0 + version: 1.60.0 + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)) + '@types/node': + specifier: ^22.10.0 + version: 22.19.19 + svelte: + specifier: ^5.15.0 + version: 5.56.0 + svelte-check: + specifier: ^4.1.0 + version: 4.5.0(picomatch@4.0.4)(svelte@5.56.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.3(@types/node@22.19.19) + vitest: + specifier: ^3.0.0 + version: 3.2.6(@types/node@22.19.19) + +packages: + + '@bufbuild/protobuf@2.12.0': + resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} + + '@bufbuild/protoc-gen-es@2.12.0': + resolution: {integrity: sha512-d9htF6jEkSwPbp9d/vSmZOBF7eeG18AvTMKmVg4I23afnrQOxL2w3WOXa9TaufMCyu24QakEUb4vux8apI5e7A==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + '@bufbuild/protobuf': 2.12.0 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + + '@bufbuild/protoplugin@2.12.0': + resolution: {integrity: sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==} + + '@connectrpc/connect-web@2.1.1': + resolution: {integrity: sha512-J8317Q2MaFRCT1jzVR1o06bZhDIBmU0UAzWx6xOIXzOq8+k71/+k7MUF7AwcBUX+34WIvbm5syRgC5HXQA8fOg==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.1 + + '@connectrpc/connect@2.1.1': + resolution: {integrity: sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + + '@rollup/rollup-android-arm-eabi@4.61.0': + resolution: {integrity: sha512-dnxczajOqt0gesZlN5pGQ1s1imQVrsmCw5G2Ci4oM+0WvNz3pyRnlWrT7McoZIb8VlFwCawdmbWRmxRn7HI+VQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.0': + resolution: {integrity: sha512-Bp3JpGP00Vu3f238ivRrjf7z3xSzVPXqCmaJYA9t2c+c8vKYvOzmXF7LkkeUalTEGd6cZcSWe+PFIP3Vy48fRg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.0': + resolution: {integrity: sha512-zaYIpr670mUmmZ1tVzUFplbQbG7h3Gugx3L5FoqhsC2m/YnLlR1a7zVLmXNPy+iY1tFPEbNG+HHBXZGyId0G5w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.0': + resolution: {integrity: sha512-+P49fvkv2dSoeevUW+lgZ/I2JHSsJCK1Lyjj7Cu6E4UHG4tS9XIefzIjo5qhgELjAclnen1rLzK2PMKJdo+Dyg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.0': + resolution: {integrity: sha512-l3FAAOyKJXH2ea6KNFN+MMgC/rnE94YGLXs2ehYqDcCoHt1DpvgWX75BhUJxN38XojP7Ul+4H8PRn7EdyqSDrw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.0': + resolution: {integrity: sha512-VokPN3TSctKj65cyCNPaUh4vMFA8awxOot/0sp+4J7ZlNRKQEhXhawqPwajoi8H5ZFt61i0ugZJuTKXBjGJ17Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + resolution: {integrity: sha512-DxH0P3wxm+Yzs/p3zrk9dw1rURu8p0Nv5+MRK/L7OtnLNg5rLZraSBFZ8iUXOd9f2BlhJyEpIZUH/emjq4UJ4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + resolution: {integrity: sha512-T6ZvMNe84kAz6TBWHC7hGAoEtzP1LWYw/AqayGWEF6uISt3Abk/st06LqRD9THd7Xz3NxzurUpzAuEAUbZf+nw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + resolution: {integrity: sha512-q/4hzvQkDs8b4jIBab1pnLiiM0ayTZsN2amBFPDzuyZxjEd4wDwx0UJFYM3cOZzSf5Kw8fnWSprJzIBMkcR44Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.0': + resolution: {integrity: sha512-vvYWX3akdEAY6km+9wAqFDnk6pQsbJKVnj7xawcvs/+fdlYBGp+U+Qq/lLfpIxYIZvZLHMAKD9HLdacSx/r3dw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + resolution: {integrity: sha512-DePa5cqOxDP/Zp0VOXpeWaGew5iIv5DXp9NYbzkX5PFQyWVX9184WCTh3hvr/7lhXo8ZVlbFLkz8+o/q1dU6gA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.0': + resolution: {integrity: sha512-LV8aWMB8UChglMCEzs7RkN0GsH29RJaLLqwm9fCIjlqwxQTiWAqNcc7wjBkH31hV0PU/yVxGYvrYsgfea2qw6g==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + resolution: {integrity: sha512-QoNSnwQtaeNu5grdBbsL0tt1uyl5EnS8DA8Mr3nluMXbhdQNyhN+G4tBax7VCdxLKj8YJ0/4OO9Ho84jMnJtKA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + resolution: {integrity: sha512-/zZp5MKapIIApE8trN8qLGNSiRN9TUoaUZ1cmVu4XnVdd5LQLOXTtyi+vtfUbNnT3iyjzpPqYeKXmvJ+gJGYWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + resolution: {integrity: sha512-RbrzcD3aJ1k3UbtMRRBNwojdVVyXjuVAFTfn/xPa6EEl6GE9Sm/akPgFTb9aAC9pMKGJ6CtWxaGrqWcabH+ySg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + resolution: {integrity: sha512-ZF+onDsBso8PJf1XaG9lB+O9RnBpKGnY6OrzC4CSHrtC1jb6jWLTKK4bRqdoCXHd22gyr2hiYmEAm8Wns/BOCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + resolution: {integrity: sha512-Atk0aSIk5Zx2Wuh9dgRQgLP0Koc8hOeYpbWryMXyk8G8/HmPkwPPkMqIIDhrXHHYqfUzSJA/I7IWSBv8xSmRBA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.0': + resolution: {integrity: sha512-0uMOcf3eZ5K+K4cYHkdxShFMPlPXCOdfDFEFn9dNYAEEd2cVvmOfH7zFgRVoDgmtQ1m9k5q7qfrHzyMAubKYUA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.0': + resolution: {integrity: sha512-mvFtE4A/t/7hRJ7X8Ozmu8FsIkAUat2nzl12pgU337BRmq87AQUJztwHz2Zv5/tjo9/C95E66CK03SI/ToEDJw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.0': + resolution: {integrity: sha512-z9b9+aTxvt8n2rNltMPvyaUfB8NJ+CVyOrGK/MdIKHx7B+lXmZpm/XbRsU7Rpf3fRqJ2uS6mBJiJveCtq8LHDg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.0': + resolution: {integrity: sha512-jXaXFqKMehsOc+g8R6oo33RRC6w07G9jDBxAE5eAKX7mOcCbZloYIPNhfG9Wl+P9O9IWHFO4OJgPi1Ml2qkt7w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + resolution: {integrity: sha512-OXNWVFocS2IA4+QplhTZZ2a+8hPZR7T8KuozsNmJKK8y7cp83StHvGksfHzPG3wczWTczyWHVQuqeiTUbjiyBg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + resolution: {integrity: sha512-AlAbNtBO637LxSldqV43z0FfXoGfl2TW1DgAg/bs7aQswFbDewz2SJm3BUhiGfbOVtW571xbc9p+REdxhyN/Eg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.0': + resolution: {integrity: sha512-QRSrQXyJ1M4tjNXdR0/G/IgV6lzfQQJYBjlWIEYkY2Xs86DRl/iEpQ4blMDjJxSl7n19eDKKXMg0AmuBVYy8pQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.0': + resolution: {integrity: sha512-tkuFxhvKO/HlGd0VsINF6vHSYH8AF8W0TcNxKDK6JZmrehngFj78pToc8iemtnvwilDjs2G/qSzYFhe9U8q+fw==} + cpu: [x64] + os: [win32] + + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript/vfs@1.6.4': + resolution: {integrity: sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==} + peerDependencies: + typescript: '*' + + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} + + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} + + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} + + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} + + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.9: + resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + rollup@4.61.0: + resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + svelte-check@4.5.0: + resolution: {integrity: sha512-9lNwPxCLWniFvQIcEv1LFqjIxcFtO3smb5+5BKbRJ3ttL4o2lXCej5rLF4DAnfLPI66oaA81vAxw6ILdIWI7kA==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.56.0: + resolution: {integrity: sha512-kTXr26t1bchFp28ROrb957LtbujpBmBDibmqMGziVpUs7awBi96TGgX6SovrA8BNoEUDVRK2Fb9FkeYlGspoVg==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@bufbuild/protobuf@2.12.0': {} + + '@bufbuild/protoc-gen-es@2.12.0(@bufbuild/protobuf@2.12.0)': + dependencies: + '@bufbuild/protoplugin': 2.12.0 + optionalDependencies: + '@bufbuild/protobuf': 2.12.0 + transitivePeerDependencies: + - supports-color + + '@bufbuild/protoplugin@2.12.0': + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@typescript/vfs': 1.6.4(typescript@5.4.5) + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@connectrpc/connect-web@2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))': + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.12.0) + + '@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0)': + dependencies: + '@bufbuild/protobuf': 2.12.0 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + + '@rollup/rollup-android-arm-eabi@4.61.0': + optional: true + + '@rollup/rollup-android-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.0': + optional: true + + '@rollup/rollup-darwin-x64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.0': + optional: true + + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)))(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)) + debug: 4.4.3 + svelte: 5.56.0 + vite: 6.4.3(@types/node@22.19.19) + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19))': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)))(svelte@5.56.0)(vite@6.4.3(@types/node@22.19.19)) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.56.0 + vite: 6.4.3(@types/node@22.19.19) + vitefu: 1.1.3(vite@6.4.3(@types/node@22.19.19)) + transitivePeerDependencies: + - supports-color + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/trusted-types@2.0.7': {} + + '@typescript/vfs@1.6.4(typescript@5.4.5)': + dependencies: + debug: 4.4.3 + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.6': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.6(vite@6.4.3(@types/node@22.19.19))': + dependencies: + '@vitest/spy': 3.2.6 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@22.19.19) + + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.6': + dependencies: + '@vitest/utils': 3.2.6 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.6': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.6': + dependencies: + '@vitest/pretty-format': 3.2.6 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + assertion-error@2.0.1: {} + + axobject-query@4.1.0: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deepmerge@4.3.1: {} + + devalue@5.8.1: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esm-env@1.2.2: {} + + esrap@2.2.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + flatbuffers@25.9.23: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + js-tokens@9.0.1: {} + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mri@1.2.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + rollup@4.61.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.0 + '@rollup/rollup-android-arm64': 4.61.0 + '@rollup/rollup-darwin-arm64': 4.61.0 + '@rollup/rollup-darwin-x64': 4.61.0 + '@rollup/rollup-freebsd-arm64': 4.61.0 + '@rollup/rollup-freebsd-x64': 4.61.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.0 + '@rollup/rollup-linux-arm-musleabihf': 4.61.0 + '@rollup/rollup-linux-arm64-gnu': 4.61.0 + '@rollup/rollup-linux-arm64-musl': 4.61.0 + '@rollup/rollup-linux-loong64-gnu': 4.61.0 + '@rollup/rollup-linux-loong64-musl': 4.61.0 + '@rollup/rollup-linux-ppc64-gnu': 4.61.0 + '@rollup/rollup-linux-ppc64-musl': 4.61.0 + '@rollup/rollup-linux-riscv64-gnu': 4.61.0 + '@rollup/rollup-linux-riscv64-musl': 4.61.0 + '@rollup/rollup-linux-s390x-gnu': 4.61.0 + '@rollup/rollup-linux-x64-gnu': 4.61.0 + '@rollup/rollup-linux-x64-musl': 4.61.0 + '@rollup/rollup-openbsd-x64': 4.61.0 + '@rollup/rollup-openharmony-arm64': 4.61.0 + '@rollup/rollup-win32-arm64-msvc': 4.61.0 + '@rollup/rollup-win32-ia32-msvc': 4.61.0 + '@rollup/rollup-win32-x64-gnu': 4.61.0 + '@rollup/rollup-win32-x64-msvc': 4.61.0 + fsevents: 2.3.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + svelte-check@4.5.0(picomatch@4.0.4)(svelte@5.56.0)(typescript@5.9.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.56.0 + typescript: 5.9.3 + transitivePeerDependencies: + - picomatch + + svelte@5.56.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.4.5: {} + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + vite-node@3.2.4(@types/node@22.19.19): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.3(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@22.19.19): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.0 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.19.19 + fsevents: 2.3.3 + + vitefu@1.1.3(vite@6.4.3(@types/node@22.19.19)): + optionalDependencies: + vite: 6.4.3(@types/node@22.19.19) + + vitest@3.2.6(@types/node@22.19.19): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@6.4.3(@types/node@22.19.19)) + '@vitest/pretty-format': 3.2.6 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.3(@types/node@22.19.19) + vite-node: 3.2.4(@types/node@22.19.19) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zimmerframe@1.1.4: {} diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml new file mode 100644 index 0000000..34a05e7 --- /dev/null +++ b/ui/pnpm-workspace.yaml @@ -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 diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs new file mode 100644 index 0000000..69bc781 --- /dev/null +++ b/ui/scripts/bundle-size.mjs @@ -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); +} diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 0000000..267b2e6 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,47 @@ + + +{#if !app.ready} +
{t('common.loading')}
+{:else if router.route.name === 'login'} + +{:else if router.route.name === 'new'} + +{:else if router.route.name === 'game'} + +{:else if router.route.name === 'profile'} + +{:else if router.route.name === 'settings'} + +{:else if router.route.name === 'about'} + +{:else} + +{/if} + + + + diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..4e4ab26 --- /dev/null +++ b/ui/src/app.css @@ -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; +} diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte new file mode 100644 index 0000000..692b282 --- /dev/null +++ b/ui/src/components/Header.svelte @@ -0,0 +1,60 @@ + + +
+ {#if back} + + {:else} + + {/if} +

{title}

+
{#if menu}{@render menu()}{/if}
+
+ + diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte new file mode 100644 index 0000000..5d7906c --- /dev/null +++ b/ui/src/components/Modal.svelte @@ -0,0 +1,46 @@ + + + + +
onclose?.()}> + +
+ + diff --git a/ui/src/components/Toast.svelte b/ui/src/components/Toast.svelte new file mode 100644 index 0000000..72e8e32 --- /dev/null +++ b/ui/src/components/Toast.svelte @@ -0,0 +1,29 @@ + + +{#if app.toast} +
{app.toast.text}
+{/if} + + diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte new file mode 100644 index 0000000..7a12ebb --- /dev/null +++ b/ui/src/game/Board.svelte @@ -0,0 +1,213 @@ + + + +
+
+ {#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} + + {/each} + {/each} +
+
+ + diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte new file mode 100644 index 0000000..7b50778 --- /dev/null +++ b/ui/src/game/Chat.svelte @@ -0,0 +1,112 @@ + + +
+
+ {#if messages.length === 0} +

{t('chat.empty')}

+ {/if} + {#each messages as m (m.id)} + {#if m.kind === 'nudge'} +
{t('chat.nudge')}
+ {:else} +
{m.body}
+ {/if} + {/each} +
+
+ e.key === 'Enter' && send()} + /> + + +
+
+ + diff --git a/ui/src/game/Controls.svelte b/ui/src/game/Controls.svelte new file mode 100644 index 0000000..2edc105 --- /dev/null +++ b/ui/src/game/Controls.svelte @@ -0,0 +1,101 @@ + + +
+
+ {#if preview} + {#if preview.legal} + {t('game.preview', { n: preview.score })} + {:else} + {t('game.previewIllegal')} + {/if} + {/if} + {#if ambiguous} + + {/if} +
+
+ + + + +
+
+ + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte new file mode 100644 index 0000000..95726f3 --- /dev/null +++ b/ui/src/game/Game.svelte @@ -0,0 +1,741 @@ + + +
+ {#snippet menu()} + + {#if menuOpen} + + +
(menuOpen = false)}>
+ + {/if} + {/snippet} +
+ +{#if view} +
+ {#each view.game.seats as s (s.seat)} +
+
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
+
{s.score}
+
+ {/each} +
+ +
+ (zoomed = !zoomed)} + /> +
+ +
+ {t('game.bag', { n: view.bagLen })} + {#if gameOver} + {t('game.over')} — {resultText()} + {:else} + {isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })} + {/if} + {t('game.hints', { n: view.hintsRemaining })} +
+ + {#if !gameOver} +
+
+ +
+ {#if placement.pending.length > 0} + + {/if} +
+ + + {/if} +{:else} +

{t('common.loading')}

+{/if} + +{#if drag} +
+ {drag.blank ? '' : drag.letter} +
+{/if} + +{#if blankPrompt} + (blankPrompt = null)}> +
+ {#each alphabet(variant) as ch (ch)} + + {/each} +
+
+{/if} + +{#if exchangeOpen && view} + (exchangeOpen = false)}> +
+ {#each view.rack as letter, i (i)} + + {/each} +
+ +
+{/if} + +{#if checkOpen} + (checkOpen = false)}> +
+ e.key === 'Enter' && runCheck()} /> + +
+ {#if checkResult} +

+ {checkResult.legal + ? t('game.wordLegal', { word: checkResult.word }) + : t('game.wordIllegal', { word: checkResult.word })} +

+ + {/if} +
+{/if} + +{#if resignOpen} + (resignOpen = false)}> +
+ + +
+
+{/if} + +{#if panel === 'chat'} + (panel = 'none')}> + + +{/if} + +{#if panel === 'history' && view} + (panel = 'none')}> +
    + {#each moves as m, i (i)} +
  1. + {view.game.seats[m.player]?.displayName ?? m.player} + {m.action === 'play' ? m.words.join(', ') : m.action} + {m.score} +
  2. + {/each} +
+
+{/if} + + diff --git a/ui/src/game/MakeMove.svelte b/ui/src/game/MakeMove.svelte new file mode 100644 index 0000000..a8f4a7a --- /dev/null +++ b/ui/src/game/MakeMove.svelte @@ -0,0 +1,122 @@ + + +
+ + + {#if popup} + + +
(popup = false)}>
+ + {/if} +
+ + diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte new file mode 100644 index 0000000..e00beae --- /dev/null +++ b/ui/src/game/Rack.svelte @@ -0,0 +1,74 @@ + + +
+ {#each slots as slot (slot.index)} + {#if slot.used} + + {:else} + + {/if} + {/each} +
+ + diff --git a/ui/src/gen/edge/v1/edge_pb.ts b/ui/src/gen/edge/v1/edge_pb.ts new file mode 100644 index 0000000..db58ee8 --- /dev/null +++ b/ui/src/gen/edge/v1/edge_pb.ts @@ -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 = /*@__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 = /*@__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 = /*@__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 = /*@__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); + diff --git a/ui/src/gen/fbs/scrabble.ts b/ui/src/gen/fbs/scrabble.ts new file mode 100644 index 0000000..1c482cb --- /dev/null +++ b/ui/src/gen/fbs/scrabble.ts @@ -0,0 +1,3 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +export * as scrabblefb from './scrabblefb.js'; diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts new file mode 100644 index 0000000..fe1bf12 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -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'; diff --git a/ui/src/gen/fbs/scrabblefb/ack.ts b/ui/src/gen/fbs/scrabblefb/ack.ts new file mode 100644 index 0000000..d7567ee --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/ack.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/chat-list.ts b/ui/src/gen/fbs/scrabblefb/chat-list.ts new file mode 100644 index 0000000..82422b8 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/chat-list.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/chat-message.ts b/ui/src/gen/fbs/scrabblefb/chat-message.ts new file mode 100644 index 0000000..bca7184 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/chat-message.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/chat-post-request.ts b/ui/src/gen/fbs/scrabblefb/chat-post-request.ts new file mode 100644 index 0000000..07adb61 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/chat-post-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/check-word-request.ts b/ui/src/gen/fbs/scrabblefb/check-word-request.ts new file mode 100644 index 0000000..8b2e17b --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/check-word-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/complaint-request.ts b/ui/src/gen/fbs/scrabblefb/complaint-request.ts new file mode 100644 index 0000000..85bf15e --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/complaint-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/email-login-request.ts b/ui/src/gen/fbs/scrabblefb/email-login-request.ts new file mode 100644 index 0000000..c5e2332 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/email-login-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/email-request-request.ts b/ui/src/gen/fbs/scrabblefb/email-request-request.ts new file mode 100644 index 0000000..73edb2d --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/email-request-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/enqueue-request.ts b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts new file mode 100644 index 0000000..293bba3 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/enqueue-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/eval-request.ts b/ui/src/gen/fbs/scrabblefb/eval-request.ts new file mode 100644 index 0000000..1ddf0df --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/eval-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/eval-result.ts b/ui/src/gen/fbs/scrabblefb/eval-result.ts new file mode 100644 index 0000000..f8e9c4e --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/eval-result.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/exchange-request.ts b/ui/src/gen/fbs/scrabblefb/exchange-request.ts new file mode 100644 index 0000000..b13025d --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/exchange-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/game-action-request.ts b/ui/src/gen/fbs/scrabblefb/game-action-request.ts new file mode 100644 index 0000000..b2687b4 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/game-action-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/game-list.ts b/ui/src/gen/fbs/scrabblefb/game-list.ts new file mode 100644 index 0000000..ff5b09a --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/game-list.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/game-view.ts b/ui/src/gen/fbs/scrabblefb/game-view.ts new file mode 100644 index 0000000..7e0f8e9 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/game-view.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/guest-login-request.ts b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts new file mode 100644 index 0000000..ff46bdd --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/guest-login-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/hint-result.ts b/ui/src/gen/fbs/scrabblefb/hint-result.ts new file mode 100644 index 0000000..511412f --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/hint-result.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/history.ts b/ui/src/gen/fbs/scrabblefb/history.ts new file mode 100644 index 0000000..abc8003 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/history.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/match-found-event.ts b/ui/src/gen/fbs/scrabblefb/match-found-event.ts new file mode 100644 index 0000000..8a0f9f7 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/match-found-event.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/match-result.ts b/ui/src/gen/fbs/scrabblefb/match-result.ts new file mode 100644 index 0000000..e0fb837 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/match-result.ts @@ -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; +} + +} diff --git a/ui/src/gen/fbs/scrabblefb/move-record.ts b/ui/src/gen/fbs/scrabblefb/move-record.ts new file mode 100644 index 0000000..6329152 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/move-record.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/move-result.ts b/ui/src/gen/fbs/scrabblefb/move-result.ts new file mode 100644 index 0000000..623950e --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/move-result.ts @@ -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; +} + +} diff --git a/ui/src/gen/fbs/scrabblefb/nudge-event.ts b/ui/src/gen/fbs/scrabblefb/nudge-event.ts new file mode 100644 index 0000000..5673ab5 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/nudge-event.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts new file mode 100644 index 0000000..654a3fd --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/opponent-moved-event.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts new file mode 100644 index 0000000..f70e192 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/profile.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/seat-view.ts b/ui/src/gen/fbs/scrabblefb/seat-view.ts new file mode 100644 index 0000000..9d253be --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/seat-view.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/session.ts b/ui/src/gen/fbs/scrabblefb/session.ts new file mode 100644 index 0000000..6882465 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/session.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/state-request.ts b/ui/src/gen/fbs/scrabblefb/state-request.ts new file mode 100644 index 0000000..84c37a9 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/state-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/state-view.ts b/ui/src/gen/fbs/scrabblefb/state-view.ts new file mode 100644 index 0000000..3f944ea --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/state-view.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/submit-play-request.ts b/ui/src/gen/fbs/scrabblefb/submit-play-request.ts new file mode 100644 index 0000000..c18543f --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/submit-play-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts new file mode 100644 index 0000000..47ba75f --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/telegram-login-request.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/tile-record.ts b/ui/src/gen/fbs/scrabblefb/tile-record.ts new file mode 100644 index 0000000..56e4115 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/tile-record.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/word-check-result.ts b/ui/src/gen/fbs/scrabblefb/word-check-result.ts new file mode 100644 index 0000000..c750c9b --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/word-check-result.ts @@ -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); +} +} diff --git a/ui/src/gen/fbs/scrabblefb/your-turn-event.ts b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts new file mode 100644 index 0000000..360fb1d --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/your-turn-event.ts @@ -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); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts new file mode 100644 index 0000000..d164955 --- /dev/null +++ b/ui/src/lib/app.svelte.ts @@ -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 | 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 { + 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 { + 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 { + try { + const s = await gateway.authGuest(app.locale); + await adoptSession(s); + navigate('/'); + } catch (err) { + handleError(err); + } +} + +export async function requestEmailCode(email: string): Promise { + try { + await gateway.authEmailRequest(email); + return true; + } catch (err) { + handleError(err); + return false; + } +} + +export async function loginEmail(email: string, code: string): Promise { + try { + const s = await gateway.authEmailLogin(email, code); + await adoptSession(s); + navigate('/'); + } catch (err) { + handleError(err); + } +} + +export async function logout(): Promise { + 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(); +} diff --git a/ui/src/lib/board.test.ts b/ui/src/lib/board.test.ts new file mode 100644 index 0000000..68bc777 --- /dev/null +++ b/ui/src/lib/board.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/board.ts b/ui/src/lib/board.ts new file mode 100644 index 0000000..1557c1c --- /dev/null +++ b/ui/src/lib/board.ts @@ -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 []; +} diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts new file mode 100644 index 0000000..9dc5578 --- /dev/null +++ b/ui/src/lib/client.ts @@ -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; + authEmailRequest(email: string): Promise; + authEmailLogin(email: string, code: string): Promise; + + // --- profile / lists --- + profileGet(): Promise; + gamesList(): Promise; + + // --- lobby --- + lobbyEnqueue(variant: Variant): Promise; + lobbyPoll(): Promise; + + // --- game --- + gameState(gameId: string): Promise; + gameHistory(gameId: string): Promise; + submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise; + pass(gameId: string): Promise; + exchange(gameId: string, tiles: string[]): Promise; + resign(gameId: string): Promise; + hint(gameId: string): Promise; + evaluate(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise; + checkWord(gameId: string, word: string): Promise; + complaint(gameId: string, word: string, note: string): Promise; + + // --- chat --- + chatPost(gameId: string, body: string): Promise; + chatList(gameId: string): Promise; + nudge(gameId: string): Promise; + + // --- 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 }; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts new file mode 100644 index 0000000..ef28f5d --- /dev/null +++ b/ui/src/lib/codec.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts new file mode 100644 index 0000000..383b846 --- /dev/null +++ b/ui/src/lib/codec.ts @@ -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 }; +} diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts new file mode 100644 index 0000000..9178fed --- /dev/null +++ b/ui/src/lib/gateway.ts @@ -0,0 +1,13 @@ +// The single GatewayClient the app uses. In `mock` mode (pnpm start) it is the +// in-memory fake; otherwise it is the real Connect/FlatBuffers transport. MODE is a +// build-time constant, so a production build tree-shakes the mock away. + +import type { GatewayClient } from './client'; +import { MockGateway } from './mock/client'; +import { createTransport } from './transport'; + +const isMock = import.meta.env.MODE === 'mock'; + +export const gateway: GatewayClient = isMock + ? new MockGateway() + : createTransport(import.meta.env.VITE_GATEWAY_URL ?? ''); diff --git a/ui/src/lib/i18n.test.ts b/ui/src/lib/i18n.test.ts new file mode 100644 index 0000000..9383297 --- /dev/null +++ b/ui/src/lib/i18n.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { en } from './i18n/en'; +import { ru } from './i18n/ru'; +import { errorKey, translate } from './i18n/catalog'; + +describe('i18n catalog', () => { + it('has identical keys in en and ru', () => { + expect(Object.keys(ru).sort()).toEqual(Object.keys(en).sort()); + }); + + it('interpolates parameters', () => { + expect(translate('en', 'game.bag', { n: 7 })).toBe('Bag 7'); + expect(translate('ru', 'game.bag', { n: 7 })).toBe('Мешок 7'); + }); + + it('maps error codes to keys with a generic fallback', () => { + expect(errorKey('not_your_turn')).toBe('error.not_your_turn'); + expect(errorKey('totally_unknown')).toBe('error.generic'); + }); +}); diff --git a/ui/src/lib/i18n/catalog.ts b/ui/src/lib/i18n/catalog.ts new file mode 100644 index 0000000..f3f13c4 --- /dev/null +++ b/ui/src/lib/i18n/catalog.ts @@ -0,0 +1,37 @@ +// Pure i18n catalog + lookup (no runes) so any module can import the types and +// translate without depending on the reactive layer. + +import { en, type MessageKey } from './en'; +import { ru } from './ru'; + +export type Locale = 'en' | 'ru'; +export type { MessageKey }; + +export const catalogs: Record> = { en, ru }; + +export function translate( + locale: Locale, + key: MessageKey, + params?: Record, +): string { + const dict = catalogs[locale] ?? en; + let s: string = dict[key] ?? en[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + s = s.replaceAll(`{${k}}`, String(v)); + } + } + return s; +} + +/** errorKey maps a gateway result/edge code to a message key, falling back to generic. */ +export function errorKey(code: string): MessageKey { + const key = `error.${code}` as MessageKey; + return key in en ? key : 'error.generic'; +} + +/** localeFrom picks a supported locale from a free-form hint (e.g. 'ru-RU' -> 'ru'). */ +export function localeFrom(hint: string | undefined | null, fallback: Locale = 'en'): Locale { + const l = (hint ?? '').slice(0, 2).toLowerCase(); + return l === 'ru' ? 'ru' : l === 'en' ? 'en' : fallback; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts new file mode 100644 index 0000000..aeef360 --- /dev/null +++ b/ui/src/lib/i18n/en.ts @@ -0,0 +1,128 @@ +// English message catalog (authoritative). Keys are flat dotted strings; ru.ts must +// provide exactly the same keys (enforced by its type and a Vitest parity test). +// {name} placeholders are filled by t(key, params). + +export const en = { + 'app.title': 'Scrabble', + + 'common.back': 'Back', + 'common.cancel': 'Cancel', + 'common.ok': 'OK', + 'common.close': 'Close', + 'common.loading': 'Loading…', + 'common.retry': 'Retry', + 'common.you': 'You', + 'common.save': 'Save', + + 'login.title': 'Sign in', + 'login.guest': 'Play as guest', + 'login.email': 'Email', + 'login.emailPlaceholder': 'you@example.com', + 'login.sendCode': 'Send code', + 'login.codePlaceholder': '6-digit code', + 'login.signIn': 'Sign in', + 'login.codeSent': 'We sent a code to {email}.', + + 'lobby.activeGames': 'Active games', + 'lobby.finishedGames': 'Finished games', + 'lobby.noActive': 'No active games yet.', + 'lobby.noFinished': 'No finished games yet.', + 'lobby.new': 'New', + 'lobby.stats': 'Stats', + 'lobby.tournaments': 'Tourn.', + 'lobby.profile': 'Profile', + 'lobby.settings': 'Settings', + 'lobby.about': 'About', + 'lobby.yourTurn': 'Your turn', + 'lobby.theirTurn': 'Their turn', + 'lobby.vs': 'vs {opponents}', + 'lobby.soon': 'Coming soon', + + 'new.title': 'New game', + 'new.subtitle': 'Auto-match with another player', + 'new.english': 'English', + 'new.russian': 'Russian', + 'new.erudit': 'Эрудит', + 'new.find': 'Find a game', + 'new.searching': 'Looking for an opponent…', + + 'game.bag': 'Bag {n}', + 'game.hints': 'Hints {n}', + 'game.yourTurn': 'Your turn', + 'game.waiting': "Waiting for {name}", + 'game.makeMove': 'Make move', + 'game.reset': 'Reset', + 'game.draw': 'Draw', + 'game.skip': 'Skip', + 'game.shuffle': 'Shuffle', + 'game.hint': 'Hint', + 'game.history': 'History', + 'game.chat': 'Chat', + 'game.checkWord': 'Check word', + 'game.dropGame': 'Drop game', + 'game.preview': 'Scores {n}', + 'game.previewIllegal': 'Not a legal move', + 'game.chooseBlank': 'Choose a letter for the blank', + 'game.exchangeTitle': 'Select tiles to exchange', + 'game.exchangeConfirm': 'Exchange {n}', + 'game.confirmResign': 'Resign this game?', + 'game.hintShown': 'Best move: {word} for {n}', + 'game.over': 'Game over', + 'game.won': 'You won', + 'game.lost': 'You lost', + 'game.tied': 'Draw', + 'game.checkWordPrompt': 'Enter a word', + 'game.wordLegal': '“{word}” is valid', + 'game.wordIllegal': '“{word}” is not valid', + 'game.complain': 'Disagree', + 'game.complaintSent': 'Thanks, sent for review.', + + 'chat.placeholder': 'Quick message…', + 'chat.send': 'Send', + 'chat.nudge': 'Nudge', + 'chat.empty': 'No messages yet.', + 'chat.nudged': '{name} nudged you', + + 'profile.title': 'Profile', + 'profile.language': 'Language', + 'profile.timezone': 'Time zone', + 'profile.hintBalance': 'Hint balance', + 'profile.guest': 'Guest account', + 'profile.readonly': 'Editing your profile arrives in a later update.', + + 'settings.title': 'Settings', + 'settings.theme': 'Theme', + 'settings.themeAuto': 'Auto', + 'settings.themeLight': 'Light', + 'settings.themeDark': 'Dark', + 'settings.language': 'Interface language', + 'settings.reduceMotion': 'Reduce motion', + + 'about.title': 'About', + 'about.description': 'A multiplatform Scrabble game.', + 'about.version': 'Version {v}', + + 'lang.en': 'English', + 'lang.ru': 'Русский', + + 'error.not_your_turn': "It is not your turn.", + 'error.illegal_play': 'That is not a legal play.', + 'error.hint_unavailable': 'No hints available.', + 'error.chat_rejected': 'Message rejected (too long or contains contact info).', + 'error.game_finished': 'This game is finished.', + 'error.not_a_player': 'You are not a player in this game.', + 'error.already_queued': 'You are already in the queue.', + 'error.email_taken': 'That email belongs to another account.', + 'error.code_invalid': 'Invalid or expired code.', + 'error.invalid_email': 'Enter a valid email address.', + 'error.invalid_config': 'Invalid game settings.', + 'error.not_found': 'Not found.', + 'error.session_invalid': 'Your session expired. Please sign in again.', + 'error.unauthenticated': 'Please sign in.', + 'error.rate_limited': 'Too many requests, slow down.', + 'error.unavailable': 'Connection problem. Retrying…', + 'error.internal': 'Something went wrong.', + 'error.generic': 'Something went wrong.', +} as const; + +export type MessageKey = keyof typeof en; diff --git a/ui/src/lib/i18n/index.svelte.ts b/ui/src/lib/i18n/index.svelte.ts new file mode 100644 index 0000000..b72baff --- /dev/null +++ b/ui/src/lib/i18n/index.svelte.ts @@ -0,0 +1,17 @@ +// Reactive i18n layer. The locale is a rune, so any component that calls t() +// re-renders when the locale changes. The catalog + lookup are pure (see catalog.ts). + +import { translate, type Locale, type MessageKey } from './catalog'; + +export { errorKey, localeFrom } from './catalog'; +export type { Locale, MessageKey }; + +export const i18n = $state<{ locale: Locale }>({ locale: 'en' }); + +export function setLocale(locale: Locale): void { + i18n.locale = locale; +} + +export function t(key: MessageKey, params?: Record): string { + return translate(i18n.locale, key, params); +} diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts new file mode 100644 index 0000000..b8c075e --- /dev/null +++ b/ui/src/lib/i18n/ru.ts @@ -0,0 +1,127 @@ +// Russian message catalog. Typed as Record so it must cover every +// key the English catalog defines (a Vitest test asserts parity too). + +import type { MessageKey } from './en'; + +export const ru: Record = { + 'app.title': 'Scrabble', + + 'common.back': 'Назад', + 'common.cancel': 'Отмена', + 'common.ok': 'ОК', + 'common.close': 'Закрыть', + 'common.loading': 'Загрузка…', + 'common.retry': 'Повторить', + 'common.you': 'Вы', + 'common.save': 'Сохранить', + + 'login.title': 'Вход', + 'login.guest': 'Играть как гость', + 'login.email': 'Эл. почта', + 'login.emailPlaceholder': 'you@example.com', + 'login.sendCode': 'Отправить код', + 'login.codePlaceholder': 'Код из 6 цифр', + 'login.signIn': 'Войти', + 'login.codeSent': 'Мы отправили код на {email}.', + + 'lobby.activeGames': 'Активные игры', + 'lobby.finishedGames': 'Завершённые игры', + 'lobby.noActive': 'Пока нет активных игр.', + 'lobby.noFinished': 'Пока нет завершённых игр.', + 'lobby.new': 'Новая', + 'lobby.stats': 'Статы', + 'lobby.tournaments': 'Турниры', + 'lobby.profile': 'Профиль', + 'lobby.settings': 'Настройки', + 'lobby.about': 'О программе', + 'lobby.yourTurn': 'Ваш ход', + 'lobby.theirTurn': 'Ход соперника', + 'lobby.vs': 'против {opponents}', + 'lobby.soon': 'Скоро', + + 'new.title': 'Новая игра', + 'new.subtitle': 'Автоподбор соперника', + 'new.english': 'Английский', + 'new.russian': 'Русский', + 'new.erudit': 'Эрудит', + 'new.find': 'Найти игру', + 'new.searching': 'Ищем соперника…', + + 'game.bag': 'Мешок {n}', + 'game.hints': 'Подсказки {n}', + 'game.yourTurn': 'Ваш ход', + 'game.waiting': 'Ожидаем {name}', + 'game.makeMove': 'Сделать ход', + 'game.reset': 'Сброс', + 'game.draw': 'Обмен', + 'game.skip': 'Пас', + 'game.shuffle': 'Перемешать', + 'game.hint': 'Подсказка', + 'game.history': 'История', + 'game.chat': 'Чат', + 'game.checkWord': 'Проверить слово', + 'game.dropGame': 'Покинуть игру', + 'game.preview': 'Очков: {n}', + 'game.previewIllegal': 'Недопустимый ход', + 'game.chooseBlank': 'Выберите букву для бланка', + 'game.exchangeTitle': 'Выберите фишки для обмена', + 'game.exchangeConfirm': 'Обменять {n}', + 'game.confirmResign': 'Сдаться в этой игре?', + 'game.hintShown': 'Лучший ход: {word} на {n}', + 'game.over': 'Игра окончена', + 'game.won': 'Вы выиграли', + 'game.lost': 'Вы проиграли', + 'game.tied': 'Ничья', + 'game.checkWordPrompt': 'Введите слово', + 'game.wordLegal': '«{word}» допустимо', + 'game.wordIllegal': '«{word}» недопустимо', + 'game.complain': 'Не согласен', + 'game.complaintSent': 'Спасибо, отправлено на проверку.', + + 'chat.placeholder': 'Короткое сообщение…', + 'chat.send': 'Отправить', + 'chat.nudge': 'Поторопить', + 'chat.empty': 'Сообщений пока нет.', + 'chat.nudged': '{name} торопит вас', + + 'profile.title': 'Профиль', + 'profile.language': 'Язык', + 'profile.timezone': 'Часовой пояс', + 'profile.hintBalance': 'Баланс подсказок', + 'profile.guest': 'Гостевой аккаунт', + 'profile.readonly': 'Редактирование профиля появится в следующем обновлении.', + + 'settings.title': 'Настройки', + 'settings.theme': 'Тема', + 'settings.themeAuto': 'Авто', + 'settings.themeLight': 'Светлая', + 'settings.themeDark': 'Тёмная', + 'settings.language': 'Язык интерфейса', + 'settings.reduceMotion': 'Меньше анимаций', + + 'about.title': 'О программе', + 'about.description': 'Мультиплатформенная игра в скрабл.', + 'about.version': 'Версия {v}', + + 'lang.en': 'English', + 'lang.ru': 'Русский', + + 'error.not_your_turn': 'Сейчас не ваш ход.', + 'error.illegal_play': 'Это недопустимый ход.', + 'error.hint_unavailable': 'Подсказки недоступны.', + 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', + 'error.game_finished': 'Эта игра уже завершена.', + 'error.not_a_player': 'Вы не участник этой игры.', + 'error.already_queued': 'Вы уже в очереди.', + 'error.email_taken': 'Эта почта принадлежит другому аккаунту.', + 'error.code_invalid': 'Неверный или истёкший код.', + 'error.invalid_email': 'Введите корректный адрес почты.', + 'error.invalid_config': 'Неверные настройки игры.', + 'error.not_found': 'Не найдено.', + 'error.session_invalid': 'Сессия истекла. Войдите снова.', + 'error.unauthenticated': 'Пожалуйста, войдите.', + 'error.rate_limited': 'Слишком много запросов, помедленнее.', + 'error.unavailable': 'Проблема соединения. Повторяем…', + 'error.internal': 'Что-то пошло не так.', + 'error.generic': 'Что-то пошло не так.', +}; diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts new file mode 100644 index 0000000..c948cf0 --- /dev/null +++ b/ui/src/lib/mock/client.ts @@ -0,0 +1,350 @@ +// In-memory mock implementation of GatewayClient. Drives the playable slice with no +// backend: it serves the seed data, applies plays/passes/exchanges/resigns to local +// state, fabricates plausible scores, and emits live events (a canned opponent reply, +// a match-found after enqueue) so the stream path is exercised too. This same fake is +// reused by the Playwright smoke. It is tree-shaken out of a production (non-mock) +// build. + +import type { + GatewayClient, + PlacedTile, + Unsubscribe, +} from '../client'; +import { GatewayError } from '../client'; +import type { + ChatMessage, + EvalResult, + GameList, + History, + HintResult, + MatchResult, + MoveResult, + Profile, + PushEvent, + Session, + StateView, + Variant, + WordCheckResult, +} from '../model'; +import { tileValue } from '../premiums'; +import { ME, PROFILE, SESSION, seedGames, type MockGame } from './data'; + +const POOL: Record = { + english: 'AAAAEEEEIIIOONNRRTTLLSSUDGBCMPFHVWYK', + russian: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', + erudit: 'ОООААЕЕИИННТТСРРВЛКМДПУЯЫЬГЗБ', +}; + +function draw(variant: Variant, n: number): string[] { + const pool = POOL[variant]; + const out: string[] = []; + for (let i = 0; i < n; i++) out.push(pool[Math.floor(Math.random() * pool.length)]); + return out; +} + +function removeFromRack(rack: string[], tiles: PlacedTile[]): string[] { + const next = [...rack]; + for (const t of tiles) { + const want = t.blank ? '?' : t.letter.toUpperCase(); + const i = next.indexOf(want); + if (i >= 0) next.splice(i, 1); + } + return next; +} + +export class MockGateway implements GatewayClient { + private readonly games = seedGames(); + private readonly profile: Profile = { ...PROFILE }; + private readonly subs = new Set<(e: PushEvent) => void>(); + private pendingMatch: string | null = null; + + setToken(_token: string | null): void { + // The mock needs no auth; the real transport stores the bearer token. + } + + private emit(e: PushEvent): void { + for (const cb of this.subs) cb(e); + } + + private game(id: string): MockGame { + const g = this.games.get(id); + if (!g) throw new GatewayError('not_found'); + return g; + } + + private mySeat(g: MockGame): number { + const s = g.view.seats.find((x) => x.accountId === ME); + return s ? s.seat : 0; + } + + // --- auth --- + async authGuest(): Promise { + return { ...SESSION }; + } + async authEmailRequest(): Promise {} + async authEmailLogin(): Promise { + return { ...SESSION, isGuest: false }; + } + + // --- profile / lists --- + async profileGet(): Promise { + return { ...this.profile }; + } + async gamesList(): Promise { + return { games: [...this.games.values()].map((g) => structuredClone(g.view)) }; + } + + // --- lobby --- + async lobbyEnqueue(variant: Variant): Promise { + // Simulate a 10s-style robot substitution, sped up: match found shortly. + const id = crypto.randomUUID(); + const g: MockGame = { + view: { + id, + variant, + dictVersion: 'v1', + status: 'active', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: 0, + endReason: '', + seats: [ + { seat: 0, accountId: ME, displayName: 'You', score: 0, hintsUsed: 0, isWinner: false }, + { seat: 1, accountId: 'robot', displayName: 'Robo', score: 0, hintsUsed: 0, isWinner: false }, + ], + }, + moves: [], + rack: draw(variant, 7), + bagLen: 86, + hintsRemaining: 1, + chat: [], + }; + this.games.set(id, g); + this.pendingMatch = id; + setTimeout(() => this.emit({ kind: 'match_found', gameId: id }), 1400); + return { matched: false }; + } + + async lobbyPoll(): Promise { + if (this.pendingMatch) { + const g = this.games.get(this.pendingMatch); + this.pendingMatch = null; + if (g) return { matched: true, game: structuredClone(g.view) }; + } + return { matched: false }; + } + + // --- game --- + async gameState(gameId: string): Promise { + const g = this.game(gameId); + return { + game: structuredClone(g.view), + seat: this.mySeat(g), + rack: [...g.rack], + bagLen: g.bagLen, + hintsRemaining: g.hintsRemaining, + }; + } + + async gameHistory(gameId: string): Promise { + const g = this.game(gameId); + return { gameId, moves: structuredClone(g.moves) }; + } + + async submitPlay(gameId: string, dir: 'H' | 'V', tiles: PlacedTile[]): Promise { + const g = this.game(gameId); + const seat = this.mySeat(g); + if (g.view.toMove !== seat) throw new GatewayError('not_your_turn'); + const variant = g.view.variant; + let score = tiles.reduce((s, t) => s + tileValue(variant, t.blank ? '?' : t.letter), 0); + if (tiles.length === 7) score += 50; + const total = g.view.seats[seat].score + score; + const move = { + player: seat, + action: 'play' as const, + dir, + mainRow: tiles[0]?.row ?? 7, + mainCol: tiles[0]?.col ?? 7, + tiles: tiles.map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })), + words: [tiles.map((t) => t.letter).join('')], + count: 1, + score, + total, + }; + g.moves.push(move); + g.view.seats[seat].score = total; + g.view.moveCount += 1; + g.rack = removeFromRack(g.rack, tiles); + const drawn = Math.min(7 - g.rack.length, g.bagLen); + g.rack.push(...draw(variant, drawn)); + g.bagLen -= drawn; + g.view.toMove = (seat + 1) % g.view.players; + this.scheduleOpponentReply(gameId); + return { move: structuredClone(move), game: structuredClone(g.view) }; + } + + private async simpleAction( + gameId: string, + action: 'pass' | 'exchange' | 'resign', + tiles: string[] = [], + ): Promise { + const g = this.game(gameId); + const seat = this.mySeat(g); + if (g.view.toMove !== seat) throw new GatewayError('not_your_turn'); + if (action === 'exchange' && tiles.length > 0) { + g.rack = removeFromRack( + g.rack, + tiles.map((l) => ({ row: 0, col: 0, letter: l, blank: l === '?' })), + ); + g.rack.push(...draw(g.view.variant, tiles.length)); + } + const move = { + player: seat, + action, + dir: '', + mainRow: 0, + mainCol: 0, + tiles: [], + words: [], + count: 0, + score: 0, + total: g.view.seats[seat].score, + }; + g.moves.push(move); + g.view.moveCount += 1; + if (action === 'resign') { + g.view.status = 'finished'; + g.view.endReason = 'resignation'; + for (const s of g.view.seats) s.isWinner = s.seat !== seat; + } else { + g.view.toMove = (seat + 1) % g.view.players; + this.scheduleOpponentReply(gameId); + } + return { move: structuredClone(move), game: structuredClone(g.view) }; + } + + pass(gameId: string): Promise { + return this.simpleAction(gameId, 'pass'); + } + exchange(gameId: string, tiles: string[]): Promise { + return this.simpleAction(gameId, 'exchange', tiles); + } + resign(gameId: string): Promise { + return this.simpleAction(gameId, 'resign'); + } + + async hint(gameId: string): Promise { + const g = this.game(gameId); + if (g.hintsRemaining <= 0) throw new GatewayError('hint_unavailable'); + g.hintsRemaining -= 1; + const letter = g.rack.find((l) => l !== '?') ?? 'A'; + return { + move: { + player: this.mySeat(g), + action: 'play', + dir: 'H', + mainRow: 7, + mainCol: 7, + tiles: [{ row: 7, col: 7, letter, blank: false }], + words: [letter], + count: 1, + score: tileValue(g.view.variant, letter), + total: 0, + }, + hintsRemaining: g.hintsRemaining, + }; + } + + async evaluate(gameId: string, _dir: 'H' | 'V', tiles: PlacedTile[]): Promise { + const g = this.game(gameId); + if (tiles.length === 0) return { legal: false, score: 0, words: [] }; + let score = tiles.reduce((s, t) => s + tileValue(g.view.variant, t.blank ? '?' : t.letter), 0); + if (tiles.length === 7) score += 50; + return { legal: true, score, words: [tiles.map((t) => t.letter).join('')] }; + } + + async checkWord(_gameId: string, word: string): Promise { + return { word, legal: word.trim().length >= 2 }; + } + async complaint(): Promise {} + + // --- chat --- + async chatPost(gameId: string, body: string): Promise { + const g = this.game(gameId); + const msg: ChatMessage = { + id: crypto.randomUUID(), + gameId, + senderId: ME, + kind: 'message', + body, + createdAtUnix: Math.floor(Date.now() / 1000), + }; + g.chat.push(msg); + return msg; + } + async chatList(gameId: string): Promise { + return [...this.game(gameId).chat]; + } + async nudge(gameId: string): Promise { + const g = this.game(gameId); + const msg: ChatMessage = { + id: crypto.randomUUID(), + gameId, + senderId: ME, + kind: 'nudge', + body: '', + createdAtUnix: Math.floor(Date.now() / 1000), + }; + g.chat.push(msg); + return msg; + } + + // --- live stream --- + subscribe(onEvent: (e: PushEvent) => void): Unsubscribe { + this.subs.add(onEvent); + return () => this.subs.delete(onEvent); + } + + // Fabricate an opponent reply shortly after the human moves, then hand the turn back. + private scheduleOpponentReply(gameId: string): void { + setTimeout(() => { + const g = this.games.get(gameId); + if (!g || g.view.status !== 'active') return; + const opp = (this.mySeat(g) + 1) % g.view.players; + if (g.view.toMove !== opp) return; + const cell = this.firstEmptyPair(g); + const move = { + player: opp, + action: 'play' as const, + dir: 'H' as const, + mainRow: cell.row, + mainCol: cell.col, + tiles: [ + { row: cell.row, col: cell.col, letter: 'O', blank: false }, + { row: cell.row, col: cell.col + 1, letter: 'K', blank: false }, + ], + words: ['OK'], + count: 1, + score: 6, + total: g.view.seats[opp].score + 6, + }; + g.moves.push(move); + g.view.seats[opp].score = move.total; + g.view.moveCount += 1; + g.view.toMove = this.mySeat(g); + this.emit({ kind: 'opponent_moved', gameId, seat: opp, action: 'play', score: 6, total: move.total }); + this.emit({ kind: 'your_turn', gameId, deadlineUnix: Math.floor(Date.now() / 1000) + 86400 }); + }, 1600); + } + + private firstEmptyPair(g: MockGame): { row: number; col: number } { + const occupied = new Set(g.moves.flatMap((m) => m.tiles.map((t) => `${t.row},${t.col}`))); + for (let row = 11; row < 15; row++) { + for (let col = 0; col < 14; col++) { + if (!occupied.has(`${row},${col}`) && !occupied.has(`${row},${col + 1}`)) return { row, col }; + } + } + return { row: 0, col: 0 }; + } +} diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts new file mode 100644 index 0000000..42801f8 --- /dev/null +++ b/ui/src/lib/mock/data.ts @@ -0,0 +1,192 @@ +// Seed data for the mock transport. Enough to exercise the playable slice locally +// (pnpm start) with no backend: a profile, one active mid-game whose board already +// has tiles, and two finished games. Coordinates are 0-indexed (centre 7,7). Words do +// not need to be strictly legal here — this is a visual/interaction fixture; real +// legality and scoring come from the backend. + +import type { ChatMessage, GameView, MoveRecord, Profile, Seat, Session } from '../model'; + +export const ME = 'me'; + +export const SESSION: Session = { + token: 'mock-token', + userId: ME, + isGuest: true, + displayName: 'You', +}; + +export const PROFILE: Profile = { + userId: ME, + displayName: 'You', + preferredLanguage: 'en', + timeZone: 'UTC', + hintBalance: 3, + blockChat: false, + blockFriendRequests: false, + isGuest: true, +}; + +function seat(s: number, accountId: string, displayName: string, score: number, isWinner = false): Seat { + return { seat: s, accountId, displayName, score, hintsUsed: 0, isWinner }; +} + +function play( + player: number, + dir: 'H' | 'V', + tiles: Array<[number, number, string]>, + words: string[], + score: number, + total: number, +): MoveRecord { + const ts = tiles.map(([row, col, letter]) => ({ row, col, letter, blank: false })); + return { + player, + action: 'play', + dir, + mainRow: ts[0]?.row ?? 7, + mainCol: ts[0]?.col ?? 7, + tiles: ts, + words, + count: words.length, + score, + total, + }; +} + +export interface MockGame { + view: GameView; + moves: MoveRecord[]; + rack: string[]; + bagLen: number; + hintsRemaining: number; + chat: ChatMessage[]; +} + +// --- active game G1: english, You (seat 0) vs Ann (seat 1), your turn --- + +const G1_MOVES: MoveRecord[] = [ + play(0, 'H', [ + [7, 5, 'H'], + [7, 6, 'E'], + [7, 7, 'L'], + [7, 8, 'L'], + [7, 9, 'O'], + ], ['HELLO'], 16, 16), + play(1, 'V', [ + [6, 9, 'W'], + [8, 9, 'R'], + [9, 9, 'L'], + [10, 9, 'D'], + ], ['WORLD'], 9, 9), + play(0, 'H', [ + [8, 10, 'A'], + [8, 11, 'T'], + ], ['RAT'], 3, 19), + play(1, 'V', [ + [9, 10, 'N'], + [10, 10, 'D'], + ], ['AND'], 4, 13), +]; + +function activeGame(): MockGame { + return { + view: { + id: 'g1', + variant: 'english', + dictVersion: 'v1', + status: 'active', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: G1_MOVES.length, + endReason: '', + seats: [seat(0, ME, 'You', 19), seat(1, 'ann', 'Ann', 13)], + }, + moves: G1_MOVES, + rack: ['R', 'E', 'T', 'I', 'N', 'A', '?'], + bagLen: 58, + hintsRemaining: 1, + chat: [ + { + id: 'c1', + gameId: 'g1', + senderId: 'ann', + kind: 'message', + body: 'good luck!', + createdAtUnix: Math.floor(Date.now() / 1000) - 3600, + }, + ], + }; +} + +// --- finished games --- + +function finishedG2(): MockGame { + return { + view: { + id: 'g2', + variant: 'english', + dictVersion: 'v1', + status: 'finished', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: 2, + endReason: 'normal', + seats: [seat(0, ME, 'You', 320, true), seat(1, 'kaya', 'Kaya', 281)], + }, + moves: [ + play(0, 'H', [ + [7, 6, 'Q'], + [7, 7, 'U'], + [7, 8, 'I'], + [7, 9, 'Z'], + ], ['QUIZ'], 48, 48), + play(1, 'V', [ + [6, 9, 'J'], + [8, 9, 'A'], + [9, 9, 'M'], + ], ['JAZM'], 30, 30), + ], + rack: [], + bagLen: 0, + hintsRemaining: 0, + chat: [], + }; +} + +function finishedG3(): MockGame { + return { + view: { + id: 'g3', + variant: 'russian', + dictVersion: 'v1', + status: 'finished', + players: 2, + toMove: 0, + turnTimeoutSecs: 86400, + moveCount: 1, + endReason: 'resignation', + seats: [seat(0, ME, 'You', 150), seat(1, 'rick', 'Rick', 212, true)], + }, + moves: [ + play(0, 'H', [ + [7, 6, 'С'], + [7, 7, 'Л'], + [7, 8, 'О'], + [7, 9, 'В'], + [7, 10, 'О'], + ], ['СЛОВО'], 12, 12), + ], + rack: [], + bagLen: 0, + hintsRemaining: 0, + chat: [], + }; +} + +export function seedGames(): Map { + const m = new Map(); + for (const g of [activeGame(), finishedG2(), finishedG3()]) m.set(g.view.id, g); + return m; +} diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts new file mode 100644 index 0000000..ea3e8b1 --- /dev/null +++ b/ui/src/lib/model.ts @@ -0,0 +1,137 @@ +// Domain model — plain TypeScript shapes the screens use, deliberately decoupled +// from the FlatBuffers wire types. Both the real transport (which decodes +// FlatBuffers) and the mock transport speak this model, so the UI never touches +// generated wire code directly. + +export type Variant = 'english' | 'russian' | 'erudit'; + +/** Backend game status strings. */ +export type GameStatus = 'active' | 'finished' | string; + +/** Decoded move action kinds (history-independent, see ARCHITECTURE §9.1). */ +export type MoveAction = 'play' | 'pass' | 'exchange' | 'resign' | 'timeout' | string; + +/** Play orientation: H is across a row, V is down a column. */ +export type Direction = 'H' | 'V'; + +export interface Tile { + row: number; + col: number; + letter: string; + blank: boolean; +} + +export interface Seat { + seat: number; + accountId: string; + displayName: string; + score: number; + hintsUsed: number; + isWinner: boolean; +} + +export interface GameView { + id: string; + variant: Variant; + dictVersion: string; + status: GameStatus; + players: number; + toMove: number; + turnTimeoutSecs: number; + moveCount: number; + endReason: string; + seats: Seat[]; +} + +export interface MoveRecord { + player: number; + action: MoveAction; + dir: string; + mainRow: number; + mainCol: number; + tiles: Tile[]; + words: string[]; + count: number; + score: number; + total: number; +} + +/** A seated player's private view of a game. */ +export interface StateView { + game: GameView; + seat: number; + rack: string[]; + bagLen: number; + hintsRemaining: number; +} + +export interface MoveResult { + move: MoveRecord; + game: GameView; +} + +export interface HintResult { + move: MoveRecord; + hintsRemaining: number; +} + +export interface EvalResult { + legal: boolean; + score: number; + words: string[]; +} + +export interface WordCheckResult { + word: string; + legal: boolean; +} + +export interface ChatMessage { + id: string; + gameId: string; + senderId: string; + kind: string; + body: string; + createdAtUnix: number; +} + +export interface Profile { + userId: string; + displayName: string; + preferredLanguage: string; + timeZone: string; + hintBalance: number; + blockChat: boolean; + blockFriendRequests: boolean; + isGuest: boolean; +} + +export interface Session { + token: string; + userId: string; + isGuest: boolean; + displayName: string; +} + +export interface MatchResult { + matched: boolean; + game?: GameView; +} + +export interface History { + gameId: string; + moves: MoveRecord[]; +} + +export interface GameList { + games: GameView[]; +} + +/** A live event delivered over the Subscribe stream. */ +export type PushEvent = + | { kind: 'your_turn'; gameId: string; deadlineUnix: number } + | { kind: 'opponent_moved'; gameId: string; seat: number; action: string; score: number; total: number } + | { kind: 'chat_message'; message: ChatMessage } + | { kind: 'nudge'; gameId: string; fromUserId: string } + | { kind: 'match_found'; gameId: string } + | { kind: 'heartbeat' }; diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts new file mode 100644 index 0000000..428ce24 --- /dev/null +++ b/ui/src/lib/placement.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + BLANK, + direction, + newPlacement, + place, + rackView, + recallAt, + reset, + toSubmit, +} from './placement'; + +const rack = ['A', 'Q', BLANK, 'N', 'I', 'W', 'E']; + +describe('placement state machine', () => { + it('places a tile and marks the rack slot used', () => { + const p = place(newPlacement(rack), 0, 7, 7); + expect(p.pending).toHaveLength(1); + expect(rackView(p)[0].used).toBe(true); + expect(rackView(p)[1].used).toBe(false); + }); + + it('rejects reusing a slot or an occupied cell', () => { + let p = place(newPlacement(rack), 0, 7, 7); + p = place(p, 0, 7, 8); // same slot -> no-op + expect(p.pending).toHaveLength(1); + p = place(p, 1, 7, 7); // occupied cell -> no-op + expect(p.pending).toHaveLength(1); + }); + + it('requires a letter for a blank slot', () => { + const noLetter = place(newPlacement(rack), 2, 7, 7); + expect(noLetter.pending).toHaveLength(0); + const withLetter = place(newPlacement(rack), 2, 7, 7, 'x'); + expect(withLetter.pending[0]).toMatchObject({ letter: 'X', blank: true }); + }); + + it('recalls a tile by cell', () => { + let p = place(newPlacement(rack), 0, 7, 7); + p = recallAt(p, 7, 7); + expect(p.pending).toHaveLength(0); + expect(reset(place(p, 0, 7, 7)).pending).toHaveLength(0); + }); + + it('infers direction H for a row, V for a column, null for a single tile', () => { + let h = place(newPlacement(rack), 0, 7, 7); + h = place(h, 1, 7, 8); + expect(direction(h)).toBe('H'); + let v = place(newPlacement(rack), 0, 7, 7); + v = place(v, 1, 8, 7); + expect(direction(v)).toBe('V'); + expect(direction(place(newPlacement(rack), 0, 7, 7))).toBeNull(); + }); + + it('builds a sorted submit payload and honours a direction override', () => { + let p = place(newPlacement(rack), 1, 7, 9); + p = place(p, 0, 7, 7); + const sub = toSubmit(p); + expect(sub?.dir).toBe('H'); + expect(sub?.tiles.map((t) => t.col)).toEqual([7, 9]); + expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V'); + expect(toSubmit(newPlacement(rack))).toBeNull(); + }); +}); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts new file mode 100644 index 0000000..5d987b9 --- /dev/null +++ b/ui/src/lib/placement.ts @@ -0,0 +1,116 @@ +// Pure placement state machine for composing a play. The UI lifts tiles from the +// rack onto board cells (via drag or tap); this tracks the pending tiles, infers the +// play direction, supports per-tile recall and a full reset, and builds the submit +// payload. It is board-agnostic (the gateway/engine does full legality validation at +// submit), which keeps it trivially unit-testable. + +import type { Direction } from './model'; +import type { PlacedTile } from './client'; + +export interface PendingTile { + /** Index of the rack slot this tile was lifted from. */ + rackIndex: number; + row: number; + col: number; + /** Designated concrete letter (for a blank, the letter the player chose). */ + letter: string; + /** Whether this tile came from a blank rack slot ("?"). */ + blank: boolean; +} + +export interface Placement { + /** The player's rack as dealt, e.g. ['A','Q','?','N','I','W','E']. */ + rack: string[]; + pending: PendingTile[]; +} + +export interface RackSlot { + index: number; + letter: string; + used: boolean; +} + +export const BLANK = '?'; + +export function newPlacement(rack: string[]): Placement { + return { rack: [...rack], pending: [] }; +} + +function usedIndexes(p: Placement): Set { + return new Set(p.pending.map((t) => t.rackIndex)); +} + +/** rackView lists each rack slot with whether it is currently placed on the board. */ +export function rackView(p: Placement): RackSlot[] { + const used = usedIndexes(p); + return p.rack.map((letter, index) => ({ index, letter, used: used.has(index) })); +} + +export function isBlankSlot(p: Placement, rackIndex: number): boolean { + return p.rack[rackIndex] === BLANK; +} + +export function cellOccupied(p: Placement, row: number, col: number): boolean { + return p.pending.some((t) => t.row === row && t.col === col); +} + +/** + * place lifts a rack slot onto (row, col). For a blank slot the caller must pass the + * designated letter. Returns the unchanged placement if the move is invalid (slot out + * of range, already used, target occupied, or a blank with no letter). + */ +export function place( + p: Placement, + rackIndex: number, + row: number, + col: number, + blankLetter?: string, +): Placement { + if (rackIndex < 0 || rackIndex >= p.rack.length) return p; + if (usedIndexes(p).has(rackIndex)) return p; + if (cellOccupied(p, row, col)) return p; + const blank = p.rack[rackIndex] === BLANK; + const letter = blank ? (blankLetter ?? '').toUpperCase() : p.rack[rackIndex]; + if (blank && !letter) return p; + return { ...p, pending: [...p.pending, { rackIndex, row, col, letter, blank }] }; +} + +export function recallAt(p: Placement, row: number, col: number): Placement { + return { ...p, pending: p.pending.filter((t) => !(t.row === row && t.col === col)) }; +} + +export function recallIndex(p: Placement, rackIndex: number): Placement { + return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) }; +} + +export function reset(p: Placement): Placement { + return { ...p, pending: [] }; +} + +/** + * direction infers the play orientation from the pending tiles: H if they share a row, + * V if they share a column, null if a single tile (ambiguous) or non-linear (invalid). + */ +export function direction(p: Placement): Direction | null { + if (p.pending.length < 2) return null; + const rows = new Set(p.pending.map((t) => t.row)); + const cols = new Set(p.pending.map((t) => t.col)); + if (rows.size === 1 && cols.size === p.pending.length) return 'H'; + if (cols.size === 1 && rows.size === p.pending.length) return 'V'; + return null; +} + +/** toSubmit builds the submit payload. dirOverride resolves a single-tile play, where + * the orientation cannot be inferred; otherwise the inferred direction is used. */ +export function toSubmit( + p: Placement, + dirOverride?: Direction, +): { dir: Direction; tiles: PlacedTile[] } | null { + if (p.pending.length === 0) return null; + const dir = dirOverride ?? direction(p) ?? 'H'; + const tiles: PlacedTile[] = p.pending + .slice() + .sort((a, b) => a.row - b.row || a.col - b.col) + .map((t) => ({ row: t.row, col: t.col, letter: t.letter, blank: t.blank })); + return { dir, tiles }; +} diff --git a/ui/src/lib/premiums.test.ts b/ui/src/lib/premiums.test.ts new file mode 100644 index 0000000..b7b8108 --- /dev/null +++ b/ui/src/lib/premiums.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { alphabet, BOARD_SIZE, centre, premiumGrid, tileValue } from './premiums'; + +// Parity with scrabble-solver/rules/rules.go: english/russian share standardBoard +// (centre is a double word); erudit shares the geometry but a non-doubling centre. +describe('premium layout', () => { + it('is a 15x15 grid with TW corners', () => { + const g = premiumGrid('english'); + expect(g.length).toBe(BOARD_SIZE); + expect(g[0].length).toBe(BOARD_SIZE); + for (const [r, c] of [ + [0, 0], + [0, 14], + [14, 0], + [14, 14], + ]) { + expect(g[r][c]).toBe('TW'); + } + }); + + it('doubles the centre for standard variants but not for erudit', () => { + expect(centre('english')).toEqual({ row: 7, col: 7 }); + expect(premiumGrid('english')[7][7]).toBe('DW'); + expect(premiumGrid('russian')[7][7]).toBe('DW'); + expect(centre('erudit')).toEqual({ row: 7, col: 7 }); + expect(premiumGrid('erudit')[7][7]).toBe(''); + }); + + it('keeps the standard premium counts', () => { + const flat = premiumGrid('english').flat(); + const count = (p: string) => flat.filter((x) => x === p).length; + expect(count('TW')).toBe(8); + expect(count('TL')).toBe(12); + expect(count('DL')).toBe(24); + expect(count('DW')).toBe(17); // 16 double-word squares + the centre + }); +}); + +describe('tile values', () => { + it('scores letters per variant and zero for a blank', () => { + expect(tileValue('english', 'A')).toBe(1); + expect(tileValue('english', 'Q')).toBe(10); + expect(tileValue('english', '?')).toBe(0); + expect(tileValue('russian', 'Ф')).toBe(10); + expect(tileValue('erudit', 'Ё')).toBe(0); + }); + + it('exposes the full alphabet for the blank chooser', () => { + expect(alphabet('english')).toHaveLength(26); + expect(alphabet('russian')).toHaveLength(33); + expect(alphabet('erudit')).toHaveLength(33); + }); +}); diff --git a/ui/src/lib/premiums.ts b/ui/src/lib/premiums.ts new file mode 100644 index 0000000..dd4f1e3 --- /dev/null +++ b/ui/src/lib/premiums.ts @@ -0,0 +1,126 @@ +// Board premium layout and tile values — ported verbatim from the engine source of +// truth, scrabble-solver/rules/rules.go (standardBoard / eruditBoard, and the +// per-variant value tables). These are NOT transmitted on the wire (StateView has +// no board), so the client renders them locally. A Vitest parity test pins the +// layout against the known geometry. Keep this in lockstep with the solver. + +import type { Variant } from './model'; + +export const BOARD_SIZE = 15; + +export type Premium = '' | 'TW' | 'DW' | 'TL' | 'DL'; + +// Legend (rules.go): T=triple word, D=double word, t=triple letter, d=double +// letter, .=plain, *=centre (a double word), +=centre with no premium. +const standardBoard = [ + 'T..d...T...d..T', + '.D...t...t...D.', + '..D...d.d...D..', + 'd..D...d...D..d', + '....D.....D....', + '.t...t...t...t.', + '..d...d.d...d..', + 'T..d...*...d..T', + '..d...d.d...d..', + '.t...t...t...t.', + '....D.....D....', + 'd..D...d...D..d', + '..D...d.d...D..', + '.D...t...t...D.', + 'T..d...T...d..T', +]; + +// Эрудит: the standard layout but a non-doubling centre ('+'). +const eruditBoard = [ + 'T..d...T...d..T', + '.D...t...t...D.', + '..D...d.d...D..', + 'd..D...d...D..d', + '....D.....D....', + '.t...t...t...t.', + '..d...d.d...d..', + 'T..d...+...d..T', + '..d...d.d...d..', + '.t...t...t...t.', + '....D.....D....', + 'd..D...d...D..d', + '..D...d.d...D..', + '.D...t...t...D.', + 'T..d...T...d..T', +]; + +function template(variant: Variant): string[] { + return variant === 'erudit' ? eruditBoard : standardBoard; +} + +function premiumOf(ch: string): Premium { + switch (ch) { + case 'T': + return 'TW'; + case 'D': + case '*': + return 'DW'; + case 't': + return 'TL'; + case 'd': + return 'DL'; + default: + return ''; + } +} + +/** premiumGrid returns the 15x15 premium layout for a variant (row-major). */ +export function premiumGrid(variant: Variant): Premium[][] { + return template(variant).map((line) => Array.from(line, premiumOf)); +} + +/** centre returns the first-move anchor square (row, col). */ +export function centre(variant: Variant): { row: number; col: number } { + const lines = template(variant); + for (let r = 0; r < lines.length; r++) { + const c = lines[r].search(/[*+]/); + if (c >= 0) return { row: r, col: c }; + } + return { row: 7, col: 7 }; +} + +// --- tile values (points shown on the tile face); blank scores 0 --- + +// English Latin a..z (rules.go English()). +const enValues = + 'a1 b3 c3 d2 e1 f4 g2 h4 i1 j8 k5 l1 m3 n1 o1 p3 q10 r1 s1 t1 u1 v4 w4 x8 y4 z10'; +// Russian а..я incl. ё (rules.go RussianScrabble()). +const ruValues = + 'а1 б3 в1 г3 д2 е1 ё3 ж5 з5 и1 й4 к2 л2 м2 н1 о1 п2 р1 с1 т1 у2 ф10 х5 ц5 ч5 ш8 щ10 ъ10 ы4 ь3 э8 ю8 я3'; +// Эрудит а..я incl. ё=0 (rules.go Erudit()). +const eruditValues = + 'а1 б3 в2 г3 д2 е1 ё0 ж5 з5 и1 й2 к2 л2 м2 н1 о1 п2 р2 с2 т2 у3 ф10 х5 ц10 ч5 ш10 щ10 ъ10 ы5 ь5 э10 ю10 я3'; + +// Split each "letter+value" token into its letter (all but trailing digits) and its +// integer value (the trailing digits). +function valueTable(spec: string): Map { + const m = new Map(); + for (const pair of spec.trim().split(/\s+/)) { + const match = pair.match(/^(.+?)(\d+)$/); + if (!match) continue; + m.set(match[1].toUpperCase(), Number(match[2])); + } + return m; +} + +const VALUES: Record> = { + english: valueTable(enValues), + russian: valueTable(ruValues), + erudit: valueTable(eruditValues), +}; + +/** tileValue returns the point value of a concrete letter; a blank ("?") is 0. */ +export function tileValue(variant: Variant, letter: string): number { + if (!letter || letter === '?') return 0; + return VALUES[variant]?.get(letter.toUpperCase()) ?? 0; +} + +/** alphabet lists a variant's letters in dictionary order (used by the blank chooser). */ +export function alphabet(variant: Variant): string[] { + return [...VALUES[variant].keys()]; +} diff --git a/ui/src/lib/router.svelte.ts b/ui/src/lib/router.svelte.ts new file mode 100644 index 0000000..333c7e7 --- /dev/null +++ b/ui/src/lib/router.svelte.ts @@ -0,0 +1,60 @@ +// Minimal dependency-free hash router. Hash routing survives a reload and works on +// a file:// origin (Capacitor native packaging), where there is no server to honour +// deep paths. The route is a reactive rune so screens re-render on navigation. + +export type RouteName = + | 'login' + | 'lobby' + | 'new' + | 'game' + | 'profile' + | 'settings' + | 'about' + | 'notfound'; + +export interface Route { + name: RouteName; + params: Record; +} + +function parse(hash: string): Route { + const path = (hash.replace(/^#/, '') || '/').split('?')[0]; + const seg = path.split('/').filter(Boolean); + if (seg.length === 0) return { name: 'lobby', params: {} }; + switch (seg[0]) { + case 'login': + return { name: 'login', params: {} }; + case 'new': + return { name: 'new', params: {} }; + case 'game': + return seg[1] ? { name: 'game', params: { id: seg[1] } } : { name: 'notfound', params: {} }; + case 'profile': + return { name: 'profile', params: {} }; + case 'settings': + return { name: 'settings', params: {} }; + case 'about': + return { name: 'about', params: {} }; + default: + return { name: 'notfound', params: {} }; + } +} + +export const router = $state<{ route: Route }>({ + route: parse(typeof location !== 'undefined' ? location.hash : ''), +}); + +if (typeof window !== 'undefined') { + window.addEventListener('hashchange', () => { + router.route = parse(location.hash); + }); +} + +/** navigate switches the hash route (and forces a re-parse if it is unchanged). */ +export function navigate(path: string): void { + const target = '#' + path; + if (location.hash === target) { + router.route = parse(target); + } else { + location.hash = path; + } +} diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts new file mode 100644 index 0000000..fac640e --- /dev/null +++ b/ui/src/lib/session.ts @@ -0,0 +1,133 @@ +// Session + preferences persistence. The session token lives in memory for the app +// session and is mirrored to IndexedDB when available (so a reload does not force a +// re-login), with a localStorage fallback. Losing the store just means re-login — +// acceptable, and for a guest it simply mints a fresh guest. + +import type { Session } from './model'; +import type { ThemePref } from './theme'; +import type { Locale } from './i18n/catalog'; + +const DB_NAME = 'scrabble'; +const STORE = 'kv'; +const LS_PREFIX = 'scrabble.'; + +let dbPromise: Promise | null | undefined; + +function openDb(): Promise | null { + if (dbPromise !== undefined) return dbPromise; + if (typeof indexedDB === 'undefined') { + dbPromise = null; + return null; + } + dbPromise = new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }).catch(() => { + dbPromise = null; + throw new Error('indexedDB unavailable'); + }); + return dbPromise; +} + +function lsGet(key: string): T | null { + try { + const v = localStorage.getItem(LS_PREFIX + key); + return v ? (JSON.parse(v) as T) : null; + } catch { + return null; + } +} + +function lsSet(key: string, value: unknown): void { + try { + localStorage.setItem(LS_PREFIX + key, JSON.stringify(value)); + } catch { + /* storage unavailable — stay in-memory only */ + } +} + +function lsDel(key: string): void { + try { + localStorage.removeItem(LS_PREFIX + key); + } catch { + /* ignore */ + } +} + +async function kvGet(key: string): Promise { + const db = openDb(); + if (!db) return lsGet(key); + try { + const d = await db; + return await new Promise((resolve, reject) => { + const r = d.transaction(STORE, 'readonly').objectStore(STORE).get(key); + r.onsuccess = () => resolve((r.result ?? null) as T | null); + r.onerror = () => reject(r.error); + }); + } catch { + return lsGet(key); + } +} + +async function kvSet(key: string, value: unknown): Promise { + const db = openDb(); + if (!db) return lsSet(key, value); + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).put(value, key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + lsSet(key, value); + } +} + +async function kvDel(key: string): Promise { + const db = openDb(); + if (!db) return lsDel(key); + try { + const d = await db; + await new Promise((resolve, reject) => { + const tx = d.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(key); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } catch { + lsDel(key); + } +} + +const SESSION_KEY = 'session'; +const PREFS_KEY = 'prefs'; + +export function loadSession(): Promise { + return kvGet(SESSION_KEY); +} + +export function saveSession(s: Session): Promise { + return kvSet(SESSION_KEY, s); +} + +export function clearSession(): Promise { + return kvDel(SESSION_KEY); +} + +export interface Prefs { + theme: ThemePref; + locale: Locale; + reduceMotion: boolean; +} + +export async function loadPrefs(): Promise> { + return (await kvGet(PREFS_KEY)) ?? {}; +} + +export function savePrefs(p: Prefs): Promise { + return kvSet(PREFS_KEY, p); +} diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts new file mode 100644 index 0000000..080f6e0 --- /dev/null +++ b/ui/src/lib/theme.ts @@ -0,0 +1,47 @@ +// Theme application. The design tokens are CSS custom properties (app.css); here we +// only flip how they resolve: 'auto' follows the OS, 'light'/'dark' force a value via +// [data-theme]. A Telegram Mini App can additionally override the token values from +// WebApp.themeParams — the mapping lives here so the token system is Telegram-ready, +// while the SDK is wired in the Telegram stage. + +export type ThemePref = 'auto' | 'light' | 'dark'; + +export function applyTheme(pref: ThemePref): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + if (pref === 'auto') root.removeAttribute('data-theme'); + else root.setAttribute('data-theme', pref); +} + +export function applyReduceMotion(on: boolean): void { + if (typeof document === 'undefined') return; + document.body.classList.toggle('reduce-motion', on); +} + +/** Subset of Telegram WebApp.themeParams we map onto our tokens. */ +export interface TelegramThemeParams { + bg_color?: string; + text_color?: string; + hint_color?: string; + link_color?: string; + button_color?: string; + button_text_color?: string; + secondary_bg_color?: string; +} + +/** applyTelegramTheme overrides token values at runtime from Telegram themeParams. */ +export function applyTelegramTheme(p: TelegramThemeParams): void { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const set = (value: string | undefined, name: string) => { + if (value) root.style.setProperty(name, value); + }; + set(p.bg_color, '--bg'); + set(p.bg_color, '--surface'); + set(p.secondary_bg_color, '--surface-2'); + set(p.text_color, '--text'); + set(p.hint_color, '--text-muted'); + set(p.button_color, '--accent'); + set(p.button_text_color, '--accent-text'); + set(p.link_color, '--accent'); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts new file mode 100644 index 0000000..1d3bc5c --- /dev/null +++ b/ui/src/lib/transport.ts @@ -0,0 +1,137 @@ +// The real Connect-RPC + FlatBuffers transport. Every unary op rides the single +// Execute envelope (message_type + FlatBuffers payload); the live stream is +// Subscribe. The session token rides in the Authorization header; domain outcomes +// come back in result_code, edge failures as Connect error codes — both normalised to +// a thrown GatewayError. In dev the Vite proxy forwards the RPC path to the h2c +// gateway; in a packaged app VITE_GATEWAY_URL points at the real origin. + +import { Code, ConnectError, createClient } from '@connectrpc/connect'; +import { createConnectTransport } from '@connectrpc/connect-web'; +import { Gateway } from '../gen/edge/v1/edge_pb'; +import { GatewayError, type GatewayClient } from './client'; +import * as codec from './codec'; + +function toGatewayError(e: unknown): GatewayError { + if (e instanceof ConnectError) { + switch (e.code) { + case Code.Unauthenticated: + return new GatewayError('session_invalid', e.message); + case Code.ResourceExhausted: + return new GatewayError('rate_limited', e.message); + case Code.Unavailable: + return new GatewayError('unavailable', e.message); + case Code.NotFound: + return new GatewayError('not_found', e.message); + default: + return new GatewayError('internal', e.message); + } + } + return new GatewayError('unavailable', String(e)); +} + +export function createTransport(baseUrl: string): GatewayClient { + const origin = baseUrl || (typeof location !== 'undefined' ? location.origin : ''); + const transport = createConnectTransport({ baseUrl: origin, useBinaryFormat: true }); + const client = createClient(Gateway, transport); + let token: string | null = null; + + const headers = (): Record | undefined => + token ? { authorization: `Bearer ${token}` } : undefined; + + async function exec(messageType: string, payload: Uint8Array): Promise { + let res; + try { + res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() }); + } catch (e) { + throw toGatewayError(e); + } + if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); + return res.payload; + } + + return { + setToken(t) { + token = t; + }, + + async authGuest(locale) { + return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); + }, + async authEmailRequest(email) { + await exec('auth.email.request', codec.encodeEmailRequest(email)); + }, + async authEmailLogin(email, code) { + return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code))); + }, + + async profileGet() { + return codec.decodeProfile(await exec('profile.get', codec.empty())); + }, + async gamesList() { + return codec.decodeGameList(await exec('games.list', codec.empty())); + }, + + async lobbyEnqueue(variant) { + return codec.decodeMatchResult(await exec('lobby.enqueue', codec.encodeEnqueue(variant))); + }, + async lobbyPoll() { + return codec.decodeMatchResult(await exec('lobby.poll', codec.empty())); + }, + + async gameState(id) { + return codec.decodeStateView(await exec('game.state', codec.encodeStateRequest(id))); + }, + async gameHistory(id) { + return codec.decodeHistory(await exec('game.history', codec.encodeGameAction(id))); + }, + async submitPlay(id, dir, tiles) { + return codec.decodeMoveResult(await exec('game.submit_play', codec.encodeSubmitPlay(id, dir, tiles))); + }, + async pass(id) { + return codec.decodeMoveResult(await exec('game.pass', codec.encodeGameAction(id))); + }, + async exchange(id, tiles) { + return codec.decodeMoveResult(await exec('game.exchange', codec.encodeExchange(id, tiles))); + }, + async resign(id) { + return codec.decodeMoveResult(await exec('game.resign', codec.encodeGameAction(id))); + }, + async hint(id) { + return codec.decodeHintResult(await exec('game.hint', codec.encodeGameAction(id))); + }, + async evaluate(id, dir, tiles) { + return codec.decodeEvalResult(await exec('game.evaluate', codec.encodeEval(id, dir, tiles))); + }, + async checkWord(id, word) { + return codec.decodeWordCheck(await exec('game.check_word', codec.encodeCheckWord(id, word))); + }, + async complaint(id, word, note) { + await exec('game.complaint', codec.encodeComplaint(id, word, note)); + }, + + async chatPost(id, body) { + return codec.decodeChatMessage(await exec('chat.post', codec.encodeChatPost(id, body))); + }, + async chatList(id) { + return codec.decodeChatList(await exec('chat.list', codec.encodeGameAction(id))); + }, + async nudge(id) { + return codec.decodeChatMessage(await exec('chat.nudge', codec.encodeGameAction(id))); + }, + + subscribe(onEvent, onError) { + const ctrl = new AbortController(); + void (async () => { + try { + for await (const ev of client.subscribe({}, { headers: headers(), signal: ctrl.signal })) { + const pe = codec.decodeEvent(ev.kind, ev.payload); + if (pe) onEvent(pe); + } + } catch (e) { + if (!ctrl.signal.aborted) onError?.(toGatewayError(e)); + } + })(); + return () => ctrl.abort(); + }, + }; +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..dd41642 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,5 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +export default mount(App, { target: document.getElementById('app')! }); diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte new file mode 100644 index 0000000..a641724 --- /dev/null +++ b/ui/src/screens/About.svelte @@ -0,0 +1,22 @@ + + +
+
+

{t('app.title')}

+

{t('about.description')}

+

{t('about.version', { v: version })}

+
+ + diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte new file mode 100644 index 0000000..4d27d3f --- /dev/null +++ b/ui/src/screens/Lobby.svelte @@ -0,0 +1,234 @@ + + +
+ {#snippet menu()} + + {#if menuOpen} + + +
(menuOpen = false)}>
+ + {/if} + {/snippet} +
+ +
+
+

{t('lobby.activeGames')}

+ {#if active.length === 0} +

{t('lobby.noActive')}

+ {/if} + {#each active as g (g.id)} + + {/each} +
+ +
+

{t('lobby.finishedGames')}

+ {#if finished.length === 0} +

{t('lobby.noFinished')}

+ {/if} + {#each finished as g (g.id)} + + {/each} +
+
+ + + + diff --git a/ui/src/screens/Login.svelte b/ui/src/screens/Login.svelte new file mode 100644 index 0000000..3e71227 --- /dev/null +++ b/ui/src/screens/Login.svelte @@ -0,0 +1,127 @@ + + +
+
+

{t('app.title')}

+ + + +
{t('login.email')}
+ + {#if stage === 'choose'} + + + {:else} +

{t('login.codeSent', { email })}

+ + + {/if} +
+
+ + diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte new file mode 100644 index 0000000..74ee12a --- /dev/null +++ b/ui/src/screens/NewGame.svelte @@ -0,0 +1,123 @@ + + +
+
+ {#if searching} +
+
+

{t('new.searching')}

+ +
+ {:else} +

{t('new.subtitle')}

+
+ {#each variants as v (v.id)} + + {/each} +
+ {/if} +
+ + diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte new file mode 100644 index 0000000..568ac57 --- /dev/null +++ b/ui/src/screens/Profile.svelte @@ -0,0 +1,67 @@ + + +
+
+ {#if app.profile} +
{app.profile.displayName}
+ {#if app.profile.isGuest}{t('profile.guest')}{/if} +
+
{t('profile.language')}
+
{app.profile.preferredLanguage}
+
{t('profile.timezone')}
+
{app.profile.timeZone}
+
{t('profile.hintBalance')}
+
{app.profile.hintBalance}
+
+

{t('profile.readonly')}

+ + {/if} +
+ + diff --git a/ui/src/screens/Settings.svelte b/ui/src/screens/Settings.svelte new file mode 100644 index 0000000..2630654 --- /dev/null +++ b/ui/src/screens/Settings.svelte @@ -0,0 +1,86 @@ + + +
+
+
+

{t('settings.theme')}

+
+ {#each themes as th (th)} + + {/each} +
+
+ +
+

{t('settings.language')}

+
+ {#each locales as lc (lc)} + + {/each} +
+
+ +
+ +
+
+ + diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..78ed9ca --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1,11 @@ +/// +/// + +interface ImportMetaEnv { + /** Base URL of the gateway Connect endpoint. Empty in dev (same-origin proxy). */ + readonly VITE_GATEWAY_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..461ef49 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,6 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +// Plain Svelte 5 (runes) — no SvelteKit. vitePreprocess enables