Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -214,21 +214,9 @@ jobs:
|
||||
run: pnpm exec playwright install chromium webkit
|
||||
timeout-minutes: 5
|
||||
|
||||
# The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the
|
||||
# release the same way the Go jobs do and point the mock preview's copy step (scripts/
|
||||
# e2e-dict.mjs, via E2E_DICT_DIR below) at it.
|
||||
- name: Fetch dictionary DAWGs
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||
|
||||
- name: E2E smoke (mock)
|
||||
run: pnpm run test:e2e
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
||||
|
||||
# conformance proves the client's local move preview (the ported dawg reader +
|
||||
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||
@@ -264,7 +252,6 @@ jobs:
|
||||
run: |
|
||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
||||
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
|
||||
go run ./backend/cmd/movegen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out "${GITHUB_WORKSPACE}/movegold"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
@@ -284,7 +271,6 @@ jobs:
|
||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||
DICT_GOLD_DIR: /tmp/dictgold
|
||||
DICT_VALID_DIR: /tmp/validgold
|
||||
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
||||
run: pnpm exec vitest run src/lib/dict/
|
||||
|
||||
# gate is the single branch-protection required check. It always runs and passes
|
||||
@@ -316,13 +302,9 @@ jobs:
|
||||
# Auto test-deploy on a PR into development and on the push that merges it.
|
||||
# A PR into master is test-only (this job is skipped); prod deploy is manual.
|
||||
# Gates on `gate` (so a real test failure blocks the deploy) but runs even when
|
||||
# some test jobs were path-skipped. Skipped entirely when neither the Go nor the
|
||||
# UI side changed (e.g. a docs-only change): the contour image is unchanged, so
|
||||
# there is nothing to redeploy. `changes` still defaults both to true when the
|
||||
# diff is uncomputable, and a workflow/deploy edit forces both true, so an
|
||||
# ambiguous or infra change still deploys as a safety net.
|
||||
needs: [changes, gate]
|
||||
if: ${{ (needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true') && ((github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development')) }}
|
||||
# some test jobs were path-skipped.
|
||||
needs: [gate]
|
||||
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -1,620 +0,0 @@
|
||||
# PLAN — monetization implementation
|
||||
|
||||
Technical, step-by-step implementation of the monetization domain. Business mechanics and
|
||||
the rationale for every rule live in [`docs/PAYMENTS.md`](docs/PAYMENTS.md) (RU mirror
|
||||
[`docs/PAYMENTS_ru.md`](docs/PAYMENTS_ru.md)); this file is the *how*. Each stage is written
|
||||
to be self-sufficient: returning to it gives full context — goal, exact touch-points,
|
||||
tests, done-criteria, and current status — without re-deriving decisions.
|
||||
|
||||
## How to use this file
|
||||
|
||||
- **Stage granularity (E0–E9) is fixed.** Do not split or merge stages. Plan each stage
|
||||
densely enough to execute whole.
|
||||
- **Never leak stage ids into the product.** Code, comments, commit messages, PR
|
||||
titles/descriptions must read as finalized feature copy — never "E5", "stage 3", etc.
|
||||
Stage ids exist only in this file.
|
||||
- **Deviations are allowed** when new unknowns surface, but the plan is then edited **whole
|
||||
and in agreement** (not piecemeal), keeping it coherent, and `docs/PAYMENTS.md` updated in
|
||||
step.
|
||||
- **Status marks:** each stage header carries `Status: TODO | WIP | DONE`. When a stage
|
||||
lands, flip it to DONE and note the PR. The **Progress** table is the at-a-glance index.
|
||||
- **Per-change discipline** (repo `CLAUDE.md`): update tests at the layers `docs/TESTING.md`
|
||||
calls out, bake doc updates into the same PR, run local full verification before pushing,
|
||||
feature branch → PR into `development`.
|
||||
|
||||
## Progress
|
||||
|
||||
| Stage | Title | Release | Status |
|
||||
|-------|-------|---------|--------|
|
||||
| E0 | Payments data foundation | 1 | DONE |
|
||||
| E1 | Trusted platform signal | 1 | TODO |
|
||||
| E2 | Currency + benefit core | 1 | TODO |
|
||||
| E3 | Wallet UI | 1 | TODO |
|
||||
| E4 | Durability (PITR) | 2 | TODO |
|
||||
| E5 | Payment intake | 2 | TODO |
|
||||
| E6 | Ads | 2 | TODO |
|
||||
| E7 | Admin & reports | 2 | TODO |
|
||||
| E8 | Guest limits | — | TODO |
|
||||
| E9 | Tournament fee | future | TODO |
|
||||
|
||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||||
parallel). E9 is future.
|
||||
|
||||
---
|
||||
|
||||
## Architecture baseline (applies to all stages)
|
||||
|
||||
Read once; individual stages assume it.
|
||||
|
||||
### Schema & isolation
|
||||
|
||||
- The payments domain owns its **own Postgres schema `payments`** in the shared instance
|
||||
(`scrabble` DB). All payments tables are `payments.*`. `backend` schema is untouched
|
||||
except for the deprecation of two legacy columns (E2).
|
||||
- **DB role.** A dedicated **NOLOGIN** role holds ALL privileges on `payments.*` and
|
||||
**nothing** on `backend` — grant-based confinement, asserted by a `SET ROLE` isolation
|
||||
test. The application still connects on its single (superuser) pool, so these grants are a
|
||||
stepping-stone to a real separate login/process, not the runtime wall. The **runtime wall
|
||||
is code-level**: only the `internal/payments` package imports the payments jet code and
|
||||
issues `payments.*` SQL (an **import-boundary test** enforces it); every other domain
|
||||
reaches payments through the narrow Go interface.
|
||||
- **No cross-schema link at all.** There is **no** foreign key from `payments.*` to
|
||||
`backend.accounts`: `account_id` is a plain `uuid`, kept referentially honest in code and
|
||||
joined to the tombstoned account / `retained_identities` dossier by the stable id. This
|
||||
keeps the spend transaction (ledger INSERT + balance UPDATE + benefit UPDATE) fully
|
||||
**within `payments`** and the domain extractable into its own database later. Benefits move
|
||||
**into** `payments` (they no longer live on `accounts` — see E2); game code reads them
|
||||
through the payments **Go interface**, never via SQL.
|
||||
- **Money.** Monetary amounts are a single **`bigint` in the currency's minor units** (RUB
|
||||
kopecks; Votes/Stars/chips are whole units, scale 1) carried in Go by the `payments.Money`
|
||||
value type — the sole constructor/formatter/arithmetic, so **no `float64` ever touches an
|
||||
amount** and a whole-unit currency structurally cannot hold a fraction. `chip_rate` is not a
|
||||
table: a chip pack is a product, so its per-method rate **is** `product_price`.
|
||||
|
||||
### Domain boundary
|
||||
|
||||
- New package `backend/internal/payments/` — `payments.go` (types + `Service`),
|
||||
`store.go` (`type Store struct{ db *sql.DB }`, go-jet against `internal/postgres/jet/
|
||||
payments`), following the `ads` domain shape (`backend/internal/ads/{ads,service,store}.go`).
|
||||
- Wired in `backend/cmd/backend/main.go` `run()` (construct after `account`, before
|
||||
`server.New`), handed into `server.Deps` (`server.go`), routes registered in
|
||||
`registerRoutes` gated `if s.payments != nil`, admin section in `registerConsole` gated
|
||||
`if s.payments != nil`.
|
||||
- Game/other domains depend on payments only through a **narrow interface** (e.g.
|
||||
`AdFree(ctx, account, platform) bool`, `SpendHint(ctx, account, platform) error`,
|
||||
`Balances(ctx, account, platform) …`). Keep the surface small; this is the seam that lets
|
||||
payments become a separate process later without touching callers.
|
||||
|
||||
### Migrations & codegen
|
||||
|
||||
- Migrations: same goose dir `backend/internal/postgres/migrations/`, sequential
|
||||
`0000N_*.sql`, `-- +goose Up/Down`, schema-qualified, **expand-contract mandatory**
|
||||
(image rollback must stay DB-safe). First payments migration does `CREATE SCHEMA IF NOT
|
||||
EXISTS payments`, creates the role, grants.
|
||||
- **jetgen** (`backend/cmd/jetgen/main.go`) is currently hardwired to `schema=backend`.
|
||||
Extend it to also generate `schema=payments` into `internal/postgres/jet/payments/
|
||||
{model,table}` (committed). Regenerate only after a payments migration; revert unrelated
|
||||
`backend`-schema churn (jetgen may reorder untouched tables — commit only intended diffs).
|
||||
- Transactions: reuse the local `withTx(ctx, db, fn)` idiom. Balance/benefit mutations are
|
||||
guarded single-row atomic UPDATEs (mirror `account.SpendHint`); the ledger INSERT +
|
||||
materialized-cache UPDATE go in one `withTx`. Default Read-Committed is sufficient given
|
||||
the guarded updates + unique idempotency keys; do not reach for Serializable without a
|
||||
demonstrated need.
|
||||
|
||||
### Transport
|
||||
|
||||
- client ↔ gateway: Connect-RPC + FlatBuffers (h2c). gateway ↔ backend: REST/JSON +
|
||||
`X-User-ID` via `gateway/internal/backendclient`. Any user-facing payments call needs the
|
||||
full chain: backend REST handler (`u := s.user` group) → `backendclient` method →
|
||||
Connect-RPC method + transcode (`gateway/internal/transcode/`) → FlatBuffers schema
|
||||
(`pkg/fbs/…`, regen with `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||||
- Provider webhooks (Robokassa/VK) do NOT use the Connect path — model on the existing
|
||||
public HMAC-signed route `s.public.GET("/dl/:id/:kind", …)` (`handlers.go`): a public
|
||||
route with signature verification, proxied by the gateway/Caddy `@gateway` matcher
|
||||
(`deploy/caddy/Caddyfile` — a new edge route MUST be added there or it falls to the
|
||||
landing catch-all).
|
||||
|
||||
### Testing layers (`docs/TESTING.md`)
|
||||
|
||||
- **unit** (Go `_test.go`, TS vitest): gate-by-context, no-ads stacking, priority draw,
|
||||
rate math, idempotency keys, catalog snapshotting. UI logic must be extracted out of
|
||||
`.svelte` into `ui/src/lib/*` to be unit-testable.
|
||||
- **integration** (`backend/internal/inttest/`, `//go:build integration`, Postgres-backed):
|
||||
atomic chip↔benefit spend, callback idempotency, order-flow, TG outbox delivery, merge/
|
||||
unlink of segments.
|
||||
- **UI** (Playwright mock e2e, Chromium+WebKit): Wallet screen, warnings, guest-hidden, GP
|
||||
stub. Mock e2e bypasses codec — wire bugs need codec unit tests.
|
||||
- Local full verification before push: integration (`-tags=integration` + DAWG sibling +
|
||||
Ryuk-off), UI check/test/build, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
|
||||
|
||||
---
|
||||
|
||||
## E0 — Payments data foundation
|
||||
|
||||
**Status:** DONE · **Release 1** · depends on: none · mechanics: PAYMENTS §14, §7, §11.
|
||||
|
||||
**Goal.** Stand up the `payments` schema, its confinement role, the jetgen target, the domain
|
||||
package skeleton and all core tables — nothing wired to real money yet. This is the substrate
|
||||
every later stage builds on.
|
||||
|
||||
**Migration `00010_payments_foundation.sql` (`payments` schema).**
|
||||
|
||||
- `CREATE SCHEMA payments`; an idempotent `DO $$` block creates a **NOLOGIN** `payments` role;
|
||||
`GRANT USAGE` + `GRANT ALL ON ALL TABLES` (+ `ALTER DEFAULT PRIVILEGES … GRANT ALL`) confine it
|
||||
to `payments.*` with nothing on `backend`. No `REFERENCES` grant (there is no cross-schema FK).
|
||||
- `payments.ledger` — append-only. `ledger_id` (uuid PK, app-generated v7), `account_id` (plain
|
||||
`uuid`, **no FK**), `kind` (`fund|spend|admin_grant|refund`), `source`/`origin` (nullable,
|
||||
`vk|telegram|direct`), `chips_delta` (signed int, 0 for a grant), `product_id` (nullable
|
||||
FK→product), `order_id` (nullable FK→orders), `provider` + `provider_payment_id` (nullable),
|
||||
`snapshot` (jsonb, written from E2), `created_at`. Immutability is a **`BEFORE UPDATE OR DELETE`
|
||||
trigger** (a superuser bypasses a privilege REVOKE). Unique **partial** index on `(provider,
|
||||
provider_payment_id) WHERE provider_payment_id IS NOT NULL` — the idempotency key.
|
||||
- `payments.balances` — `(account_id, source)` PK, `chips int CHECK (>=0)`, `updated_at`. Created
|
||||
**lazily** on first fund (up to three rows/account, absent = zero).
|
||||
- `payments.benefits` — `(account_id, origin)` PK, `ads_paid_until timestamptz` null,
|
||||
`ads_forever bool`, `hints int CHECK (>=0)`, `updated_at`. Created lazily.
|
||||
- `payments.catalog_atom` — `atom_type` PK (`chips|hints|noads_days|tournament`); the four atoms
|
||||
are **seeded** here (the `tournament` atom is provisioned for E9).
|
||||
- `payments.product` (`product_id` PK, `title`, `active bool` soft-delete, timestamps);
|
||||
`payments.product_item` (`(product_id, atom_type)` PK → `quantity`); `payments.product_price`
|
||||
(`(product_id, COALESCE(method,''), currency)` unique → `amount bigint` minor units): a chip
|
||||
pack carries a money price per `method` (`vk|telegram|direct`), a value carries one
|
||||
`currency='CHIP', method=NULL` row. `currency ∈ {RUB, VOTE, XTR, CHIP}`; `amount CHECK (>=0)`.
|
||||
- `payments.config` — a single typed row (`only_row bool PK CHECK`): `rewarded_payout_chips`,
|
||||
`cooldown_global_seconds` (300), `cooldown_vs_ai_seconds` (1800), `cooldown_hint_seconds` (60),
|
||||
`order_ttl_seconds` (1800). No `chip_rate` table — the pack rate is `product_price`.
|
||||
- `payments.orders` — `order_id` (uuid PK), `account_id`, `platform`, `product_id` (FK),
|
||||
`expected_amount bigint` + `currency`, `origin`, `status` (`pending|paid|expired`), `provider`,
|
||||
`provider_payment_id` (nullable), timestamps. Index `(status, created_at)` for the pending sweep.
|
||||
- `payments.payment_events` — `event_id` (uuid PK), `account_id`, `order_id` (nullable FK), `type`
|
||||
(`succeeded|failed|refunded`), `payload jsonb`, `created_at`, `dispatched_at` (nullable, partial
|
||||
index for the dispatch queue).
|
||||
- Full `-- +goose Down` (DROP SCHEMA CASCADE + DROP OWNED BY + DROP ROLE); expand-contract, proven
|
||||
reversible by an integration test.
|
||||
|
||||
**Backend.**
|
||||
|
||||
- `backend/internal/payments/{payments,service,store}.go` — the `Money` value type + `Currency`
|
||||
(bigint minor units, exact, no float); `Store{db *sql.DB}` with a `Ping` health read via jet;
|
||||
`Service` over the store. (`withTx` arrives with E2's first transaction.)
|
||||
- `cmd/jetgen` generates the `payments` schema into `internal/postgres/jet/payments/` (a second
|
||||
`GenerateDB` call); committed. Constructed in `cmd/backend/main.go` and passed to `server.Deps`
|
||||
(`Payments`) with a boot-time reachability check; its routes are registered from E2.
|
||||
|
||||
**Legacy deprecation (expand phase only).** A `--` header note marks `accounts.hint_balance` and
|
||||
`accounts.paid_account` deprecated; they are **not** dropped (the DROP is the contract phase after
|
||||
E2 flips reads and after Release 2). Reads still use the old columns until E2.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- integration (`inttest`): schema + role + seeds present; `SET ROLE payments` cannot read
|
||||
`backend.accounts` (grant confinement); ledger rejects UPDATE/DELETE (trigger); idempotency
|
||||
partial index holds (NULL-keyed rows repeat); balance/benefit/amount/enum CHECKs bite; migration
|
||||
applies forward **and backward** on a throwaway PG.
|
||||
- unit: `Money` — exact round-trip, per-currency scale, no-float parse rejecting a fraction for a
|
||||
whole-unit currency, arithmetic, formatting. Import-boundary test: only `internal/payments`
|
||||
imports the payments jet code.
|
||||
|
||||
**Done-criteria (met).** Migrations apply forward+backward on a throwaway PG 17; `go build
|
||||
./backend/...` + `go vet` + `gofmt -l .` clean; committed `jet/payments/`; all tests green; no
|
||||
behaviour change for users.
|
||||
|
||||
**Notes.** jetgen regenerates the whole `backend` schema; its committed jet had drifted from the
|
||||
migrations (`robot_blocks`, `robot_friend_requests`, the `feedback_messages` `app_version` /
|
||||
`browser_tz` columns, and `UseSchema` gaps), so this change also **regenerates and commits
|
||||
`jet/backend`** to bring it back in sync (additive only — all suites stay green). The `payments`
|
||||
role creation is idempotent (`DO $$` / `IF NOT EXISTS`) for fresh volumes.
|
||||
|
||||
---
|
||||
|
||||
## E1 — Trusted platform signal
|
||||
|
||||
**Status:** TODO · **Release 1** · depends on: none (parallel to E0) · mechanics: PAYMENTS §8.
|
||||
|
||||
**Goal.** Make the server know the execution platform from a trusted, unforgeable source,
|
||||
carried on the session and re-confirmed each cold start. This is the foundation the gate
|
||||
(E2) stands on; without it the gate is meaningless.
|
||||
|
||||
**Model.** `platform = {kind: vk|telegram|direct, subtype: ios|android|web}` becomes a
|
||||
property of the **session**. On cold start the client submits the fresh wrapper signature
|
||||
(VK launch-params `sign` / TG `initData`); the gateway (or backend session-resolve)
|
||||
validates it and records/refreshes the session's platform. `direct` needs no signature — it
|
||||
is implied by a web/native session's creation path.
|
||||
|
||||
**Backend.**
|
||||
|
||||
- Session store (`backend/internal/session/`): add `platform` (kind+subtype) to the session
|
||||
row + `Session` struct; set it at creation and refresh it on a validated cold-start call.
|
||||
- Session resolve (`handlers_auth.go handleResolveSession` → `resolveResponse` DTO
|
||||
`dto.go`): return `platform` so the gateway can carry it.
|
||||
- Validation: TG `initData` validator already exists (`platform/telegram/internal/initdata`)
|
||||
— reuse. Add VK launch-params `sign` verification (HMAC over sorted params with the VK app
|
||||
secret) — new small verifier, unit-tested against known VK vectors.
|
||||
- Gateway (`gateway/internal/backendclient`): inject a trusted `X-Platform` header (mirror
|
||||
`X-User-ID` injection in `client.go do()`), sourced from the resolved session — never from
|
||||
the client request body.
|
||||
- Backend middleware: read `X-Platform` into context alongside `X-User-ID`
|
||||
(`middleware.go`); expose a typed accessor `platform(c)`.
|
||||
- **Fail-closed default:** absent/invalid/stale platform ⇒ context = untrusted; the payments
|
||||
interface treats untrusted as "view only" (enforced in E2's gate, but the signal plumbing
|
||||
lands here).
|
||||
|
||||
**Client (`ui/`).** On cold start, submit the wrapper signature to the session-establish/
|
||||
refresh call: VK via `ui/src/lib/vk.ts` (launch params), TG via `ui/src/lib/telegram.ts`
|
||||
(`initData`). `direct` submits nothing (web/native). Compose the platform from
|
||||
`insideVK()` + `insideTelegram()` + `clientChannel()` + `vkPlatform()` (no single
|
||||
discriminator exists today — see `ui/src/lib/channel.ts`, note VK reads as `web` there).
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit (Go): VK sign verifier accepts valid / rejects tampered params; TG initData path
|
||||
(existing) covered; platform kind+subtype derivation.
|
||||
- integration: session carries platform; resolve returns it; stale/absent → untrusted.
|
||||
- UI: cold start submits the right signature per wrapper (mock).
|
||||
|
||||
**Done-criteria.** A VK/TG session resolves to a trusted `{kind,subtype}`; a forged body
|
||||
cannot change it; `direct` sessions resolve to `direct`; untrusted path is reachable and
|
||||
observable. No user-visible change.
|
||||
|
||||
**Notes/risks.** VK iOS must surface as `subtype=ios` (drives the frozen state in E2/E3).
|
||||
Old sessions predating this stage have no platform → untrusted → fail-closed (acceptable;
|
||||
re-cold-start fixes it). Keep the VK app secret in config/secret store, not code.
|
||||
|
||||
---
|
||||
|
||||
## E2 — Currency + benefit core
|
||||
|
||||
**Status:** TODO · **Release 1** · depends on: E0, E1 · mechanics: PAYMENTS §2–§6, §11.
|
||||
|
||||
**Goal.** The full internal money mechanic, exercisable end-to-end **without real money**
|
||||
via `admin_grant`: chip balances, spend→benefit atomically, the compliance gate by context,
|
||||
benefit application (no-ads stacking, hints per-origin), the legacy migration, and the
|
||||
`SpendHint` rewrite. This is the heart.
|
||||
|
||||
**Payments service (Go).**
|
||||
|
||||
- `Balances(ctx, account, platform)` → the segments spendable in the platform's context
|
||||
(VK→vk; TG→tg; direct→direct+vk+tg; VK-iOS→frozen/view; untrusted→none).
|
||||
- `Spend(ctx, account, platform, productID)` → gate-checked purchase of a value with chips:
|
||||
resolve spendable segments by context, draw by priority (direct→vk→tg on web), write a
|
||||
`spend` ledger row + decrement balance + apply benefit (extend `ads_paid_until[origin]`
|
||||
from `max(now,end)` / set `ads_forever` / add hints) **in one tx**, stamping `origin` =
|
||||
platform context. Refuse if untrusted (fail-closed) or funds insufficient.
|
||||
- `Grant(ctx, account, origin, atoms)` → `admin_grant` ledger row (price 0, concrete values
|
||||
only, never chips), same benefit application; origin chosen by the admin (E7 UI; here the
|
||||
service method + an internal/admin entrypoint).
|
||||
- `AdFree(ctx, account, platform)` / `HintsAvailable(ctx, account, platform)` /
|
||||
`SpendHint(ctx, account, platform)` → apply the one-directional origin rule: in context P,
|
||||
usable origins = {P} plus, when P is web/native, {vk,tg} (relaxation outward); in VK only
|
||||
vk; in TG only tg.
|
||||
- `Merge` / `Unlink` hooks: extend `accountmerge` to merge segments+benefits by origin
|
||||
(same-origin add, different coexist), and make segment availability follow identity
|
||||
presence (unlink → sleep, re-link → wake). Warn-before-unlink is a UI concern (surface the
|
||||
balance via the interface).
|
||||
|
||||
**Backend wiring & migration.**
|
||||
|
||||
- Deprecate-and-flip `accounts.hint_balance` / `accounts.paid_account`: E0 added the tables;
|
||||
here, move reads/writes to `payments.benefits`. `SpendHint` (`game/service.go` ~:1147) and
|
||||
`ads.Eligible` (`ads/ads.go` :107) call the payments interface instead of the account
|
||||
columns. Legacy values were never set in prod → zero them; the DROP is the contract phase
|
||||
(guarded, after Release 2 — keep columns until then for rollback safety).
|
||||
- `vs_ai` hints stay free/unlimited (unchanged 30-min gate); only online-game hint spend
|
||||
routes through the segmented, context-aware path.
|
||||
|
||||
**API (user-facing, Connect chain).** Read-only wallet view + spend:
|
||||
`GET /api/v1/user/wallet` (balances+benefits for the context), `POST /api/v1/user/wallet/
|
||||
buy` (spend chips on a product). Add the backend handlers (`u := s.user`), `backendclient`
|
||||
methods, Connect methods + transcode + FBS. Admin grant is internal/admin (E7 UI).
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit (Go): gate-by-context matrix (every row of PAYMENTS §4); priority draw; no-ads
|
||||
stacking (`+=` from max(now,end)) + forever override; per-origin hint applicability;
|
||||
merge/unlink segment math.
|
||||
- integration: atomic spend (ledger+balance+benefit in one tx; failure rolls all back);
|
||||
admin_grant credits values not chips; legacy migration zeroes and flips reads; merge/
|
||||
unlink over Postgres.
|
||||
- UI: none new (E3 builds the screen); wallet DTO covered by codec unit tests.
|
||||
|
||||
**Done-criteria.** With chips seeded via `admin_grant`, a user can buy no-ads/hints; the gate
|
||||
blocks cross-context spend and cross-origin application; ads gate reads the new benefit;
|
||||
`SpendHint` uses segments; all layers green; **compliance regression** (a `direct` benefit
|
||||
never activates inside VK/TG) is a named test.
|
||||
|
||||
**Notes/risks.** This is the high-blast-radius core (money semantics + a live path
|
||||
`SpendHint`/`ads.Eligible`). Minimize surface, keep the interface narrow, no mixed-in
|
||||
refactors. The legacy flip is expand-contract: reads move first, DROP much later.
|
||||
|
||||
---
|
||||
|
||||
## E3 — Wallet UI
|
||||
|
||||
**Status:** TODO · **Release 1** · depends on: E2 · mechanics: PAYMENTS §1, §7, §6, §13, §4.
|
||||
|
||||
**Goal.** The user-facing "Кошелёк" (Wallet) section: balances + active benefits + the
|
||||
catalog storefront, honouring guest-hidden, GP-stub, and the web-spend warning.
|
||||
|
||||
**UI (`ui/`).**
|
||||
|
||||
- New `'wallet'` tab in `ui/src/screens/SettingsHub.svelte` — `SettingsTab` union, tab
|
||||
button **between Friends (:65) and About (:66)**, body branch in the `:42-51` switch,
|
||||
`'wallet'` route in `ui/src/lib/routeparse.ts` + `ui/src/App.svelte`. Reuse the hub's
|
||||
guest/offline gating.
|
||||
- `Wallet.svelte` screen: **minimal** — context-available chip balances + active benefits
|
||||
(no-ads until date, hints count). **No history feed** (PAYMENTS §11 — noise). Storefront:
|
||||
products from the configurable catalog, prices in chips (values) / per-method (chip packs).
|
||||
- **Guest:** section hidden entirely (durable-only).
|
||||
- **GP build:** purchase CTA replaced by a stub ("install the RuStore build to make
|
||||
purchases"); rewarded + spending earned chips still work. Detect the GP native build.
|
||||
- **Web-spend warning:** before spending vk/tg chips in a web/native (direct) context, show
|
||||
an own `Modal` (not `showPopup` — that eats the user-activation gesture) warning the value
|
||||
will be web/native-only.
|
||||
- Extract all logic into `ui/src/lib/*` (unit-testable); keep `.svelte` thin.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit (vitest): storefront/price formatting, context-available-segment selection, warning
|
||||
trigger condition.
|
||||
- UI (Playwright mock, Chromium+WebKit): Wallet renders between Friends/About; guest hides
|
||||
it; GP stub; warning modal on web vk/tg spend. Mock overlay must stay instant under
|
||||
`MODE==='mock'` (or it intercepts taps).
|
||||
|
||||
**Done-criteria.** Owner can review the Wallet on the deployed test contour (visual sign-off
|
||||
is on the contour, not local); balances/benefits/storefront correct per context; guest/GP/
|
||||
warning paths verified. Release 1 is now demonstrable end-to-end via `admin_grant`.
|
||||
|
||||
**Notes/risks.** No global `.btn`/`.ghost` in `ui/` — style per-component with scoped CSS +
|
||||
tokens (mirror NewGame `.invite` for a CTA). Svelte whitespace/`$state` naming gotchas
|
||||
apply. iOS WebView download/gesture caveats are irrelevant here (no file delivery).
|
||||
|
||||
---
|
||||
|
||||
## E4 — Durability (PITR)
|
||||
|
||||
**Status:** TODO · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14.
|
||||
|
||||
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
|
||||
real money** is accepted (E5 prod). Protects both money and game data.
|
||||
|
||||
**Work.**
|
||||
|
||||
- Add WAL archiving to the prod Postgres (pgBackRest or WAL-G): base backups + continuous
|
||||
WAL to a second host or object storage. Wire into `deploy/` (compose/prod overlay +
|
||||
ansible `deploy/ansible/`), config + secrets under the `PROD_` prefix.
|
||||
- Restore runbook in `deploy/README.md`: base + WAL replay to a timestamp; test the restore
|
||||
on a scratch target.
|
||||
- Keep the existing migration-time `pg_dump` (`deploy/prod-deploy.sh`) as belt-and-braces.
|
||||
|
||||
**Mandatory pre-prod assessment (owner-required, gates the Release 2 prod rollout).**
|
||||
Before the first money goes live, produce and record here:
|
||||
|
||||
1. **Disk-storage cost estimate** for the WAL archive + base backups (retention window ×
|
||||
WAL volume; account for both hosts / object-storage pricing). This can change hosting
|
||||
sizing.
|
||||
2. **PG performance-degradation assessment** from continuous archiving (write amplification,
|
||||
archive_command latency, backup I/O contention) on the prod-sized instance.
|
||||
|
||||
Neither blocks building the plan, but both **must** be completed and reviewed before the
|
||||
prod release — surface the numbers to the owner (they may change host settings).
|
||||
|
||||
**Tests.** Restore drill on a scratch instance (base + WAL → target timestamp) documented
|
||||
and passing. No app-level tests.
|
||||
|
||||
**Done-criteria.** WAL archiving live on prod PG; a timed restore verified; cost + perf
|
||||
assessment recorded and owner-reviewed; runbook in `deploy/README.md`.
|
||||
|
||||
**Notes/risks.** The tg host is weak (1 vCPU) — if the archive lands there, watch
|
||||
contention. Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
|
||||
|
||||
---
|
||||
|
||||
## E5 — Payment intake
|
||||
|
||||
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
|
||||
|
||||
**Goal.** Accept real money on all three rails into the payments domain: order-flow,
|
||||
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
|
||||
receipts, and refunds.
|
||||
|
||||
**Order-flow & intake (single writer).**
|
||||
|
||||
- `POST /api/v1/user/wallet/order` → create `order(pending)` (account/platform/product/
|
||||
expected amount/origin), return the provider-specific launch payload with `order_id`
|
||||
threaded in (Robokassa `InvId` / TG `invoice_payload` / VK `item`).
|
||||
- Robokassa + VK **public webhooks**: new edge routes (add to `deploy/caddy/Caddyfile`
|
||||
`@gateway` matcher — or they fall to the landing catch-all), signature/HMAC verified,
|
||||
proxied into a payments intake handler. Match by `order_id`, verify amount, credit
|
||||
(ledger `fund` + balance UPDATE in one tx), mark order `paid`, emit `payment_event`.
|
||||
Idempotent by `(provider, provider_payment_id)`.
|
||||
- **Pending sweep:** background reaper expires pending orders after the configured timeout
|
||||
(~30 min). Expiry is cosmetic — a later valid callback still credits (revive/honour).
|
||||
|
||||
**TG bot outbox (`platform/telegram/`).**
|
||||
|
||||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
|
||||
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
|
||||
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
|
||||
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
|
||||
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
|
||||
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
|
||||
|
||||
**Events & notifications.**
|
||||
|
||||
- `payment_events` dispatcher: `succeeded`/`failed`/`refunded` → live gRPC stream if the
|
||||
user is connected, else `botlink` push / email relay (existing). "failed" = an active
|
||||
provider decline (not an abandoned pending) surfaced to the user; "succeeded" hook
|
||||
(email/bot message).
|
||||
|
||||
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
|
||||
handles Votes tax itself; TG Stars — no receipt.
|
||||
|
||||
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
|
||||
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
|
||||
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit: signature/HMAC verifiers per rail; order-id matching; idempotency-key dedup.
|
||||
- integration: full order→callback→credit per rail; duplicate callback credits once; expired
|
||||
order still honoured on late callback; TG outbox store→forward→ack→cleanup + restart
|
||||
re-drive; refund revoke best-effort/never-negative.
|
||||
- UI: purchase flow reaches the provider launch (mock); success/failure surfaced.
|
||||
|
||||
**Done-criteria.** A real (sandbox) payment on each rail credits chips exactly once; a
|
||||
replayed callback does not double-credit; the TG outbox survives a bot restart mid-flight;
|
||||
failed/succeeded events reach the user; refund path exercised. **PITR (E4) armed and the
|
||||
cost/perf assessment reviewed before this goes to prod.**
|
||||
|
||||
**Notes/risks.** Manual `docker run -p` boot tests fail from the shell — verify the runnable
|
||||
artifact via the testcontainers integration suite. Distroless services run UID 65532;
|
||||
bind-mounted secrets must be 0644. Edge routes silently fall through if not added to the
|
||||
Caddyfile — add a CI probe. The prod rolling deploy skips caddy on config-only changes —
|
||||
force-recreate when the Caddyfile changes.
|
||||
|
||||
---
|
||||
|
||||
## E6 — Ads
|
||||
|
||||
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
|
||||
mechanics: PAYMENTS §10.
|
||||
|
||||
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
|
||||
(credits chips via server verify), plus extending the existing banner suppression to
|
||||
per-origin.
|
||||
|
||||
**Work.**
|
||||
|
||||
- **Provider abstraction:** a small ads-network interface (VK impl now) so a future network
|
||||
for other platforms slots in without rework. VK integration via `ui/src/lib/vk.ts`.
|
||||
- **Rewarded:** voluntary view → the network's **server verify callback** → payments intake
|
||||
credits chips to the `vk` segment (client not believed, like a payment). On-launch
|
||||
anti-fraud = provider verify only (no own daily cap; abstraction allows later).
|
||||
- **Interstitial** (post-move fullscreen), configurable server values (from `payments`
|
||||
config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
|
||||
application triggers a post-move interstitial independently with its own 1-min cooldown;
|
||||
offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side
|
||||
(per user) or client-mirrored from a server value — pick and document at implementation.
|
||||
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
|
||||
on the **origin benefit applicable in the current context** (E2 interface) instead of the
|
||||
single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit: cooldown logic (global 5m / vs_ai 30m / hint-trigger 1m independence); rewarded
|
||||
credit path; banner eligibility per context.
|
||||
- integration: rewarded verify → credit exactly once (idempotent like a payment).
|
||||
- UI: interstitial shows/respects cooldown (mock); rewarded button; no-ads hides banner +
|
||||
interstitial; rewarded still available under no-ads.
|
||||
|
||||
**Done-criteria.** VK rewarded credits chips once per verified view; interstitial respects
|
||||
all cooldowns incl. the hint trigger; no-ads suppresses banner+interstitial but not
|
||||
rewarded; offline shows banner only.
|
||||
|
||||
**Notes/risks.** Crypto-payout networks stay rejected. Rewarded-without-payout is pointless —
|
||||
only enable where the network pays (VK). Keep the interstitial frequency as server config so
|
||||
it tunes without a store release.
|
||||
|
||||
---
|
||||
|
||||
## E7 — Admin & reports
|
||||
|
||||
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
|
||||
mechanics: PAYMENTS §11.
|
||||
|
||||
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
|
||||
refund UI, ledger export.
|
||||
|
||||
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
|
||||
|
||||
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
|
||||
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
|
||||
read from the append-only ledger + materialized cache.
|
||||
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
|
||||
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
|
||||
`admin_grant` ledger row (E2 `Grant`).
|
||||
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
|
||||
path); records a `refund` ledger row; best-effort benefit revoke.
|
||||
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
|
||||
reconciliation (export-ready schema; reconciliation itself not built).
|
||||
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- unit: report view assembly; grant refuses chips; export shape.
|
||||
- integration: grant/refund write correct ledger rows; report reflects balances+history.
|
||||
|
||||
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
|
||||
values (origin-picked, never chips), issue a manual refund, and export the ledger.
|
||||
|
||||
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
|
||||
those with the segmented view as the legacy columns retire.
|
||||
|
||||
---
|
||||
|
||||
## E8 — Guest limits
|
||||
|
||||
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
|
||||
none · mechanics: PAYMENTS §6.
|
||||
|
||||
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
|
||||
gating is UI-only).
|
||||
|
||||
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
|
||||
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
|
||||
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
|
||||
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
|
||||
in the UI's stead.
|
||||
|
||||
**Work.**
|
||||
|
||||
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
|
||||
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
|
||||
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
|
||||
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
|
||||
- Keep the existing UI gating; this adds the server as the source of truth.
|
||||
|
||||
**Tests.**
|
||||
|
||||
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
|
||||
2nd random / 2nd vs_ai; durable account unaffected.
|
||||
- UI: guest sees the funnel (existing hides + any new messaging).
|
||||
|
||||
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
|
||||
durable accounts unchanged; regression that this does not break existing durable flows.
|
||||
|
||||
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
|
||||
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
|
||||
on creation, not mid-game) at implementation and record it here.
|
||||
|
||||
---
|
||||
|
||||
## E9 — Tournament fee (future)
|
||||
|
||||
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
|
||||
mechanics: PAYMENTS §5, §7.
|
||||
|
||||
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
|
||||
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
|
||||
feature and its coupling to entry are out of scope until the tournament feature exists.
|
||||
|
||||
**Done-criteria.** Deferred — revisit when tournaments are built.
|
||||
|
||||
---
|
||||
|
||||
## Verification & CI (all stages)
|
||||
|
||||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||||
`direct` benefit must never activate inside VK/TG) and runs from E2 onward.
|
||||
- Local full verification before every push: `go build/vet ./backend/... && gofmt -l .`,
|
||||
integration (`-tags=integration` + DAWG sibling + Ryuk-off), `pnpm -C ui check/test:unit/
|
||||
build`, Playwright mock e2e, codegen (`make -C pkg proto fbs`, `pnpm -C ui codegen`).
|
||||
- Contour: each PR into `development` auto-deploys the test contour; owner does visual sign-
|
||||
off there (not local). Keep a multi-PR batch a linear stack (one shared contour,
|
||||
last-deploy-wins).
|
||||
- Prod (Release 2): expand-contract migrations only (image rollback DB-safe); PITR armed +
|
||||
the E4 cost/perf assessment reviewed **before** the first money; new edge routes added to
|
||||
the Caddyfile with a CI probe; caddy force-recreated on config-only changes.
|
||||
- Release framing: **shipped docs/code/commits/PRs carry no stage ids** — finalize copy for
|
||||
the release, not the plan.
|
||||
@@ -31,7 +31,6 @@ import (
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/pushgrpc"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
@@ -261,15 +260,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// block and the banner admin console section.
|
||||
adsSvc := ads.NewService(ads.NewStore(db))
|
||||
|
||||
// In-game currency domain (data foundation): the payments schema behind a
|
||||
// narrow interface. A boot-time reachability check fails fast if the schema
|
||||
// did not migrate; the wallet routes are registered when that surface lands.
|
||||
paymentsSvc := payments.NewService(payments.NewStore(db))
|
||||
if err := paymentsSvc.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("payments schema unreachable: %w", err)
|
||||
}
|
||||
logger.Info("payments domain ready")
|
||||
|
||||
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||
var renderer *render.Client
|
||||
@@ -297,7 +287,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
RateWatch: rateWatch,
|
||||
BanView: banView,
|
||||
Ads: adsSvc,
|
||||
Payments: paymentsSvc,
|
||||
Notifier: hub,
|
||||
ExportSignKey: cfg.ExportSignKey,
|
||||
Renderer: renderer,
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||
// 2. open it with search_path=backend and apply the embedded goose migrations
|
||||
// 3. drop goose's bookkeeping table so jet does not generate a model for it
|
||||
// 4. run jet's PostgreSQL generator for the backend and payments schemas into
|
||||
// internal/postgres/jet (one subdirectory per schema)
|
||||
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -37,7 +36,6 @@ const (
|
||||
superuserPassword = "scrabble"
|
||||
superuserDatabase = "scrabble_backend"
|
||||
backendSchema = "backend"
|
||||
paymentsSchema = "payments"
|
||||
containerStartup = 90 * time.Second
|
||||
jetOutputDirSuffix = "internal/postgres/jet"
|
||||
)
|
||||
@@ -107,16 +105,11 @@ func run(ctx context.Context) error {
|
||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||
}
|
||||
|
||||
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
||||
// generator wipes only that subtree), so the two calls do not clobber each
|
||||
// other. GenerateDB takes the schema explicitly, so the connection's
|
||||
// search_path=backend does not affect the payments introspection.
|
||||
for _, schema := range []string{backendSchema, paymentsSchema} {
|
||||
if err := jetpostgres.GenerateDB(db, schema, outputDir); err != nil {
|
||||
return fmt.Errorf("jet generate schema=%s: %w", schema, err)
|
||||
}
|
||||
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, schema)
|
||||
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
||||
return fmt.Errorf("jet generate: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
// Command movegen emits golden conformance fixtures for the client-side move
|
||||
// generator port (ui/src/lib/dict). It is a dev tool, run by hand; its output is
|
||||
// committed so the TypeScript parity tests run without a Go toolchain.
|
||||
//
|
||||
// For each small sample dictionary (English and Russian — the latter reaches
|
||||
// alphabet index 32, exercising the 33-letter cross-set boundary) it writes:
|
||||
//
|
||||
// - sample_<tag>.dawg the serialized dictionary (the reader/cursor fixture)
|
||||
// - sample_<tag>.words.json the stored words + their alphabet indexes
|
||||
// - sample_<tag>.gen.json ranked move-generation results from the real solver,
|
||||
// for a handful of positions, plus the ruleset the TS
|
||||
// side rebuilds to score identically
|
||||
//
|
||||
// Positions are built with only the solver's public API: an empty board, and
|
||||
// two-ply positions reached by applying the solver's own top move (so no internal
|
||||
// encoding is needed). Regenerate with:
|
||||
//
|
||||
// go run ./backend/cmd/movegen -out ui/src/lib/dict/testdata
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
||||
dawg "github.com/iliadenisov/dafsa"
|
||||
)
|
||||
|
||||
// sampleWordsEN is the English sample dictionary, in strictly increasing
|
||||
// alphabet-index order (the builder requires it). Shared prefixes (car/care/cars),
|
||||
// shared suffixes (cats/dogs), internal-final nodes (do, an) and a one-letter word.
|
||||
var sampleWordsEN = []string{
|
||||
"a", "an", "and", "ant",
|
||||
"car", "care", "cared", "cares", "cars", "cat", "cats",
|
||||
"do", "doe", "does", "dog", "dogs", "done", "dot",
|
||||
}
|
||||
|
||||
// sampleWordsRU is the Russian sample dictionary, in strictly increasing index
|
||||
// order. It deliberately includes words starting with я (index 32) so the ported
|
||||
// cross-set handles alphabet indexes past JS's 31-bit shift boundary.
|
||||
var sampleWordsRU = []string{"ад", "ар", "оса", "я", "яд", "яр"}
|
||||
|
||||
// sampleFixture is the JSON committed with the .dawg so the TypeScript cursor test
|
||||
// knows the exact word set (as alphabet indexes) to expect from enumeration.
|
||||
type sampleFixture struct {
|
||||
Alphabet string `json:"alphabet"`
|
||||
NumAdded int `json:"numAdded"`
|
||||
Words []string `json:"words"`
|
||||
Indexes [][]int `json:"indexes"`
|
||||
}
|
||||
|
||||
// genFixture is the move-generation golden set for one sample dictionary.
|
||||
type genFixture struct {
|
||||
Ruleset genRuleset `json:"ruleset"`
|
||||
Cases []genCase `json:"cases"`
|
||||
}
|
||||
|
||||
// genRuleset is the scoring data the TS side rebuilds so evaluate() matches the Go
|
||||
// solver: letter values, premium multipliers per square, the centre, rack size and bonus.
|
||||
type genRuleset struct {
|
||||
Size int `json:"size"`
|
||||
Cols int `json:"cols"`
|
||||
Center int `json:"center"`
|
||||
RackSize int `json:"rackSize"`
|
||||
Bingo int `json:"bingo"`
|
||||
Values []int `json:"values"`
|
||||
LetterMult [][]int `json:"letterMult"`
|
||||
WordMult [][]int `json:"wordMult"`
|
||||
}
|
||||
|
||||
// genTile is one placed tile (a board tile or a move placement).
|
||||
type genTile struct {
|
||||
Row int `json:"row"`
|
||||
Col int `json:"col"`
|
||||
Letter int `json:"letter"`
|
||||
Blank bool `json:"blank"`
|
||||
}
|
||||
|
||||
// genRack is a rack as a multiset of letter indexes plus a blank count.
|
||||
type genRack struct {
|
||||
Letters []int `json:"letters"`
|
||||
Blanks int `json:"blanks"`
|
||||
}
|
||||
|
||||
// genMove is one ranked generated play: its orientation, placed tiles and total score.
|
||||
type genMove struct {
|
||||
Dir int `json:"dir"`
|
||||
Tiles []genTile `json:"tiles"`
|
||||
Score int `json:"score"`
|
||||
}
|
||||
|
||||
// genCase is one generation position: the tiles already on the board (empty when
|
||||
// none), the rack, the mode/rule and the ranked moves the solver returns.
|
||||
type genCase struct {
|
||||
Name string `json:"name"`
|
||||
Placed []genTile `json:"placed"`
|
||||
Rack genRack `json:"rack"`
|
||||
Mode int `json:"mode"`
|
||||
IgnoreCrossWords bool `json:"ignoreCrossWords"`
|
||||
Moves []genMove `json:"moves"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "ui/src/lib/dict/testdata", "output directory for fixtures")
|
||||
dawgDir := flag.String("dawg-dir", "", "when set, emit real-dictionary move-gen golden from the .dawg files in this dir (conformance mode) instead of the committed samples")
|
||||
flag.Parse()
|
||||
|
||||
if err := os.MkdirAll(*out, 0o755); err != nil {
|
||||
log.Fatalf("movegen: mkdir %s: %v", *out, err)
|
||||
}
|
||||
|
||||
if *dawgDir != "" {
|
||||
buildReal(*dawgDir, *out)
|
||||
return
|
||||
}
|
||||
|
||||
emitRulesets()
|
||||
|
||||
buildSample(*out, "en", rules.English(), sampleWordsEN, []genCase{
|
||||
emptyCase("empty-cared", englishRack("caredts", 0), scrabble.Both, false),
|
||||
emptyCase("empty-dogs", englishRack("dogsent", 0), scrabble.Both, false),
|
||||
emptyCase("empty-blank", englishRack("caret", 1), scrabble.Both, false),
|
||||
emptyCase("empty-single-word", englishRack("caredts", 0), scrabble.Both, true),
|
||||
})
|
||||
|
||||
buildSample(*out, "ru", rules.RussianScrabble(), sampleWordsRU, []genCase{
|
||||
emptyCase("empty-yad", russianRack("ядрасо", 0), scrabble.Both, false),
|
||||
})
|
||||
}
|
||||
|
||||
// buildSample writes the dawg, the word fixture and the generation golden set for
|
||||
// one sample dictionary. Two-ply cases are appended: the solver's own top move from
|
||||
// the first non-empty result is applied, then generation runs again on the new rack.
|
||||
func buildSample(out, tag string, rs *rules.Ruleset, words []string, cases []genCase) {
|
||||
idx := rs.Alphabet
|
||||
b := dawg.New(idx)
|
||||
indexes := make([][]int, 0, len(words))
|
||||
for _, w := range words {
|
||||
if err := b.Add(w); err != nil {
|
||||
log.Fatalf("movegen[%s]: add %q: %v", tag, w, err)
|
||||
}
|
||||
enc, err := idx.Encode(w)
|
||||
if err != nil {
|
||||
log.Fatalf("movegen[%s]: encode %q: %v", tag, w, err)
|
||||
}
|
||||
ints := make([]int, len(enc))
|
||||
for i, x := range enc {
|
||||
ints[i] = int(x)
|
||||
}
|
||||
indexes = append(indexes, ints)
|
||||
}
|
||||
finder := b.Finish()
|
||||
|
||||
writeJSON(filepath.Join(out, "sample_"+tag+".words.json"), sampleFixture{
|
||||
Alphabet: tag, NumAdded: finder.NumAdded(), Words: words, Indexes: indexes,
|
||||
})
|
||||
dawgPath := filepath.Join(out, "sample_"+tag+".dawg")
|
||||
if _, err := finder.Save(dawgPath); err != nil {
|
||||
log.Fatalf("movegen[%s]: save %s: %v", tag, dawgPath, err)
|
||||
}
|
||||
|
||||
s := scrabble.NewSolver(rs, finder)
|
||||
for i := range cases {
|
||||
runCase(s, rs, &cases[i], nil)
|
||||
}
|
||||
// A two-ply position from the first standard case that produced a move.
|
||||
if two := twoPly(s, rs, cases); two != nil {
|
||||
cases = append(cases, *two)
|
||||
}
|
||||
|
||||
writeJSON(filepath.Join(out, "sample_"+tag+".gen.json"), genFixture{
|
||||
Ruleset: rulesetOf(rs), Cases: cases,
|
||||
})
|
||||
log.Printf("movegen[%s]: %d words, %d cases", tag, finder.NumAdded(), len(cases))
|
||||
}
|
||||
|
||||
// realVariant maps a shipped dictionary file to the ruleset that scores it and the racks
|
||||
// the conformance positions use. smallRack/blankRack keep the first-move (empty board)
|
||||
// lists bounded on a dense dictionary; fullRack drives a deep 7-tile mid-game position,
|
||||
// kept small by the anchors around the already-placed word.
|
||||
type realVariant struct {
|
||||
file, variant, smallRack, blankRack, fullRack string
|
||||
rs *rules.Ruleset
|
||||
}
|
||||
|
||||
// buildReal emits move-generation golden from the real shipped dictionaries in dawgDir —
|
||||
// the full alphabets and deep graphs the tiny samples cannot reach — one
|
||||
// <variant>.movegen.json per variant. Like the dictgen/validategen vectors it is
|
||||
// regenerated in CI and never committed, so it pins no dictionary version into the repo.
|
||||
func buildReal(dawgDir, out string) {
|
||||
reals := []realVariant{
|
||||
{"en_sowpods", "scrabble_en", "aine", "ain", "aeinrst", rules.English()},
|
||||
{"ru_scrabble", "scrabble_ru", "аеин", "аен", "аеиноср", rules.RussianScrabble()},
|
||||
{"ru_erudit", "erudit_ru", "аеин", "аен", "аеиноср", rules.Erudit()},
|
||||
}
|
||||
for _, v := range reals {
|
||||
data, err := os.ReadFile(filepath.Join(dawgDir, v.file+".dawg"))
|
||||
if err != nil {
|
||||
log.Fatalf("movegen[%s]: read dawg: %v", v.variant, err)
|
||||
}
|
||||
finder, err := dawg.Read(bytes.NewReader(data), 0)
|
||||
if err != nil {
|
||||
log.Fatalf("movegen[%s]: parse dawg: %v", v.variant, err)
|
||||
}
|
||||
s := scrabble.NewSolver(v.rs, finder)
|
||||
|
||||
cases := []genCase{
|
||||
emptyCase("first-move", encRack(v.rs, v.smallRack, 0), scrabble.Both, false),
|
||||
emptyCase("first-move-blank", encRack(v.rs, v.blankRack, 1), scrabble.Both, false),
|
||||
}
|
||||
for i := range cases {
|
||||
runCase(s, v.rs, &cases[i], nil)
|
||||
}
|
||||
// A deep 7-tile mid-game: place the top first move, then generate again. The
|
||||
// anchors around the placed word bound the list while still exercising a full rack,
|
||||
// deep left/right extension and wide cross-sets over the real graph.
|
||||
full := encRack(v.rs, v.fullRack, 0)
|
||||
b := board.New(v.rs.Rows, v.rs.Cols)
|
||||
if m1 := s.GenerateMovesOpts(b, toRack(v.rs.Size(), full), scrabble.Both, scrabble.PlayOptions{}); len(m1) > 0 {
|
||||
mid := genCase{Name: "mid-game", Rack: full, Mode: int(scrabble.Both)}
|
||||
runCase(s, v.rs, &mid, tilesOf(m1[0].Tiles))
|
||||
cases = append(cases, mid)
|
||||
}
|
||||
|
||||
writeJSON(filepath.Join(out, v.variant+".movegen.json"), genFixture{Ruleset: rulesetOf(v.rs), Cases: cases})
|
||||
total := 0
|
||||
for _, c := range cases {
|
||||
total += len(c.Moves)
|
||||
}
|
||||
_ = finder.Close()
|
||||
log.Printf("movegen[%s]: %d cases, %d golden moves", v.variant, len(cases), total)
|
||||
}
|
||||
}
|
||||
|
||||
// encRack encodes a rack given as the variant's letters (plus a blank count) into the
|
||||
// index-based genRack the fixtures carry.
|
||||
func encRack(rs *rules.Ruleset, letters string, blanks int) genRack {
|
||||
enc, err := rs.Alphabet.Encode(letters)
|
||||
if err != nil {
|
||||
log.Fatalf("movegen: encode rack %q: %v", letters, err)
|
||||
}
|
||||
idx := make([]int, len(enc))
|
||||
for i, b := range enc {
|
||||
idx[i] = int(b)
|
||||
}
|
||||
return genRack{Letters: idx, Blanks: blanks}
|
||||
}
|
||||
|
||||
// runCase fills a case's Moves by generating on a board holding the given placed
|
||||
// tiles (nil = empty board).
|
||||
func runCase(s *scrabble.Solver, rs *rules.Ruleset, c *genCase, placed []genTile) {
|
||||
bd := board.New(rs.Rows, rs.Cols)
|
||||
for _, t := range placed {
|
||||
bd.Set(t.Row, t.Col, cellByte(t.Letter, t.Blank))
|
||||
}
|
||||
c.Placed = placed
|
||||
rk := toRack(rs.Size(), c.Rack)
|
||||
moves := s.GenerateMovesOpts(bd, rk, scrabble.Mode(c.Mode), scrabble.PlayOptions{IgnoreCrossWords: c.IgnoreCrossWords})
|
||||
c.Moves = movesOf(moves)
|
||||
}
|
||||
|
||||
// twoPly reaches a mid-game position by applying the top move of the first standard
|
||||
// case that generated one, then generates again with a fresh rack of the same tiles.
|
||||
func twoPly(s *scrabble.Solver, rs *rules.Ruleset, cases []genCase) *genCase {
|
||||
for _, c := range cases {
|
||||
if c.IgnoreCrossWords || len(c.Moves) == 0 {
|
||||
continue
|
||||
}
|
||||
placed := c.Moves[0].Tiles
|
||||
next := genCase{Name: "two-ply", Rack: c.Rack, Mode: int(scrabble.Both)}
|
||||
runCase(s, rs, &next, placed)
|
||||
return &next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// cellByte encodes a board cell the way internal/encoding.Cell does (bits 0-5 hold
|
||||
// letter+1, bit 7 marks a blank). Duplicated here because that package is internal
|
||||
// to the solver module and cannot be imported.
|
||||
func cellByte(letter int, blank bool) byte {
|
||||
v := byte(letter+1) & 0x3f
|
||||
if blank {
|
||||
v |= 0x80
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func toRack(size int, r genRack) rack.Rack {
|
||||
rk := rack.New(size)
|
||||
for _, l := range r.Letters {
|
||||
rk.Add(byte(l))
|
||||
}
|
||||
for i := 0; i < r.Blanks; i++ {
|
||||
rk.AddBlank()
|
||||
}
|
||||
return rk
|
||||
}
|
||||
|
||||
func rulesetOf(rs *rules.Ruleset) genRuleset {
|
||||
lm := make([][]int, rs.Rows)
|
||||
wm := make([][]int, rs.Rows)
|
||||
for r := 0; r < rs.Rows; r++ {
|
||||
lm[r] = make([]int, rs.Cols)
|
||||
wm[r] = make([]int, rs.Cols)
|
||||
for c := 0; c < rs.Cols; c++ {
|
||||
p := rs.Premium(r, c)
|
||||
lm[r][c] = p.LetterMult()
|
||||
wm[r][c] = p.WordMult()
|
||||
}
|
||||
}
|
||||
return genRuleset{
|
||||
Size: rs.Size(), Cols: rs.Cols, Center: rs.Center, RackSize: rs.RackSize,
|
||||
Bingo: rs.Bingo, Values: rs.Values, LetterMult: lm, WordMult: wm,
|
||||
}
|
||||
}
|
||||
|
||||
func movesOf(ms []scrabble.Move) []genMove {
|
||||
out := make([]genMove, len(ms))
|
||||
for i, m := range ms {
|
||||
out[i] = genMove{Dir: int(m.Dir), Tiles: tilesOf(m.Tiles), Score: m.Score}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func tilesOf(ps []scrabble.Placement) []genTile {
|
||||
out := make([]genTile, len(ps))
|
||||
for i, p := range ps {
|
||||
out[i] = genTile{Row: p.Row, Col: p.Col, Letter: int(p.Letter), Blank: p.Blank}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emptyCase builds an empty-board case (Moves filled later by runCase).
|
||||
func emptyCase(name string, r genRack, mode scrabble.Mode, ignoreCross bool) genCase {
|
||||
return genCase{Name: name, Rack: r, Mode: int(mode), IgnoreCrossWords: ignoreCross}
|
||||
}
|
||||
|
||||
// englishRack builds a rack from lowercase a-z letters (index = letter-'a').
|
||||
func englishRack(letters string, blanks int) genRack {
|
||||
idx := make([]int, 0, len(letters))
|
||||
for _, ch := range letters {
|
||||
idx = append(idx, int(ch-'a'))
|
||||
}
|
||||
return genRack{Letters: idx, Blanks: blanks}
|
||||
}
|
||||
|
||||
// russianRack builds a rack from the Russian sample letters used above.
|
||||
func russianRack(letters string, blanks int) genRack {
|
||||
m := map[rune]int{'а': 0, 'д': 4, 'о': 15, 'р': 17, 'с': 18, 'я': 32}
|
||||
idx := make([]int, 0, len([]rune(letters)))
|
||||
for _, ch := range letters {
|
||||
i, ok := m[ch]
|
||||
if !ok {
|
||||
log.Fatalf("movegen: russianRack: no index for %q", string(ch))
|
||||
}
|
||||
idx = append(idx, i)
|
||||
}
|
||||
return genRack{Letters: idx, Blanks: blanks}
|
||||
}
|
||||
|
||||
// emitRulesets writes the per-variant static ruleset data (tile values, bag counts, blanks,
|
||||
// bingo, rack size) the offline engine mirrors in ui/src/lib/localgame/ruleset.ts, so a TS
|
||||
// parity test can pin that hand-copied table to the Go rulesets (scrabble-solver/rules).
|
||||
func emitRulesets() {
|
||||
type rsFix struct {
|
||||
Size int `json:"size"`
|
||||
RackSize int `json:"rackSize"`
|
||||
Bingo int `json:"bingo"`
|
||||
Blanks int `json:"blanks"`
|
||||
Values []int `json:"values"`
|
||||
Counts []int `json:"counts"`
|
||||
Letters []string `json:"letters"`
|
||||
}
|
||||
out := map[string]rsFix{}
|
||||
for _, v := range []struct {
|
||||
name string
|
||||
rs *rules.Ruleset
|
||||
}{
|
||||
{"scrabble_en", rules.English()},
|
||||
{"scrabble_ru", rules.RussianScrabble()},
|
||||
{"erudit_ru", rules.Erudit()},
|
||||
} {
|
||||
letters := make([]string, v.rs.Size())
|
||||
for i := range letters {
|
||||
ch, err := v.rs.Alphabet.Character(byte(i))
|
||||
if err != nil {
|
||||
log.Fatalf("movegen: %s letter %d: %v", v.name, i, err)
|
||||
}
|
||||
letters[i] = strings.ToUpper(ch)
|
||||
}
|
||||
out[v.name] = rsFix{Size: v.rs.Size(), RackSize: v.rs.RackSize, Bingo: v.rs.Bingo, Blanks: v.rs.Blanks, Values: v.rs.Values, Counts: v.rs.Counts, Letters: letters}
|
||||
}
|
||||
dir := filepath.Join("ui", "src", "lib", "localgame", "testdata")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
log.Fatalf("movegen: mkdir %s: %v", dir, err)
|
||||
}
|
||||
writeJSON(filepath.Join(dir, "rulesets.json"), out)
|
||||
log.Printf("movegen: wrote %s (3 variants)", filepath.Join(dir, "rulesets.json"))
|
||||
}
|
||||
|
||||
func writeJSON(path string, v any) {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("movegen: marshal %s: %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
log.Fatalf("movegen: write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
||||
)
|
||||
|
||||
// The offline engine (ui/src/lib/localgame) reproduces the end-of-game rack settlement and the
|
||||
// winner rule so a local game finishes with the same scores as the server. These golden fixtures
|
||||
// pin the ported pure functions (applyEndAdjustment / winner / rackValue) to the real Go engine.
|
||||
// Being in-package, this emitter constructs Game values directly and drives the unexported
|
||||
// end-game math on chosen positions.
|
||||
|
||||
type endCaseIn struct {
|
||||
Name string `json:"name"`
|
||||
Variant string `json:"variant"`
|
||||
Reason string `json:"reason"`
|
||||
Hands [][]int `json:"hands"`
|
||||
Scores []int `json:"scores"`
|
||||
Resigned []bool `json:"resigned"`
|
||||
ToMove int `json:"toMove"`
|
||||
}
|
||||
|
||||
type endCaseOut struct {
|
||||
endCaseIn
|
||||
ScoresAfter []int `json:"scoresAfter"`
|
||||
Winner int `json:"winner"`
|
||||
}
|
||||
|
||||
func rulesetFor(variant string) *rules.Ruleset {
|
||||
switch variant {
|
||||
case "scrabble_ru":
|
||||
return rules.RussianScrabble()
|
||||
case "erudit_ru":
|
||||
return rules.Erudit()
|
||||
default:
|
||||
return rules.English()
|
||||
}
|
||||
}
|
||||
|
||||
func reasonFor(s string) EndReason {
|
||||
switch s {
|
||||
case "out_of_tiles":
|
||||
return EndOutOfTiles
|
||||
case "scoreless":
|
||||
return EndScoreless
|
||||
case "resign":
|
||||
return EndResign
|
||||
case "aborted":
|
||||
return EndAborted
|
||||
}
|
||||
return EndNotOver
|
||||
}
|
||||
|
||||
func handsBytes(hands [][]int) [][]byte {
|
||||
out := make([][]byte, len(hands))
|
||||
for i, h := range hands {
|
||||
b := make([]byte, len(h))
|
||||
for j, x := range h {
|
||||
b[j] = byte(x)
|
||||
}
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestEmitEndgameFixtures regenerates ui/src/lib/localgame/testdata/endgame.json. Gated by
|
||||
// EMIT_ENGINE_FIXTURES. Regenerate with:
|
||||
//
|
||||
// EMIT_ENGINE_FIXTURES=1 go test ./backend/internal/engine -run TestEmitEndgameFixtures
|
||||
func TestEmitEndgameFixtures(t *testing.T) {
|
||||
if os.Getenv("EMIT_ENGINE_FIXTURES") == "" {
|
||||
t.Skip("set EMIT_ENGINE_FIXTURES=1 to regenerate ui/src/lib/localgame/testdata/endgame.json")
|
||||
}
|
||||
|
||||
cases := []endCaseIn{
|
||||
{"out-basic", "scrabble_en", "out_of_tiles", [][]int{{}, {0, 1, 2}}, []int{50, 40}, []bool{false, false}, 0},
|
||||
{"out-blank", "scrabble_en", "out_of_tiles", [][]int{{}, {255, 0}}, []int{30, 30}, []bool{false, false}, 0},
|
||||
{"out-erudit-yo", "erudit_ru", "out_of_tiles", [][]int{{}, {6, 32}}, []int{10, 10}, []bool{false, false}, 0},
|
||||
{"out-tie", "scrabble_en", "out_of_tiles", [][]int{{}, {}}, []int{30, 30}, []bool{false, false}, 0},
|
||||
{"out-3p", "scrabble_ru", "out_of_tiles", [][]int{{}, {0, 1}, {2}}, []int{10, 10, 10}, []bool{false, false, false}, 0},
|
||||
{"scoreless", "scrabble_en", "scoreless", [][]int{{0, 1}, {2, 3}}, []int{20, 20}, []bool{false, false}, 0},
|
||||
{"resign-2p", "scrabble_en", "resign", [][]int{{}, {0}}, []int{100, 10}, []bool{true, false}, 1},
|
||||
{"resign-3p", "scrabble_en", "resign", [][]int{{}, {}, {}}, []int{50, 60, 40}, []bool{false, true, false}, 0},
|
||||
{"aborted", "scrabble_en", "aborted", [][]int{{0}, {1}}, []int{40, 30}, []bool{false, false}, 0},
|
||||
}
|
||||
|
||||
out := make([]endCaseOut, 0, len(cases))
|
||||
for _, c := range cases {
|
||||
rs := rulesetFor(c.Variant)
|
||||
scores := append([]int(nil), c.Scores...)
|
||||
g := &Game{
|
||||
rules: rs,
|
||||
hands: handsBytes(c.Hands),
|
||||
scores: scores,
|
||||
resigned: c.Resigned,
|
||||
toMove: c.ToMove,
|
||||
}
|
||||
reason := reasonFor(c.Reason)
|
||||
g.over = true
|
||||
g.reason = reason
|
||||
g.applyEndAdjustment(reason)
|
||||
out = append(out, endCaseOut{endCaseIn: c, ScoresAfter: g.scores, Winner: g.winner()})
|
||||
}
|
||||
|
||||
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "localgame", "testdata")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(map[string]any{"cases": out}, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "endgame.json")
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
t.Logf("wrote %s (%d cases)", path, len(out))
|
||||
}
|
||||
@@ -69,29 +69,6 @@ func TestHintsRemaining(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHintUnlockLeftSeconds(t *testing.T) {
|
||||
now := time.Now()
|
||||
// A gated vs_ai game on the caller's turn, the robot having moved 10 min ago (turn started then).
|
||||
gated := Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}
|
||||
cases := []struct {
|
||||
name string
|
||||
g Game
|
||||
seat int
|
||||
want int
|
||||
}{
|
||||
{"non-vs_ai is open", Game{VsAI: false, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-10 * time.Minute)}, 0, 0},
|
||||
{"not the caller's turn is open", gated, 1, 0},
|
||||
{"human first move (no robot move) is open", Game{VsAI: true, ToMove: 0, MoveCount: 0, TurnStartedAt: now}, 0, 0},
|
||||
{"robot moved 10 min ago leaves 20 min", gated, 0, 20 * 60},
|
||||
{"robot moved past the window is open", Game{VsAI: true, ToMove: 0, MoveCount: 2, TurnStartedAt: now.Add(-40 * time.Minute)}, 0, 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := hintUnlockLeftSeconds(c.g, c.seat, now); got != c.want {
|
||||
t.Errorf("%s: hintUnlockLeftSeconds = %d, want %d", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedTimeout(t *testing.T) {
|
||||
if !allowedTimeout(24 * time.Hour) {
|
||||
t.Error("24h must be allowed")
|
||||
|
||||
@@ -1058,31 +1058,10 @@ func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Varia
|
||||
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
|
||||
}
|
||||
|
||||
// hintIdleWindow is how long a vs_ai player must be stuck on a turn (since the robot's last move)
|
||||
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
||||
const hintIdleWindow = 30 * time.Minute
|
||||
|
||||
// hintUnlockLeftSeconds is the seconds until g's vs_ai idle hint unlocks for seat, measured from now:
|
||||
// the robot's last move (the current turn's start, on the human's turn) plus the window, floored at
|
||||
// 0 and ceiled to whole seconds. It is 0 for a non-vs_ai game, when it is not seat's turn, or on the
|
||||
// human's first move (MoveCount 0, no robot move yet) — the gate is an anti-frustration aid, not a
|
||||
// first-move tax. The client anchors a monotonic countdown to it, so a client clock cannot skew it.
|
||||
func hintUnlockLeftSeconds(g Game, seat int, now time.Time) int {
|
||||
if !g.VsAI || g.ToMove != seat || g.MoveCount < 1 {
|
||||
return 0
|
||||
}
|
||||
left := g.TurnStartedAt.Add(hintIdleWindow).Sub(now)
|
||||
if left <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int((left + time.Second - 1) / time.Second) // ceil to whole seconds (no math import)
|
||||
}
|
||||
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their turn. For a human game
|
||||
// it spends one hint from the per-game allowance then the profile wallet (ErrHintsDisabled /
|
||||
// ErrNoHintsLeft / ErrNoHintAvailable). For a vs_ai game the hint is unlimited and wallet-free but
|
||||
// idle-gated from the server clock (ErrHintLocked until the window elapses) and counts toward no
|
||||
// hint statistic.
|
||||
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||
// turn, spending one hint from their per-game allowance and then their profile
|
||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||
// appropriate.
|
||||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||||
pre, err := svc.store.GetGame(ctx, gameID)
|
||||
if err != nil {
|
||||
@@ -1101,26 +1080,6 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
||||
if !pre.HintsAllowed {
|
||||
return HintResult{}, ErrHintsDisabled
|
||||
}
|
||||
if pre.VsAI {
|
||||
// vs_ai: unlimited and wallet-free, but idle-gated from the server clock — and counted toward
|
||||
// no hint statistic. Enforce the gate (the client normally pre-gates from the view's
|
||||
// HintUnlockLeftSeconds; this is the authoritative backstop), then serve the top move without
|
||||
// touching the allowance, the wallet or hints_used.
|
||||
if hintUnlockLeftSeconds(pre, seat, svc.clock()) > 0 {
|
||||
return HintResult{}, ErrHintLocked
|
||||
}
|
||||
unlock := svc.locks.lock(gameID)
|
||||
defer unlock()
|
||||
g, err := svc.liveGame(ctx, pre)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
}
|
||||
move, ok := g.HintView()
|
||||
if !ok {
|
||||
return HintResult{}, ErrNoHintAvailable
|
||||
}
|
||||
return HintResult{Move: move}, nil
|
||||
}
|
||||
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||
if err != nil {
|
||||
return HintResult{}, err
|
||||
@@ -1249,8 +1208,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
||||
BagLen: g.BagLen(),
|
||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||
WalletBalance: acc.HintBalance,
|
||||
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
|
||||
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -65,10 +65,6 @@ var (
|
||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
||||
// ErrHintLocked is returned when a vs_ai game's idle hint is still gated (the player has not yet
|
||||
// been stuck the idle window since the robot's last move). The client normally pre-gates from
|
||||
// StateView.HintUnlockLeftSeconds, so this is the authoritative server-clock backstop.
|
||||
ErrHintLocked = errors.New("game: hint is not available yet")
|
||||
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
||||
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
||||
@@ -244,11 +240,6 @@ type StateView struct {
|
||||
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
|
||||
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||
WalletBalance int
|
||||
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
|
||||
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
|
||||
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
|
||||
// unlimited and wallet-free; this idle gate replaces the allowance/wallet for it.
|
||||
HintUnlockLeftSeconds int
|
||||
}
|
||||
|
||||
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/pressly/goose/v3"
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"scrabble/backend/internal/postgres"
|
||||
"scrabble/backend/internal/postgres/migrations"
|
||||
)
|
||||
|
||||
// isPgCode reports whether err is a PostgreSQL server error with the given
|
||||
// SQLSTATE code.
|
||||
func isPgCode(err error, code string) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == code
|
||||
}
|
||||
|
||||
// TestPaymentsSchemaAndSeeds checks the payments schema, its role and the fixed
|
||||
// seed rows are present after migration.
|
||||
func TestPaymentsSchemaAndSeeds(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
var atoms int
|
||||
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.catalog_atom").Scan(&atoms); err != nil {
|
||||
t.Fatalf("count catalog_atom: %v", err)
|
||||
}
|
||||
if atoms != 4 {
|
||||
t.Errorf("catalog_atom rows = %d, want 4 (chips/hints/noads_days/tournament)", atoms)
|
||||
}
|
||||
|
||||
var cfg int
|
||||
if err := testDB.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&cfg); err != nil {
|
||||
t.Fatalf("count config: %v", err)
|
||||
}
|
||||
if cfg != 1 {
|
||||
t.Errorf("config rows = %d, want the single seeded row", cfg)
|
||||
}
|
||||
|
||||
var roleExists bool
|
||||
if err := testDB.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
|
||||
t.Fatalf("check payments role: %v", err)
|
||||
}
|
||||
if !roleExists {
|
||||
t.Error("payments role missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsRoleConfinement proves the payments role is confined to its own
|
||||
// schema: it can read payments.* but is denied on backend.*. The application
|
||||
// itself connects as a superuser (which bypasses this), so this asserts the
|
||||
// grants are correct as the stepping-stone to a real separate login; the runtime
|
||||
// wall for the superuser pool is the import-boundary test in the payments package.
|
||||
func TestPaymentsRoleConfinement(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx, err := testDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, "SET LOCAL ROLE payments"); err != nil {
|
||||
t.Fatalf("SET LOCAL ROLE payments: %v", err)
|
||||
}
|
||||
|
||||
// The payments role can read its own schema.
|
||||
var n int
|
||||
if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM payments.config").Scan(&n); err != nil {
|
||||
t.Errorf("payments role denied on payments.config (grants wrong): %v", err)
|
||||
}
|
||||
|
||||
// The payments role must NOT reach the backend schema.
|
||||
err = tx.QueryRowContext(ctx, "SELECT count(*) FROM backend.accounts").Scan(&n)
|
||||
if err == nil {
|
||||
t.Fatal("payments role could read backend.accounts — cross-schema isolation broken")
|
||||
}
|
||||
if !isPgCode(err, "42501") {
|
||||
t.Errorf("reading backend.accounts as payments: got %v, want permission-denied (42501)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsLedgerAppendOnly verifies the append-only trigger rejects any
|
||||
// UPDATE or DELETE on the ledger (a superuser is bound by the trigger, unlike a
|
||||
// mere privilege REVOKE).
|
||||
func TestPaymentsLedgerAppendOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
id := uuid.New()
|
||||
if _, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO payments.ledger (ledger_id, account_id, kind, chips_delta) VALUES ($1, $2, 'admin_grant', 0)`,
|
||||
id, uuid.New()); err != nil {
|
||||
t.Fatalf("insert ledger row: %v", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.ExecContext(ctx, `UPDATE payments.ledger SET chips_delta = 1 WHERE ledger_id = $1`, id); err == nil {
|
||||
t.Error("UPDATE on payments.ledger succeeded — append-only violated")
|
||||
} else if !isPgCode(err, "P0001") {
|
||||
t.Errorf("UPDATE ledger: got %v, want the append-only exception (P0001)", err)
|
||||
}
|
||||
|
||||
if _, err := testDB.ExecContext(ctx, `DELETE FROM payments.ledger WHERE ledger_id = $1`, id); err == nil {
|
||||
t.Error("DELETE on payments.ledger succeeded — append-only violated")
|
||||
} else if !isPgCode(err, "P0001") {
|
||||
t.Errorf("DELETE ledger: got %v, want the append-only exception (P0001)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsCheckConstraints verifies the domain CHECKs bite: non-negative
|
||||
// balances/hints/amount and the enum-like columns.
|
||||
func TestPaymentsCheckConstraints(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
prod := uuid.New()
|
||||
if _, err := testDB.ExecContext(ctx, `INSERT INTO payments.product (product_id, title) VALUES ($1, 'test')`, prod); err != nil {
|
||||
t.Fatalf("insert product: %v", err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{"balances chips negative", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
|
||||
{"balances bad source", `INSERT INTO payments.balances (account_id, source, chips) VALUES ($1, 'nope', 0)`, []any{uuid.New()}},
|
||||
{"benefits hints negative", `INSERT INTO payments.benefits (account_id, origin, hints) VALUES ($1, 'vk', -1)`, []any{uuid.New()}},
|
||||
{"ledger bad kind", `INSERT INTO payments.ledger (ledger_id, account_id, kind) VALUES ($1, $2, 'bogus')`, []any{uuid.New(), uuid.New()}},
|
||||
{"product_price amount negative", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'RUB', -1)`, []any{prod}},
|
||||
{"product_price bad currency", `INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1, 'direct', 'EUR', 100)`, []any{prod}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := testDB.ExecContext(ctx, c.sql, c.args...); err == nil {
|
||||
t.Errorf("%s: insert succeeded, want a CHECK violation", c.name)
|
||||
} else if !isPgCode(err, "23514") {
|
||||
t.Errorf("%s: got %v, want check_violation (23514)", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsLedgerIdempotencyIndex verifies the partial unique index makes a
|
||||
// (provider, provider_payment_id) pair collide once but leaves NULL-keyed rows
|
||||
// unconstrained.
|
||||
func TestPaymentsLedgerIdempotencyIndex(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
insert := func(ppid *string) error {
|
||||
_, err := testDB.ExecContext(ctx,
|
||||
`INSERT INTO payments.ledger (ledger_id, account_id, kind, provider, provider_payment_id) VALUES ($1, $2, 'fund', 'robokassa', $3)`,
|
||||
uuid.New(), uuid.New(), ppid)
|
||||
return err
|
||||
}
|
||||
|
||||
ppid := "inv-" + uuid.NewString()
|
||||
if err := insert(&ppid); err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
if err := insert(&ppid); err == nil {
|
||||
t.Error("duplicate (provider, provider_payment_id) inserted — idempotency index missing")
|
||||
} else if !isPgCode(err, "23505") {
|
||||
t.Errorf("duplicate insert: got %v, want unique_violation (23505)", err)
|
||||
}
|
||||
|
||||
// A NULL provider_payment_id is outside the partial index — repeats allowed.
|
||||
if err := insert(nil); err != nil {
|
||||
t.Fatalf("first null-ppid insert: %v", err)
|
||||
}
|
||||
if err := insert(nil); err != nil {
|
||||
t.Errorf("second null-ppid insert should be allowed by the partial index: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPaymentsMigrationReversible proves the migration is expand-contract: it
|
||||
// applies, rolls fully back (schema, role, trigger and all), and re-applies
|
||||
// cleanly — so a backend image rollback stays DB-safe. It uses its own container
|
||||
// so the Down does not disturb the shared suite database.
|
||||
func TestPaymentsMigrationReversible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
container, err := tcpostgres.Run(ctx, pgImage,
|
||||
tcpostgres.WithDatabase(pgDatabase),
|
||||
tcpostgres.WithUsername(pgUser),
|
||||
tcpostgres.WithPassword(pgPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(containerStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("start container: %v", err)
|
||||
}
|
||||
defer func() { _ = container.Terminate(context.Background()) }()
|
||||
|
||||
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("connection string: %v", err)
|
||||
}
|
||||
dsn, err := withSearchPath(baseDSN, pgSchema)
|
||||
if err != nil {
|
||||
t.Fatalf("search path: %v", err)
|
||||
}
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.DSN = dsn
|
||||
db, err := postgres.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("open pool: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
// Up: apply every migration, including the payments foundation.
|
||||
if err := postgres.ApplyMigrations(ctx, db); err != nil {
|
||||
t.Fatalf("apply up: %v", err)
|
||||
}
|
||||
if !schemaExists(ctx, t, db, "payments") {
|
||||
t.Fatal("payments schema absent after up")
|
||||
}
|
||||
|
||||
// Down the payments migration and assert a clean teardown.
|
||||
goose.SetBaseFS(migrations.Migrations())
|
||||
defer goose.SetBaseFS(nil)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
t.Fatalf("set dialect: %v", err)
|
||||
}
|
||||
if err := goose.DownToContext(ctx, db, ".", 9); err != nil {
|
||||
t.Fatalf("down to 9: %v", err)
|
||||
}
|
||||
if schemaExists(ctx, t, db, "payments") {
|
||||
t.Error("payments schema survived the down migration")
|
||||
}
|
||||
var roleExists bool
|
||||
if err := db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM pg_roles WHERE rolname = 'payments')").Scan(&roleExists); err != nil {
|
||||
t.Fatalf("check role after down: %v", err)
|
||||
}
|
||||
if roleExists {
|
||||
t.Error("payments role survived the down migration")
|
||||
}
|
||||
|
||||
// Re-apply and assert it comes back cleanly.
|
||||
if err := goose.UpContext(ctx, db, "."); err != nil {
|
||||
t.Fatalf("re-apply up: %v", err)
|
||||
}
|
||||
if !schemaExists(ctx, t, db, "payments") {
|
||||
t.Fatal("payments schema absent after re-apply")
|
||||
}
|
||||
}
|
||||
|
||||
// schemaExists reports whether a schema of the given name is present.
|
||||
func schemaExists(ctx context.Context, t *testing.T, db *sql.DB, name string) bool {
|
||||
t.Helper()
|
||||
var exists bool
|
||||
if err := db.QueryRowContext(ctx,
|
||||
"SELECT EXISTS(SELECT 1 FROM information_schema.schemata WHERE schema_name = $1)", name).Scan(&exists); err != nil {
|
||||
t.Fatalf("check schema %s: %v", name, err)
|
||||
}
|
||||
return exists
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestPaymentsSchemaImportBoundary enforces that only this package reaches the
|
||||
// payments jet code. The payments schema is isolated behind this domain; the
|
||||
// application connects to Postgres as a superuser that bypasses the schema
|
||||
// grants, so the runtime wall that keeps every other package out of payments.*
|
||||
// is this import boundary, not the DB privileges. If another package imported
|
||||
// the generated payments tables it could issue SQL against the schema directly,
|
||||
// breaking the single-writer guarantee and the domain's extractability.
|
||||
func TestPaymentsSchemaImportBoundary(t *testing.T) {
|
||||
const jetPkg = "scrabble/backend/internal/postgres/jet/payments"
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("cannot resolve the test file path")
|
||||
}
|
||||
// thisFile = .../backend/internal/payments/boundary_test.go
|
||||
backendRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", ".."))
|
||||
allowedPrefix := filepath.Join(backendRoot, "internal", "payments") + string(filepath.Separator)
|
||||
|
||||
fset := token.NewFileSet()
|
||||
walkErr := filepath.WalkDir(backendRoot, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !strings.HasSuffix(path, ".go") {
|
||||
return nil
|
||||
}
|
||||
file, perr := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
for _, imp := range file.Imports {
|
||||
p := strings.Trim(imp.Path.Value, `"`)
|
||||
if p != jetPkg && !strings.HasPrefix(p, jetPkg+"/") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(path, allowedPrefix) {
|
||||
t.Errorf("%s imports %s — only internal/payments may reach the payments jet code", path, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
t.Fatalf("walk backend tree: %v", walkErr)
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package payments
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMoneyFromMinor(t *testing.T) {
|
||||
m, err := MoneyFromMinor(14950, CurrencyRUB)
|
||||
if err != nil {
|
||||
t.Fatalf("MoneyFromMinor: %v", err)
|
||||
}
|
||||
if m.Minor() != 14950 || m.Currency() != CurrencyRUB {
|
||||
t.Fatalf("got minor=%d currency=%s", m.Minor(), m.Currency())
|
||||
}
|
||||
if _, err := MoneyFromMinor(1, Currency("EUR")); err == nil {
|
||||
t.Fatal("expected an error for an unknown currency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoneyFromMajor(t *testing.T) {
|
||||
cases := []struct {
|
||||
major int64
|
||||
cur Currency
|
||||
minor int64
|
||||
}{
|
||||
{149, CurrencyRUB, 14900}, // roubles scale to kopecks
|
||||
{250, CurrencyStar, 250}, // Stars are whole units
|
||||
{7, CurrencyVote, 7},
|
||||
{50, CurrencyChip, 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m, err := MoneyFromMajor(c.major, c.cur)
|
||||
if err != nil {
|
||||
t.Fatalf("MoneyFromMajor(%d,%s): %v", c.major, c.cur, err)
|
||||
}
|
||||
if m.Minor() != c.minor {
|
||||
t.Errorf("MoneyFromMajor(%d,%s): minor=%d, want %d", c.major, c.cur, m.Minor(), c.minor)
|
||||
}
|
||||
}
|
||||
if _, err := MoneyFromMajor(1<<62, CurrencyRUB); err == nil {
|
||||
t.Fatal("expected an overflow error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoneyExact(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
cur Currency
|
||||
minor int64
|
||||
}{
|
||||
{"149.50", CurrencyRUB, 14950},
|
||||
{"149", CurrencyRUB, 14900},
|
||||
{"0.01", CurrencyRUB, 1},
|
||||
{"0.10", CurrencyRUB, 10}, // 0.1 is inexact as a float; big.Rat keeps it exact
|
||||
{"-5.00", CurrencyRUB, -500},
|
||||
{"250", CurrencyStar, 250},
|
||||
{"7", CurrencyVote, 7},
|
||||
{"50", CurrencyChip, 50},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m, err := ParseMoney(c.text, c.cur)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseMoney(%q,%s): %v", c.text, c.cur, err)
|
||||
}
|
||||
if m.Minor() != c.minor {
|
||||
t.Errorf("ParseMoney(%q,%s): minor=%d, want %d", c.text, c.cur, m.Minor(), c.minor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMoneyRejectsFraction(t *testing.T) {
|
||||
// A whole-unit currency (Vote/Star/chip) must never accept a fraction, and
|
||||
// the rouble must never accept finer than a kopeck — the no-float gate.
|
||||
bad := []struct {
|
||||
text string
|
||||
cur Currency
|
||||
}{
|
||||
{"250.5", CurrencyStar},
|
||||
{"1.5", CurrencyChip},
|
||||
{"7.01", CurrencyVote},
|
||||
{"1.999", CurrencyRUB},
|
||||
{"0.001", CurrencyRUB},
|
||||
{"abc", CurrencyRUB},
|
||||
{"12.34", Currency("EUR")}, // unknown currency
|
||||
}
|
||||
for _, c := range bad {
|
||||
if m, err := ParseMoney(c.text, c.cur); err == nil {
|
||||
t.Errorf("ParseMoney(%q,%s) = %d, want an error", c.text, c.cur, m.Minor())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoneyAddCmp(t *testing.T) {
|
||||
a, _ := MoneyFromMinor(14900, CurrencyRUB)
|
||||
b, _ := MoneyFromMinor(50, CurrencyRUB)
|
||||
sum, err := a.Add(b)
|
||||
if err != nil || sum.Minor() != 14950 {
|
||||
t.Fatalf("Add: minor=%d err=%v", sum.Minor(), err)
|
||||
}
|
||||
if c, err := a.Cmp(b); err != nil || c != 1 {
|
||||
t.Fatalf("Cmp a>b: got %d err=%v", c, err)
|
||||
}
|
||||
if c, err := b.Cmp(a); err != nil || c != -1 {
|
||||
t.Fatalf("Cmp b<a: got %d err=%v", c, err)
|
||||
}
|
||||
if c, err := a.Cmp(a); err != nil || c != 0 {
|
||||
t.Fatalf("Cmp a==a: got %d err=%v", c, err)
|
||||
}
|
||||
// Cross-currency arithmetic is refused, not silently coerced.
|
||||
star, _ := MoneyFromMinor(1, CurrencyStar)
|
||||
if _, err := a.Add(star); err == nil {
|
||||
t.Error("Add across currencies: expected an error")
|
||||
}
|
||||
if _, err := a.Cmp(star); err == nil {
|
||||
t.Error("Cmp across currencies: expected an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoneyString(t *testing.T) {
|
||||
cases := []struct {
|
||||
minor int64
|
||||
cur Currency
|
||||
want string
|
||||
}{
|
||||
{14900, CurrencyRUB, "149.00 RUB"},
|
||||
{14950, CurrencyRUB, "149.50 RUB"},
|
||||
{1, CurrencyRUB, "0.01 RUB"},
|
||||
{-500, CurrencyRUB, "-5.00 RUB"},
|
||||
{250, CurrencyStar, "250 XTR"},
|
||||
{5, CurrencyChip, "5 CHIP"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m, _ := MoneyFromMinor(c.minor, c.cur)
|
||||
if got := m.String(); got != c.want {
|
||||
t.Errorf("Money{%d,%s}.String() = %q, want %q", c.minor, c.cur, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// Package payments is the in-game currency, wallet, benefit and catalog domain.
|
||||
//
|
||||
// It owns its own Postgres schema (payments) and is the only backend package
|
||||
// that issues SQL against it — an import-boundary test forbids any other package
|
||||
// from importing the payments jet code, which keeps the domain extractable into
|
||||
// its own database or process later. There is no cross-schema foreign key to the
|
||||
// account schema: an account id is a plain uuid here, kept referentially honest
|
||||
// in code.
|
||||
//
|
||||
// Money is carried exclusively by [Money] — an exact integer amount in a
|
||||
// currency's minor units — so no floating-point value ever reaches a monetary
|
||||
// amount, and a whole-unit currency (Vote, Star, chip) can never hold a
|
||||
// fraction. This file is the data-foundation layer: the currency value type; the
|
||||
// wallet mechanics build on the schema and this package later.
|
||||
package payments
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Currency identifies the unit a monetary amount is denominated in.
|
||||
type Currency string
|
||||
|
||||
const (
|
||||
// CurrencyRUB is the Russian rouble; its minor unit is the kopeck (1/100).
|
||||
CurrencyRUB Currency = "RUB"
|
||||
// CurrencyVote is the VK Vote — a whole unit with no sub-unit.
|
||||
CurrencyVote Currency = "VOTE"
|
||||
// CurrencyStar is the Telegram Star (XTR) — a whole unit with no sub-unit.
|
||||
CurrencyStar Currency = "XTR"
|
||||
// CurrencyChip is the in-game chip, the unit a value's price is quoted in —
|
||||
// a whole unit with no sub-unit.
|
||||
CurrencyChip Currency = "CHIP"
|
||||
)
|
||||
|
||||
// minorPerUnit reports how many minor units make one major unit of the currency.
|
||||
// Every currency except the rouble is a whole-unit currency (scale 1), so an
|
||||
// amount in it structurally cannot carry a fraction.
|
||||
func (c Currency) minorPerUnit() int64 {
|
||||
if c == CurrencyRUB {
|
||||
return 100
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// Valid reports whether the currency is one of the known units.
|
||||
func (c Currency) Valid() bool {
|
||||
switch c {
|
||||
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Money is an exact monetary amount: a signed integer count of a currency's
|
||||
// minor units (rouble kopecks; Vote/Star/chip whole units). It is the sole
|
||||
// carrier of money in the payments domain — construction, arithmetic and
|
||||
// formatting all go through it, so no float ever reaches an amount and a
|
||||
// whole-unit currency can never hold a fraction. The zero value has an empty,
|
||||
// invalid currency; build a value with [MoneyFromMinor], [MoneyFromMajor] or
|
||||
// [ParseMoney].
|
||||
type Money struct {
|
||||
minor int64
|
||||
currency Currency
|
||||
}
|
||||
|
||||
// MoneyFromMinor builds a Money from a raw count of the currency's minor units
|
||||
// (kopecks for the rouble, whole units otherwise). It errors on an unknown
|
||||
// currency.
|
||||
func MoneyFromMinor(minor int64, c Currency) (Money, error) {
|
||||
if !c.Valid() {
|
||||
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
|
||||
}
|
||||
return Money{minor: minor, currency: c}, nil
|
||||
}
|
||||
|
||||
// MoneyFromMajor builds a Money from a whole number of major units (roubles,
|
||||
// Votes, Stars, chips). It errors on an unknown currency or on overflow.
|
||||
func MoneyFromMajor(major int64, c Currency) (Money, error) {
|
||||
if !c.Valid() {
|
||||
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
|
||||
}
|
||||
scaled := new(big.Int).Mul(big.NewInt(major), big.NewInt(c.minorPerUnit()))
|
||||
if !scaled.IsInt64() {
|
||||
return Money{}, fmt.Errorf("payments: %d %s overflows", major, c)
|
||||
}
|
||||
return Money{minor: scaled.Int64(), currency: c}, nil
|
||||
}
|
||||
|
||||
// ParseMoney parses a decimal amount (e.g. "149.50", "250") in the currency,
|
||||
// exactly and without floating point (via math/big). It rejects a value with
|
||||
// finer precision than the currency allows — in particular, any fractional part
|
||||
// for a whole-unit currency — which is the gate that keeps a fraction out of an
|
||||
// integer currency.
|
||||
func ParseMoney(text string, c Currency) (Money, error) {
|
||||
if !c.Valid() {
|
||||
return Money{}, fmt.Errorf("payments: unknown currency %q", c)
|
||||
}
|
||||
r, ok := new(big.Rat).SetString(text)
|
||||
if !ok {
|
||||
return Money{}, fmt.Errorf("payments: %q is not a valid amount", text)
|
||||
}
|
||||
scaled := new(big.Rat).Mul(r, new(big.Rat).SetInt64(c.minorPerUnit()))
|
||||
if !scaled.IsInt() {
|
||||
return Money{}, fmt.Errorf("payments: %q has finer precision than %s allows", text, c)
|
||||
}
|
||||
num := scaled.Num() // the denominator is 1 once scaled to an integer
|
||||
if !num.IsInt64() {
|
||||
return Money{}, fmt.Errorf("payments: %q overflows %s", text, c)
|
||||
}
|
||||
return Money{minor: num.Int64(), currency: c}, nil
|
||||
}
|
||||
|
||||
// Minor returns the amount as a count of the currency's minor units — the value
|
||||
// persisted to an amount column.
|
||||
func (m Money) Minor() int64 { return m.minor }
|
||||
|
||||
// Currency returns the amount's currency.
|
||||
func (m Money) Currency() Currency { return m.currency }
|
||||
|
||||
// IsZero reports whether the amount is zero.
|
||||
func (m Money) IsZero() bool { return m.minor == 0 }
|
||||
|
||||
// Add returns the sum of the two amounts. It errors when the currencies differ.
|
||||
func (m Money) Add(o Money) (Money, error) {
|
||||
if m.currency != o.currency {
|
||||
return Money{}, fmt.Errorf("payments: cannot add %s to %s", o.currency, m.currency)
|
||||
}
|
||||
return Money{minor: m.minor + o.minor, currency: m.currency}, nil
|
||||
}
|
||||
|
||||
// Cmp compares the two amounts, returning -1, 0 or +1. It errors when the
|
||||
// currencies differ.
|
||||
func (m Money) Cmp(o Money) (int, error) {
|
||||
if m.currency != o.currency {
|
||||
return 0, fmt.Errorf("payments: cannot compare %s to %s", o.currency, m.currency)
|
||||
}
|
||||
switch {
|
||||
case m.minor < o.minor:
|
||||
return -1, nil
|
||||
case m.minor > o.minor:
|
||||
return 1, nil
|
||||
default:
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
|
||||
// String renders the amount as "<value> <currency>", with the currency's
|
||||
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
|
||||
func (m Money) String() string {
|
||||
scale := m.currency.minorPerUnit()
|
||||
if scale == 1 {
|
||||
return fmt.Sprintf("%d %s", m.minor, m.currency)
|
||||
}
|
||||
neg := m.minor < 0
|
||||
abs := m.minor
|
||||
if neg {
|
||||
abs = -abs
|
||||
}
|
||||
width := 0
|
||||
for s := scale; s > 1; s /= 10 {
|
||||
width++
|
||||
}
|
||||
sign := ""
|
||||
if neg {
|
||||
sign = "-"
|
||||
}
|
||||
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package payments
|
||||
|
||||
import "context"
|
||||
|
||||
// Service is the payments domain's application layer — the narrow surface other
|
||||
// domains depend on, keeping the schema reachable through one seam. The
|
||||
// data-foundation layer exposes only a health check; the wallet, benefit and
|
||||
// store-compliance operations arrive with the currency mechanics.
|
||||
type Service struct {
|
||||
store *Store
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store.
|
||||
func NewService(store *Store) *Service { return &Service{store: store} }
|
||||
|
||||
// Ping reports whether the payments schema is reachable.
|
||||
func (s *Service) Ping(ctx context.Context) error { return s.store.Ping(ctx) }
|
||||
@@ -1,37 +0,0 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
|
||||
"scrabble/backend/internal/postgres/jet/payments/model"
|
||||
"scrabble/backend/internal/postgres/jet/payments/table"
|
||||
)
|
||||
|
||||
// Store is the Postgres-backed query surface for the payments schema. It is the
|
||||
// only place in the backend that issues SQL against payments.* (an
|
||||
// import-boundary test enforces it), so the domain stays extractable into its
|
||||
// own database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewStore constructs a Store wrapping db.
|
||||
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
|
||||
|
||||
// Ping verifies the payments schema is reachable by reading the singleton config
|
||||
// row. It is the data-foundation health check; the wallet query surface arrives
|
||||
// with the currency mechanics.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
var row model.Config
|
||||
if err := postgres.SELECT(table.Config.AllColumns).
|
||||
FROM(table.Config).
|
||||
LIMIT(1).
|
||||
QueryContext(ctx, s.db, &row); err != nil {
|
||||
return fmt.Errorf("payments: ping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -27,6 +27,4 @@ type FeedbackMessages struct {
|
||||
ReplyReadAt *time.Time
|
||||
CreatedAt time.Time
|
||||
Lang *string
|
||||
AppVersion *string
|
||||
BrowserTz *string
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RobotBlocks struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
BlockerID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
Seat int16
|
||||
RobotID uuid.UUID
|
||||
DisplayName string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RobotFriendRequests struct {
|
||||
ID uuid.UUID `sql:"primary_key"`
|
||||
RequesterID uuid.UUID
|
||||
GameID uuid.UUID
|
||||
Seat int16
|
||||
RobotID uuid.UUID
|
||||
DisplayName string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -31,8 +31,6 @@ type feedbackMessagesTable struct {
|
||||
ReplyReadAt postgres.ColumnTimestampz
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
Lang postgres.ColumnString
|
||||
AppVersion postgres.ColumnString
|
||||
BrowserTz postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -88,10 +86,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
||||
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
LangColumn = postgres.StringColumn("lang")
|
||||
AppVersionColumn = postgres.StringColumn("app_version")
|
||||
BrowserTzColumn = postgres.StringColumn("browser_tz")
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn, AppVersionColumn, BrowserTzColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn, AppVersionColumn, BrowserTzColumn}
|
||||
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
@@ -113,8 +109,6 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
||||
ReplyReadAt: ReplyReadAtColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
Lang: LangColumn,
|
||||
AppVersion: AppVersionColumn,
|
||||
BrowserTz: BrowserTzColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var RobotBlocks = newRobotBlocksTable("backend", "robot_blocks", "")
|
||||
|
||||
type robotBlocksTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
BlockerID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
Seat postgres.ColumnInteger
|
||||
RobotID postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type RobotBlocksTable struct {
|
||||
robotBlocksTable
|
||||
|
||||
EXCLUDED robotBlocksTable
|
||||
}
|
||||
|
||||
// AS creates new RobotBlocksTable with assigned alias
|
||||
func (a RobotBlocksTable) AS(alias string) *RobotBlocksTable {
|
||||
return newRobotBlocksTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new RobotBlocksTable with assigned schema name
|
||||
func (a RobotBlocksTable) FromSchema(schemaName string) *RobotBlocksTable {
|
||||
return newRobotBlocksTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new RobotBlocksTable with assigned table prefix
|
||||
func (a RobotBlocksTable) WithPrefix(prefix string) *RobotBlocksTable {
|
||||
return newRobotBlocksTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new RobotBlocksTable with assigned table suffix
|
||||
func (a RobotBlocksTable) WithSuffix(suffix string) *RobotBlocksTable {
|
||||
return newRobotBlocksTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newRobotBlocksTable(schemaName, tableName, alias string) *RobotBlocksTable {
|
||||
return &RobotBlocksTable{
|
||||
robotBlocksTable: newRobotBlocksTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newRobotBlocksTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newRobotBlocksTableImpl(schemaName, tableName, alias string) robotBlocksTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
BlockerIDColumn = postgres.StringColumn("blocker_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
SeatColumn = postgres.IntegerColumn("seat")
|
||||
RobotIDColumn = postgres.StringColumn("robot_id")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{IDColumn, BlockerIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{BlockerIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return robotBlocksTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
BlockerID: BlockerIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
Seat: SeatColumn,
|
||||
RobotID: RobotIDColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var RobotFriendRequests = newRobotFriendRequestsTable("backend", "robot_friend_requests", "")
|
||||
|
||||
type robotFriendRequestsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnString
|
||||
RequesterID postgres.ColumnString
|
||||
GameID postgres.ColumnString
|
||||
Seat postgres.ColumnInteger
|
||||
RobotID postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type RobotFriendRequestsTable struct {
|
||||
robotFriendRequestsTable
|
||||
|
||||
EXCLUDED robotFriendRequestsTable
|
||||
}
|
||||
|
||||
// AS creates new RobotFriendRequestsTable with assigned alias
|
||||
func (a RobotFriendRequestsTable) AS(alias string) *RobotFriendRequestsTable {
|
||||
return newRobotFriendRequestsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new RobotFriendRequestsTable with assigned schema name
|
||||
func (a RobotFriendRequestsTable) FromSchema(schemaName string) *RobotFriendRequestsTable {
|
||||
return newRobotFriendRequestsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new RobotFriendRequestsTable with assigned table prefix
|
||||
func (a RobotFriendRequestsTable) WithPrefix(prefix string) *RobotFriendRequestsTable {
|
||||
return newRobotFriendRequestsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new RobotFriendRequestsTable with assigned table suffix
|
||||
func (a RobotFriendRequestsTable) WithSuffix(suffix string) *RobotFriendRequestsTable {
|
||||
return newRobotFriendRequestsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newRobotFriendRequestsTable(schemaName, tableName, alias string) *RobotFriendRequestsTable {
|
||||
return &RobotFriendRequestsTable{
|
||||
robotFriendRequestsTable: newRobotFriendRequestsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newRobotFriendRequestsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newRobotFriendRequestsTableImpl(schemaName, tableName, alias string) robotFriendRequestsTable {
|
||||
var (
|
||||
IDColumn = postgres.StringColumn("id")
|
||||
RequesterIDColumn = postgres.StringColumn("requester_id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
SeatColumn = postgres.IntegerColumn("seat")
|
||||
RobotIDColumn = postgres.StringColumn("robot_id")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{IDColumn, RequesterIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{RequesterIDColumn, GameIDColumn, SeatColumn, RobotIDColumn, DisplayNameColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return robotFriendRequestsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
RequesterID: RequesterIDColumn,
|
||||
GameID: GameIDColumn,
|
||||
Seat: SeatColumn,
|
||||
RobotID: RobotIDColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ package table
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
AccountBestMove = AccountBestMove.FromSchema(schema)
|
||||
AccountRoles = AccountRoles.FromSchema(schema)
|
||||
AccountStats = AccountStats.FromSchema(schema)
|
||||
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
||||
@@ -36,8 +35,6 @@ func UseSchema(schema string) {
|
||||
Games = Games.FromSchema(schema)
|
||||
Identities = Identities.FromSchema(schema)
|
||||
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
||||
RobotBlocks = RobotBlocks.FromSchema(schema)
|
||||
RobotFriendRequests = RobotFriendRequests.FromSchema(schema)
|
||||
Sessions = Sessions.FromSchema(schema)
|
||||
SuspensionReasons = SuspensionReasons.FromSchema(schema)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Balances struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Source string `sql:"primary_key"`
|
||||
Chips int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Benefits struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
Origin string `sql:"primary_key"`
|
||||
AdsPaidUntil *time.Time
|
||||
AdsForever bool
|
||||
Hints int32
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
type CatalogAtom struct {
|
||||
AtomType string `sql:"primary_key"`
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
type Config struct {
|
||||
OnlyRow bool `sql:"primary_key"`
|
||||
RewardedPayoutChips int32
|
||||
CooldownGlobalSeconds int32
|
||||
CooldownVsAiSeconds int32
|
||||
CooldownHintSeconds int32
|
||||
OrderTTLSeconds int32
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Ledger struct {
|
||||
LedgerID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
Kind string
|
||||
Source *string
|
||||
Origin *string
|
||||
ChipsDelta int32
|
||||
ProductID *uuid.UUID
|
||||
OrderID *uuid.UUID
|
||||
Provider *string
|
||||
ProviderPaymentID *string
|
||||
Snapshot *string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Orders struct {
|
||||
OrderID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
Platform string
|
||||
ProductID uuid.UUID
|
||||
ExpectedAmount int64
|
||||
Currency string
|
||||
Origin string
|
||||
Status string
|
||||
Provider *string
|
||||
ProviderPaymentID *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PaymentEvents struct {
|
||||
EventID uuid.UUID `sql:"primary_key"`
|
||||
AccountID uuid.UUID
|
||||
OrderID *uuid.UUID
|
||||
Type string
|
||||
Payload *string
|
||||
CreatedAt time.Time
|
||||
DispatchedAt *time.Time
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Product struct {
|
||||
ProductID uuid.UUID `sql:"primary_key"`
|
||||
Title string
|
||||
Active bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ProductItem struct {
|
||||
ProductID uuid.UUID `sql:"primary_key"`
|
||||
AtomType string `sql:"primary_key"`
|
||||
Quantity int32
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ProductPrice struct {
|
||||
ProductID uuid.UUID
|
||||
Method *string
|
||||
Currency string
|
||||
Amount int64
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Balances = newBalancesTable("payments", "balances", "")
|
||||
|
||||
type balancesTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Source postgres.ColumnString
|
||||
Chips postgres.ColumnInteger
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type BalancesTable struct {
|
||||
balancesTable
|
||||
|
||||
EXCLUDED balancesTable
|
||||
}
|
||||
|
||||
// AS creates new BalancesTable with assigned alias
|
||||
func (a BalancesTable) AS(alias string) *BalancesTable {
|
||||
return newBalancesTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new BalancesTable with assigned schema name
|
||||
func (a BalancesTable) FromSchema(schemaName string) *BalancesTable {
|
||||
return newBalancesTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new BalancesTable with assigned table prefix
|
||||
func (a BalancesTable) WithPrefix(prefix string) *BalancesTable {
|
||||
return newBalancesTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new BalancesTable with assigned table suffix
|
||||
func (a BalancesTable) WithSuffix(suffix string) *BalancesTable {
|
||||
return newBalancesTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newBalancesTable(schemaName, tableName, alias string) *BalancesTable {
|
||||
return &BalancesTable{
|
||||
balancesTable: newBalancesTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newBalancesTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newBalancesTableImpl(schemaName, tableName, alias string) balancesTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
SourceColumn = postgres.StringColumn("source")
|
||||
ChipsColumn = postgres.IntegerColumn("chips")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, SourceColumn, ChipsColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ChipsColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return balancesTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Source: SourceColumn,
|
||||
Chips: ChipsColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Benefits = newBenefitsTable("payments", "benefits", "")
|
||||
|
||||
type benefitsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
Origin postgres.ColumnString
|
||||
AdsPaidUntil postgres.ColumnTimestampz
|
||||
AdsForever postgres.ColumnBool
|
||||
Hints postgres.ColumnInteger
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type BenefitsTable struct {
|
||||
benefitsTable
|
||||
|
||||
EXCLUDED benefitsTable
|
||||
}
|
||||
|
||||
// AS creates new BenefitsTable with assigned alias
|
||||
func (a BenefitsTable) AS(alias string) *BenefitsTable {
|
||||
return newBenefitsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new BenefitsTable with assigned schema name
|
||||
func (a BenefitsTable) FromSchema(schemaName string) *BenefitsTable {
|
||||
return newBenefitsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new BenefitsTable with assigned table prefix
|
||||
func (a BenefitsTable) WithPrefix(prefix string) *BenefitsTable {
|
||||
return newBenefitsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new BenefitsTable with assigned table suffix
|
||||
func (a BenefitsTable) WithSuffix(suffix string) *BenefitsTable {
|
||||
return newBenefitsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newBenefitsTable(schemaName, tableName, alias string) *BenefitsTable {
|
||||
return &BenefitsTable{
|
||||
benefitsTable: newBenefitsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newBenefitsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newBenefitsTableImpl(schemaName, tableName, alias string) benefitsTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
OriginColumn = postgres.StringColumn("origin")
|
||||
AdsPaidUntilColumn = postgres.TimestampzColumn("ads_paid_until")
|
||||
AdsForeverColumn = postgres.BoolColumn("ads_forever")
|
||||
HintsColumn = postgres.IntegerColumn("hints")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, OriginColumn, AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AdsPaidUntilColumn, AdsForeverColumn, HintsColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{AdsForeverColumn, HintsColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return benefitsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
Origin: OriginColumn,
|
||||
AdsPaidUntil: AdsPaidUntilColumn,
|
||||
AdsForever: AdsForeverColumn,
|
||||
Hints: HintsColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var CatalogAtom = newCatalogAtomTable("payments", "catalog_atom", "")
|
||||
|
||||
type catalogAtomTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AtomType postgres.ColumnString
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type CatalogAtomTable struct {
|
||||
catalogAtomTable
|
||||
|
||||
EXCLUDED catalogAtomTable
|
||||
}
|
||||
|
||||
// AS creates new CatalogAtomTable with assigned alias
|
||||
func (a CatalogAtomTable) AS(alias string) *CatalogAtomTable {
|
||||
return newCatalogAtomTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new CatalogAtomTable with assigned schema name
|
||||
func (a CatalogAtomTable) FromSchema(schemaName string) *CatalogAtomTable {
|
||||
return newCatalogAtomTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new CatalogAtomTable with assigned table prefix
|
||||
func (a CatalogAtomTable) WithPrefix(prefix string) *CatalogAtomTable {
|
||||
return newCatalogAtomTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new CatalogAtomTable with assigned table suffix
|
||||
func (a CatalogAtomTable) WithSuffix(suffix string) *CatalogAtomTable {
|
||||
return newCatalogAtomTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newCatalogAtomTable(schemaName, tableName, alias string) *CatalogAtomTable {
|
||||
return &CatalogAtomTable{
|
||||
catalogAtomTable: newCatalogAtomTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newCatalogAtomTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newCatalogAtomTableImpl(schemaName, tableName, alias string) catalogAtomTable {
|
||||
var (
|
||||
AtomTypeColumn = postgres.StringColumn("atom_type")
|
||||
allColumns = postgres.ColumnList{AtomTypeColumn}
|
||||
mutableColumns = postgres.ColumnList{}
|
||||
defaultColumns = postgres.ColumnList{}
|
||||
)
|
||||
|
||||
return catalogAtomTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AtomType: AtomTypeColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Config = newConfigTable("payments", "config", "")
|
||||
|
||||
type configTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
OnlyRow postgres.ColumnBool
|
||||
RewardedPayoutChips postgres.ColumnInteger
|
||||
CooldownGlobalSeconds postgres.ColumnInteger
|
||||
CooldownVsAiSeconds postgres.ColumnInteger
|
||||
CooldownHintSeconds postgres.ColumnInteger
|
||||
OrderTTLSeconds postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ConfigTable struct {
|
||||
configTable
|
||||
|
||||
EXCLUDED configTable
|
||||
}
|
||||
|
||||
// AS creates new ConfigTable with assigned alias
|
||||
func (a ConfigTable) AS(alias string) *ConfigTable {
|
||||
return newConfigTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ConfigTable with assigned schema name
|
||||
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
|
||||
return newConfigTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ConfigTable with assigned table prefix
|
||||
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
|
||||
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ConfigTable with assigned table suffix
|
||||
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
|
||||
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
|
||||
return &ConfigTable{
|
||||
configTable: newConfigTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newConfigTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
|
||||
var (
|
||||
OnlyRowColumn = postgres.BoolColumn("only_row")
|
||||
RewardedPayoutChipsColumn = postgres.IntegerColumn("rewarded_payout_chips")
|
||||
CooldownGlobalSecondsColumn = postgres.IntegerColumn("cooldown_global_seconds")
|
||||
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
|
||||
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
|
||||
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
|
||||
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
|
||||
)
|
||||
|
||||
return configTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
OnlyRow: OnlyRowColumn,
|
||||
RewardedPayoutChips: RewardedPayoutChipsColumn,
|
||||
CooldownGlobalSeconds: CooldownGlobalSecondsColumn,
|
||||
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
|
||||
CooldownHintSeconds: CooldownHintSecondsColumn,
|
||||
OrderTTLSeconds: OrderTTLSecondsColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Ledger = newLedgerTable("payments", "ledger", "")
|
||||
|
||||
type ledgerTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
LedgerID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Kind postgres.ColumnString
|
||||
Source postgres.ColumnString
|
||||
Origin postgres.ColumnString
|
||||
ChipsDelta postgres.ColumnInteger
|
||||
ProductID postgres.ColumnString
|
||||
OrderID postgres.ColumnString
|
||||
Provider postgres.ColumnString
|
||||
ProviderPaymentID postgres.ColumnString
|
||||
Snapshot postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type LedgerTable struct {
|
||||
ledgerTable
|
||||
|
||||
EXCLUDED ledgerTable
|
||||
}
|
||||
|
||||
// AS creates new LedgerTable with assigned alias
|
||||
func (a LedgerTable) AS(alias string) *LedgerTable {
|
||||
return newLedgerTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new LedgerTable with assigned schema name
|
||||
func (a LedgerTable) FromSchema(schemaName string) *LedgerTable {
|
||||
return newLedgerTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new LedgerTable with assigned table prefix
|
||||
func (a LedgerTable) WithPrefix(prefix string) *LedgerTable {
|
||||
return newLedgerTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new LedgerTable with assigned table suffix
|
||||
func (a LedgerTable) WithSuffix(suffix string) *LedgerTable {
|
||||
return newLedgerTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newLedgerTable(schemaName, tableName, alias string) *LedgerTable {
|
||||
return &LedgerTable{
|
||||
ledgerTable: newLedgerTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newLedgerTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newLedgerTableImpl(schemaName, tableName, alias string) ledgerTable {
|
||||
var (
|
||||
LedgerIDColumn = postgres.StringColumn("ledger_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
KindColumn = postgres.StringColumn("kind")
|
||||
SourceColumn = postgres.StringColumn("source")
|
||||
OriginColumn = postgres.StringColumn("origin")
|
||||
ChipsDeltaColumn = postgres.IntegerColumn("chips_delta")
|
||||
ProductIDColumn = postgres.StringColumn("product_id")
|
||||
OrderIDColumn = postgres.StringColumn("order_id")
|
||||
ProviderColumn = postgres.StringColumn("provider")
|
||||
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||
SnapshotColumn = postgres.StringColumn("snapshot")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{LedgerIDColumn, AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, KindColumn, SourceColumn, OriginColumn, ChipsDeltaColumn, ProductIDColumn, OrderIDColumn, ProviderColumn, ProviderPaymentIDColumn, SnapshotColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{ChipsDeltaColumn, CreatedAtColumn}
|
||||
)
|
||||
|
||||
return ledgerTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
LedgerID: LedgerIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Kind: KindColumn,
|
||||
Source: SourceColumn,
|
||||
Origin: OriginColumn,
|
||||
ChipsDelta: ChipsDeltaColumn,
|
||||
ProductID: ProductIDColumn,
|
||||
OrderID: OrderIDColumn,
|
||||
Provider: ProviderColumn,
|
||||
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||
Snapshot: SnapshotColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Orders = newOrdersTable("payments", "orders", "")
|
||||
|
||||
type ordersTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
OrderID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
Platform postgres.ColumnString
|
||||
ProductID postgres.ColumnString
|
||||
ExpectedAmount postgres.ColumnInteger
|
||||
Currency postgres.ColumnString
|
||||
Origin postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
Provider postgres.ColumnString
|
||||
ProviderPaymentID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type OrdersTable struct {
|
||||
ordersTable
|
||||
|
||||
EXCLUDED ordersTable
|
||||
}
|
||||
|
||||
// AS creates new OrdersTable with assigned alias
|
||||
func (a OrdersTable) AS(alias string) *OrdersTable {
|
||||
return newOrdersTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new OrdersTable with assigned schema name
|
||||
func (a OrdersTable) FromSchema(schemaName string) *OrdersTable {
|
||||
return newOrdersTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new OrdersTable with assigned table prefix
|
||||
func (a OrdersTable) WithPrefix(prefix string) *OrdersTable {
|
||||
return newOrdersTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new OrdersTable with assigned table suffix
|
||||
func (a OrdersTable) WithSuffix(suffix string) *OrdersTable {
|
||||
return newOrdersTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newOrdersTable(schemaName, tableName, alias string) *OrdersTable {
|
||||
return &OrdersTable{
|
||||
ordersTable: newOrdersTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newOrdersTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
||||
var (
|
||||
OrderIDColumn = postgres.StringColumn("order_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
PlatformColumn = postgres.StringColumn("platform")
|
||||
ProductIDColumn = postgres.StringColumn("product_id")
|
||||
ExpectedAmountColumn = postgres.IntegerColumn("expected_amount")
|
||||
CurrencyColumn = postgres.StringColumn("currency")
|
||||
OriginColumn = postgres.StringColumn("origin")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
ProviderColumn = postgres.StringColumn("provider")
|
||||
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return ordersTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
OrderID: OrderIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
Platform: PlatformColumn,
|
||||
ProductID: ProductIDColumn,
|
||||
ExpectedAmount: ExpectedAmountColumn,
|
||||
Currency: CurrencyColumn,
|
||||
Origin: OriginColumn,
|
||||
Status: StatusColumn,
|
||||
Provider: ProviderColumn,
|
||||
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var PaymentEvents = newPaymentEventsTable("payments", "payment_events", "")
|
||||
|
||||
type paymentEventsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
EventID postgres.ColumnString
|
||||
AccountID postgres.ColumnString
|
||||
OrderID postgres.ColumnString
|
||||
Type postgres.ColumnString
|
||||
Payload postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
DispatchedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type PaymentEventsTable struct {
|
||||
paymentEventsTable
|
||||
|
||||
EXCLUDED paymentEventsTable
|
||||
}
|
||||
|
||||
// AS creates new PaymentEventsTable with assigned alias
|
||||
func (a PaymentEventsTable) AS(alias string) *PaymentEventsTable {
|
||||
return newPaymentEventsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new PaymentEventsTable with assigned schema name
|
||||
func (a PaymentEventsTable) FromSchema(schemaName string) *PaymentEventsTable {
|
||||
return newPaymentEventsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new PaymentEventsTable with assigned table prefix
|
||||
func (a PaymentEventsTable) WithPrefix(prefix string) *PaymentEventsTable {
|
||||
return newPaymentEventsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new PaymentEventsTable with assigned table suffix
|
||||
func (a PaymentEventsTable) WithSuffix(suffix string) *PaymentEventsTable {
|
||||
return newPaymentEventsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newPaymentEventsTable(schemaName, tableName, alias string) *PaymentEventsTable {
|
||||
return &PaymentEventsTable{
|
||||
paymentEventsTable: newPaymentEventsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newPaymentEventsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newPaymentEventsTableImpl(schemaName, tableName, alias string) paymentEventsTable {
|
||||
var (
|
||||
EventIDColumn = postgres.StringColumn("event_id")
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
OrderIDColumn = postgres.StringColumn("order_id")
|
||||
TypeColumn = postgres.StringColumn("type")
|
||||
PayloadColumn = postgres.StringColumn("payload")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
DispatchedAtColumn = postgres.TimestampzColumn("dispatched_at")
|
||||
allColumns = postgres.ColumnList{EventIDColumn, AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{AccountIDColumn, OrderIDColumn, TypeColumn, PayloadColumn, CreatedAtColumn, DispatchedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||
)
|
||||
|
||||
return paymentEventsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
EventID: EventIDColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
OrderID: OrderIDColumn,
|
||||
Type: TypeColumn,
|
||||
Payload: PayloadColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
DispatchedAt: DispatchedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var Product = newProductTable("payments", "product", "")
|
||||
|
||||
type productTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ProductID postgres.ColumnString
|
||||
Title postgres.ColumnString
|
||||
Active postgres.ColumnBool
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ProductTable struct {
|
||||
productTable
|
||||
|
||||
EXCLUDED productTable
|
||||
}
|
||||
|
||||
// AS creates new ProductTable with assigned alias
|
||||
func (a ProductTable) AS(alias string) *ProductTable {
|
||||
return newProductTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ProductTable with assigned schema name
|
||||
func (a ProductTable) FromSchema(schemaName string) *ProductTable {
|
||||
return newProductTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ProductTable with assigned table prefix
|
||||
func (a ProductTable) WithPrefix(prefix string) *ProductTable {
|
||||
return newProductTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ProductTable with assigned table suffix
|
||||
func (a ProductTable) WithSuffix(suffix string) *ProductTable {
|
||||
return newProductTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newProductTable(schemaName, tableName, alias string) *ProductTable {
|
||||
return &ProductTable{
|
||||
productTable: newProductTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newProductTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newProductTableImpl(schemaName, tableName, alias string) productTable {
|
||||
var (
|
||||
ProductIDColumn = postgres.StringColumn("product_id")
|
||||
TitleColumn = postgres.StringColumn("title")
|
||||
ActiveColumn = postgres.BoolColumn("active")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{ProductIDColumn, TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{TitleColumn, ActiveColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
)
|
||||
|
||||
return productTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ProductID: ProductIDColumn,
|
||||
Title: TitleColumn,
|
||||
Active: ActiveColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var ProductItem = newProductItemTable("payments", "product_item", "")
|
||||
|
||||
type productItemTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ProductID postgres.ColumnString
|
||||
AtomType postgres.ColumnString
|
||||
Quantity postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ProductItemTable struct {
|
||||
productItemTable
|
||||
|
||||
EXCLUDED productItemTable
|
||||
}
|
||||
|
||||
// AS creates new ProductItemTable with assigned alias
|
||||
func (a ProductItemTable) AS(alias string) *ProductItemTable {
|
||||
return newProductItemTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ProductItemTable with assigned schema name
|
||||
func (a ProductItemTable) FromSchema(schemaName string) *ProductItemTable {
|
||||
return newProductItemTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ProductItemTable with assigned table prefix
|
||||
func (a ProductItemTable) WithPrefix(prefix string) *ProductItemTable {
|
||||
return newProductItemTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ProductItemTable with assigned table suffix
|
||||
func (a ProductItemTable) WithSuffix(suffix string) *ProductItemTable {
|
||||
return newProductItemTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newProductItemTable(schemaName, tableName, alias string) *ProductItemTable {
|
||||
return &ProductItemTable{
|
||||
productItemTable: newProductItemTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newProductItemTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newProductItemTableImpl(schemaName, tableName, alias string) productItemTable {
|
||||
var (
|
||||
ProductIDColumn = postgres.StringColumn("product_id")
|
||||
AtomTypeColumn = postgres.StringColumn("atom_type")
|
||||
QuantityColumn = postgres.IntegerColumn("quantity")
|
||||
allColumns = postgres.ColumnList{ProductIDColumn, AtomTypeColumn, QuantityColumn}
|
||||
mutableColumns = postgres.ColumnList{QuantityColumn}
|
||||
defaultColumns = postgres.ColumnList{}
|
||||
)
|
||||
|
||||
return productItemTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ProductID: ProductIDColumn,
|
||||
AtomType: AtomTypeColumn,
|
||||
Quantity: QuantityColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var ProductPrice = newProductPriceTable("payments", "product_price", "")
|
||||
|
||||
type productPriceTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ProductID postgres.ColumnString
|
||||
Method postgres.ColumnString
|
||||
Currency postgres.ColumnString
|
||||
Amount postgres.ColumnInteger
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type ProductPriceTable struct {
|
||||
productPriceTable
|
||||
|
||||
EXCLUDED productPriceTable
|
||||
}
|
||||
|
||||
// AS creates new ProductPriceTable with assigned alias
|
||||
func (a ProductPriceTable) AS(alias string) *ProductPriceTable {
|
||||
return newProductPriceTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new ProductPriceTable with assigned schema name
|
||||
func (a ProductPriceTable) FromSchema(schemaName string) *ProductPriceTable {
|
||||
return newProductPriceTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new ProductPriceTable with assigned table prefix
|
||||
func (a ProductPriceTable) WithPrefix(prefix string) *ProductPriceTable {
|
||||
return newProductPriceTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new ProductPriceTable with assigned table suffix
|
||||
func (a ProductPriceTable) WithSuffix(suffix string) *ProductPriceTable {
|
||||
return newProductPriceTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newProductPriceTable(schemaName, tableName, alias string) *ProductPriceTable {
|
||||
return &ProductPriceTable{
|
||||
productPriceTable: newProductPriceTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newProductPriceTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newProductPriceTableImpl(schemaName, tableName, alias string) productPriceTable {
|
||||
var (
|
||||
ProductIDColumn = postgres.StringColumn("product_id")
|
||||
MethodColumn = postgres.StringColumn("method")
|
||||
CurrencyColumn = postgres.StringColumn("currency")
|
||||
AmountColumn = postgres.IntegerColumn("amount")
|
||||
allColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
|
||||
mutableColumns = postgres.ColumnList{ProductIDColumn, MethodColumn, CurrencyColumn, AmountColumn}
|
||||
defaultColumns = postgres.ColumnList{}
|
||||
)
|
||||
|
||||
return productPriceTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ProductID: ProductIDColumn,
|
||||
Method: MethodColumn,
|
||||
Currency: CurrencyColumn,
|
||||
Amount: AmountColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
Balances = Balances.FromSchema(schema)
|
||||
Benefits = Benefits.FromSchema(schema)
|
||||
CatalogAtom = CatalogAtom.FromSchema(schema)
|
||||
Config = Config.FromSchema(schema)
|
||||
Ledger = Ledger.FromSchema(schema)
|
||||
Orders = Orders.FromSchema(schema)
|
||||
PaymentEvents = PaymentEvents.FromSchema(schema)
|
||||
Product = Product.FromSchema(schema)
|
||||
ProductItem = ProductItem.FromSchema(schema)
|
||||
ProductPrice = ProductPrice.FromSchema(schema)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
-- Payments data foundation: a self-contained `payments` schema for the in-game
|
||||
-- currency, wallets, benefits, catalog, orders and the append-only operations
|
||||
-- ledger. Nothing is wired to real money yet — this is the substrate the rest of
|
||||
-- the payments domain builds on. Mechanics live in docs/PAYMENTS.md.
|
||||
--
|
||||
-- Isolation. The schema is confined to a dedicated NOLOGIN role that holds ALL
|
||||
-- privileges on `payments.*` and none on `backend.*`; the backend application
|
||||
-- keeps its single (superuser) pool, so the DB grants are a stepping-stone to a
|
||||
-- future separate login/process rather than a runtime wall. The runtime wall is
|
||||
-- code-level: only the payments package reaches these tables (an import-boundary
|
||||
-- test guards it). There is deliberately NO cross-schema foreign key to
|
||||
-- `backend.accounts`: `account_id` is a plain uuid, kept referentially honest in
|
||||
-- code and joined to the tombstoned account / retained-identities dossier by the
|
||||
-- stable account id — which keeps the domain extractable into its own database.
|
||||
--
|
||||
-- The ledger is append-only, enforced by a trigger (a superuser bypasses table
|
||||
-- privileges, so a REVOKE would not bind the application). Money is stored as an
|
||||
-- integer amount in the currency's minor units (RUB kopecks; Votes/Stars/chips
|
||||
-- are whole units, scale 1), never a float — integer-currency integrality is
|
||||
-- therefore structural.
|
||||
--
|
||||
-- Legacy note: backend.accounts.hint_balance and backend.accounts.paid_account
|
||||
-- are superseded by the segmented model here and are DEPRECATED. They are NOT
|
||||
-- dropped in this expand phase — reads still use them until the currency core
|
||||
-- flips them, and the DROP is a later contract-phase migration (image rollback
|
||||
-- must stay DB-safe). Neither was ever set in production.
|
||||
--
|
||||
-- Expand-contract: everything here is additive in its own schema, so a backend
|
||||
-- image rollback stays DB-safe (older code simply ignores the new schema).
|
||||
|
||||
-- +goose Up
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS payments;
|
||||
|
||||
-- +goose StatementBegin
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN
|
||||
CREATE ROLE payments NOLOGIN;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
GRANT USAGE ON SCHEMA payments TO payments;
|
||||
|
||||
-- Base value types (atoms) a product is composed of. A fixed, code-known set.
|
||||
CREATE TABLE payments.catalog_atom (
|
||||
atom_type text NOT NULL,
|
||||
CONSTRAINT catalog_atom_pkey PRIMARY KEY (atom_type),
|
||||
CONSTRAINT catalog_atom_type_chk
|
||||
CHECK ((atom_type = ANY (ARRAY['chips'::text, 'hints'::text, 'noads_days'::text, 'tournament'::text])))
|
||||
);
|
||||
|
||||
INSERT INTO payments.catalog_atom (atom_type) VALUES
|
||||
('chips'), ('hints'), ('noads_days'), ('tournament');
|
||||
|
||||
-- A sellable product: a set of atoms at a price. Soft-deleted (deactivated),
|
||||
-- never removed, so historical orders/ledger keep resolving it.
|
||||
CREATE TABLE payments.product (
|
||||
product_id uuid NOT NULL,
|
||||
title text DEFAULT ''::text NOT NULL,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT product_pkey PRIMARY KEY (product_id)
|
||||
);
|
||||
|
||||
-- The atom composition of a product (e.g. 250 hints + 30 no-ads days).
|
||||
CREATE TABLE payments.product_item (
|
||||
product_id uuid NOT NULL,
|
||||
atom_type text NOT NULL,
|
||||
quantity integer NOT NULL,
|
||||
CONSTRAINT product_item_pkey PRIMARY KEY (product_id, atom_type),
|
||||
CONSTRAINT product_item_product_fkey FOREIGN KEY (product_id)
|
||||
REFERENCES payments.product (product_id) ON DELETE CASCADE,
|
||||
CONSTRAINT product_item_atom_fkey FOREIGN KEY (atom_type)
|
||||
REFERENCES payments.catalog_atom (atom_type),
|
||||
CONSTRAINT product_item_quantity_chk CHECK ((quantity > 0))
|
||||
);
|
||||
|
||||
-- Price of a product. A chip pack carries a money price per method
|
||||
-- (multi-currency Votes/Stars/RUB); a value carries a single chips price
|
||||
-- (currency='CHIP', method NULL). `amount` is an integer in the currency's
|
||||
-- minor units (RUB kopecks; Votes/Stars/chips whole, scale 1).
|
||||
CREATE TABLE payments.product_price (
|
||||
product_id uuid NOT NULL,
|
||||
method text,
|
||||
currency text NOT NULL,
|
||||
amount bigint NOT NULL,
|
||||
CONSTRAINT product_price_product_fkey FOREIGN KEY (product_id)
|
||||
REFERENCES payments.product (product_id) ON DELETE CASCADE,
|
||||
CONSTRAINT product_price_method_chk
|
||||
CHECK ((method IS NULL) OR (method = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||
CONSTRAINT product_price_currency_chk
|
||||
CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))),
|
||||
CONSTRAINT product_price_amount_chk CHECK ((amount >= 0))
|
||||
);
|
||||
|
||||
-- One price row per (product, method, currency); NULL method (a value's chip
|
||||
-- price) collapses to one row via the COALESCE key.
|
||||
CREATE UNIQUE INDEX product_price_unique_idx
|
||||
ON payments.product_price (product_id, COALESCE(method, ''::text), currency);
|
||||
|
||||
-- A pre-created purchase intent. `expected_amount` is in `currency` minor units.
|
||||
CREATE TABLE payments.orders (
|
||||
order_id uuid NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
platform text NOT NULL,
|
||||
product_id uuid NOT NULL,
|
||||
expected_amount bigint NOT NULL,
|
||||
currency text NOT NULL,
|
||||
origin text NOT NULL,
|
||||
status text DEFAULT 'pending'::text NOT NULL,
|
||||
provider text,
|
||||
provider_payment_id text,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT orders_pkey PRIMARY KEY (order_id),
|
||||
CONSTRAINT orders_product_fkey FOREIGN KEY (product_id)
|
||||
REFERENCES payments.product (product_id),
|
||||
CONSTRAINT orders_status_chk
|
||||
CHECK ((status = ANY (ARRAY['pending'::text, 'paid'::text, 'expired'::text]))),
|
||||
CONSTRAINT orders_origin_chk
|
||||
CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||
CONSTRAINT orders_currency_chk
|
||||
CHECK ((currency = ANY (ARRAY['RUB'::text, 'VOTE'::text, 'XTR'::text, 'CHIP'::text]))),
|
||||
CONSTRAINT orders_expected_amount_chk CHECK ((expected_amount >= 0))
|
||||
);
|
||||
|
||||
-- The pending-expiry sweep scans (status, created_at).
|
||||
CREATE INDEX orders_status_created_at_idx ON payments.orders (status, created_at);
|
||||
|
||||
-- The append-only operations ledger: every fund / spend / admin_grant / refund.
|
||||
-- `chips_delta` is signed (0 for a grant, which credits values not chips);
|
||||
-- `snapshot` records the sold atoms + price for a spend/grant. Never updated or
|
||||
-- deleted (a trigger enforces it); a reversal is a new `refund` row.
|
||||
CREATE TABLE payments.ledger (
|
||||
ledger_id uuid NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
kind text NOT NULL,
|
||||
source text,
|
||||
origin text,
|
||||
chips_delta integer DEFAULT 0 NOT NULL,
|
||||
product_id uuid,
|
||||
order_id uuid,
|
||||
provider text,
|
||||
provider_payment_id text,
|
||||
snapshot jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT ledger_pkey PRIMARY KEY (ledger_id),
|
||||
CONSTRAINT ledger_product_fkey FOREIGN KEY (product_id)
|
||||
REFERENCES payments.product (product_id),
|
||||
CONSTRAINT ledger_order_fkey FOREIGN KEY (order_id)
|
||||
REFERENCES payments.orders (order_id),
|
||||
CONSTRAINT ledger_kind_chk
|
||||
CHECK ((kind = ANY (ARRAY['fund'::text, 'spend'::text, 'admin_grant'::text, 'refund'::text]))),
|
||||
CONSTRAINT ledger_source_chk
|
||||
CHECK ((source IS NULL) OR (source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||
CONSTRAINT ledger_origin_chk
|
||||
CHECK ((origin IS NULL) OR (origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text])))
|
||||
);
|
||||
|
||||
-- Idempotency key: a provider callback credits at most once. Partial so rows
|
||||
-- without a provider payment id (spend/admin_grant) are unconstrained.
|
||||
CREATE UNIQUE INDEX ledger_provider_payment_idx
|
||||
ON payments.ledger (provider, provider_payment_id)
|
||||
WHERE provider_payment_id IS NOT NULL;
|
||||
|
||||
-- +goose StatementBegin
|
||||
CREATE FUNCTION payments.ledger_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'payments.ledger is append-only: % denied', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
|
||||
CREATE TRIGGER ledger_no_mutation
|
||||
BEFORE UPDATE OR DELETE ON payments.ledger
|
||||
FOR EACH ROW EXECUTE FUNCTION payments.ledger_append_only();
|
||||
|
||||
-- Materialised chip balance, segmented by funding source. A fast cache of the
|
||||
-- ledger, recomputable from it; created lazily on first fund (up to three rows
|
||||
-- per account, absent = zero).
|
||||
CREATE TABLE payments.balances (
|
||||
account_id uuid NOT NULL,
|
||||
source text NOT NULL,
|
||||
chips integer DEFAULT 0 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT balances_pkey PRIMARY KEY (account_id, source),
|
||||
CONSTRAINT balances_source_chk
|
||||
CHECK ((source = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||
CONSTRAINT balances_chips_chk CHECK ((chips >= 0))
|
||||
);
|
||||
|
||||
-- Materialised benefits, segmented by the purchase origin. no-ads is a duration
|
||||
-- (ads_paid_until) plus a perpetual override (ads_forever); hints is a count.
|
||||
-- Created lazily on first grant/spend.
|
||||
CREATE TABLE payments.benefits (
|
||||
account_id uuid NOT NULL,
|
||||
origin text NOT NULL,
|
||||
ads_paid_until timestamp with time zone,
|
||||
ads_forever boolean DEFAULT false NOT NULL,
|
||||
hints integer DEFAULT 0 NOT NULL,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT benefits_pkey PRIMARY KEY (account_id, origin),
|
||||
CONSTRAINT benefits_origin_chk
|
||||
CHECK ((origin = ANY (ARRAY['vk'::text, 'telegram'::text, 'direct'::text]))),
|
||||
CONSTRAINT benefits_hints_chk CHECK ((hints >= 0))
|
||||
);
|
||||
|
||||
-- The single-row tunable config (durations in whole seconds — go-jet stringifies
|
||||
-- interval; payouts as counts). Edited in the admin console, no release needed.
|
||||
CREATE TABLE payments.config (
|
||||
only_row boolean DEFAULT true NOT NULL,
|
||||
rewarded_payout_chips integer DEFAULT 0 NOT NULL,
|
||||
cooldown_global_seconds integer DEFAULT 300 NOT NULL,
|
||||
cooldown_vs_ai_seconds integer DEFAULT 1800 NOT NULL,
|
||||
cooldown_hint_seconds integer DEFAULT 60 NOT NULL,
|
||||
order_ttl_seconds integer DEFAULT 1800 NOT NULL,
|
||||
CONSTRAINT config_pkey PRIMARY KEY (only_row),
|
||||
CONSTRAINT config_single_row_chk CHECK (only_row)
|
||||
);
|
||||
|
||||
INSERT INTO payments.config (only_row) VALUES (true);
|
||||
|
||||
-- Payment lifecycle events for the dispatcher (live stream / botlink / email).
|
||||
CREATE TABLE payments.payment_events (
|
||||
event_id uuid NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
order_id uuid,
|
||||
type text NOT NULL,
|
||||
payload jsonb,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
dispatched_at timestamp with time zone,
|
||||
CONSTRAINT payment_events_pkey PRIMARY KEY (event_id),
|
||||
CONSTRAINT payment_events_order_fkey FOREIGN KEY (order_id)
|
||||
REFERENCES payments.orders (order_id),
|
||||
CONSTRAINT payment_events_type_chk
|
||||
CHECK ((type = ANY (ARRAY['succeeded'::text, 'failed'::text, 'refunded'::text])))
|
||||
);
|
||||
|
||||
CREATE INDEX payment_events_dispatch_idx
|
||||
ON payments.payment_events (dispatched_at)
|
||||
WHERE dispatched_at IS NULL;
|
||||
|
||||
-- Confine the payments role to this schema: ALL on its tables, nothing on
|
||||
-- backend. New tables added later inherit the grant via default privileges.
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA payments TO payments;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA payments GRANT ALL ON TABLES TO payments;
|
||||
|
||||
-- +goose Down
|
||||
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA payments REVOKE ALL ON TABLES FROM payments;
|
||||
DROP SCHEMA IF EXISTS payments CASCADE;
|
||||
|
||||
-- +goose StatementBegin
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'payments') THEN
|
||||
EXECUTE 'DROP OWNED BY payments';
|
||||
DROP ROLE payments;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
-- +goose StatementEnd
|
||||
@@ -1,146 +0,0 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"scrabble/backend/internal/engine"
|
||||
)
|
||||
|
||||
// The client-side offline robot (ui/src/lib/robot) reproduces the robot's move choice
|
||||
// so a local vs_ai game plays the same way as the server. These golden fixtures pin that
|
||||
// TS port to the real Go strategy. Being in-package, this emitter can exercise the
|
||||
// unexported mix / playToWin / deviates / selectMove that the port mirrors.
|
||||
|
||||
type mixCase struct {
|
||||
Seed string `json:"seed"`
|
||||
Salt string `json:"salt"`
|
||||
Nums []int `json:"nums"`
|
||||
Value string `json:"value"` // the mix() uint64, as a decimal string (exceeds JS safe ints)
|
||||
}
|
||||
|
||||
type decisionCase struct {
|
||||
Seed string `json:"seed"`
|
||||
MoveCount int `json:"moveCount"`
|
||||
MyScore int `json:"myScore"`
|
||||
OppScore int `json:"oppScore"`
|
||||
CandScores []int `json:"candScores"`
|
||||
RackLen int `json:"rackLen"`
|
||||
BagLen int `json:"bagLen"`
|
||||
PlayToWin bool `json:"playToWin"`
|
||||
Win bool `json:"win"` // the per-turn intent after the deviate flip
|
||||
Kind string `json:"kind"`
|
||||
Index int `json:"index"` // chosen candidate index for a play, else -1
|
||||
}
|
||||
|
||||
type strategyFixture struct {
|
||||
PlayToWinPercent int `json:"playToWinPercent"`
|
||||
Mix []mixCase `json:"mix"`
|
||||
Decisions []decisionCase `json:"decisions"`
|
||||
}
|
||||
|
||||
// scenario is one selectMove situation exercised across several seeds.
|
||||
type scenario struct {
|
||||
moveCount, myScore, oppScore, rackLen, bagLen int
|
||||
cands []int
|
||||
}
|
||||
|
||||
// TestEmitStrategyFixtures regenerates ui/src/lib/robot/testdata/strategy.json. It is
|
||||
// gated by EMIT_STRATEGY_FIXTURES so a normal `go test` skips it; the committed output is
|
||||
// what the TS parity test consumes. Regenerate with:
|
||||
//
|
||||
// EMIT_STRATEGY_FIXTURES=1 go test ./backend/internal/robot -run TestEmitStrategyFixtures
|
||||
func TestEmitStrategyFixtures(t *testing.T) {
|
||||
if os.Getenv("EMIT_STRATEGY_FIXTURES") == "" {
|
||||
t.Skip("set EMIT_STRATEGY_FIXTURES=1 to regenerate ui/src/lib/robot/testdata/strategy.json")
|
||||
}
|
||||
|
||||
seeds := []int64{1, 42, -7, 1000003, 9999999999, 123456789, -123456789}
|
||||
|
||||
mixCases := []mixCase{}
|
||||
addMix := func(seed int64, salt string, nums ...int) {
|
||||
mixCases = append(mixCases, mixCase{
|
||||
Seed: strconv.FormatInt(seed, 10),
|
||||
Salt: salt,
|
||||
Nums: append([]int{}, nums...),
|
||||
Value: strconv.FormatUint(mix(seed, salt, nums...), 10),
|
||||
})
|
||||
}
|
||||
for _, sd := range seeds {
|
||||
addMix(sd, "win")
|
||||
addMix(sd, "deviate", 0)
|
||||
addMix(sd, "deviate", 7)
|
||||
}
|
||||
|
||||
scenarios := []scenario{
|
||||
{3, 40, 55, 7, 20, []int{30, 12, 8}}, // behind, mid-game
|
||||
{10, 120, 90, 7, 8, []int{25, 18, 5}}, // ahead, late
|
||||
{1, 0, 0, 7, 30, []int{60, 30, 15, 7}}, // first move, big spread
|
||||
{20, 200, 40, 5, 2, []int{50, 20}}, // big lead, bag near empty (no deviate)
|
||||
{5, 30, 30, 7, 14, []int{20, 20, 20}}, // equal margins (tie-break)
|
||||
{8, 50, 60, 7, 20, []int{}}, // no play, bag can refill -> exchange
|
||||
{8, 50, 60, 3, 2, []int{}}, // no play, bag can't refill -> pass
|
||||
{15, 300, 10, 7, 6, []int{80, 40, 10}}, // overshoots the band -> nearest to +30
|
||||
}
|
||||
|
||||
decisions := []decisionCase{}
|
||||
for _, sd := range seeds {
|
||||
for _, sc := range scenarios {
|
||||
cands := make([]engine.MoveRecord, len(sc.cands))
|
||||
for i, s := range sc.cands {
|
||||
cands[i] = engine.MoveRecord{Score: s, Player: i}
|
||||
}
|
||||
rack := make([]string, sc.rackLen)
|
||||
for i := range rack {
|
||||
rack[i] = "a"
|
||||
}
|
||||
|
||||
ptw := playToWin(sd)
|
||||
win := ptw
|
||||
if deviates(sd, sc.moveCount, sc.bagLen) {
|
||||
win = !win
|
||||
}
|
||||
d := selectMove(cands, sc.myScore, sc.oppScore, win, defaultBand, rack, sc.bagLen)
|
||||
|
||||
kind, index := "pass", -1
|
||||
switch d.kind {
|
||||
case decidePlay:
|
||||
kind, index = "play", d.move.Player
|
||||
case decideExchange:
|
||||
kind = "exchange"
|
||||
}
|
||||
|
||||
decisions = append(decisions, decisionCase{
|
||||
Seed: strconv.FormatInt(sd, 10),
|
||||
MoveCount: sc.moveCount,
|
||||
MyScore: sc.myScore,
|
||||
OppScore: sc.oppScore,
|
||||
CandScores: append([]int{}, sc.cands...),
|
||||
RackLen: sc.rackLen,
|
||||
BagLen: sc.bagLen,
|
||||
PlayToWin: ptw,
|
||||
Win: win,
|
||||
Kind: kind,
|
||||
Index: index,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fx := strategyFixture{PlayToWinPercent: playToWinPercent, Mix: mixCases, Decisions: decisions}
|
||||
dir := filepath.Join("..", "..", "..", "ui", "src", "lib", "robot", "testdata")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
data, err := json.MarshalIndent(fx, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "strategy.json")
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
t.Logf("wrote %s (%d mix, %d decisions)", path, len(mixCases), len(decisions))
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/ads"
|
||||
"scrabble/backend/internal/engine"
|
||||
"scrabble/backend/internal/notify"
|
||||
)
|
||||
|
||||
@@ -57,34 +56,9 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
||||
r := profileResponseFor(acc)
|
||||
r.Banner = s.bannerFor(ctx, acc)
|
||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||
r.DictVersions = s.currentDictVersions()
|
||||
return r
|
||||
}
|
||||
|
||||
// currentDictVersions reports the current dictionary version of every game variant with a
|
||||
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
|
||||
// so an offline-capable client preloads the matching dawg per variant off the cold-start
|
||||
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
|
||||
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
|
||||
func (s *Server) currentDictVersions() []dictVersion {
|
||||
if s.registry == nil {
|
||||
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
|
||||
}
|
||||
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
|
||||
out := make([]dictVersion, 0, len(variants))
|
||||
for _, v := range variants {
|
||||
version, _, err := s.registry.Latest(v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, dictVersion{Variant: v.String(), Version: version})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
|
||||
// flags from the account's identities, so the client offers the right link / unlink /
|
||||
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
||||
|
||||
@@ -63,20 +63,6 @@ type profileResponse struct {
|
||||
Email string `json:"email"`
|
||||
TelegramLinked bool `json:"telegram_linked"`
|
||||
VkLinked bool `json:"vk_linked"`
|
||||
// DictVersions lists the current dictionary version per game variant (engine.Variant
|
||||
// stable labels). An installed PWA preparing for offline play reads it to preload the
|
||||
// right dawg per enabled variant off this cold-start response, without an extra request.
|
||||
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
|
||||
// for callers that build the DTO without a Server. See Server.profileResponse.
|
||||
DictVersions []dictVersion `json:"dict_versions,omitempty"`
|
||||
}
|
||||
|
||||
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
|
||||
// dictionary version. profileResponse.DictVersions carries the set so an offline-capable
|
||||
// client preloads the matching dawg per variant.
|
||||
type dictVersion struct {
|
||||
Variant string `json:"variant"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// tileDTO is one placed (or to-place) tile.
|
||||
@@ -163,16 +149,13 @@ type alphabetEntryDTO struct {
|
||||
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (a
|
||||
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
||||
type stateDTO struct {
|
||||
Game gameDTO `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
||||
// game / first move / not your turn). The client anchors a monotonic countdown to it.
|
||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||
Game gameDTO `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||
}
|
||||
|
||||
// matchDTO reports whether the caller has been paired into a game.
|
||||
@@ -318,13 +301,12 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
|
||||
return stateDTO{}, err
|
||||
}
|
||||
dto := stateDTO{
|
||||
Game: gameDTOFromGame(v.Game),
|
||||
Seat: v.Seat,
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
||||
Game: gameDTOFromGame(v.Game),
|
||||
Seat: v.Seat,
|
||||
Rack: rack,
|
||||
BagLen: v.BagLen,
|
||||
HintsRemaining: v.HintsRemaining,
|
||||
WalletBalance: v.WalletBalance,
|
||||
}
|
||||
if includeAlphabet {
|
||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||
|
||||
@@ -239,9 +239,6 @@ func statusForError(err error) (int, string) {
|
||||
return http.StatusConflict, "no_hint_available"
|
||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||
return http.StatusConflict, "hint_unavailable"
|
||||
case errors.Is(err, game.ErrHintLocked):
|
||||
// A vs_ai idle hint is still gated (the server-clock backstop); the client shows the lock.
|
||||
return http.StatusConflict, "hint_locked"
|
||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||
return http.StatusUnprocessableEntity, "illegal_play"
|
||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||
|
||||
@@ -28,7 +28,6 @@ import (
|
||||
"scrabble/backend/internal/link"
|
||||
"scrabble/backend/internal/lobby"
|
||||
"scrabble/backend/internal/notify"
|
||||
"scrabble/backend/internal/payments"
|
||||
"scrabble/backend/internal/ratewatch"
|
||||
"scrabble/backend/internal/render"
|
||||
"scrabble/backend/internal/session"
|
||||
@@ -94,11 +93,6 @@ type Deps struct {
|
||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||
// omits the banner block and disables the banner console.
|
||||
Ads *ads.Service
|
||||
// Payments is the in-game currency domain service (wallet, benefits, catalog).
|
||||
// The data-foundation layer exposes only a reachability check; its user and
|
||||
// console routes are registered when the wallet surface lands. A nil Payments
|
||||
// omits them.
|
||||
Payments *payments.Service
|
||||
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||
// them (notify.Nop).
|
||||
@@ -135,7 +129,6 @@ type Server struct {
|
||||
ratewatch *ratewatch.Watch
|
||||
banview *banview.View
|
||||
ads *ads.Service
|
||||
payments *payments.Service
|
||||
notifier notify.Publisher
|
||||
console *adminconsole.Renderer
|
||||
exportKey []byte
|
||||
@@ -188,7 +181,6 @@ func New(addr string, deps Deps) *Server {
|
||||
ratewatch: deps.RateWatch,
|
||||
banview: deps.BanView,
|
||||
ads: deps.Ads,
|
||||
payments: deps.Payments,
|
||||
notifier: notifier,
|
||||
renderer: deps.Renderer,
|
||||
http: &http.Server{Addr: addr, Handler: engine},
|
||||
|
||||
+6
-68
@@ -1238,74 +1238,12 @@ separate site; the SPA shell is `noindex`, and the landing always boots in Russi
|
||||
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
||||
`assets/icons/`, same tile design as the VK loader). On the web (`/app/`) the SPA is an installable **PWA**:
|
||||
`manifest.webmanifest` ships unhashed from `ui/public/`, while the **service worker** (`sw.js`) is
|
||||
built from `ui/src/sw.ts` by **vite-plugin-pwa** (injectManifest strategy) — the client registers it
|
||||
**web-only**, never inside a Mini App or the mock build (the plugin is disabled there entirely). It
|
||||
**precaches the app shell and the hashed assets** (Workbox) so an installed PWA cold-launches with
|
||||
no network. Shell **navigations are network-first** — the fresh `no-cache` shell is fetched from the
|
||||
server (so a new deploy is picked up on the very next launch, not the one after), falling back to the
|
||||
precached shell only when the network is unreachable or too slow; the immutable hashed assets stay
|
||||
cache-first, and the hash router resolves the route client-side. This navigation route is registered
|
||||
**before** the precache route on purpose: Workbox matches in registration order, and the precache
|
||||
route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve the shell
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||
current turn, **exclude** a player (a forced seat resign, `resignSeat`) or **terminate** the game
|
||||
(deleted, no winner/loser); each seat may also carry its own **optional PIN** that withholds its rack
|
||||
(the board stays visible — only the tray is gated) until it is entered, so players pass the device
|
||||
around freely and lock a seat only against a distrusted tablemate. The unlock is **per turn**
|
||||
(re-locks on advance). PINs are a **social lock, not cryptography** — a salted SHA-256 kept only in
|
||||
the device record (`lib/pin`); a 4-digit PIN is trivially brute-forceable and the racks live in
|
||||
client memory regardless, so they defeat a casual glance, not a determined peek. A hotseat game is
|
||||
**not `vs_ai`**: hints (the freed control becomes the host button) and chat/self-resign are gone, and
|
||||
its lobby card — active *and* finished — is master-PIN-gated to delete (so the last mover cannot
|
||||
instantly wipe it); a naturally finished game is saved with its result like any local game. A `vs_ai`
|
||||
hint (online and offline alike) is unlimited and wallet-free but idle-gated
|
||||
(unlocked ~30 min into a stuck turn), and counts toward no hint statistic. The **source** reports the
|
||||
**seconds left** (`hint_unlock_left_seconds` on the game view) — computed by the **backend from the
|
||||
server clock** online (which also enforces the gate, returning `hint_locked` for an early request),
|
||||
and by the offline source from the device clock (persisted, capped at the window). The **client
|
||||
anchors a MONOTONIC countdown** (`performance.now()`, `lib/hints`) to that seconds-left when it lands
|
||||
(on load, and to the full window when the robot moves), so a client clock change cannot skew it, and
|
||||
a relaunch re-reads a fresh value so the wait is not forgotten. To have data ready before the switch, the **Profile advertises the current dictionary
|
||||
version per variant** (`dict_versions`,
|
||||
filled from the registry on the existing cold-start profile request — no extra round-trip), and an
|
||||
eligible installed PWA (standalone web + confirmed email) **background-preloads** those dictionaries
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||
while offline. The gateway registers the `.webmanifest` MIME type
|
||||
`manifest.webmanifest` and an install-only **service worker** (`sw.js`) ship unhashed from
|
||||
`ui/public/`, and the client registers the worker **web-only** — never inside a Mini App or the
|
||||
mock build. The worker intercepts only top-level navigations (network-first with a cached-shell
|
||||
fallback), leaving `/assets/*` and the Connect stream untouched; it exists to satisfy Chromium's
|
||||
installability requirement (a registered SW, needed for install on Android) and is the single
|
||||
growth point for a future opt-in offline mode. The gateway registers the `.webmanifest` MIME type
|
||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
|
||||
+1
-48
@@ -215,16 +215,7 @@ unlimited and offers a complaint on any result; for a word it finds, it also lin
|
||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||
English) to look it up. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||
anti-frustration aid: one unlocks only after the player has been **stuck ~30 minutes
|
||||
on a turn** (timed from the robot's last move; the very first move, before the robot
|
||||
has played, is exempt). While gated the hint button carries a small **🔒 lock** and a
|
||||
tap shows how long remains; the lock lifts live at the mark. The same gate applies **online and
|
||||
offline**: the countdown runs on a **steady in-app timer**, not the device clock, so changing the
|
||||
clock cannot skew it, and a relaunch re-reads the remaining time so a stuck turn is not forgotten.
|
||||
Online the **server enforces** it from its own clock (which the player cannot touch); a vs_ai hint
|
||||
counts toward no hint statistic. The game ends when the
|
||||
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
||||
by resignation, or by the per-game move timeout (5 minutes to 24 hours, default
|
||||
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||
@@ -265,44 +256,6 @@ the same occasional move against its plan that fades out by the endgame), and ch
|
||||
add-friend are off. AI games are **practice** — they never count toward a player's
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
||||
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
||||
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
|
||||
(once installed) launches from a precached shell even with no network. Switching **to** offline first
|
||||
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
|
||||
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
|
||||
(the download keeps running in the background, so a later switch is instant). The mode is
|
||||
device-scoped and sticky across launches.
|
||||
|
||||
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
||||
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
|
||||
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
|
||||
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||
During the game the board is always visible, but a PIN-locked seat shows an **Unlock** button in place
|
||||
of its rack until its owner enters the PIN — so the device passes hand to hand and only a protected
|
||||
seat hides its letters; the lock returns each turn. The **leader** (the host button in the game) can,
|
||||
with the leader password, **skip** the current player's turn, **remove** a player, or **end the game
|
||||
early** — an early end discards the game with no winner or loser. Hints and chat are off in a
|
||||
pass-and-play game. A game that finishes normally is saved to the offline lobby like any other;
|
||||
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
||||
so no one can wipe a game the moment they have moved.
|
||||
|
||||
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
|
||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
||||
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
|
||||
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
|
||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
||||
|
||||
### Social: friends, block, chat, nudge
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
digits, valid for twelve hours), or send a **request to someone you have played
|
||||
|
||||
+1
-49
@@ -222,16 +222,7 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
||||
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
||||
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
||||
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
|
||||
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
|
||||
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
|
||||
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
|
||||
одной статистике подсказок. Партия
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||
@@ -270,45 +261,6 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
|
||||
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
|
||||
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
|
||||
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
|
||||
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
|
||||
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
|
||||
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
# PAYMENTS — monetization mechanics
|
||||
|
||||
Authoritative specification of the monetization domain: the in-game currency, wallets,
|
||||
benefits, store-compliance rules, payment intake, ads, catalog, ledger, and reporting.
|
||||
English is authoritative; [`PAYMENTS_ru.md`](PAYMENTS_ru.md) mirrors it in plain Russian
|
||||
(mirror every point edit in the same PR, like `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).
|
||||
|
||||
Read this before changing any payments behaviour. The technical implementation plan lives
|
||||
in [`../PLAN.md`](../PLAN.md).
|
||||
|
||||
> Status: specification approved; implementation staged (see `PLAN.md`). Nothing here is
|
||||
> live yet.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The game earns through two orthogonal channels:
|
||||
|
||||
- **Purchases** of the in-game currency **Фишка** (chips) with real money, then spending
|
||||
chips on **values** (benefits).
|
||||
- **Ads** — a rewarded video tops chips up; an interstitial and a house banner monetize by
|
||||
impression.
|
||||
|
||||
The currency is **two-tier**:
|
||||
|
||||
```
|
||||
money (VK Votes / TG Stars / RUB via Robokassa) ─┐
|
||||
├─► Фишки (chips) ──► values
|
||||
rewarded ad view ─┘ (no-ads, hints,
|
||||
tournament fee)
|
||||
```
|
||||
|
||||
Chips are the single storefront unit. Money and rewarded views **fund** chips; chips
|
||||
**buy** values. There is no path that spends money directly on a value — always through
|
||||
chips.
|
||||
|
||||
## 2. Currency model
|
||||
|
||||
**One chip is one chip everywhere** — the unit is uniform. The difference between payment
|
||||
methods lives entirely in the **purchase rate**: a chip pack costs *X* Votes / *Y* Stars /
|
||||
*Z* RUB, and the rate absorbs each store's commission. Value prices are fixed **in chips**
|
||||
and identical across methods.
|
||||
|
||||
The chip balance is **segmented by `source`** — the platform where the chips were funded:
|
||||
|
||||
| `source` | Funded by |
|
||||
|------------|----------------------------------|
|
||||
| `vk` | VK Votes purchase, VK rewarded ad |
|
||||
| `telegram` | TG Stars purchase |
|
||||
| `direct` | Robokassa purchase (web / native)|
|
||||
|
||||
A single account holds all three segments simultaneously: `balance = (account_id, source)`,
|
||||
up to three rows — a row is materialised lazily on the segment's first funding, and an absent
|
||||
segment reads as zero. Segmentation is **not** per-identity — see §6.
|
||||
|
||||
Why segmented, not one pooled balance: store rules forbid activating value that was paid
|
||||
for outside the store's own cash desk. Chips funded inside VK (Votes) may only be spent in
|
||||
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
||||
the stores. See §4.
|
||||
|
||||
## 3. Three operations, kept distinct
|
||||
|
||||
Do not conflate these — they use different keys:
|
||||
|
||||
1. **Fund chips** — money/ad → chip `source` segment, keyed by the **execution context**.
|
||||
2. **Spend chips = buy a value** — segment → benefit, keyed by context with the gate (§4).
|
||||
This is where a benefit is **born** and stamped with its `origin`.
|
||||
3. **Apply a benefit** over time (e.g. "no-ads until *T*") — governed by the origin rule
|
||||
(§5).
|
||||
|
||||
`source` (where chips were funded) and `origin` (where a value was bought) share the same
|
||||
value set `{vk, telegram, direct}` but mean different things. On the web they can diverge:
|
||||
web spending may draw `vk` chips (`source=vk`) into a `direct` purchase (`origin=direct`).
|
||||
|
||||
## 4. Store-compliance gate
|
||||
|
||||
The compliance wall is **one-directional**. The dangerous direction — activating externally
|
||||
paid value *inside* a store wrapper — is blocked; the safe direction (a store benefit
|
||||
leaking out to the open web) is allowed.
|
||||
|
||||
**Spend context → which segments/benefits are usable:**
|
||||
|
||||
| Execution context | Spendable chip segments | Spend priority |
|
||||
|---------------------|-------------------------|--------------------|
|
||||
| Inside VK (Android) | `vk` | — |
|
||||
| Inside VK (iOS) | none — frozen (view only)| — |
|
||||
| Inside Telegram | `telegram` | — |
|
||||
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||
|
||||
- Inside VK/TG only the same-named segment is usable; everything else (chiefly `direct`) is
|
||||
invisible-as-spendable there.
|
||||
- On the web the store has no jurisdiction, so all attached segments are spendable, drained
|
||||
by priority direct → vk → tg.
|
||||
- **VK iOS** is frozen for spending (Apple forbids spending virtual currency on digital
|
||||
goods outside IAP inside VK on iOS): the balance is shown as a number, but no purchase or
|
||||
spend is possible. A previously bought benefit still *applies* there.
|
||||
|
||||
The account is **single** (identities merge, one profile/friends/stats). The gate is
|
||||
**logical**: in a VK/TG context the server activates only the same-named segment. It rests
|
||||
on a **trusted platform signal** (§8) — the client is never believed. When the platform
|
||||
cannot be trusted, the gate is **fail-closed**: spends/purchases are denied, view only.
|
||||
|
||||
### One-directional benefit application
|
||||
|
||||
A benefit carries `origin` = the **purchase context** (not "what it was paid with"). Buying
|
||||
on the web with `vk` chips still yields `origin=direct`.
|
||||
|
||||
| `origin` | Where the benefit applies |
|
||||
|------------------|----------------------------------------------------|
|
||||
| `vk` / `telegram`| **Everywhere** — inside its store *and* out on web/native |
|
||||
| `direct` | **Only** web/native — never inside VK/TG (= store ban) |
|
||||
|
||||
Before a web spend draws `vk`/`telegram` chips, the UI **warns** the user that the value
|
||||
will be available here (web/native) only, because of VK/TG restrictions.
|
||||
|
||||
## 5. Benefits
|
||||
|
||||
Three distinct entities, do not merge:
|
||||
|
||||
- **Chips** — currency. Segment by `source`.
|
||||
- **Hints** — consumable, bought with chips. Segment by `origin`. Spent one-per-hint in
|
||||
online games; `vs_ai` hints stay free/unlimited (30-min idle gate, not counted). A hint
|
||||
spent in a game is drawn from an `origin` applicable to the current context (same
|
||||
one-directional relaxation as above).
|
||||
- **No-ads** — a duration benefit, bought with chips. Segment by `origin`.
|
||||
|
||||
**No-ads stacking.** Buying a term extends `paid_until[origin] += term` from
|
||||
`max(now, current end)` — the remainder is never lost ("terms add up"). **Forever** is a
|
||||
separate perpetual flag that overrides terms. "Ads off in context *P*" ⇔ some `origin`
|
||||
applicable in *P* has `paid_until > now` (on the web take the max over direct/vk/tg; in VK
|
||||
only vk; in TG only tg).
|
||||
|
||||
**What no-ads suppresses:** the top banner **and** the post-move fullscreen interstitial.
|
||||
The voluntary **rewarded** view (for chips) is never suppressed — it is the user's choice.
|
||||
|
||||
**Tournament fee** — a future value type; the atom is provisioned but the mechanic ships
|
||||
later.
|
||||
|
||||
## 6. Wallet lifecycle
|
||||
|
||||
**Segment availability rule.** A segment is spendable ⇔ the account has an identity of that
|
||||
`source` (for `direct`: a durable identity/email). This makes unlink/merge fall out
|
||||
naturally.
|
||||
|
||||
**Unlink (vk/tg).** Allowed even with a non-zero balance/active benefit. The segment is not
|
||||
burned — it **sleeps** (no identity ⇒ unavailable in VK *and*, lacking the attachment,
|
||||
unavailable as an attached web segment); re-linking wakes it. The user is warned before
|
||||
unlinking ("N chips will be unavailable until you re-link"). The last identity cannot be
|
||||
unlinked (existing `ErrLastIdentity`).
|
||||
|
||||
**Merge.** Segments and benefits merge **by origin**: same-origin segments add (chips sum,
|
||||
benefit terms extend per origin), different origins coexist. This extends the current
|
||||
account-merge (`hint_balance +=`, `paid_account OR=`) so origin is preserved and nothing
|
||||
leaks across platforms.
|
||||
|
||||
**Guest.** A guest account has **no wallet at all** — the Wallet section is hidden, no
|
||||
purchases, no balance, by design. Balances belong only to durable accounts. The guest
|
||||
reaper therefore deletes guests freely (no money can exist there). In `direct`, email is
|
||||
required **before the first purchase** as a recovery anchor (in VK/TG the vk/tg identity is
|
||||
already the anchor); the existing email flow (request code → confirm → clear guest →
|
||||
durable) is reused.
|
||||
|
||||
## 7. Catalog and pricing
|
||||
|
||||
The catalog is **configurable** — products, prices, purchase rates, and rewarded payout
|
||||
live in the database and are edited in the admin console, no release required.
|
||||
|
||||
- **Base values (atoms):** chips, hints, no-ads days, tournament entry.
|
||||
- **Product = a set of atoms + a price.** Sold singly or as a combo (e.g. "250 hints + 30
|
||||
no-ads days").
|
||||
- **Chip pack** (funds chips) — priced **per method** (multi-currency Votes/Stars/RUB) as a
|
||||
single product.
|
||||
- **Value** (bought with chips) — priced **in chips** (uniform across methods).
|
||||
|
||||
**Deactivation, not deletion.** Products are soft-deleted (deactivated). A completed
|
||||
purchase stores a **snapshot** of what was sold (atom composition + price at the time) into
|
||||
an archive, so history/receipts/tax are independent of later catalog edits.
|
||||
|
||||
## 8. Trusted platform signal
|
||||
|
||||
The gate (§4) needs a **trusted, unforgeable** platform context on the server. The client is
|
||||
never the source of truth.
|
||||
|
||||
- The platform is a **property of the session**, re-confirmed by a fresh signature on **every
|
||||
cold start** — VK launch-params `sign` / TG `initData` (validator already exists at
|
||||
`platform/telegram/internal/initdata`). VK/TG wrappers resend the signature on every open,
|
||||
so this is not a one-time login.
|
||||
- `direct` is established by the fact of a web/native session's creation (no external
|
||||
signer, and none needed — reaching vk/tg segments in a direct context still requires a
|
||||
real attachment, §6).
|
||||
- The platform carries **kind** (`vk`/`telegram`/`direct`) **plus subtype**
|
||||
(`ios`/`android`/`web`) — the subtype is mandatory (VK iOS is frozen).
|
||||
- The gateway resolves the session and passes `platform` to the backend (alongside the
|
||||
existing `X-User-ID`).
|
||||
- **Fail-closed:** an untrusted/unconfirmed platform (a VK/TG session without a valid
|
||||
cold-start signature; an old session with no recorded platform) denies spends/purchases
|
||||
and applying any foreign origin — view only.
|
||||
|
||||
## 9. Payment intake
|
||||
|
||||
**Server callback only.** Chips are credited solely on a **verified** (signature/HMAC)
|
||||
provider callback — Robokassa webhook / TG `successful_payment` / VK callback. A client "I
|
||||
paid" is ignored.
|
||||
|
||||
**Single writer.** One payments domain is the only writer of the ledger. Public webhooks
|
||||
(Robokassa/VK) terminate at the edge (Caddy/gateway) and proxy into payments; TG
|
||||
`successful_payment` reaches the bot, which forwards into payments. One place credits and
|
||||
dedupes.
|
||||
|
||||
**Order-flow.** The server pre-creates an `order(pending)` with account / platform / pack /
|
||||
expected amount / origin. The `order_id` is threaded to the provider (Robokassa `InvId` / TG
|
||||
`invoice_payload` / VK `item` — confirm the exact VK field at integration). The callback
|
||||
matches by `order_id` (never by amount, so equal-amount collisions cannot happen), verifies
|
||||
the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provider_payment_id)`.
|
||||
|
||||
**Pending is invisible** to the user; it auto-expires on a timeout (~30 min, DB hygiene). A
|
||||
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
|
||||
the money is real, the chips are owed). The user sees only successful purchases.
|
||||
|
||||
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
|
||||
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
|
||||
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
|
||||
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
|
||||
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
|
||||
intake = credited exactly once.
|
||||
|
||||
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
|
||||
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
|
||||
existing `botlink` push / email relay. "Payment failed" (an **active** provider decline, not
|
||||
an abandoned pending) is surfaced to the user; "payment succeeded" is a hook (email / bot
|
||||
message).
|
||||
|
||||
**Refunds.** ToS is **non-refundable** — we do not offer refunds to the user. An admin may
|
||||
issue a **manual** refund (edge case: a user demands one shortly after paying / closes their
|
||||
account — tie into `accountdelete`, which already preserves messages). **External** refunds
|
||||
(chargeback / store decision / TG / VK) are honoured: the system takes the `refunded` event,
|
||||
**best-effort** revokes the benefit (never going negative; if chips were already spent, it
|
||||
records the loss + an abuse flag), and writes to the ledger. The ledger is **export-ready**
|
||||
for future tax reporting and Robokassa reconciliation (reconciliation itself is not built
|
||||
yet; the schema stays compatible).
|
||||
|
||||
## 10. Ads
|
||||
|
||||
**Launch scope: VK only** for video (rewarded payout in RUB, ОРД automatic, API ready).
|
||||
web/native/TG keep the existing house **text banner** only; video is deferred until a
|
||||
ruble-paying in-app network exists. The ad provider is behind an **abstraction** so a future
|
||||
network for other platforms slots in without rework. Crypto-payout networks (AdsGram/AdMob)
|
||||
are rejected — no legal ruble income for a self-employed (НПД) developer.
|
||||
|
||||
**Rewarded** (voluntary video for chips) credits chips through payments on the network's
|
||||
**server verify callback** (client not believed, like a payment). On-launch anti-fraud is
|
||||
the provider's verify only (no own daily cap yet; the abstraction allows adding caps later).
|
||||
|
||||
**Interstitial** (post-move fullscreen), configurable server-side values:
|
||||
|
||||
- Global cooldown **per user, across all games**, default **5 min**.
|
||||
- **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players).
|
||||
- Applying a **hint** triggers a post-move interstitial **independently** of the main
|
||||
cooldown, with its own **1-min** cooldown.
|
||||
- Offline — banner only.
|
||||
- Respect VK's own frequency caps.
|
||||
|
||||
**No ads offline** beyond the house banner. The `no-ads` benefit suppresses the banner via
|
||||
the existing `ads.Eligible` (`backend/internal/ads/ads.go`), which is extended to gate on
|
||||
the **origin benefit applicable in the current context** rather than one global flag.
|
||||
|
||||
## 11. Admin, audit, reporting
|
||||
|
||||
**Append-only ledger + materialized balance.** The operations ledger is **INSERT-only**
|
||||
(never UPDATE/DELETE — full audit). Segment balances `(account, source)` and benefits
|
||||
`(account, origin)` are a fast **materialized** cache, updated **in the same transaction** as
|
||||
the ledger write, and recomputable from the ledger for reconciliation.
|
||||
|
||||
**Admin rewards.** An admin grants **concrete values only** (no-ads / hints) — **never
|
||||
chips** (a gifted currency balance = a store cash-desk bypass). The admin **picks the
|
||||
origin** at grant time (compliance is on them: `origin=vk` point-wise/low-volume = low risk,
|
||||
`origin=direct` = safe). A grant is a ledger transaction of type `admin_grant`, price 0
|
||||
chips — full audit of rewards.
|
||||
|
||||
**Per-user financial report** in the admin console `/_gm` — segment balances, payments,
|
||||
spends, grants, refunds, full history — as an extension of the existing user card
|
||||
(`UserDetailView`, `handlers_admin_console.go`). Plus a ledger export.
|
||||
|
||||
## 12. Taxes and compliance
|
||||
|
||||
Receipts are automatic **through the provider**, and differ by rail:
|
||||
|
||||
- **Robokassa** (direct) — self-employed НПД receipt on payment.
|
||||
- **VK** — VK processes Votes through the tax authority itself; nothing to do.
|
||||
- **TG Stars** — no tax side (for a RU self-employed, Stars are not legally withdrawable = not
|
||||
НПД income; accepted, no receipt issued).
|
||||
|
||||
**ОРД** (ad marking) for VK ads is handled on VK's side. (Not legal advice — the owner
|
||||
confirms the exact НПД scheme with a tax advisor.)
|
||||
|
||||
## 13. Distribution (native Android)
|
||||
|
||||
- **RuStore** — Robokassa/external gate allowed (0%); native = clean `direct` context.
|
||||
- **Google Play** — direct purchases are **hidden**; the Wallet shows a stub ("install the
|
||||
RuStore build to make purchases"). Rewarded ads and spending already-earned chips still
|
||||
work. Confirm Google's current in-app-currency rules before the GP release.
|
||||
|
||||
## 14. Data model (schema `payments`)
|
||||
|
||||
The payments domain lives in its **own schema `payments`** in the shared Postgres instance,
|
||||
with its own DB role (rights limited to `payments`) and a domain package behind a hard
|
||||
interface. There is **no cross-schema foreign key** to `backend.accounts` — an account id is a
|
||||
plain value here, kept consistent in code and joined to the tombstoned account /
|
||||
retained-identities dossier by the stable id. That keeps the chip↔benefit spend atomic
|
||||
**within `payments`** and the domain extractable into its own database. Durability is **PITR**
|
||||
(continuous WAL archive), independent of DB topology.
|
||||
|
||||
Core tables (final names/columns fixed in `PLAN.md`):
|
||||
|
||||
- **ledger** — append-only operations: fund / spend / `admin_grant` / refund; `(provider,
|
||||
provider_payment_id)` unique for idempotency; export-ready.
|
||||
- **balances** — materialized `(account_id, source) → chips`.
|
||||
- **benefits** — materialized `(account_id, origin)` → no-ads `paid_until`/`forever` +
|
||||
hints count.
|
||||
- **catalog** — atoms + products (soft-deletable), per-method prices, chip-purchase rates,
|
||||
rewarded payout.
|
||||
- **orders** — pending purchases, `order_id`, expected amount, origin, status
|
||||
(pending/paid/expired).
|
||||
- **payment_events** — succeeded/failed/refunded for the dispatcher.
|
||||
|
||||
Legacy `accounts.hint_balance` and `accounts.paid_account` are **deprecated** in favour of the
|
||||
segmented model and dropped in a later **contract-phase** migration (expand-contract, after the
|
||||
currency core flips reads and Release 2 — image rollback stays DB-safe); neither was ever set in
|
||||
prod (no purchase flow existed), so legacy values are zeroed.
|
||||
|
||||
## 15. Glossary
|
||||
|
||||
- **Фишка / chip** — the in-game currency; uniform unit, segmented by `source`.
|
||||
- **source** — where chips were funded (`vk`/`telegram`/`direct`).
|
||||
- **origin** — where a value was bought; governs where the benefit applies.
|
||||
- **value / benefit** — what chips buy (no-ads, hints, tournament fee).
|
||||
- **gate** — the one-directional store-compliance rule (§4).
|
||||
- **ledger** — the append-only record of all money/value operations.
|
||||
- **rail** — a payment provider (Robokassa / VK Votes / TG Stars).
|
||||
@@ -1,343 +0,0 @@
|
||||
# PAYMENTS — механики монетизации
|
||||
|
||||
Русское зеркало [`PAYMENTS.md`](PAYMENTS.md) (английская версия — основная). Описывает
|
||||
домен монетизации: игровую валюту, кошельки, ценности, правила комплаенса сторов, приём
|
||||
платежей, рекламу, каталог, журнал операций и отчёты. Каждую правку `PAYMENTS.md` зеркалим
|
||||
сюда в том же PR (как `FUNCTIONAL.md`/`FUNCTIONAL_ru.md`).
|
||||
|
||||
Читать перед любым изменением платёжного поведения. Технический план внедрения —
|
||||
[`../PLAN.md`](../PLAN.md).
|
||||
|
||||
> Статус: спецификация утверждена; внедрение поэтапно (см. `PLAN.md`). В проде пока ничего
|
||||
> из этого нет.
|
||||
|
||||
## 1. Обзор
|
||||
|
||||
Игра зарабатывает двумя независимыми каналами:
|
||||
|
||||
- **Покупки** игровой валюты **«Фишка»** за реальные деньги, затем трата Фишек на
|
||||
**ценности** (бенефиты).
|
||||
- **Реклама** — ролик за награду пополняет Фишки; полноэкранный ролик и наш баннер
|
||||
зарабатывают показами.
|
||||
|
||||
Валюта **двухуровневая**:
|
||||
|
||||
```
|
||||
деньги (Голоса VK / Stars TG / рубли через Robokassa) ─┐
|
||||
├─► Фишки ──► ценности
|
||||
просмотр ролика за награду ─┘ (без рекламы,
|
||||
подсказки,
|
||||
турнирный взнос)
|
||||
```
|
||||
|
||||
Фишки — единая единица витрины. Деньги и просмотры **пополняют** Фишки; Фишки **покупают**
|
||||
ценности. Прямой оплаты ценности деньгами нет — всегда через Фишки.
|
||||
|
||||
## 2. Модель валюты
|
||||
|
||||
**Одна Фишка равна одной Фишке везде** — единица единая. Разница между методами оплаты
|
||||
целиком в **курсе покупки**: пакет Фишек стоит *X* Голосов / *Y* Stars / *Z* рублей, и курс
|
||||
учитывает комиссию каждого стора. Цены ценностей фиксированы **в Фишках** и одинаковы для
|
||||
всех методов.
|
||||
|
||||
Баланс Фишек **сегментирован по «источнику» (`source`)** — платформе, где Фишки пополнены:
|
||||
|
||||
| `source` | Чем пополняется |
|
||||
|------------|------------------------------------|
|
||||
| `vk` | Покупка за Голоса VK, ролик за награду в VK |
|
||||
| `telegram` | Покупка за Stars TG |
|
||||
| `direct` | Покупка через Robokassa (web / native) |
|
||||
|
||||
Один аккаунт держит все три сегмента одновременно: `баланс = (account_id, source)`, до трёх
|
||||
записей — запись материализуется лениво при первом пополнении сегмента, отсутствующий сегмент
|
||||
читается как ноль. Сегментация **не** по привязке (identity) — см. §6.
|
||||
|
||||
Почему сегментировано, а не один общий баланс: правила сторов запрещают активировать
|
||||
ценность, оплаченную мимо их кассы. Фишки, пополненные внутри VK (Голоса), тратятся только
|
||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||
сторов. См. §4.
|
||||
|
||||
## 3. Три операции, которые нельзя путать
|
||||
|
||||
Не смешивать — у них разные ключи:
|
||||
|
||||
1. **Пополнить Фишки** — деньги/реклама → сегмент `source` Фишек, по **контексту
|
||||
исполнения**.
|
||||
2. **Потратить Фишки = купить ценность** — сегмент → бенефит, по контексту с гейтом (§4).
|
||||
Здесь бенефит **рождается** и помечается своим `origin`.
|
||||
3. **Применить бенефит** во времени (напр. «без рекламы до *T*») — по правилу `origin`
|
||||
(§5).
|
||||
|
||||
`source` (где пополнены Фишки) и `origin` (где куплена ценность) используют одно множество
|
||||
значений `{vk, telegram, direct}`, но значат разное. В вебе они расходятся: покупка в вебе
|
||||
может списать `vk`-Фишки (`source=vk`) в `direct`-покупку (`origin=direct`).
|
||||
|
||||
## 4. Гейт комплаенса сторов
|
||||
|
||||
Стена комплаенса **односторонняя**. Опасное направление — активация оплаченной снаружи
|
||||
ценности *внутри* обёртки стора — заблокировано; безопасное (бенефит стора действует
|
||||
наружу, в открытом вебе) — разрешено.
|
||||
|
||||
**Контекст траты → какие сегменты/бенефиты доступны:**
|
||||
|
||||
| Контекст исполнения | Тратимые сегменты Фишек | Приоритет траты |
|
||||
|----------------------|---------------------------|--------------------|
|
||||
| Внутри VK (Android) | `vk` | — |
|
||||
| Внутри VK (iOS) | нет — заморожено (только просмотр) | — |
|
||||
| Внутри Telegram | `telegram` | — |
|
||||
| Web / native (Direct)| `direct` + `vk` + `telegram` | direct → vk → tg |
|
||||
|
||||
- Внутри VK/TG доступен только одноимённый сегмент; всё остальное (в первую очередь
|
||||
`direct`) там невидимо как тратимое.
|
||||
- В вебе у стора нет юрисдикции, поэтому доступны все привязанные сегменты, списываются по
|
||||
приоритету direct → vk → tg.
|
||||
- **VK iOS** заморожен для траты (Apple запрещает тратить виртуальную валюту на цифровые
|
||||
товары мимо IAP внутри VK на iOS): баланс показывается числом, но покупка/трата
|
||||
невозможны. Ранее купленный бенефит там всё равно *действует*.
|
||||
|
||||
Аккаунт **единый** (привязки сливаются, один профиль/друзья/статистика). Гейт
|
||||
**логический**: в контексте VK/TG сервер активирует только одноимённый сегмент. Держится на
|
||||
**доверенном сигнале платформы** (§8) — клиенту не верим. Когда платформу нельзя доверенно
|
||||
установить, гейт **fail-closed**: траты/покупки запрещены, только просмотр.
|
||||
|
||||
### Одностороннее применение бенефита
|
||||
|
||||
Бенефит несёт `origin` = **контекст покупки** (не «чем оплачено»). Покупка в вебе за
|
||||
`vk`-Фишки всё равно даёт `origin=direct`.
|
||||
|
||||
| `origin` | Где действует бенефит |
|
||||
|------------------|----------------------------------------------------|
|
||||
| `vk` / `telegram`| **Везде** — внутри своего стора *и* наружу в web/native |
|
||||
| `direct` | **Только** web/native — никогда внутри VK/TG (= бан) |
|
||||
|
||||
Перед тратой в вебе `vk`/`telegram`-Фишек интерфейс **предупреждает**, что ценность будет
|
||||
доступна только здесь (web/native) из-за ограничений VK/TG.
|
||||
|
||||
## 5. Ценности
|
||||
|
||||
Три разные сущности, не смешивать:
|
||||
|
||||
- **Фишки** — валюта. Сегмент по `source`.
|
||||
- **Подсказки** — расходник, покупается за Фишки. Сегмент по `origin`. Тратятся по одной в
|
||||
онлайн-играх; в `vs_ai` подсказки бесплатны/безлимитны (30-мин кулдаун, не в счёт).
|
||||
Подсказка в игре списывается из `origin`, применимого в текущем контексте (то же
|
||||
одностороннее послабление).
|
||||
- **Без рекламы** — срок-бенефит, покупается за Фишки. Сегмент по `origin`.
|
||||
|
||||
**Складывание «без рекламы».** Покупка срока продлевает `paid_until[origin] += срок` от
|
||||
`max(сейчас, текущий конец)` — остаток не теряется («сроки плюсуются»). **Навсегда** —
|
||||
отдельный вечный флаг, перекрывает сроки. «Реклама выключена в контексте *P*» ⇔ есть
|
||||
применимый в *P* `origin` с `paid_until > сейчас` (в вебе берём максимум по direct/vk/tg; в
|
||||
VK только vk; в TG только tg).
|
||||
|
||||
**Что гасит «без рекламы»:** верхний баннер **и** полноэкранный ролик после хода.
|
||||
Добровольный **ролик за награду** (за Фишки) не гасится — это выбор пользователя.
|
||||
|
||||
**Турнирный взнос** — будущий тип ценности; атом заложен, механика позже.
|
||||
|
||||
## 6. Жизненный цикл кошелька
|
||||
|
||||
**Правило доступности сегмента.** Сегмент тратим ⇔ на аккаунте есть привязка этого
|
||||
`source` (для `direct` — устойчивая привязка/email). Отсюда unlink/мерж выводятся
|
||||
естественно.
|
||||
|
||||
**Отвязка (vk/tg).** Разрешена даже при ненулевом балансе/активном бенефите. Сегмент не
|
||||
сжигается — он **засыпает** (нет привязки ⇒ недоступен в VK *и*, без привязки, недоступен
|
||||
как подтянутый веб-сегмент); повторная привязка будит. Перед отвязкой предупреждение
|
||||
(«N Фишек станут недоступны до повторной привязки»). Последнюю привязку отвязать нельзя
|
||||
(существующий `ErrLastIdentity`).
|
||||
|
||||
**Мерж.** Сегменты и бенефиты сливаются **по origin**: одноимённые складываются (Фишки
|
||||
суммируются, сроки бенефита продлеваются по origin), разные сосуществуют. Это расширяет
|
||||
текущий мерж аккаунтов (`hint_balance +=`, `paid_account OR=`), так что origin сохраняется и
|
||||
ничего не протекает между платформами.
|
||||
|
||||
**Гость.** У гостевого аккаунта **вообще нет кошелька** — раздел «Кошелёк» скрыт, покупок
|
||||
нет, баланса нет, by design. Балансы — только у durable-аккаунтов. Поэтому чистильщик
|
||||
гостей удаляет их свободно (денег там быть не может). В `direct` email обязателен **перед
|
||||
первой покупкой** как якорь восстановления (в VK/TG якорь — сама vk/tg-привязка);
|
||||
переиспользуем существующий флоу email (запрос кода → подтверждение → снятие флага гостя →
|
||||
durable).
|
||||
|
||||
## 7. Каталог и цены
|
||||
|
||||
Каталог **конфигурируемый** — продукты, цены, курсы покупки и награда за ролик живут в базе
|
||||
и правятся в админке, без релиза.
|
||||
|
||||
- **Базовые ценности (атомы):** Фишки, подсказки, дни без рекламы, участие в турнире.
|
||||
- **Продукт = набор атомов + цена.** Продаётся по одной или комбо (напр. «250 подсказок +
|
||||
30 дней без рекламы»).
|
||||
- **Пакет Фишек** (пополняет Фишки) — цена **per-метод** (мультивалютная Голоса/Stars/руб)
|
||||
одним продуктом.
|
||||
- **Ценность** (за Фишки) — цена **в Фишках** (единая для всех методов).
|
||||
|
||||
**Деактивация, не удаление.** Продукты деактивируются (soft-delete). Состоявшаяся покупка
|
||||
хранит **снимок** проданного (состав атомов + цена на момент) в архиве, чтобы
|
||||
история/чеки/налоги не зависели от последующих правок каталога.
|
||||
|
||||
## 8. Доверенный сигнал платформы
|
||||
|
||||
Гейту (§4) нужен **доверенный, неподделываемый** контекст платформы на сервере. Клиент —
|
||||
никогда не источник правды.
|
||||
|
||||
- Платформа — **свойство сессии**, переподтверждается свежей подписью на **каждом холодном
|
||||
старте** — VK launch-params `sign` / TG `initData` (валидатор уже есть в
|
||||
`platform/telegram/internal/initdata`). Обёртки VK/TG шлют подпись при каждом открытии,
|
||||
так что это не одноразовый вход.
|
||||
- `direct` устанавливается самим фактом создания веб/native-сессии (внешней подписи нет и
|
||||
не нужно — доступ к vk/tg-сегментам в direct-контексте всё равно требует реальной
|
||||
привязки, §6).
|
||||
- Платформа несёт **kind** (`vk`/`telegram`/`direct`) **плюс подтип**
|
||||
(`ios`/`android`/`web`) — подтип обязателен (VK iOS заморожен).
|
||||
- Гейтвей резолвит сессию и передаёт `platform` в бэкенд (рядом с существующим
|
||||
`X-User-ID`).
|
||||
- **Fail-closed:** недоверенная/неподтверждённая платформа (VK/TG-сессия без валидной
|
||||
подписи на холодном старте; старая сессия без записанной платформы) запрещает
|
||||
траты/покупки и применение любого чужого origin — только просмотр.
|
||||
|
||||
## 9. Приём платежей
|
||||
|
||||
**Только серверный колбэк провайдера.** Фишки начисляются лишь по **проверенному**
|
||||
(подпись/HMAC) серверному колбэку — Robokassa webhook / TG `successful_payment` / VK
|
||||
callback. Клиентское «я оплатил» игнорируется.
|
||||
|
||||
**Единственный писатель.** Один платёжный домен — единственный, кто пишет в журнал операций.
|
||||
Публичные вебхуки (Robokassa/VK) терминируются на краю (Caddy/gateway) и проксируются в
|
||||
платёжный домен; TG `successful_payment` приходит боту, тот форвардит в платёжный домен.
|
||||
Одно место начисляет и защищает от повторов.
|
||||
|
||||
**Флоу заказа.** Сервер заранее создаёт `order(pending)` с account / платформой / пакетом /
|
||||
ожидаемой суммой / origin. `order_id` прокидывается провайдеру (Robokassa `InvId` / TG
|
||||
`invoice_payload` / VK `item` — точную форму VK уточнить при интеграции). Колбэк матчится по
|
||||
`order_id` (никогда по сумме, поэтому коллизии одинаковых сумм невозможны), сверяет сумму,
|
||||
начисляет, помечает `paid`. **Защита от повторов:** дедуп по `(провайдер,
|
||||
provider_payment_id)`.
|
||||
|
||||
**Pending невидим** пользователю; авто-истекает по таймауту (~30 мин, гигиена базы).
|
||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
||||
|
||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
|
||||
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
|
||||
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
|
||||
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
|
||||
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
|
||||
at-least-once + идемпотентный приём = начисление ровно один раз.
|
||||
|
||||
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
|
||||
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
||||
существующий пуш `botlink` / email. «Оплата не прошла» (**активный** отказ провайдера, не
|
||||
брошенный pending) доводится до пользователя; «оплата прошла» — хук (письмо / сообщение в
|
||||
бота).
|
||||
|
||||
**Возвраты.** ToS — **невозвратно**, пользователю возврат не предлагаем. Админ может сделать
|
||||
**ручной** возврат (крайний случай: пользователь требует вскоре после оплаты / закрывает
|
||||
аккаунт — связано с `accountdelete`, где уже сохраняются сообщения). **Внешние** возвраты
|
||||
(чарджбек / решение стора / TG / VK) обрабатываются: система принимает событие `refunded`,
|
||||
**по возможности** отзывает бенефит (в минус не уходим; если Фишки уже потрачены —
|
||||
фиксирует убыток + флаг защиты от злоупотреблений), пишет в журнал. Журнал операций
|
||||
**спроектирован экспортопригодным** для будущей налоговой отчётности и сверки с Robokassa
|
||||
(саму сверку пока не строим; схема остаётся совместимой).
|
||||
|
||||
## 10. Реклама
|
||||
|
||||
**Охват на старте: только VK** для видео (награда в рублях, ОРД автоматом, API готов).
|
||||
web/native/TG держат только существующий наш **текст-баннер**; видео отложено до появления
|
||||
рублёвой in-app сети. Рекламный провайдер за **абстракцией**, чтобы будущая сеть для других
|
||||
платформ встроилась без переделки. Крипто-сети (AdsGram/AdMob) отвергнуты — нет легального
|
||||
рублёвого дохода самозанятому (НПД).
|
||||
|
||||
**Ролик за награду** (добровольное видео за Фишки) начисляет Фишки через платёжный домен по
|
||||
**серверному verify-колбэку** сети (клиенту не верим, как платежу). Антифрод на старте — только
|
||||
verify провайдера (своего дневного потолка пока нет; абстракция позволит добавить лимиты
|
||||
позже).
|
||||
|
||||
**Полноэкранный ролик** (после хода), конфигурируемые серверные значения:
|
||||
|
||||
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
|
||||
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
|
||||
- Применение **подсказки** триггерит ролик после хода **независимо** от основного кулдауна,
|
||||
со своим кулдауном **1 мин**.
|
||||
- Оффлайн — только баннер.
|
||||
- Уважать собственные лимиты частоты VK.
|
||||
|
||||
**Оффлайн без рекламы**, кроме нашего баннера. Бенефит `без рекламы` гасит баннер через
|
||||
существующий `ads.Eligible` (`backend/internal/ads/ads.go`), который расширяется до гейта по
|
||||
**origin-бенефиту, применимому в текущем контексте**, а не по одному глобальному флагу.
|
||||
|
||||
## 11. Админ, аудит, отчётность
|
||||
|
||||
**Неизменяемый журнал операций + материализованный баланс.** Журнал операций **только на
|
||||
INSERT** (никогда UPDATE/DELETE — полный аудит). Балансы сегментов `(account, source)` и
|
||||
бенефиты `(account, origin)` — быстрый **материализованный** кэш, обновляется **в той же
|
||||
транзакции**, что и запись журнала, и пересчитывается из журнала для сверки.
|
||||
|
||||
**Награждение админом.** Админ начисляет **только конкретные ценности** (без рекламы /
|
||||
подсказки) — **никогда не Фишки** (подаренный баланс валюты = обход кассы стора). Админ
|
||||
**выбирает origin** при выдаче (ответственность за комплаенс на нём: `origin=vk`
|
||||
точечно/малый объём = низкий риск, `origin=direct` = безопасно). Грант — транзакция журнала
|
||||
типа `admin_grant`, цена 0 Фишек — полный аудит наград.
|
||||
|
||||
**Финансовый отчёт по пользователю** в админке `/_gm` — балансы сегментов, платежи, траты,
|
||||
гранты, возвраты, полная история — расширение существующей карточки (`UserDetailView`,
|
||||
`handlers_admin_console.go`). Плюс экспорт журнала.
|
||||
|
||||
## 12. Налоги и комплаенс
|
||||
|
||||
Чеки формируются автоматически **на стороне провайдера** и отличаются по каналу:
|
||||
|
||||
- **Robokassa** (direct) — чек НПД самозанятого при оплате.
|
||||
- **VK** — VK сам процессит Голоса через налоговую; делать нечего.
|
||||
- **TG Stars** — налоговой стороны нет (для РФ-самозанятого Stars легально невыводимы = не
|
||||
доход НПД; принимаем, чек не формируем).
|
||||
|
||||
**ОРД** (маркировка рекламы) по VK-рекламе — на стороне VK. (Не юридическая консультация —
|
||||
владелец сверяет точную схему НПД с налоговым консультантом.)
|
||||
|
||||
## 13. Дистрибуция (native Android)
|
||||
|
||||
- **RuStore** — Robokassa/внешний гейт разрешён (0%); native = чистый контекст `direct`.
|
||||
- **Google Play** — direct-покупки **скрыты**; «Кошелёк» показывает заглушку («установите
|
||||
версию из RuStore для покупок»). Ролик за награду и трата уже накопленных Фишек работают.
|
||||
Перед GP-релизом свериться с актуальными правилами Google по внутренней валюте.
|
||||
|
||||
## 14. Модель данных (схема `payments`)
|
||||
|
||||
Платёжный домен живёт в **своей схеме `payments`** в общем инстансе Postgres, со своим
|
||||
DB-ролём (права только на `payments`) и доменным пакетом за жёстким интерфейсом.
|
||||
**Cross-schema внешнего ключа** к `backend.accounts` **нет** — идентификатор аккаунта здесь
|
||||
обычное значение, согласуемое в коде и связываемое с tombstone-аккаунтом / досье
|
||||
retained-identities по стабильному id. Это держит трату «Фишки↔бенефит» атомарной **внутри
|
||||
`payments`** и делает домен извлекаемым в свою базу. Сохранность — **PITR** (непрерывный
|
||||
WAL-архив), независимо от топологии базы.
|
||||
|
||||
Основные таблицы (финальные имена/колонки — в `PLAN.md`):
|
||||
|
||||
- **журнал операций (ledger)** — append-only операции: пополнение / трата / `admin_grant` /
|
||||
возврат; `(провайдер, provider_payment_id)` уникален для защиты от повторов;
|
||||
экспортопригоден.
|
||||
- **балансы** — материализованные `(account_id, source) → Фишки`.
|
||||
- **бенефиты** — материализованные `(account_id, origin)` → «без рекламы»
|
||||
`paid_until`/`forever` + счётчик подсказок.
|
||||
- **каталог** — атомы + продукты (деактивируемые), цены per-метод, курсы покупки Фишек,
|
||||
награда за ролик.
|
||||
- **заказы (orders)** — pending-покупки, `order_id`, ожидаемая сумма, origin, статус
|
||||
(pending/paid/expired).
|
||||
- **payment_events** — succeeded/failed/refunded для диспетчера.
|
||||
|
||||
Legacy `accounts.hint_balance` и `accounts.paid_account` **устаревают** в пользу
|
||||
сегментированной модели и удаляются в отдельной **contract-миграции** (expand-contract, после
|
||||
того как валютное ядро переключит чтения, и после Release 2 — откат образа остаётся безопасным
|
||||
для БД); ни то, ни другое в проде никогда не выставлялось (потока покупки не было), поэтому
|
||||
legacy-значения обнуляются.
|
||||
|
||||
## 15. Словарь
|
||||
|
||||
- **Фишка** — игровая валюта; единая единица, сегментирована по `source`.
|
||||
- **source (источник)** — где пополнены Фишки (`vk`/`telegram`/`direct`).
|
||||
- **origin (происхождение)** — где куплена ценность; определяет, где действует бенефит.
|
||||
- **ценность / бенефит** — то, что покупается за Фишки (без рекламы, подсказки, турнирный
|
||||
взнос).
|
||||
- **гейт** — одностороннее правило комплаенса сторов (§4).
|
||||
- **журнал операций (ledger)** — неизменяемая запись всех операций с деньгами/ценностями.
|
||||
- **платёжный канал / рельса** — платёжный провайдер (Robokassa / Голоса VK / Stars TG).
|
||||
+1
-12
@@ -20,18 +20,7 @@ tests or touching CI.
|
||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
||||
mock for the friends screen (code issue/redeem, accept a request), the lobby
|
||||
invitations section, the stats screen, profile editing, and the export chooser's
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
||||
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
||||
end-to-end: it forces the installed-PWA display mode, enters offline through the
|
||||
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
||||
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
||||
rack is deterministic and the human can tap out a precomputed opening), asserting the
|
||||
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
|
||||
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
|
||||
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
|
||||
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
|
||||
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||
production build.
|
||||
finished-only visibility + its signed-URL download flow (route-intercepted).
|
||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
||||
real self-played 35-move game) must rasterize to a plausible PNG
|
||||
|
||||
+3
-4
@@ -8,10 +8,9 @@ backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (unhashed from `ui/public/`,
|
||||
with the `.webmanifest` MIME type registered in-process — the distroless image has no
|
||||
`/etc/mime.types`) and the app-shell `sw.js` (built from `ui/src/sw.ts` by vite-plugin-pwa, which
|
||||
precaches the shell + assets so an installed PWA cold-launches offline).
|
||||
The web SPA is an installable **PWA**: it serves `manifest.webmanifest` (with the `.webmanifest`
|
||||
MIME type registered in-process — the distroless image has no `/etc/mime.types`) and the
|
||||
install-only `sw.js`, both shipped unhashed from `ui/public/`.
|
||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
|
||||
@@ -42,15 +42,6 @@ type ProfileResp struct {
|
||||
Email string `json:"email"`
|
||||
TelegramLinked bool `json:"telegram_linked"`
|
||||
VkLinked bool `json:"vk_linked"`
|
||||
// DictVersions is the current dictionary version per game variant, forwarded verbatim
|
||||
// into the Profile payload so an offline-capable client preloads the matching dawg.
|
||||
DictVersions []DictVersion `json:"dict_versions,omitempty"`
|
||||
}
|
||||
|
||||
// DictVersion pairs a game variant's stable label with its current dictionary version.
|
||||
type DictVersion struct {
|
||||
Variant string `json:"variant"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||
@@ -181,14 +172,13 @@ type AlphabetEntryJSON struct {
|
||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
||||
// Alphabet is present only when the request asked for it.
|
||||
type StateResp struct {
|
||||
Game GameResp `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||
Game GameResp `json:"game"`
|
||||
Seat int `json:"seat"`
|
||||
Rack []int `json:"rack"`
|
||||
BagLen int `json:"bag_len"`
|
||||
HintsRemaining int `json:"hints_remaining"`
|
||||
WalletBalance int `json:"wallet_balance"`
|
||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||
}
|
||||
|
||||
// MatchResp reports an auto-match outcome.
|
||||
|
||||
@@ -95,7 +95,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
banner = encodeBanner(b, *p.Banner)
|
||||
}
|
||||
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
||||
dictVersions := encodeDictVersions(b, p.DictVersions)
|
||||
fb.ProfileStart(b)
|
||||
fb.ProfileAddUserId(b, uid)
|
||||
fb.ProfileAddDisplayName(b, name)
|
||||
@@ -112,9 +111,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
fb.ProfileAddEmail(b, email)
|
||||
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
||||
fb.ProfileAddVkLinked(b, p.VkLinked)
|
||||
if dictVersions != 0 {
|
||||
fb.ProfileAddDictVersions(b, dictVersions)
|
||||
}
|
||||
if p.Banner != nil {
|
||||
fb.ProfileAddBanner(b, banner)
|
||||
}
|
||||
@@ -122,31 +118,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
// encodeDictVersions builds the Profile's dict_versions vector — one DictVersion table per
|
||||
// variant/version pair — and returns its offset, or 0 when there are none (the field is then
|
||||
// omitted). Every child table and its strings are created before the vector is opened, per the
|
||||
// FlatBuffers rule against nesting a table under one still being built; the caller invokes it
|
||||
// before ProfileStart for the same reason.
|
||||
func encodeDictVersions(b *flatbuffers.Builder, dvs []backendclient.DictVersion) flatbuffers.UOffsetT {
|
||||
if len(dvs) == 0 {
|
||||
return 0
|
||||
}
|
||||
offsets := make([]flatbuffers.UOffsetT, len(dvs))
|
||||
for i, dv := range dvs {
|
||||
variant := b.CreateString(dv.Variant)
|
||||
version := b.CreateString(dv.Version)
|
||||
fb.DictVersionStart(b)
|
||||
fb.DictVersionAddVariant(b, variant)
|
||||
fb.DictVersionAddVersion(b, version)
|
||||
offsets[i] = fb.DictVersionEnd(b)
|
||||
}
|
||||
fb.ProfileStartDictVersionsVector(b, len(offsets))
|
||||
for i := len(offsets) - 1; i >= 0; i-- {
|
||||
b.PrependUOffsetT(offsets[i])
|
||||
}
|
||||
return b.EndVector(len(offsets))
|
||||
}
|
||||
|
||||
// optString creates a FlatBuffers string for a non-empty value, or 0 to omit the
|
||||
// optional field (the client then reads it as absent).
|
||||
func optString(b *flatbuffers.Builder, s string) flatbuffers.UOffsetT {
|
||||
@@ -299,14 +270,13 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||
}
|
||||
return wire.StateView{
|
||||
Game: toWireGame(s.Game),
|
||||
Seat: s.Seat,
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
||||
Alphabet: alphabet,
|
||||
Game: toWireGame(s.Game),
|
||||
Seat: s.Seat,
|
||||
Rack: s.Rack,
|
||||
BagLen: s.BagLen,
|
||||
HintsRemaining: s.HintsRemaining,
|
||||
WalletBalance: s.WalletBalance,
|
||||
Alphabet: alphabet,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3,"hint_unlock_left_seconds":1200}`))
|
||||
_, _ = w.Write([]byte(`{"game":{"id":"g-1","variant":"scrabble_en","status":"active","players":2,"to_move":1,"seats":[{"seat":0,"account_id":"u-7","score":5}]},"seat":0,"rack":[0,1],"bag_len":80,"hints_remaining":4,"wallet_balance":3}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
st := fb.GetRootAsStateView(payload, 0)
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 {
|
||||
t.Fatalf("state decoded wrong: bag=%d rack=%d hints=%d wallet=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance())
|
||||
}
|
||||
game := st.Game(nil)
|
||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||
|
||||
@@ -71,11 +71,6 @@ func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
|
||||
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else if name == "sw.js" {
|
||||
// The service worker must be revalidated on every load so a new deploy's worker (and its
|
||||
// fresh precache manifest) is picked up promptly; a cached sw.js would strand clients on
|
||||
// the old build. The worker's network-first navigation then serves the fresh shell online.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
@@ -94,10 +94,6 @@ func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
|
||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
|
||||
t.Errorf("Content-Type = %q, want text/javascript", ct)
|
||||
}
|
||||
// Revalidated on every load so a new deploy's worker (and its fresh precache) is picked up.
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("sw.js Cache-Control = %q, want no-cache", cc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandlerFallsBackToShellForUnknownManifestPath guards the trap: a manifest/sw path NOT emitted
|
||||
|
||||
@@ -223,13 +223,6 @@ table BannerInfo {
|
||||
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
||||
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
||||
// (all added trailing — backward-compatible).
|
||||
// DictVersion is one variant's current dictionary version, carried on the profile so an offline
|
||||
// client learns it from an existing cold-start request (no extra call for a rarely-used feature).
|
||||
table DictVersion {
|
||||
variant:string;
|
||||
version:string;
|
||||
}
|
||||
|
||||
table Profile {
|
||||
user_id:string;
|
||||
display_name:string;
|
||||
@@ -252,10 +245,6 @@ table Profile {
|
||||
email:string;
|
||||
telegram_linked:bool;
|
||||
vk_linked:bool;
|
||||
// dict_versions carries each variant's current dictionary version so an offline client can
|
||||
// preload the right dictionary and pin a new local game without a separate request (added
|
||||
// trailing — backward-compatible).
|
||||
dict_versions:[DictVersion];
|
||||
}
|
||||
|
||||
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||
@@ -316,13 +305,6 @@ table StateView {
|
||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
||||
// backward-compatible).
|
||||
wallet_balance:int;
|
||||
// hint_unlock_left_seconds is, for a vs_ai game, how many seconds until the idle hint unlocks
|
||||
// (the robot's last move plus the idle window, computed from the SERVER clock online / the device
|
||||
// clock offline, capped at the window and floored at 0); 0 for a human's first move (no robot move
|
||||
// yet) or a non-vs_ai game. The client anchors a MONOTONIC countdown (performance.now()) to it, so a
|
||||
// client clock change cannot skew it — see lib/hints. Seconds granularity is enough; sent trailing
|
||||
// (additive, backward-compatible).
|
||||
hint_unlock_left_seconds:int;
|
||||
}
|
||||
|
||||
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type DictVersion struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &DictVersion{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsDictVersion(buf []byte, offset flatbuffers.UOffsetT) *DictVersion {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &DictVersion{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedDictVersionBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Variant() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *DictVersion) Version() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DictVersionStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(2)
|
||||
}
|
||||
func DictVersionAddVariant(builder *flatbuffers.Builder, variant flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(variant), 0)
|
||||
}
|
||||
func DictVersionAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(version), 0)
|
||||
}
|
||||
func DictVersionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -211,28 +211,8 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(34, n)
|
||||
}
|
||||
|
||||
func (rcv *Profile) DictVersions(obj *DictVersion, j int) bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||
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 *Profile) DictVersionsLength() int {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(36))
|
||||
if o != 0 {
|
||||
return rcv._tab.VectorLen(o)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ProfileStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(17)
|
||||
builder.StartObject(16)
|
||||
}
|
||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||
@@ -285,12 +265,6 @@ func ProfileAddTelegramLinked(builder *flatbuffers.Builder, telegramLinked bool)
|
||||
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
||||
builder.PrependBoolSlot(15, vkLinked, false)
|
||||
}
|
||||
func ProfileAddDictVersions(builder *flatbuffers.Builder, dictVersions flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(16, flatbuffers.UOffsetT(dictVersions), 0)
|
||||
}
|
||||
func ProfileStartDictVersionsVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||
return builder.StartVector(4, numElems, 4)
|
||||
}
|
||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -156,20 +156,8 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(16, n)
|
||||
}
|
||||
|
||||
func (rcv *StateView) HintUnlockLeftSeconds() int32 {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetInt32(o + rcv._tab.Pos)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (rcv *StateView) MutateHintUnlockLeftSeconds(n int32) bool {
|
||||
return rcv._tab.MutateInt32Slot(18, n)
|
||||
}
|
||||
|
||||
func StateViewStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(8)
|
||||
builder.StartObject(7)
|
||||
}
|
||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
||||
@@ -198,9 +186,6 @@ func StateViewStartAlphabetVector(builder *flatbuffers.Builder, numElems int) fl
|
||||
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||
}
|
||||
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
||||
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
||||
}
|
||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
+7
-9
@@ -87,14 +87,13 @@ type AlphabetEntry struct {
|
||||
// (wire alphabet indices), bag size and hint budget. Alphabet is set only when the
|
||||
// recipient may not have cached the variant's display table yet.
|
||||
type StateView struct {
|
||||
Game GameView
|
||||
Seat int
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
HintUnlockLeftSeconds int
|
||||
Alphabet []AlphabetEntry
|
||||
Game GameView
|
||||
Seat int
|
||||
Rack []int
|
||||
BagLen int
|
||||
HintsRemaining int
|
||||
WalletBalance int
|
||||
Alphabet []AlphabetEntry
|
||||
}
|
||||
|
||||
// AccountRef is a referenced account with its display name resolved.
|
||||
@@ -257,7 +256,6 @@ func BuildStateView(b *flatbuffers.Builder, s StateView) flatbuffers.UOffsetT {
|
||||
fb.StateViewAddBagLen(b, int32(s.BagLen))
|
||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
||||
if hasAlphabet {
|
||||
fb.StateViewAddAlphabet(b, alphabet)
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||
// Settings offline toggle shows.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q: string) =>
|
||||
(q.includes('display-mode: standalone')
|
||||
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||
: orig(q)) as MediaQueryList;
|
||||
});
|
||||
}
|
||||
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
async function goOffline(page: Page): Promise<void> {
|
||||
await page.locator('button.tab').nth(2).click(); // Settings
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||
// It first waits for the dots to be empty, so a call made right after a previous entry does not lose
|
||||
// digits during the 250 ms verdict pause (the 4th digit → pause → clear/advance).
|
||||
async function typePin(page: Page, digits: string): Promise<void> {
|
||||
await expect(page.locator('.pad .dot.on')).toHaveCount(0);
|
||||
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||
}
|
||||
|
||||
test.describe('offline hotseat (pass-and-play)', () => {
|
||||
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
||||
await forceStandalone(page);
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
await goOffline(page);
|
||||
|
||||
// A known seed: the bag deals a full rack deterministically, AND the seeded seating shuffle keeps
|
||||
// the roster order (Ann first) so the locked-seat assertions below are stable. (Seat order is
|
||||
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||
|
||||
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||
|
||||
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||
|
||||
// Set the host PIN (enter + confirm), then decline taking a seat.
|
||||
await page.locator('.hostpin .plink').click();
|
||||
await typePin(page, '9999');
|
||||
await typePin(page, '9999');
|
||||
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||
|
||||
// Pick the English variant (the plaques mirror Quick Match; "Scrabble" is the Latin English name).
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
|
||||
// Two players: Ann (PIN-locked) and Bob (open).
|
||||
await page.locator('.pname').nth(0).fill('Ann');
|
||||
await page.locator('.pname').nth(1).fill('Bob');
|
||||
|
||||
// Row-delete: a 3rd player's kebab asks the host PIN, which ARMS a ❌ (not a silent delete);
|
||||
// tapping the ❌ removes the row.
|
||||
await page.getByRole('button', { name: /Add player|Добавить/i }).click();
|
||||
await expect(page.locator('.prow')).toHaveCount(3);
|
||||
await page.locator('.prow').nth(2).locator('.pkebab').click();
|
||||
await typePin(page, '9999');
|
||||
const rowDel = page.locator('.prow').nth(2).locator('.prow-del');
|
||||
await expect(rowDel).toBeVisible();
|
||||
await rowDel.click();
|
||||
await expect(page.locator('.prow')).toHaveCount(2);
|
||||
|
||||
// Lock Ann's seat with a PIN.
|
||||
await page.locator('.prow').nth(0).locator('.plink').click();
|
||||
await typePin(page, '1234');
|
||||
await typePin(page, '1234');
|
||||
|
||||
await page.locator('button.invite').click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
||||
await expect(page.locator('.unlock')).toBeVisible();
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
||||
|
||||
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
||||
await page.locator('.unlock').click();
|
||||
await typePin(page, '0000');
|
||||
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
||||
await typePin(page, '1234');
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||
|
||||
// Opening the history reveals NO social controls on the seat plaques (a local game is
|
||||
// account-less), and the Dictionary entry (the comms button) is kept for the active game.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('.fico')).toHaveCount(0);
|
||||
await expect(page.locator('.chat-ico')).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
|
||||
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||
// whose seat is open, so his rack shows without a lock.
|
||||
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||
await typePin(page, '9999');
|
||||
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
||||
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
||||
await expect(page.locator('.unlock')).toHaveCount(0);
|
||||
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||
|
||||
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||
await page.locator('.kebab').first().click();
|
||||
const del = page.locator('.rowwrap.revealed .del');
|
||||
await expect(del).toBeVisible();
|
||||
await del.click();
|
||||
await typePin(page, '9999');
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q: string) =>
|
||||
(q.includes('display-mode: standalone')
|
||||
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||
: orig(q)) as MediaQueryList;
|
||||
});
|
||||
}
|
||||
|
||||
// After a load, land in the lobby. The mock cold-starts on the login screen (no seeded session), so
|
||||
// click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes
|
||||
// this deterministic: a count() sampled during the pre-login splash frame returned 0, skipped the
|
||||
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest|гост/i }).first().click();
|
||||
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
|
||||
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||
// onboarding strips after it completes).
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
|
||||
// all-distinct letters, so the glyph is unambiguous).
|
||||
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
||||
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||||
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||||
}
|
||||
|
||||
test.describe('offline mode', () => {
|
||||
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||
await forceStandalone(page);
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
|
||||
// Online: a seeded online game (vs Ann) is listed.
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||
await page.locator('button.tab').nth(2).click();
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
||||
|
||||
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
|
||||
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
await page.locator('button.invite').click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
|
||||
await expect(page.locator('.lock')).toHaveCount(0);
|
||||
|
||||
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
||||
await placeTile(page, 'W', 7, 5);
|
||||
await placeTile(page, 'A', 7, 6);
|
||||
await placeTile(page, 'Y', 7, 7);
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||
await page.locator('.make').click();
|
||||
|
||||
// The play commits and the local robot replies with a real move, so the board carries more than
|
||||
// WAY's three tiles.
|
||||
await expect(async () => {
|
||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||
}).toPass({ timeout: 15000 });
|
||||
const filled = await page.locator('[data-cell].filled').count();
|
||||
|
||||
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
|
||||
// button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here).
|
||||
await expect(page.locator('.lock')).toBeVisible();
|
||||
|
||||
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||
// IndexedDB with every committed tile intact.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
||||
// The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload —
|
||||
// the wait was not reset by relaunching.
|
||||
await expect(page.locator('.lock')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
// The hash router must update its reactive `route` rune synchronously inside navigate(), not defer it
|
||||
// to the asynchronous `hashchange` event. Bootstrap flips `app.ready` in the same tick right after
|
||||
// `navigate('/login')` on an unauthenticated cold start; a route that trailed the hash for one frame
|
||||
// let App.svelte render the stale route (the empty-hash lobby) under the new login screen — a visible
|
||||
// lobby-shell flash plus a doomed games.list. It also made the offline e2e flaky: enterLobby could
|
||||
// latch that transient lobby tab-bar instead of clicking through the login screen.
|
||||
|
||||
test('navigate updates the reactive route synchronously (no hashchange lag)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// The bundle has loaded and installed the mock __router seam once the login screen is up.
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
||||
|
||||
// Read the route in the SAME microtask as the navigate: the fix makes it the new route at once;
|
||||
// the pre-fix code returned the previous route (it waited for the async hashchange).
|
||||
const routeAfterNavigate = await page.evaluate(() => {
|
||||
const r = (window as unknown as { __router: { navigate(p: string): void; route(): string } }).__router;
|
||||
r.navigate('/settings');
|
||||
return r.route();
|
||||
});
|
||||
expect(routeAfterNavigate).toBe('settings');
|
||||
|
||||
// The hash was written too, so a reload/back still resolves the same route.
|
||||
expect(new URL(page.url()).hash).toBe('#/settings');
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Playwright's WebKit has no real soft keyboard, so emulate iOS's visual-viewport behaviour: replace
|
||||
// window.visualViewport with a controllable fake BEFORE the app boots. The app's syncViewport
|
||||
// (app.svelte.ts) reads visualViewport.height/offsetTop and mirrors them into --vvh / --vv-top, which
|
||||
// position the pinned app-shell (app.css html.app-shell body). Driving the fake exercises the exact
|
||||
// code path the real keyboard triggers on iOS — where the layout viewport does NOT shrink and the
|
||||
// visual viewport instead offsets down toward the focused field.
|
||||
async function installFakeViewport(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const fake = new EventTarget() as EventTarget & { height: number; offsetTop: number; width: number };
|
||||
fake.height = window.innerHeight;
|
||||
fake.offsetTop = 0;
|
||||
fake.width = window.innerWidth;
|
||||
Object.defineProperty(window, 'visualViewport', { configurable: true, value: fake });
|
||||
(window as unknown as { __vv: typeof fake }).__vv = fake;
|
||||
});
|
||||
}
|
||||
|
||||
async function setViewport(page: Page, height: number, offsetTop: number): Promise<void> {
|
||||
await page.evaluate(
|
||||
([h, t]) => {
|
||||
const vv = (window as unknown as { __vv: { height: number; offsetTop: number; dispatchEvent(e: Event): boolean } }).__vv;
|
||||
vv.height = h;
|
||||
vv.offsetTop = t;
|
||||
vv.dispatchEvent(new Event('resize'));
|
||||
vv.dispatchEvent(new Event('scroll'));
|
||||
},
|
||||
[height, offsetTop],
|
||||
);
|
||||
}
|
||||
|
||||
async function shell(page: Page): Promise<{ vvh: string; top: string; bodyTop: string }> {
|
||||
return page.evaluate(() => ({
|
||||
vvh: getComputedStyle(document.documentElement).getPropertyValue('--vvh').trim(),
|
||||
top: getComputedStyle(document.documentElement).getPropertyValue('--vv-top').trim(),
|
||||
bodyTop: getComputedStyle(document.body).top,
|
||||
}));
|
||||
}
|
||||
|
||||
test.describe('visual-viewport shell (soft-keyboard alignment)', () => {
|
||||
test('the pinned shell follows the visual viewport height AND offset', async ({ page }) => {
|
||||
await installFakeViewport(page);
|
||||
await page.goto('/');
|
||||
await expect(page.locator('html.app-shell')).toBeAttached();
|
||||
const innerH = await page.evaluate(() => window.innerHeight);
|
||||
|
||||
// Keyboard closed: full height, no offset.
|
||||
await setViewport(page, innerH, 0);
|
||||
let s = await shell(page);
|
||||
expect(s.top).toBe('0px');
|
||||
expect(s.bodyTop).toBe('0px');
|
||||
|
||||
// Keyboard open + iOS offset: the visual viewport shrinks AND offsets down. The pinned shell must
|
||||
// follow BOTH — the body's top must equal the offset (not stay at 0), else the top-anchored
|
||||
// content shows empty space below (the iOS bug this fixes).
|
||||
await setViewport(page, innerH - 300, 180);
|
||||
s = await shell(page);
|
||||
expect(s.vvh).toBe(`${innerH - 300}px`);
|
||||
expect(s.top).toBe('180px');
|
||||
expect(s.bodyTop).toBe('180px');
|
||||
|
||||
// Keyboard closed again: the offset reverts to 0 — no stale shift left behind.
|
||||
await setViewport(page, innerH, 0);
|
||||
s = await shell(page);
|
||||
expect(s.top).toBe('0px');
|
||||
expect(s.bodyTop).toBe('0px');
|
||||
});
|
||||
});
|
||||
+1
-5
@@ -32,10 +32,6 @@
|
||||
"svelte-check": "^4.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-pwa": "^0.21.2",
|
||||
"vitest": "^3.0.0",
|
||||
"workbox-core": "^7.4.1",
|
||||
"workbox-precaching": "^7.4.1",
|
||||
"workbox-routing": "^7.4.1"
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,8 @@ export default defineConfig({
|
||||
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
|
||||
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
|
||||
// URLs, so the minified JS under test is identical to the contour's.
|
||||
// After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the
|
||||
// offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in
|
||||
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling).
|
||||
command:
|
||||
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
||||
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
||||
url: 'http://localhost:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
|
||||
Generated
+21
-3195
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
// Install-only service worker.
|
||||
//
|
||||
// Its sole job today is to satisfy Chromium's PWA installability requirement (a registered
|
||||
// service worker with a fetch handler) so the web app can be installed to the home screen /
|
||||
// desktop — notably on Android, where the manifest alone is not enough. It deliberately does
|
||||
// NOT cache assets or the Connect-RPC stream: only top-level navigations are handled,
|
||||
// network-first with a cached-shell fallback, so the live app, its immutable hashed assets and
|
||||
// the live event stream are never served stale.
|
||||
//
|
||||
// This is the single designated growth point for the planned offline mode (a Settings toggle,
|
||||
// default online — see docs/ARCHITECTURE.md). Extend the fetch router here; keep the same
|
||||
// /app/ scope; gate any real caching behind the toggle and coordinate updates with the boot
|
||||
// version guard.
|
||||
const SHELL = 'scrabble-shell-v1'; // bump only when the SW strategy itself changes.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// No cached content to migrate carefully, so activate the new worker immediately.
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(keys.filter((k) => k !== SHELL).map((k) => caches.delete(k)));
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const req = event.request;
|
||||
// Only top-level navigations are handled. Hashed assets and the Connect-RPC requests are
|
||||
// left untouched (no respondWith -> the browser performs its default fetch), so the worker
|
||||
// can never break the live stream or serve a stale immutable asset.
|
||||
if (req.mode !== 'navigate') return;
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(req);
|
||||
const cache = await caches.open(SHELL);
|
||||
// One shell entry regardless of the deep-link path: the hash router resolves the route
|
||||
// client-side, so any offline navigation can be served the same cached shell.
|
||||
cache.put('shell', res.clone());
|
||||
return res;
|
||||
} catch {
|
||||
const cache = await caches.open(SHELL);
|
||||
const cached = await cache.match('shell');
|
||||
return cached ?? Response.error();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -20,16 +20,10 @@ import { join } from 'node:path';
|
||||
const DIST = 'dist';
|
||||
|
||||
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
||||
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
|
||||
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
|
||||
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
|
||||
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
||||
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
||||
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
||||
// hashing stay in lazy chunks / are negligible). The heavy parts — the dict loader, the move
|
||||
// generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS
|
||||
// chunk, not this JS budget.
|
||||
const BUDGET = { app: 120, shared: 30, landing: 5 };
|
||||
// wiring, then to 112 for the PWA install feature (the install CTA + the pwa detection /
|
||||
// service-worker registration live in the app entry; the heavy dict subsystem stays in lazy
|
||||
// chunks). Its scoped CSS lands in the CSS chunk, not this JS budget.
|
||||
const BUDGET = { app: 112, shared: 30, landing: 5 };
|
||||
|
||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Copies the real per-variant dictionary DAWGs into the mock e2e preview output (dist-e2e/e2edict/)
|
||||
// so the offline spec can create and play a real local vs_ai game (the mock's fetchDict serves these
|
||||
// files — see lib/mock/client.ts). The dawgs are NEVER committed and never enter the production
|
||||
// build; they live only in the throwaway dist-e2e/ that `vite preview` serves for Playwright.
|
||||
//
|
||||
// Source directory: E2E_DICT_DIR — in CI the fetched scrabble-dictionary release
|
||||
// ($GITHUB_WORKSPACE/dawg); locally it defaults to the sibling scrabble-solver/dawg checkout. A
|
||||
// missing source only warns (so the other, dawg-free specs still run); the offline spec then fails
|
||||
// with an obvious HTTP 404 from fetchDict.
|
||||
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const srcDir = process.env.E2E_DICT_DIR ?? '../../scrabble-solver/dawg';
|
||||
const outDir = 'dist-e2e/e2edict';
|
||||
|
||||
// The app's Variant enum value -> the release dawg file name (matches the movegen/parity mapping).
|
||||
const dawgFor = {
|
||||
scrabble_en: 'en_sowpods',
|
||||
scrabble_ru: 'ru_scrabble',
|
||||
erudit_ru: 'ru_erudit',
|
||||
};
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
let copied = 0;
|
||||
for (const [variant, file] of Object.entries(dawgFor)) {
|
||||
const src = join(srcDir, `${file}.dawg`);
|
||||
if (!existsSync(src)) {
|
||||
console.warn(`e2e-dict: missing ${src} — the offline spec will 404 (set E2E_DICT_DIR)`);
|
||||
continue;
|
||||
}
|
||||
copyFileSync(src, join(outDir, `${variant}.dawg`));
|
||||
copied++;
|
||||
}
|
||||
console.log(`e2e-dict: copied ${copied}/${Object.keys(dawgFor).length} dawgs from ${srcDir} -> ${outDir}`);
|
||||
+1
-59
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { app, bootstrap, resolveOfflinePrompt } from './lib/app.svelte';
|
||||
import { app, bootstrap } from './lib/app.svelte';
|
||||
import { router, type RouteName } from './lib/router.svelte';
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import Toast from './components/Toast.svelte';
|
||||
@@ -136,21 +136,6 @@
|
||||
<Coachmark />
|
||||
<MaintenanceOverlay />
|
||||
|
||||
<!-- Cold-start "no connection" dialog: the reachability check timed out with the network interface
|
||||
reportedly online, so it is ambiguous. The player chooses to go offline (play local vs_ai) or to
|
||||
keep trying to connect. bootstrap awaits this choice (app.offlinePrompt). -->
|
||||
{#if app.offlinePrompt}
|
||||
<div class="offprompt" role="dialog" aria-modal="true">
|
||||
<div class="card">
|
||||
<p class="msg">{t('offline.promptTitle')}</p>
|
||||
<div class="acts">
|
||||
<button class="opt primary" onclick={() => resolveOfflinePrompt(true)}>{t('offline.promptYes')}</button>
|
||||
<button class="opt" onclick={() => resolveOfflinePrompt(false)}>{t('offline.promptNo')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
|
||||
<Splash />
|
||||
{/if}
|
||||
@@ -160,49 +145,6 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* The cold-start "no connection" dialog — a centred card over a scrim, above the splash. */
|
||||
.offprompt {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: var(--pad);
|
||||
}
|
||||
.offprompt .card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.offprompt .msg {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.offprompt .acts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.offprompt .opt {
|
||||
padding: 11px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.offprompt .opt.primary {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.splash {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
|
||||
+1
-9
@@ -162,15 +162,7 @@ html.app-shell {
|
||||
}
|
||||
html.app-shell body {
|
||||
position: fixed;
|
||||
/* Follow the visual viewport (top + height), not merely fill the layout viewport: iOS Safari /
|
||||
WKWebView does not shrink the layout viewport for the soft keyboard — it offsets the visual
|
||||
viewport down toward the focused field — so a top-anchored (inset:0) shell would misalign, showing
|
||||
empty space below the content (worst on a repeat focus). --vv-top / --vvh are kept in sync from
|
||||
app.svelte.ts (syncViewport). The fallbacks equal inset:0 before the first sync / on the landing. */
|
||||
top: var(--vv-top, 0px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--vvh, 100%);
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode, dictPreloadWarning } from '../lib/offline.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app, openDebug } from '../lib/app.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
@@ -28,7 +27,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="nav" class:grow class:offline={offlineMode.active}>
|
||||
<header class="nav" class:grow>
|
||||
<div class="bar">
|
||||
{#if showBack}
|
||||
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||
@@ -37,20 +36,15 @@
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
{#if connection.online || offlineMode.active}
|
||||
{#if connection.online}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<h1 onclick={onTitleTap}>{title}</h1>
|
||||
{:else}
|
||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||
{/if}
|
||||
<!-- A right-hand spacer balances the back button so the title stays centred; in offline mode it
|
||||
carries the "Offline" chip so the deliberate mode is always visible (not just the blue tint). -->
|
||||
{#if offlineMode.active}
|
||||
<span class="chip">{t('settings.offline')}</span>
|
||||
{:else}
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
||||
same place on every screen — and in the game (grown nav) the spare height falls below
|
||||
@@ -58,11 +52,7 @@
|
||||
coachmark overlay is up (app.coachActive) so the scrolling strip does not run behind the
|
||||
dimmed onboarding layer; the engine keeps rotating (module scope) and the strip reappears,
|
||||
per this same condition, once onboarding closes. -->
|
||||
{#if dictPreloadWarning.active}
|
||||
<!-- A background dictionary preload for offline readiness failed (poor connection): a soft
|
||||
notice takes the ad banner's slot until a later preload succeeds and clears it. -->
|
||||
<p class="preload-warn" role="alert">{t('offline.preloadWarning')}</p>
|
||||
{:else if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
||||
{#if app.profile?.banner && app.profile.banner.campaigns.length && !app.coachActive}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
@@ -84,12 +74,6 @@
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
/* Deliberate offline mode: a blue-tinted nav, mixed from the accent so it tracks the light/dark
|
||||
theme (and any Telegram theme override) — the offline state is unmistakable at a glance. */
|
||||
.nav.offline {
|
||||
background: color-mix(in srgb, var(--accent) 20%, var(--bg-elev));
|
||||
border-bottom-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
}
|
||||
.nav.grow {
|
||||
/* Grow into spare height (banner under the title, the board pinned to the bottom), but
|
||||
never shrink: on a short viewport the banner keeps its height and the board's own
|
||||
@@ -130,34 +114,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* The offline chip: a compact accent-tinted pill in the header's right slot. */
|
||||
.chip {
|
||||
min-width: 40px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 16%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* The offline-readiness preload warning: a soft, muted strip in the ad banner's slot, sized like
|
||||
the banner region so the bar does not jump when it appears. */
|
||||
.preload-warn {
|
||||
margin: 0;
|
||||
padding: 7px var(--pad);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.3;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-2);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
<script lang="ts">
|
||||
// Apple-lock-screen-style 4-digit PIN pad for offline pass-and-play (hotseat) games.
|
||||
// Modes:
|
||||
// set — enter a PIN, then repeat it to confirm; emits { kind: 'set', lock }.
|
||||
// verify — enter a PIN; runs `verify(pin)` on the 4th digit; emits { kind: 'verified' }
|
||||
// or shakes + clears on a wrong PIN.
|
||||
// change — run `verify` on the current PIN, then offer "set a new PIN" (the set flow)
|
||||
// or, when allowRemove, "remove PIN" ({ kind: 'removed' }).
|
||||
// Verification is delegated to the caller's `verify` callback: at creation time it checks a
|
||||
// UI-held lock (lib/pin verifyPin); in-game it delegates to the local source (which holds the
|
||||
// stored lock). The pad never shows an OK button: entering the 4th digit is the action. PIN
|
||||
// storage is a social lock, not cryptography — see lib/pin.ts.
|
||||
import Modal from './Modal.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { newLock, type PinLock } from '../lib/pin';
|
||||
|
||||
/** PinResult is the outcome handed to onresult when the pad completes. */
|
||||
export type PinResult =
|
||||
| { kind: 'set'; lock: PinLock }
|
||||
| { kind: 'verified' }
|
||||
| { kind: 'removed' };
|
||||
|
||||
let {
|
||||
mode,
|
||||
title = '',
|
||||
verify,
|
||||
allowRemove = false,
|
||||
onclose,
|
||||
onresult,
|
||||
}: {
|
||||
mode: 'set' | 'verify' | 'change';
|
||||
title?: string;
|
||||
/** Verifies an entered PIN (required for 'verify' and 'change'). */
|
||||
verify?: (pin: string) => Promise<boolean>;
|
||||
allowRemove?: boolean;
|
||||
onclose?: () => void;
|
||||
onresult: (r: PinResult) => void;
|
||||
} = $props();
|
||||
|
||||
const LEN = 4;
|
||||
|
||||
// 'old' verifies the existing PIN (change); 'menu' offers set-new / remove (change);
|
||||
// 'enter' collects a PIN; 'confirm' re-enters it (set / change→new). `mode` is fixed
|
||||
// for the pad's lifetime (callers mount a fresh pad per operation), so capturing its
|
||||
// initial value here is intentional.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let phase = $state<'old' | 'menu' | 'enter' | 'confirm'>(mode === 'change' ? 'old' : 'enter');
|
||||
let buffer = $state('');
|
||||
let first = $state(''); // the first entry, awaiting confirmation
|
||||
let error = $state(false); // drives the error prompt + shake
|
||||
let shakeSeq = $state(0); // bumped on each error to replay the shake animation
|
||||
let busy = $state(false); // guards the async check between the 4th digit and the outcome
|
||||
|
||||
function promptText(): string {
|
||||
if (error) return phase === 'confirm' ? t('pin.mismatch') : t('pin.wrong');
|
||||
switch (phase) {
|
||||
case 'old':
|
||||
return t('pin.enterCurrent');
|
||||
case 'confirm':
|
||||
return t('pin.repeat');
|
||||
default:
|
||||
return mode === 'verify' ? t('pin.enter') : t('pin.create');
|
||||
}
|
||||
}
|
||||
|
||||
function fail(): void {
|
||||
buffer = '';
|
||||
error = true;
|
||||
shakeSeq += 1;
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
busy = true;
|
||||
try {
|
||||
// Let the 4th dot register before the verdict: a beat so the fill (and, on failure, the shake)
|
||||
// reads as a deliberate response rather than an instant flicker.
|
||||
await new Promise((r) => setTimeout(r, 250));
|
||||
if (phase === 'old') {
|
||||
if (verify && (await verify(buffer))) {
|
||||
buffer = '';
|
||||
error = false;
|
||||
phase = 'menu';
|
||||
} else fail();
|
||||
} else if (phase === 'enter' && mode === 'verify') {
|
||||
if (verify && (await verify(buffer))) onresult({ kind: 'verified' });
|
||||
else fail();
|
||||
} else if (phase === 'enter') {
|
||||
first = buffer;
|
||||
buffer = '';
|
||||
error = false;
|
||||
phase = 'confirm';
|
||||
} else if (phase === 'confirm') {
|
||||
if (buffer === first) onresult({ kind: 'set', lock: await newLock(buffer) });
|
||||
else {
|
||||
first = '';
|
||||
phase = 'enter';
|
||||
fail();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function press(d: string): void {
|
||||
if (busy || phase === 'menu' || buffer.length >= LEN) return;
|
||||
error = false;
|
||||
buffer += d;
|
||||
if (buffer.length === LEN) void commit();
|
||||
}
|
||||
|
||||
function back(): void {
|
||||
if (busy || phase === 'menu') return;
|
||||
buffer = buffer.slice(0, -1);
|
||||
}
|
||||
|
||||
function startNew(): void {
|
||||
error = false;
|
||||
first = '';
|
||||
buffer = '';
|
||||
phase = 'enter';
|
||||
}
|
||||
|
||||
function onkey(e: KeyboardEvent): void {
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
press(e.key);
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Backspace') {
|
||||
back();
|
||||
e.preventDefault();
|
||||
} else if (e.key === 'Escape') {
|
||||
onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9'];
|
||||
const slots = Array.from({ length: LEN }, (_, i) => i);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onkey} />
|
||||
|
||||
<Modal {title} {onclose} overlayKeyboard>
|
||||
<div class="pad">
|
||||
{#if phase === 'menu'}
|
||||
<div class="menu">
|
||||
<button class="opt" onclick={startNew}>{t('pin.setNew')}</button>
|
||||
{#if allowRemove}
|
||||
<button class="opt danger" onclick={() => onresult({ kind: 'removed' })}>
|
||||
{t('pin.remove')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="prompt" class:err={error}>{promptText()}</p>
|
||||
{#key shakeSeq}
|
||||
<div class="dots" class:shake={error} aria-hidden="true">
|
||||
{#each slots as i}
|
||||
<span class="dot" class:on={i < buffer.length}></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/key}
|
||||
<div class="grid">
|
||||
{#each keys as k}
|
||||
<button class="key" onclick={() => press(k)} aria-label={k}>{k}</button>
|
||||
{/each}
|
||||
<span class="key spacer"></span>
|
||||
<button class="key" onclick={() => press('0')} aria-label="0">0</button>
|
||||
<button class="key back" onclick={back} aria-label="⌫" disabled={buffer.length === 0}>⌫</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.pad {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.prompt {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--muted, var(--text));
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.prompt.err {
|
||||
color: var(--danger);
|
||||
}
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
.dot {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dot.on {
|
||||
background: var(--text);
|
||||
}
|
||||
.shake {
|
||||
animation: shake 0.32s ease;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
20% { transform: translateX(-7px); }
|
||||
40% { transform: translateX(7px); }
|
||||
60% { transform: translateX(-5px); }
|
||||
80% { transform: translateX(5px); }
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.key {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.key:active {
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
}
|
||||
.key.back {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.key.back:disabled {
|
||||
opacity: 0.3;
|
||||
}
|
||||
.key.spacer {
|
||||
border: none;
|
||||
background: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.opt {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
font-weight: 600;
|
||||
}
|
||||
.opt.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { handleError, showToast } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { alphabetLetters } from '../lib/alphabet';
|
||||
@@ -21,10 +20,8 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
|
||||
// through gameSource so an offline (local) game resolves its state from the device, not the
|
||||
// network.
|
||||
const st = await gameSource(id).gameState(id, true);
|
||||
// Include the alphabet so input sanitising + the check accept the variant's letters.
|
||||
const st = await gateway.gameState(id, true);
|
||||
variant = st.game.variant;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
@@ -44,7 +41,7 @@
|
||||
cooling = true;
|
||||
setTimeout(() => (cooling = false), 5000);
|
||||
try {
|
||||
const r = await gameSource(id).checkWord(id, w, variant);
|
||||
const r = await gateway.checkWord(id, w, variant);
|
||||
checked.set(w, r.legal);
|
||||
result = { word: w, legal: r.legal };
|
||||
} catch (e) {
|
||||
@@ -80,11 +77,7 @@
|
||||
: t('game.wordIllegal', { word: result.word })}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
|
||||
receive them, so the control is dropped there. -->
|
||||
{#if !isLocalGameId(id)}
|
||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||
{/if}
|
||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||
{#if result.legal}
|
||||
<a
|
||||
class="lookup"
|
||||
|
||||
@@ -16,21 +16,15 @@
|
||||
// The game is rendered (and cached) before its comms open, so the cache tells us whether
|
||||
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
||||
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
||||
// A local game has NO chat: an honest-AI game (vs_ai) and an offline pass-and-play (hotseat) game.
|
||||
// Its hub is Dictionary-only.
|
||||
const chatless = $derived(
|
||||
(getCachedGame(id)?.view.game.vsAi ?? false) || (getCachedGame(id)?.view.game.hotseat ?? false),
|
||||
);
|
||||
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
|
||||
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false);
|
||||
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
||||
// a chatless game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||
// deep-link falls back to Chat).
|
||||
// A chatless game starts on the Dictionary regardless of the entry route — this keeps ChatScreen
|
||||
// (which fetches chat over the network) from mounting even for a beat, which would otherwise raise
|
||||
// an error toast in offline mode.
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tab = $state<CommsTab>(chatless ? 'dictionary' : initialTab);
|
||||
let tab = $state<CommsTab>(initialTab);
|
||||
$effect(() => {
|
||||
if (chatless) tab = 'dictionary';
|
||||
if (vsAi) tab = 'dictionary';
|
||||
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||
});
|
||||
</script>
|
||||
@@ -49,7 +43,7 @@
|
||||
|
||||
{#snippet tabbar()}
|
||||
<TabBar>
|
||||
{#if !chatless}
|
||||
{#if !vsAi}
|
||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||
</button>
|
||||
|
||||
+49
-392
@@ -4,28 +4,24 @@
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import TapConfirm from '../components/TapConfirm.svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import PinPad from '../components/PinPad.svelte';
|
||||
import DictWarmup from '../components/DictWarmup.svelte';
|
||||
import Board from './Board.svelte';
|
||||
import Rack from './Rack.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
import type { EvalResult, MoveRecord, MoveResult, StateView, Tile } from '../lib/model';
|
||||
import { lastMoveCells, replay } from '../lib/board';
|
||||
import { badgeKind } from '../lib/unread';
|
||||
import { seatMedal } from '../lib/result';
|
||||
import { historyGrid } from '../lib/history';
|
||||
import { centre, premiumGrid } from '../lib/premiums';
|
||||
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
|
||||
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
|
||||
import { hintsLeft, hintGateRemainingMs, hintLockMinutes, HINT_GATE_MS } from '../lib/hints';
|
||||
import { hintsLeft } from '../lib/hints';
|
||||
import { downloadUrl, shareOrDownloadGcg, shareUrlAsFile } from '../lib/share';
|
||||
import { insideVK, vkAndroidWebView, vkCopyText, vkDownloadFile, vkPlatform, vkShowImages } from '../lib/vk';
|
||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||
@@ -51,18 +47,6 @@
|
||||
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
// The game's source: the local engine for an offline game id, otherwise the network gateway. The
|
||||
// screen calls the game-loop methods (state/history/submit/pass/exchange/resign/hint/evaluate/
|
||||
// draft) through it, so the same UI drives a local vs_ai game and an online one alike.
|
||||
const source = $derived(gameSource(id));
|
||||
// A local (offline) game plays entirely on-device, so it is always ready; an online game needs a
|
||||
// live connection — and offline mode's kill switch refuses its calls — so its network actions are
|
||||
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
|
||||
// toasts a blocked call would otherwise raise).
|
||||
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
|
||||
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
||||
let localUnsub: (() => void) | null = null;
|
||||
|
||||
let view = $state<StateView | null>(null);
|
||||
let moves = $state<MoveRecord[]>([]);
|
||||
let placement = $state<Placement>(newPlacement([]));
|
||||
@@ -165,42 +149,10 @@
|
||||
const playable = $derived(!!view && (view.game.status === 'active' || view.game.status === 'open'));
|
||||
const isMyTurn = $derived(!!view && playable && view.game.toMove === view.seat);
|
||||
const gameOver = $derived(!!view && view.game.status === 'finished');
|
||||
// Offline hotseat: the seat to move is PIN-locked until its owner enters the seat PIN this turn.
|
||||
// While locked the board stays visible but the rack is withheld and the move controls disabled;
|
||||
// canMove folds the lock into isMyTurn (locked is always false for a network / vs_ai game).
|
||||
const locked = $derived(!!view?.locked);
|
||||
const canMove = $derived(isMyTurn && !locked);
|
||||
// The hint badge: this game's allowance remaining plus the LIVE global wallet. Reading the
|
||||
// wallet from the profile (not the per-game view snapshot) keeps it correct after a wallet
|
||||
// hint was spent in another game (see lib/hints).
|
||||
const hintCount = $derived(hintsLeft(view, app.profile?.hintBalance ?? 0));
|
||||
// A vs_ai game's hint is unlimited and wallet-free, but idle-gated (an anti-frustration aid: it
|
||||
// unlocks only after the player has been stuck ~30 min on a turn). The source (the SERVER clock
|
||||
// online, the device clock offline) tells us the SECONDS LEFT (view.hintUnlockLeftSeconds); we
|
||||
// anchor a MONOTONIC countdown (performance.now()) to it, so a client clock change cannot skew it.
|
||||
// A fresh value arrives on load (armHintGate below); when the robot moves the wait is the full
|
||||
// window. `monoNow` ticks so the 🔒 lifts live at the mark.
|
||||
let hintGateStart = $state<number | null>(null); // performance.now() at the last anchor; null = open
|
||||
let gateLeftMs = $state(0);
|
||||
let monoNow = $state(performance.now());
|
||||
const hintRemaining = $derived(hintGateRemainingMs(hintGateStart, gateLeftMs, monoNow));
|
||||
const hintGated = $derived(hintRemaining > 0);
|
||||
// Anchor the countdown to a freshly received seconds-left (or open the gate). Called on every state
|
||||
// load from the view, and on the robot's move to the full window (the robot just moved).
|
||||
function armHintGate(leftSeconds: number): void {
|
||||
if (leftSeconds > 0) {
|
||||
hintGateStart = performance.now();
|
||||
gateLeftMs = leftSeconds * 1000;
|
||||
} else {
|
||||
hintGateStart = null;
|
||||
gateLeftMs = 0;
|
||||
}
|
||||
}
|
||||
$effect(() => {
|
||||
if (!view?.game.vsAi) return;
|
||||
const iv = setInterval(() => (monoNow = performance.now()), 10_000);
|
||||
return () => clearInterval(iv);
|
||||
});
|
||||
// RACK_SIZE mirrors the engine's rules.RackSize (7 for every current variant). The exchange
|
||||
// gate is only a UX guard: the backend stays the source of truth and rejects an under-supplied
|
||||
// exchange regardless (engine rejects when bag.Len() < rules.RackSize).
|
||||
@@ -238,13 +190,11 @@
|
||||
// Fetch the saved draft alongside state and history (best-effort) so the composition is
|
||||
// applied in the same tick the board appears — never as a second, visible rack→board step.
|
||||
const [st, hist, draft] = await Promise.all([
|
||||
source.gameState(id, includeAlphabet),
|
||||
source.gameHistory(id),
|
||||
source.draftGet(id).catch(() => ''),
|
||||
gateway.gameState(id, includeAlphabet),
|
||||
gateway.gameHistory(id),
|
||||
gateway.draftGet(id).catch(() => ''),
|
||||
]);
|
||||
view = st;
|
||||
// Anchor the vs_ai idle-hint countdown to the freshly fetched seconds-left (0 = open / non-vs_ai).
|
||||
armHintGate(st.game.vsAi ? (st.hintUnlockLeftSeconds ?? 0) : 0);
|
||||
syncWallet(st.walletBalance);
|
||||
// Seed the unread flag from the authoritative state (the live stream only raises it).
|
||||
seedChatUnread(id, st.game.unreadChat, st.game.unreadMessages);
|
||||
@@ -270,7 +220,7 @@
|
||||
setCachedDraft(id, json);
|
||||
if (draftSaveTimer) clearTimeout(draftSaveTimer);
|
||||
draftSaveTimer = setTimeout(() => {
|
||||
void source.draftSave(id, json).catch(() => {});
|
||||
void gateway.draftSave(id, json).catch(() => {});
|
||||
}, 500);
|
||||
}
|
||||
// applyDraft restores the player's saved composition over a freshly loaded state: the rack
|
||||
@@ -297,9 +247,6 @@
|
||||
if (cached) {
|
||||
view = cached.view;
|
||||
moves = cached.moves;
|
||||
// Arm the vs_ai idle-hint countdown from the cached seconds-left so the 🔒 shows at once on a
|
||||
// warm open (no wait for load()); the cached value is a snapshot, refreshed by load() below.
|
||||
armHintGate(cached.view.game.vsAi ? (cached.view.hintUnlockLeftSeconds ?? 0) : 0);
|
||||
// Apply the cached draft synchronously so a warm/preloaded open paints the pending tiles
|
||||
// already on the board (no full-rack-then-jump). load() then refreshes in the background.
|
||||
applyDraft(cached.view, cached.draft ?? '');
|
||||
@@ -317,12 +264,6 @@
|
||||
dict = m;
|
||||
});
|
||||
}
|
||||
// A local (offline) game has no live stream: route the source's robot-reply events through the
|
||||
// same app event hub the network stream feeds, so the event effect above reacts to
|
||||
// opponent_moved / game_over identically.
|
||||
if (isLocalGameId(id)) {
|
||||
localUnsub = localSource.events(id, (e) => (app.lastEvent = e));
|
||||
}
|
||||
});
|
||||
|
||||
// Warm the game's dictionary for the local move preview once, when both the game and the
|
||||
@@ -368,11 +309,7 @@
|
||||
// While composing, reload so a draft overlapping the new move is reconciled; otherwise apply
|
||||
// the move as a delta with no fetch.
|
||||
if (placement.pending.length > 0) void load();
|
||||
else {
|
||||
applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||
// The robot just moved (vs_ai) → the human's turn begins with the full idle window.
|
||||
if (view?.game.vsAi) armHintGate(HINT_GATE_MS / 1000);
|
||||
}
|
||||
else applyDelta(applyMoveDelta(cacheSnapshot(), { move: e.move, game: e.game, bagLen: e.bagLen }));
|
||||
} else if (e.kind === 'your_turn' && e.gameId === id) {
|
||||
// The opponent_moved delta carries the new state; your_turn only confirms the turn. Refetch
|
||||
// only if we missed the move (our cached count trails the event's).
|
||||
@@ -674,13 +611,8 @@
|
||||
// Flush a pending draft save so leaving mid-composition still persists it.
|
||||
if (draftSaveTimer) {
|
||||
clearTimeout(draftSaveTimer);
|
||||
void source.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||
}
|
||||
localUnsub?.();
|
||||
// Leaving a hotseat game re-locks the current seat, so returning from the lobby re-prompts its
|
||||
// PIN. Read the id from the loaded view, NOT the `id` prop: a $props() value reads back undefined
|
||||
// during Svelte teardown, and isLocalGameId(undefined) would throw and abort this cleanup.
|
||||
if (view?.game.hotseat) localSource.relock(view.game.id);
|
||||
});
|
||||
|
||||
function onCell(row: number, col: number) {
|
||||
@@ -785,8 +717,8 @@
|
||||
// rapid placements do not pile up requests on a slow link.
|
||||
evalCtrl?.abort();
|
||||
evalCtrl = null;
|
||||
// Off-turn (or a locked hotseat seat) the composition is position-only: no score preview.
|
||||
if (!canMove) return;
|
||||
// Off-turn the composition is position-only: no score preview or evaluate.
|
||||
if (!isMyTurn) return;
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
// Instant on-device preview when the game's dictionary is warm; the network otherwise.
|
||||
@@ -808,7 +740,7 @@
|
||||
evalCtrl = ctrl;
|
||||
previewTimer = setTimeout(async () => {
|
||||
try {
|
||||
preview = await source.evaluate(id, sub.tiles, variant, ctrl.signal);
|
||||
preview = await gateway.evaluate(id, sub.tiles, variant, ctrl.signal);
|
||||
notePreviewNetwork();
|
||||
} catch {
|
||||
/* best-effort (or aborted) */
|
||||
@@ -844,106 +776,12 @@
|
||||
refreshRecent();
|
||||
}
|
||||
|
||||
// --- offline hotseat: seat unlock + host (referee) overrides -------------------
|
||||
let unlockOpen = $state(false); // the current locked seat's owner enters their PIN to reveal the rack
|
||||
// The host menu: 'pin' collects the master PIN, then 'menu' lists the overrides. hostPinEntered is
|
||||
// the verified master PIN, reused to authorise the chosen action (the source re-checks it).
|
||||
let hostMenuStep = $state<'closed' | 'pin' | 'menu'>('closed');
|
||||
let hostPinEntered = $state('');
|
||||
// The pending host action awaiting its confirm ("Skip X's turn?" etc.); null shows the menu list.
|
||||
let hostConfirm = $state<{ action: 'skip' | 'resign' | 'terminate'; seat?: number; name?: string } | null>(null);
|
||||
let hostDone = $state(false); // the transient success ✅ shown after an override
|
||||
|
||||
// hotseatName is the plain display name of a seat by index (hotseat seats are account-less local
|
||||
// players, so no "you"/🤖 resolution is needed — unlike the seat-object seatName used elsewhere).
|
||||
function hotseatName(i: number): string {
|
||||
return view?.game.seats[i]?.displayName ?? '';
|
||||
}
|
||||
|
||||
function startSkip(): void {
|
||||
if (!view) return;
|
||||
hostConfirm = { action: 'skip', seat: view.game.toMove, name: hotseatName(view.game.toMove) };
|
||||
}
|
||||
|
||||
function flashDone(): void {
|
||||
hostDone = true;
|
||||
setTimeout(() => (hostDone = false), 1100);
|
||||
}
|
||||
|
||||
// advanceHotseat re-points the screen at the next seat after a hotseat move: the source has advanced
|
||||
// the turn and re-locked, so a fresh state gives the next seat's rack (empty while it is locked).
|
||||
async function advanceHotseat(): Promise<void> {
|
||||
const st = await source.gameState(id, false);
|
||||
view = st;
|
||||
rackIds = st.rack.map((_, i) => i);
|
||||
placement = newPlacement(st.rack);
|
||||
selected = null;
|
||||
preview = null;
|
||||
setCachedGame(id, st, moves, '');
|
||||
}
|
||||
|
||||
// onUnlock verifies the current seat's PIN through the source (which reveals the rack on success)
|
||||
// and applies the unlocked state; handed to the PIN pad as its verifier.
|
||||
async function onUnlock(pin: string): Promise<boolean> {
|
||||
try {
|
||||
const st = await localSource.unlockSeat(id, pin);
|
||||
view = st;
|
||||
rackIds = st.rack.map((_, i) => i);
|
||||
placement = newPlacement(st.rack);
|
||||
selected = null;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// verifyHost captures the entered master PIN (reused to authorise the chosen action) and checks it.
|
||||
async function verifyHost(pin: string): Promise<boolean> {
|
||||
hostPinEntered = pin;
|
||||
return localSource.verifyHostPin(id, pin);
|
||||
}
|
||||
|
||||
function hostConfirmText(c: { action: 'skip' | 'resign' | 'terminate'; name?: string }): string {
|
||||
if (c.action === 'skip') return t('hotseat.askSkip', { name: c.name ?? '' });
|
||||
if (c.action === 'resign') return t('hotseat.askExclude', { name: c.name ?? '' });
|
||||
return t('hotseat.askTerminate');
|
||||
}
|
||||
|
||||
async function runHostAction(): Promise<void> {
|
||||
if (!hostConfirm) return;
|
||||
const { action, seat } = hostConfirm;
|
||||
hostConfirm = null;
|
||||
hostMenuStep = 'closed';
|
||||
busy = true;
|
||||
try {
|
||||
const st = await localSource.hostAction(id, hostPinEntered, action, seat);
|
||||
if (st === null) {
|
||||
navigate('/'); // terminated: the game is deleted, so return to the lobby
|
||||
return;
|
||||
}
|
||||
view = st;
|
||||
rackIds = st.rack.map((_, i) => i);
|
||||
placement = newPlacement(st.rack);
|
||||
selected = null;
|
||||
preview = null;
|
||||
moves = (await source.gameHistory(id)).moves; // a skip / resign added a journal move
|
||||
setCachedGame(id, st, moves, '');
|
||||
flashDone();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
hostPinEntered = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function commit() {
|
||||
const sub = toSubmit(placement);
|
||||
if (!sub) return;
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.submitPlay(id, sub.tiles, variant));
|
||||
if (view?.game.hotseat) await advanceHotseat();
|
||||
applyMoveResult(await gateway.submitPlay(id, sub.tiles, variant));
|
||||
haptic('success');
|
||||
zoomed = false;
|
||||
} catch (e) {
|
||||
@@ -962,8 +800,7 @@
|
||||
async function doPass() {
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.pass(id));
|
||||
if (view?.game.hotseat) await advanceHotseat();
|
||||
applyMoveResult(await gateway.pass(id));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -984,7 +821,7 @@
|
||||
resignOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.resign(id));
|
||||
applyMoveResult(await gateway.resign(id));
|
||||
// Reveal the final board once the game is resigned: close the move-history drawer
|
||||
// (portrait — the landscape dock is unaffected) and zoom out if it was magnified.
|
||||
historyOpen = false;
|
||||
@@ -996,17 +833,8 @@
|
||||
}
|
||||
}
|
||||
async function doHint() {
|
||||
// vs_ai: the idle gate replaces the wallet. While it is still closed, a tap only explains when the
|
||||
// hint unlocks (no wallet is ever spent) — matching the 🔒 badge. Measured fresh at tap time.
|
||||
if (view?.game.vsAi) {
|
||||
const remaining = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
||||
if (remaining > 0) {
|
||||
showToast(t('game.hintLockedIn', { n: hintLockMinutes(remaining) }), 'info');
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const h = await source.hint(id);
|
||||
const h = await gateway.hint(id);
|
||||
if (h.move.tiles.length && view) {
|
||||
placement = placementFromHint(h.move.tiles, view.rack);
|
||||
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
|
||||
@@ -1041,12 +869,6 @@
|
||||
// The backend does not spend a hint when there is no move.
|
||||
if (e instanceof GatewayError && e.code === 'no_hint_available') {
|
||||
showToast(t('game.noHintOptions'), 'info');
|
||||
} else if (e instanceof GatewayError && e.code === 'hint_locked') {
|
||||
// The server-clock backstop refused a vs_ai hint the client thought was open (a rare client
|
||||
// clock desync): re-sync the countdown from a fresh fetch and explain the gate.
|
||||
await load();
|
||||
const left = hintGateRemainingMs(hintGateStart, gateLeftMs, performance.now());
|
||||
showToast(t('game.hintLockedIn', { n: hintLockMinutes(left || 60_000) }), 'info');
|
||||
} else {
|
||||
handleError(e);
|
||||
}
|
||||
@@ -1084,8 +906,7 @@
|
||||
exchangeOpen = false;
|
||||
busy = true;
|
||||
try {
|
||||
applyMoveResult(await source.exchange(id, tiles, variant));
|
||||
if (view?.game.hotseat) await advanceHotseat();
|
||||
applyMoveResult(await gateway.exchange(id, tiles, variant));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
@@ -1107,15 +928,7 @@
|
||||
if (!view) return '';
|
||||
const me = view.game.seats[view.seat];
|
||||
if (me?.isWinner) return t('game.won');
|
||||
if (view.game.endReason === 'aborted') return t('game.tied'); // an abort is a draw for everyone
|
||||
const myScore = me?.score ?? 0;
|
||||
// A declared winner (incl. by resignation) that is not me, or anyone who scored higher → a loss.
|
||||
if (view.game.seats.some((s) => s.isWinner) || view.game.seats.some((s) => !s.resigned && s.score > myScore)) {
|
||||
return t('game.lost');
|
||||
}
|
||||
// No one above me and no declared winner: a tie for the lead is a (shared) win; only an
|
||||
// all-level finish is a draw.
|
||||
return view.game.seats.filter((s) => !s.resigned).every((s) => s.score === myScore) ? t('game.tied') : t('game.won');
|
||||
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
|
||||
}
|
||||
|
||||
// The finished-game export offers two formats — the GCG file and the server-rendered PNG
|
||||
@@ -1431,9 +1244,8 @@
|
||||
// (not the still-empty seat of an open game) who is not yet a friend (an already-requested
|
||||
// opponent still shows it, but disabled).
|
||||
function canAddFriend(s: { accountId: string; seat: number }): boolean {
|
||||
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player — nor in
|
||||
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
|
||||
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||
// Never offer add-friend against an AI opponent, an existing friend, or a blocked player.
|
||||
if (view?.game.vsAi) return false;
|
||||
return (
|
||||
!!s.accountId &&
|
||||
!app.profile?.isGuest &&
|
||||
@@ -1447,7 +1259,7 @@
|
||||
// may still be blocked (the block overrides the friendship), so it omits the friend exclusion.
|
||||
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
||||
function canBlock(s: { accountId: string; seat: number }): boolean {
|
||||
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||
if (view?.game.vsAi) return false;
|
||||
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||
}
|
||||
</script>
|
||||
@@ -1505,7 +1317,7 @@
|
||||
{#if badge}<span class="unread-dot sbadge-dot" class:nudge={badge === 'nudge'}></span>{/if}
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
|
||||
<div class="nm" class:struck={seatBlocked(s)}>{#if gameOver && view.game.hotseat}<span class="medal" aria-hidden="true">{seatMedal(view.game, s.seat)}</span>{/if}{seatName(s)}</div>
|
||||
<div class="nm" class:struck={seatBlocked(s)}>{seatName(s)}</div>
|
||||
<div class="sc" class:blockprompt={blockConfirm[s.seat]}>
|
||||
{#if blockConfirm[s.seat]}{t('game.blockShort')}{:else if addConfirm[s.seat]}{t('game.addFriendShort')}{:else}{s.score}{/if}
|
||||
</div>
|
||||
@@ -1549,18 +1361,13 @@
|
||||
{:else}
|
||||
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{/if}
|
||||
{:else if view.game.hotseat}
|
||||
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||
<span aria-hidden="true"></span>
|
||||
{:else}
|
||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||
<!-- The comms entry opens Chat + the word Dictionary. A local game (vs_ai or hotseat) has no
|
||||
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||
— so drop the entry only when a local game is finished; an active local game keeps it for
|
||||
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
||||
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
|
||||
{#if !(gameOver && view.game.vsAi)}
|
||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||
</button>
|
||||
@@ -1631,12 +1438,9 @@
|
||||
<div class="status">
|
||||
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
|
||||
{#if gameOver}
|
||||
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
|
||||
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
|
||||
game keeps the "you won/lost" text (one human). -->
|
||||
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
|
||||
<strong class="over">{resultText()}</strong>
|
||||
{:else if placement.pending.length === 0}
|
||||
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
<span class="scores">
|
||||
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||
@@ -1650,58 +1454,36 @@
|
||||
a finished game shows the final rack greyed out and the controls disabled. -->
|
||||
<div class="rack-row" class:inert={gameOver} data-coach="game-rack">
|
||||
<div class="rack-wrap">
|
||||
{#if locked}
|
||||
<!-- A PIN-locked hotseat seat: the rack is withheld until its owner unlocks it. The board
|
||||
above stays visible; only this tray is gated. -->
|
||||
<button class="unlock" onclick={() => (unlockOpen = true)}>🔒 {t('hotseat.unlock')}</button>
|
||||
{:else}
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{/if}
|
||||
<Rack
|
||||
slots={rackSlots}
|
||||
{variant}
|
||||
{selected}
|
||||
{landscape}
|
||||
shuffling={shuffling && !app.reduceMotion}
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
</div>
|
||||
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet controlButtons()}
|
||||
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
|
||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online} onclick={openExchange}>
|
||||
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
{#if view?.game.hotseat}
|
||||
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
|
||||
(even on a locked seat: the host may skip a player who forgot their PIN). -->
|
||||
<button class="tab" disabled={busy || gameOver} onclick={() => (hostMenuStep = 'pin')}>
|
||||
<span class="sq">🔐</span><span class="lbl">{t('hotseat.host')}</span>
|
||||
</button>
|
||||
{:else if view?.game.vsAi}
|
||||
<!-- vs_ai hints are unlimited and wallet-free, so no confirm and no count. A 🔒 badge marks the
|
||||
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
|
||||
lifts live at the unlock moment. -->
|
||||
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
|
||||
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
label={t('game.hint')}
|
||||
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
|
||||
onconfirm={doHint}
|
||||
>
|
||||
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</TapConfirm>
|
||||
{/if}
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
label={t('game.hint')}
|
||||
disabled={busy || !isMyTurn || !connection.online || hintCount <= 0}
|
||||
onconfirm={doHint}
|
||||
>
|
||||
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
|
||||
<span class="lbl">{t('game.hint')}</span>
|
||||
</TapConfirm>
|
||||
{#if placement.pending.length > 0}
|
||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||
@@ -1754,60 +1536,11 @@
|
||||
<Modal title={t('game.confirmResign')} onclose={() => (resignOpen = false)}>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (resignOpen = false)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={doResign} disabled={!netReady}>{t('game.dropGame')}</button>
|
||||
<button class="danger" onclick={doResign} disabled={!connection.online}>{t('game.dropGame')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<!-- Offline hotseat: unlock the current seat's rack. -->
|
||||
{#if unlockOpen && view}
|
||||
<PinPad
|
||||
mode="verify"
|
||||
title={t('hotseat.unlockTitle', { name: hotseatName(view.game.toMove) })}
|
||||
verify={onUnlock}
|
||||
onclose={() => (unlockOpen = false)}
|
||||
onresult={() => (unlockOpen = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Offline hotseat: the host (referee) enters the master PIN, then picks an override. -->
|
||||
{#if hostMenuStep === 'pin'}
|
||||
<PinPad
|
||||
mode="verify"
|
||||
title={t('hotseat.host')}
|
||||
verify={verifyHost}
|
||||
onclose={() => (hostMenuStep = 'closed')}
|
||||
onresult={() => (hostMenuStep = 'menu')}
|
||||
/>
|
||||
{/if}
|
||||
{#if hostMenuStep === 'menu' && view}
|
||||
<Modal title={t('hotseat.host')} onclose={() => { hostMenuStep = 'closed'; hostConfirm = null; }}>
|
||||
{#if hostConfirm}
|
||||
<p class="host-q">{hostConfirmText(hostConfirm)}</p>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (hostConfirm = null)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={runHostAction}>{t('common.ok')}</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="host-menu">
|
||||
<button class="host-act" onclick={startSkip}>
|
||||
{t('hotseat.skip')}
|
||||
</button>
|
||||
<div class="host-sub">{t('hotseat.exclude')}</div>
|
||||
{#each view.game.seats as s (s.seat)}
|
||||
<button class="host-seat" onclick={() => (hostConfirm = { action: 'resign', seat: s.seat, name: s.displayName })}>
|
||||
{s.displayName}
|
||||
</button>
|
||||
{/each}
|
||||
<button class="host-act danger" onclick={() => (hostConfirm = { action: 'terminate' })}>{t('hotseat.terminate')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
{/if}
|
||||
{#if hostDone}
|
||||
<div class="host-done" aria-hidden="true">✅</div>
|
||||
{/if}
|
||||
|
||||
{#if exportOpen}
|
||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||
<div class="export-opts">
|
||||
@@ -1815,7 +1548,7 @@
|
||||
<button
|
||||
class="confirm"
|
||||
onclick={() => { exportOpen = false; void exportArtifact('png'); }}
|
||||
disabled={!netReady}
|
||||
disabled={!connection.online}
|
||||
>
|
||||
{t('game.exportImageOpt')}
|
||||
</button>
|
||||
@@ -1823,7 +1556,7 @@
|
||||
<button
|
||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||
onclick={() => { exportOpen = false; void exportArtifact('gcg'); }}
|
||||
disabled={!netReady}
|
||||
disabled={!connection.online}
|
||||
>
|
||||
{t('game.exportGcgOpt')}
|
||||
</button>
|
||||
@@ -1877,10 +1610,6 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* The finished-game place medal on a local (offline) seat plaque, left of the name. */
|
||||
.medal {
|
||||
margin-right: 3px;
|
||||
}
|
||||
.sc {
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -2302,76 +2031,4 @@
|
||||
.scoreboard.flat {
|
||||
cursor: default;
|
||||
}
|
||||
/* The vs_ai hint's idle lock: a small 🔒 at the icon-square corner (the .sq is positioned by the
|
||||
shared TabBar rule). No count pill — the hint is unlimited; the lock alone marks the gate. */
|
||||
.sq .lock {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
}
|
||||
/* --- offline hotseat --- */
|
||||
/* The locked-seat rack overlay: fills the rack tray with an "unlock" button, the board above
|
||||
stays visible. */
|
||||
.unlock {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 44px;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-act,
|
||||
.host-seat {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
.host-act.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
.host-sub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.host-q {
|
||||
margin: 0 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
/* The transient success tick after a host override — a centred ✅ that fades out on a timer. */
|
||||
.host-done {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
animation: host-done-fade 1.1s ease forwards;
|
||||
}
|
||||
@keyframes host-done-fade {
|
||||
0% { opacity: 0; transform: scale(0.7); }
|
||||
25% { opacity: 1; transform: scale(1); }
|
||||
75% { opacity: 1; transform: scale(1); }
|
||||
100% { opacity: 0; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,6 @@ export { ChatPostRequest } from './scrabblefb/chat-post-request.js';
|
||||
export { CheckWordRequest } from './scrabblefb/check-word-request.js';
|
||||
export { ComplaintRequest } from './scrabblefb/complaint-request.js';
|
||||
export { CreateInvitationRequest } from './scrabblefb/create-invitation-request.js';
|
||||
export { DictVersion } from './scrabblefb/dict-version.js';
|
||||
export { DraftRequest } from './scrabblefb/draft-request.js';
|
||||
export { DraftView } from './scrabblefb/draft-view.js';
|
||||
export { EmailConfirmLinkRequest } from './scrabblefb/email-confirm-link-request.js';
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class DictVersion {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):DictVersion {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
|
||||
return (obj || new DictVersion()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsDictVersion(bb:flatbuffers.ByteBuffer, obj?:DictVersion):DictVersion {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new DictVersion()).__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;
|
||||
}
|
||||
|
||||
version():string|null
|
||||
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
version(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 startDictVersion(builder:flatbuffers.Builder) {
|
||||
builder.startObject(2);
|
||||
}
|
||||
|
||||
static addVariant(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, variantOffset, 0);
|
||||
}
|
||||
|
||||
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, versionOffset, 0);
|
||||
}
|
||||
|
||||
static endDictVersion(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createDictVersion(builder:flatbuffers.Builder, variantOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
DictVersion.startDictVersion(builder);
|
||||
DictVersion.addVariant(builder, variantOffset);
|
||||
DictVersion.addVersion(builder, versionOffset);
|
||||
return DictVersion.endDictVersion(builder);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
import { BannerInfo } from '../scrabblefb/banner-info.js';
|
||||
import { DictVersion } from '../scrabblefb/dict-version.js';
|
||||
|
||||
|
||||
export class Profile {
|
||||
@@ -125,18 +124,8 @@ vkLinked():boolean {
|
||||
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
|
||||
}
|
||||
|
||||
dictVersions(index: number, obj?:DictVersion):DictVersion|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||
return offset ? (obj || new DictVersion()).__init(this.bb!.__indirect(this.bb!.__vector(this.bb_pos + offset) + index * 4), this.bb!) : null;
|
||||
}
|
||||
|
||||
dictVersionsLength():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 36);
|
||||
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startProfile(builder:flatbuffers.Builder) {
|
||||
builder.startObject(17);
|
||||
builder.startObject(16);
|
||||
}
|
||||
|
||||
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
|
||||
@@ -215,22 +204,6 @@ static addVkLinked(builder:flatbuffers.Builder, vkLinked:boolean) {
|
||||
builder.addFieldInt8(15, +vkLinked, +false);
|
||||
}
|
||||
|
||||
static addDictVersions(builder:flatbuffers.Builder, dictVersionsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(16, dictVersionsOffset, 0);
|
||||
}
|
||||
|
||||
static createDictVersionsVector(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 startDictVersionsVector(builder:flatbuffers.Builder, numElems:number) {
|
||||
builder.startVector(4, numElems, 4);
|
||||
}
|
||||
|
||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
|
||||
@@ -74,13 +74,8 @@ walletBalance():number {
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
hintUnlockLeftSeconds():number {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 18);
|
||||
return offset ? this.bb!.readInt32(this.bb_pos + offset) : 0;
|
||||
}
|
||||
|
||||
static startStateView(builder:flatbuffers.Builder) {
|
||||
builder.startObject(8);
|
||||
builder.startObject(7);
|
||||
}
|
||||
|
||||
static addGame(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset) {
|
||||
@@ -135,16 +130,12 @@ static addWalletBalance(builder:flatbuffers.Builder, walletBalance:number) {
|
||||
builder.addFieldInt32(6, walletBalance, 0);
|
||||
}
|
||||
|
||||
static addHintUnlockLeftSeconds(builder:flatbuffers.Builder, hintUnlockLeftSeconds:number) {
|
||||
builder.addFieldInt32(7, hintUnlockLeftSeconds, 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, alphabetOffset:flatbuffers.Offset, walletBalance:number, hintUnlockLeftSeconds:number):flatbuffers.Offset {
|
||||
static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offset, seat:number, rackOffset:flatbuffers.Offset, bagLen:number, hintsRemaining:number, alphabetOffset:flatbuffers.Offset, walletBalance:number):flatbuffers.Offset {
|
||||
StateView.startStateView(builder);
|
||||
StateView.addGame(builder, gameOffset);
|
||||
StateView.addSeat(builder, seat);
|
||||
@@ -153,7 +144,6 @@ static createStateView(builder:flatbuffers.Builder, gameOffset:flatbuffers.Offse
|
||||
StateView.addHintsRemaining(builder, hintsRemaining);
|
||||
StateView.addAlphabet(builder, alphabetOffset);
|
||||
StateView.addWalletBalance(builder, walletBalance);
|
||||
StateView.addHintUnlockLeftSeconds(builder, hintUnlockLeftSeconds);
|
||||
return StateView.endStateView(builder);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-188
@@ -43,9 +43,6 @@ import { parseStartParam } from './deeplink';
|
||||
import {
|
||||
clearOnboarding,
|
||||
clearSession,
|
||||
clearProfile,
|
||||
loadProfile,
|
||||
saveProfile,
|
||||
loadOnboarding,
|
||||
loadPrefs,
|
||||
loadSession,
|
||||
@@ -55,9 +52,7 @@ import {
|
||||
savePrefs,
|
||||
} from './session';
|
||||
import type { CoachSeries } from './coachmark';
|
||||
import { connection, reportOffline, reportOnline, resetConnection, checkReachable } from './connection.svelte';
|
||||
import { offlineMode, setOfflineMode } from './offline.svelte';
|
||||
import { shouldBootOffline } from './offline';
|
||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||
import { isConnectionCode } from './retry';
|
||||
import { clearGameCache, getCachedGame, setCachedGame } from './gamecache';
|
||||
import { advanceCached } from './gamedelta';
|
||||
@@ -78,10 +73,6 @@ export const app = $state<{
|
||||
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
|
||||
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||
bootError: boolean;
|
||||
/** True while the cold-start "no connection — go offline?" dialog is up (the reachability check
|
||||
* timed out with the network interface reportedly online). App.svelte renders it over the splash;
|
||||
* bootstrap awaits the choice via resolveOfflinePrompt. */
|
||||
offlinePrompt: boolean;
|
||||
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
||||
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
||||
@@ -152,7 +143,6 @@ export const app = $state<{
|
||||
}>({
|
||||
ready: false,
|
||||
bootError: false,
|
||||
offlinePrompt: false,
|
||||
launchError: null,
|
||||
debugOpen: false,
|
||||
lobbyReady: false,
|
||||
@@ -304,9 +294,7 @@ export function handleError(err: unknown): void {
|
||||
void enterBlocked();
|
||||
return;
|
||||
}
|
||||
// A blocked call in offline mode ('offline', the transport kill switch) is expected, not an error
|
||||
// to surface — the offline chrome already signals the state; never a red toast.
|
||||
if (isConnectionCode(code) || code === 'offline' || !connection.online) return;
|
||||
if (isConnectionCode(code) || !connection.online) return;
|
||||
haptic('error');
|
||||
showToast(t(code ? errorKey(code) : 'error.generic'), 'error');
|
||||
}
|
||||
@@ -472,7 +460,6 @@ export async function refreshProfile(): Promise<void> {
|
||||
if (!app.session) return;
|
||||
try {
|
||||
app.profile = await gateway.profileGet();
|
||||
void saveProfile($state.snapshot(app.profile)); // keep the offline-boot cache fresh (plain snapshot)
|
||||
} catch {
|
||||
// Best-effort; the banner just stays as it was until the next fetch.
|
||||
}
|
||||
@@ -530,11 +517,6 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
await saveSession(s);
|
||||
try {
|
||||
app.profile = await gateway.profileGet();
|
||||
// Persist the profile so a later offline cold start can launch from it (its variant
|
||||
// preferences, dictionary versions and display name) without a network fetch. Snapshot to a
|
||||
// plain object first — the reactive $state proxy is not structured-cloneable, so it would fail
|
||||
// the IndexedDB write and fall back to a localStorage entry that loadProfile never reads.
|
||||
void saveProfile($state.snapshot(app.profile));
|
||||
// The live interface language follows the device — the explicit local choice (saved in
|
||||
// prefs) or the system guess made at bootstrap — and is no longer overridden from the
|
||||
// account here: the Telegram bot a user signs in through must not dictate the UI, so a
|
||||
@@ -558,74 +540,6 @@ async function adoptSession(s: Session): Promise<void> {
|
||||
void refreshNotifications();
|
||||
}
|
||||
|
||||
// The cold-start "no connection — go offline?" dialog. bootstrap raises it and awaits the choice
|
||||
// here; App.svelte's dialog buttons call resolveOfflinePrompt.
|
||||
const BOOT_REACHABILITY_TIMEOUT_MS = 3000;
|
||||
let offlinePromptResolve: ((goOffline: boolean) => void) | null = null;
|
||||
|
||||
/** resolveOfflinePrompt answers the cold-start "no connection — go offline?" dialog (App.svelte). */
|
||||
export function resolveOfflinePrompt(goOffline: boolean): void {
|
||||
app.offlinePrompt = false;
|
||||
const resolve = offlinePromptResolve;
|
||||
offlinePromptResolve = null;
|
||||
resolve?.(goOffline);
|
||||
}
|
||||
|
||||
/** promptOfflineChoice raises the dialog and resolves to the player's choice (true = go offline). */
|
||||
function promptOfflineChoice(): Promise<boolean> {
|
||||
app.offlinePrompt = true;
|
||||
return new Promise<boolean>((resolve) => {
|
||||
offlinePromptResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* initNetworkReactivity reacts to mid-session network changes (e.g. the player toggling flight mode):
|
||||
* losing the network interface enters offline mode automatically (session-only); regaining it
|
||||
* verifies the gateway is really reachable (an interface being up does not guarantee it) and returns
|
||||
* to online — but only if the offline was auto. A deliberate offline (the Settings toggle or the
|
||||
* cold-start dialog) is left as the player's choice. These are passive OS events — no polling, no
|
||||
* battery cost. Web-only and skipped in the mock (the e2e drives connectivity directly).
|
||||
*/
|
||||
// While in auto-offline, poll for the network to return so the app comes back online — robust to the
|
||||
// unreliable `online` event (which often does not fire in an installed PWA). Each tick is cheap: it
|
||||
// reads navigator.onLine (no radio) and only hits the network (a reachability check) when the
|
||||
// interface is actually up, so while flight mode is on it costs nothing. The poll runs ONLY in
|
||||
// auto-offline (a deliberate offline is the player's choice) and stops on returning online.
|
||||
let recoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
export function scheduleRecovery(delayMs: number): void {
|
||||
if (recoveryTimer) {
|
||||
clearTimeout(recoveryTimer);
|
||||
recoveryTimer = null;
|
||||
}
|
||||
if (typeof window === 'undefined' || !offlineMode.active || !offlineMode.auto) return;
|
||||
recoveryTimer = setTimeout(async () => {
|
||||
recoveryTimer = null;
|
||||
if (!offlineMode.active || !offlineMode.auto) return;
|
||||
const interfaceUp = typeof navigator === 'undefined' || navigator.onLine;
|
||||
if (interfaceUp && (await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS))) {
|
||||
if (offlineMode.active && offlineMode.auto) setOfflineMode(false);
|
||||
return;
|
||||
}
|
||||
scheduleRecovery(4000);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
export function initNetworkReactivity(): void {
|
||||
if (typeof window === 'undefined' || import.meta.env.MODE === 'mock') return;
|
||||
window.addEventListener('offline', () => {
|
||||
if (!offlineMode.active) {
|
||||
setOfflineMode(true, false); // auto (session)
|
||||
scheduleRecovery(4000); // poll for the network to return
|
||||
}
|
||||
});
|
||||
// The online event, when it fires, just kicks an immediate reachability check; the poll above is
|
||||
// the reliable fallback for platforms where it does not fire.
|
||||
window.addEventListener('online', () => {
|
||||
if (offlineMode.active && offlineMode.auto) scheduleRecovery(0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* applyLinkResult applies a completed account link or merge: it adopts a
|
||||
* switched session (a guest initiator whose durable counterpart won, so the active
|
||||
@@ -725,37 +639,17 @@ function syncTelegramSafeArea(): void {
|
||||
root.classList.toggle('tg-fullscreen', top > 0);
|
||||
}
|
||||
|
||||
// The soft-keyboard height threshold (px) that distinguishes an open keyboard from browser chrome
|
||||
// (an address bar shrinks the visual viewport by far less), used to gate the focus-into-view scroll.
|
||||
const KEYBOARD_MIN_PX = 120;
|
||||
|
||||
/**
|
||||
* syncViewport mirrors the visual viewport into CSS vars so the pinned app-shell (app.css
|
||||
* `html.app-shell body`) both FITS (`--vvh`) and FOLLOWS (`--vv-top`) the visible area above the soft
|
||||
* keyboard. iOS Safari / WKWebView does not shrink the layout viewport for the keyboard (Android
|
||||
* Chrome does); instead the visual viewport shrinks AND offsets down to reveal the focused field, and
|
||||
* those values do not always cleanly revert. Tracking only the height left the top-anchored shell
|
||||
* misaligned on iOS — empty space below the content, worst on a repeat focus; tracking offsetTop too
|
||||
* keeps the shell on the visible area on every platform. On the keyboard-OPEN transition it also
|
||||
* scrolls the focused field into view, since iOS does not reliably scroll a pinned document to it.
|
||||
* syncViewportHeight mirrors the visual-viewport height into the --vvh CSS var so a screen can
|
||||
* fit the visible area above an open soft keyboard (iOS does not shrink dvh for the keyboard).
|
||||
* On a screen whose input sits at the bottom (chat, word-check) this keeps the input visible
|
||||
* without the page scrolling, so the layout no longer jumps when the keyboard appears.
|
||||
*/
|
||||
let keyboardOpen = false;
|
||||
function syncViewport(): void {
|
||||
function syncViewportHeight(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const win = typeof window !== 'undefined' ? window : null;
|
||||
const vv = win ? win.visualViewport : null;
|
||||
const h = vv ? vv.height : win ? win.innerHeight : 0;
|
||||
const top = vv ? vv.offsetTop : 0;
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
||||
const h = vv ? vv.height : typeof window !== 'undefined' ? window.innerHeight : 0;
|
||||
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
|
||||
document.documentElement.style.setProperty('--vv-top', `${top}px`);
|
||||
const open = !!vv && !!win && win.innerHeight - h > KEYBOARD_MIN_PX;
|
||||
if (open && !keyboardOpen) {
|
||||
const el = document.activeElement;
|
||||
if (el instanceof HTMLElement && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA')) {
|
||||
requestAnimationFrame(() => el.scrollIntoView({ block: 'center' }));
|
||||
}
|
||||
}
|
||||
keyboardOpen = open;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -815,23 +709,11 @@ export async function bootstrap(): Promise<void> {
|
||||
setLocale(guess);
|
||||
}
|
||||
|
||||
// Track the visual viewport (height + offset) so screens fit and follow the visible area above an
|
||||
// open soft keyboard (--vvh / --vv-top). Both resize AND scroll fire on iOS when the keyboard moves
|
||||
// the visual viewport.
|
||||
syncViewport();
|
||||
// Track the visual-viewport height so screens fit above an open soft keyboard (--vvh).
|
||||
syncViewportHeight();
|
||||
if (typeof window !== 'undefined' && window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', syncViewport);
|
||||
window.visualViewport.addEventListener('scroll', syncViewport);
|
||||
}
|
||||
|
||||
// Enter on a single-line <input> dismisses the soft keyboard (blur): the keyboard's default
|
||||
// return/"Go" key otherwise does nothing on a bare input, which reads as stuck. A <textarea> (the
|
||||
// feedback form) is excluded so Enter still inserts a newline; a form's own Enter→submit still
|
||||
// fires first (this listener is on the document, so it runs after the field's own handler).
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && e.target instanceof HTMLInputElement) e.target.blur();
|
||||
});
|
||||
window.visualViewport.addEventListener('resize', syncViewportHeight);
|
||||
window.visualViewport.addEventListener('scroll', syncViewportHeight);
|
||||
}
|
||||
|
||||
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
|
||||
@@ -907,61 +789,12 @@ export async function bootstrap(): Promise<void> {
|
||||
// worker so the app is installable — Chromium needs a registered SW, notably on Android. Web-only
|
||||
// and skipped in the mock build; see lib/pwa.svelte.
|
||||
registerServiceWorker();
|
||||
// React to mid-session network changes (e.g. flight mode) for the rest of the session.
|
||||
initNetworkReactivity();
|
||||
|
||||
// Deliberate offline mode carried over from a prior session: with no network the session adoption
|
||||
// + profile fetch below would hang the splash, so skip them and launch straight from the cached
|
||||
// session + profile into the offline lobby. Without a cached profile (e.g. storage cleared) fall
|
||||
// through to the normal online flow but KEEP the sticky flag — the mode is the player's choice, and
|
||||
// an online boot re-persists the profile so the next launch goes offline. (A truly offline launch
|
||||
// with no cached profile is unreachable: enabling offline requires a prior online session.)
|
||||
if (offlineMode.active) {
|
||||
const [offlineSession, offlineProfile] = await Promise.all([loadSession(), loadProfile()]);
|
||||
if (shouldBootOffline({ offlineActive: true, hasSession: !!offlineSession, hasProfile: !!offlineProfile })) {
|
||||
gateway.setToken(offlineSession!.token);
|
||||
app.session = offlineSession!;
|
||||
app.profile = offlineProfile!;
|
||||
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await loadSession();
|
||||
// A VK ID web-link callback (?code&device_id&state) rides the URL after the redirect back
|
||||
// from VK; capture and clear it here (it needs the restored session to link against).
|
||||
const vkcb = pendingVKLink();
|
||||
if (saved) {
|
||||
// Cold-start network auto-detect (deliberate offline is handled above). With a cached profile to
|
||||
// fall back on and no pending VK-link, do not hang on adoptSession's retrying profile fetch when
|
||||
// the network is down: a device with no network interface goes offline for the session (no
|
||||
// dialog); an interface that is up but cannot reach the gateway within a short window asks the
|
||||
// player. Web-only reaches here (the Mini-App branches returned above), so isStandalone gates it.
|
||||
const cachedProfile = await loadProfile();
|
||||
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
|
||||
if (canOffline) {
|
||||
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
gateway.setToken(saved.token);
|
||||
const reachable = interfaceOffline ? false : await checkReachable(BOOT_REACHABILITY_TIMEOUT_MS);
|
||||
if (!reachable) {
|
||||
// No network interface → go offline for the session (no dialog); interface up but the
|
||||
// gateway is unreachable → ambiguous, so ask.
|
||||
const goOffline = interfaceOffline ? true : await promptOfflineChoice();
|
||||
if (goOffline) {
|
||||
// A deliberate dialog choice is sticky; an auto (no-interface) offline is session-only.
|
||||
setOfflineMode(true, !interfaceOffline);
|
||||
if (interfaceOffline) scheduleRecovery(4000); // auto-offline: poll for the network to return
|
||||
app.session = saved;
|
||||
app.profile = cachedProfile;
|
||||
if (router.route.name === 'login' || router.route.name === 'confirm') navigate('/');
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
// "Нет" — keep trying online: fall through to adoptSession (it retries; the connection
|
||||
// watcher shows "Connecting…").
|
||||
}
|
||||
}
|
||||
await adoptSession(saved);
|
||||
if (vkcb) {
|
||||
// The full-page redirect lost the in-app route, so hand the callback to Profile, which
|
||||
@@ -1190,7 +1023,6 @@ export async function logout(): Promise<void> {
|
||||
app.splashDone = false;
|
||||
gateway.setToken(null);
|
||||
await clearSession();
|
||||
await clearProfile();
|
||||
app.session = null;
|
||||
app.profile = null;
|
||||
navigate('/login');
|
||||
@@ -1342,11 +1174,4 @@ if (import.meta.env.MODE === 'mock' && typeof window !== 'undefined') {
|
||||
closeStream();
|
||||
app.blocked = { blocked: true, permanent: true, until: '', reason: '', ...b };
|
||||
};
|
||||
// Expose the router so the e2e can assert navigate() updates the route synchronously (no
|
||||
// hashchange lag): a route that trailed the hash for a frame let bootstrap's `app.ready` flip
|
||||
// render the previous route under the new screen (the empty-hash lobby flashing under login).
|
||||
(window as unknown as { __router?: { navigate(p: string): void; route(): string } }).__router = {
|
||||
navigate,
|
||||
route: () => router.route.name,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -712,28 +712,6 @@ describe('codec', () => {
|
||||
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
|
||||
});
|
||||
|
||||
it('decodes the profile dictionary versions into a per-variant map', () => {
|
||||
const b = new Builder(128);
|
||||
const uid = b.createString('u-1');
|
||||
const en = fb.DictVersion.createDictVersion(b, b.createString('scrabble_en'), b.createString('v1.3.0'));
|
||||
const er = fb.DictVersion.createDictVersion(b, b.createString('erudit_ru'), b.createString('v1.2.0'));
|
||||
const vec = fb.Profile.createDictVersionsVector(b, [en, er]);
|
||||
fb.Profile.startProfile(b);
|
||||
fb.Profile.addUserId(b, uid);
|
||||
fb.Profile.addDictVersions(b, vec);
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({ scrabble_en: 'v1.3.0', erudit_ru: 'v1.2.0' });
|
||||
});
|
||||
|
||||
it('decodes an absent dict_versions vector to an empty map', () => {
|
||||
const b = new Builder(64);
|
||||
const uid = b.createString('u-1');
|
||||
fb.Profile.startProfile(b);
|
||||
fb.Profile.addUserId(b, uid);
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).dictVersions).toEqual({});
|
||||
});
|
||||
|
||||
it('encodes the update-profile variant preferences', () => {
|
||||
const buf = encodeUpdateProfile({
|
||||
displayName: 'Kaya',
|
||||
|
||||
@@ -364,25 +364,9 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
email: s(p.email()),
|
||||
telegramLinked: p.telegramLinked(),
|
||||
vkLinked: p.vkLinked(),
|
||||
dictVersions: decodeDictVersions(p),
|
||||
};
|
||||
}
|
||||
|
||||
// decodeDictVersions reads the profile's per-variant current dictionary versions into a
|
||||
// variant-keyed map, so the offline preloader can look up the version for each enabled
|
||||
// variant. An entry with an unknown variant or empty version is skipped.
|
||||
function decodeDictVersions(p: fb.Profile): Partial<Record<Variant, string>> {
|
||||
const out: Partial<Record<Variant, string>> = {};
|
||||
for (let i = 0; i < p.dictVersionsLength(); i++) {
|
||||
const dv = p.dictVersions(i);
|
||||
if (!dv) continue;
|
||||
const variant = s(dv.variant()) as Variant;
|
||||
const version = s(dv.version());
|
||||
if (variant && version) out[variant] = version;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
|
||||
// the player enabled in Settings).
|
||||
function decodeVariantPreferences(p: fb.Profile): Variant[] {
|
||||
@@ -461,7 +445,6 @@ function decodeStateViewTable(v: fb.StateView): StateView {
|
||||
bagLen: v.bagLen(),
|
||||
hintsRemaining: v.hintsRemaining(),
|
||||
walletBalance: v.walletBalance(),
|
||||
hintUnlockLeftSeconds: v.hintUnlockLeftSeconds(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
// other traffic is in flight.
|
||||
|
||||
import { backoffMs } from './retry';
|
||||
import { offlineMode } from './offline.svelte';
|
||||
|
||||
let online = $state(true);
|
||||
let watchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -28,24 +27,6 @@ export function registerProbe(fn: () => Promise<void>): void {
|
||||
probe = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* checkReachable runs the reachability probe once, bounded by timeoutMs, and reports whether the
|
||||
* gateway answered — a single attempt (no retry loop), for the cold-start network decision — while
|
||||
* updating the online signal. It reports false when no probe is registered or the timeout wins.
|
||||
*/
|
||||
export async function checkReachable(timeoutMs: number): Promise<boolean> {
|
||||
if (!probe) return false;
|
||||
try {
|
||||
await Promise.race([
|
||||
probe(),
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('reachability timeout')), timeoutMs)),
|
||||
]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** reportOnline marks the gateway reachable and stops the watcher. */
|
||||
export function reportOnline(): void {
|
||||
online = true;
|
||||
@@ -70,9 +51,7 @@ function scheduleProbe(attempt: number): void {
|
||||
watchTimer = setTimeout(
|
||||
() => {
|
||||
watchTimer = null;
|
||||
// Never probe the network in offline mode (the kill switch); the online/offline events drive
|
||||
// recovery there instead.
|
||||
if (online || !probe || offlineMode.active) return;
|
||||
if (online || !probe) return;
|
||||
probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6)));
|
||||
},
|
||||
backoffMs(attempt),
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Dawg } from './dawg';
|
||||
|
||||
// The step-by-step DAWG cursor (root/final/next/arcs) is the primitive the move
|
||||
// generator walks. These fast unit tests pin it against a small committed sample
|
||||
// dictionary (backend/cmd/movegen); the full parity vs the Go solver lands with
|
||||
// the generator's conformance fixtures.
|
||||
const bytes = new Uint8Array(readFileSync(new URL('./testdata/sample_en.dawg', import.meta.url)));
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(new URL('./testdata/sample_en.words.json', import.meta.url), 'utf8'),
|
||||
) as { numAdded: number; words: string[]; indexes: number[][] };
|
||||
|
||||
const key = (w: number[]): string => w.join(',');
|
||||
|
||||
// enumerateWords walks the whole automaton depth-first, collecting the index path
|
||||
// at every accepting node — i.e. every stored word.
|
||||
function enumerateWords(d: Dawg): number[][] {
|
||||
const out: number[][] = [];
|
||||
const path: number[] = [];
|
||||
const visit = (node: number): void => {
|
||||
d.arcs(node, (label, dest, final) => {
|
||||
path.push(label);
|
||||
if (final) out.push(path.slice());
|
||||
visit(dest);
|
||||
path.pop();
|
||||
return true;
|
||||
});
|
||||
};
|
||||
if (d.final(d.root())) out.push([]);
|
||||
visit(d.root());
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('dawg cursor', () => {
|
||||
it('parses the sample fixture', () => {
|
||||
const d = new Dawg(bytes);
|
||||
expect(d.numAdded).toBe(fixture.numAdded);
|
||||
});
|
||||
|
||||
it('root is not an accepting state (the sample has no empty word)', () => {
|
||||
const d = new Dawg(bytes);
|
||||
expect(d.final(d.root())).toBe(false);
|
||||
});
|
||||
|
||||
it('enumerates exactly the stored words', () => {
|
||||
const d = new Dawg(bytes);
|
||||
const got = enumerateWords(d).map(key).sort();
|
||||
const want = fixture.indexes.map(key).sort();
|
||||
expect(got).toEqual(want);
|
||||
});
|
||||
|
||||
it('next walks a stored word to an accepting node and rejects a non-edge', () => {
|
||||
const d = new Dawg(bytes);
|
||||
let node = d.root();
|
||||
for (const ch of [2, 0, 17, 4, 3]) {
|
||||
// "cared"
|
||||
node = d.next(node, ch);
|
||||
expect(node).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
expect(d.final(node)).toBe(true);
|
||||
// "care" (index [2,0,17,4]) is an internal accepting node on the way to "cared".
|
||||
const care = [2, 0, 17, 4].reduce((n, ch) => d.next(n, ch), d.root());
|
||||
expect(d.final(care)).toBe(true);
|
||||
// No stored word starts with 'z' (index 25).
|
||||
expect(d.next(d.root(), 25)).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -95,85 +95,6 @@ export class Dawg {
|
||||
return this.indexOf(word) >= 0;
|
||||
}
|
||||
|
||||
// --- Step-by-step traversal (the move generator's primitive) ---------------
|
||||
//
|
||||
// A `Node` is a bit offset into the graph; 0 denotes the root (which resolves
|
||||
// to firstNodeOffset). These mirror dafsa's traverse.go Cursor (Root/Final/
|
||||
// Next/Arcs) over the same bitstream this reader already decodes, so the ported
|
||||
// generator can drive the automaton one transition at a time. Single-threaded
|
||||
// JS shares this reader's position across calls; every method re-seeks to its
|
||||
// node on entry, and arcs brackets the callback with a save/restore, so nested
|
||||
// use during a walk is safe. Mirrors dafsa (*Cursor).
|
||||
|
||||
/** root returns the start state of the automaton. */
|
||||
root(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** final reports whether node is an accepting state (a stored word ends there). */
|
||||
final(node: number): boolean {
|
||||
if (this.numEdges <= 0) {
|
||||
return this.hasEmptyWord && node === 0;
|
||||
}
|
||||
this.p = node === 0 ? this.firstNodeOffset : node;
|
||||
return this.readBits(1) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* next follows the edge labelled ch (an alphabet index) from node, returning the
|
||||
* destination node, or -1 when no such edge exists.
|
||||
*/
|
||||
next(node: number, ch: number): number {
|
||||
return this.getEdge(node, ch) ? this.eNode : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* arcs calls fn for each out-edge of node in ascending label order, passing the
|
||||
* edge's label, its destination node and whether that destination is accepting.
|
||||
* It stops early if fn returns false. Mirrors dafsa (*Cursor).Arcs.
|
||||
*/
|
||||
arcs(node: number, fn: (label: number, dest: number, final: boolean) => boolean): void {
|
||||
if (this.numEdges <= 0) {
|
||||
return;
|
||||
}
|
||||
this.p = node === 0 ? this.firstNodeOffset : node;
|
||||
this.readBits(1); // node final flag — not needed here
|
||||
const fallthrough = this.readBits(1);
|
||||
|
||||
if (fallthrough === 1) {
|
||||
const label = this.readBits(this.cbits);
|
||||
// The reader now sits at the destination node, whose first bit is its final flag.
|
||||
const dest = this.p;
|
||||
const final = this.readBits(1) === 1;
|
||||
fn(label, dest, final);
|
||||
return;
|
||||
}
|
||||
|
||||
const nskiplen = bitsLen(this.wbits);
|
||||
let nskip = 0;
|
||||
let numEdges = 1;
|
||||
if (this.readBits(1) !== 1) {
|
||||
// not a single edge
|
||||
numEdges = this.readUnsigned();
|
||||
nskip = this.readBits(nskiplen);
|
||||
}
|
||||
|
||||
for (let i = 0; i < numEdges; i++) {
|
||||
const label = this.readBits(this.cbits);
|
||||
if (i > 0) {
|
||||
this.readBits(nskip); // per-edge skip count, unused for traversal
|
||||
}
|
||||
const dest = this.readBits(this.abits);
|
||||
const resume = this.p;
|
||||
this.p = dest;
|
||||
const final = this.readBits(1) === 1;
|
||||
if (!fn(label, dest, final)) {
|
||||
return;
|
||||
}
|
||||
this.p = resume;
|
||||
}
|
||||
}
|
||||
|
||||
// getEdge resolves the outgoing edge for ch from the node at the given bit
|
||||
// offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors
|
||||
// dafsa (*dawg).getEdge.
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { Dawg } from './dawg';
|
||||
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
||||
import type { Ruleset } from './validate';
|
||||
|
||||
// Conformance gate for the ported move generator: for each committed position it must
|
||||
// return exactly the ranked play list the real Go solver returns (backend/cmd/movegen).
|
||||
// The Russian sample reaches alphabet index 32, exercising the 33-letter cross-set.
|
||||
|
||||
interface Tile {
|
||||
row: number;
|
||||
col: number;
|
||||
letter: number;
|
||||
blank: boolean;
|
||||
}
|
||||
interface GenMove {
|
||||
dir: number;
|
||||
tiles: Tile[];
|
||||
score: number;
|
||||
}
|
||||
interface Fixture {
|
||||
ruleset: {
|
||||
size: number;
|
||||
cols: number;
|
||||
center: number;
|
||||
rackSize: number;
|
||||
bingo: number;
|
||||
values: number[];
|
||||
letterMult: number[][];
|
||||
wordMult: number[][];
|
||||
};
|
||||
cases: {
|
||||
name: string;
|
||||
placed: Tile[] | null;
|
||||
rack: { letters: number[]; blanks: number };
|
||||
mode: number;
|
||||
ignoreCrossWords: boolean;
|
||||
moves: GenMove[];
|
||||
}[];
|
||||
}
|
||||
|
||||
function load(tag: string): { dawg: Dawg; fx: Fixture } {
|
||||
const bytes = new Uint8Array(readFileSync(new URL(`./testdata/sample_${tag}.dawg`, import.meta.url)));
|
||||
const fx = JSON.parse(
|
||||
readFileSync(new URL(`./testdata/sample_${tag}.gen.json`, import.meta.url), 'utf8'),
|
||||
) as Fixture;
|
||||
return { dawg: new Dawg(bytes), fx };
|
||||
}
|
||||
|
||||
function buildBoard(placed: Tile[], cols: number): GenBoard {
|
||||
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
|
||||
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
|
||||
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
|
||||
return {
|
||||
rows: cols,
|
||||
cols,
|
||||
inBounds,
|
||||
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
||||
cellAt: (r, c) => grid[r * cols + c]!,
|
||||
isEmpty: () => placed.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
|
||||
const r = fx.ruleset;
|
||||
return {
|
||||
cols: r.cols,
|
||||
center: r.center,
|
||||
rackSize: r.rackSize,
|
||||
bingo: r.bingo,
|
||||
values: r.values,
|
||||
letterMult: (row, col) => r.letterMult[row][col],
|
||||
wordMult: (row, col) => r.wordMult[row][col],
|
||||
ignoreCrossWords,
|
||||
};
|
||||
}
|
||||
|
||||
// sig is an order-stable signature of a move: orientation, score and its placed tiles
|
||||
// sorted by square — so two lists compare equal iff they rank the same plays the same way.
|
||||
function sig(m: GenMove): string {
|
||||
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
|
||||
}
|
||||
|
||||
for (const tag of ['en', 'ru']) {
|
||||
describe(`move generator parity vs Go solver (${tag})`, () => {
|
||||
const { dawg, fx } = load(tag);
|
||||
for (const c of fx.cases) {
|
||||
it(c.name, () => {
|
||||
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
|
||||
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
|
||||
const rs = rulesetFor(fx, c.ignoreCrossWords);
|
||||
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
|
||||
expect(got.map(sig)).toEqual(c.moves.map(sig));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Dawg } from './dawg';
|
||||
import { generateMoves, GenRack, type GenBoard, type Mode } from './generate';
|
||||
import type { Ruleset } from './validate';
|
||||
|
||||
// Full-dictionary conformance for the ported move generator: for positions over the real
|
||||
// shipped dictionaries (deep graphs and full 26/33-letter alphabets the tiny committed
|
||||
// samples cannot reach) it must return exactly the ranked list the Go solver does. The
|
||||
// golden vectors come from `go run ./backend/cmd/movegen -dawg-dir <dawg> -out <dir>` and
|
||||
// the CI conformance job wires the two directories in; the suite skips when they are unset.
|
||||
const dawgDir = process.env.DICT_DAWG_DIR;
|
||||
const goldDir = process.env.DICT_MOVEGEN_DIR;
|
||||
const ready = !!dawgDir && !!goldDir && existsSync(dawgDir) && existsSync(goldDir);
|
||||
|
||||
// variant -> the release dawg file name (matches dawg.parity.test.ts / the movegen tool).
|
||||
const variants = [
|
||||
{ variant: 'scrabble_en', dawg: 'en_sowpods' },
|
||||
{ variant: 'scrabble_ru', dawg: 'ru_scrabble' },
|
||||
{ variant: 'erudit_ru', dawg: 'ru_erudit' },
|
||||
];
|
||||
|
||||
interface Tile {
|
||||
row: number;
|
||||
col: number;
|
||||
letter: number;
|
||||
blank: boolean;
|
||||
}
|
||||
interface GenMove {
|
||||
dir: number;
|
||||
tiles: Tile[];
|
||||
score: number;
|
||||
}
|
||||
interface Fixture {
|
||||
ruleset: {
|
||||
size: number;
|
||||
cols: number;
|
||||
center: number;
|
||||
rackSize: number;
|
||||
bingo: number;
|
||||
values: number[];
|
||||
letterMult: number[][];
|
||||
wordMult: number[][];
|
||||
};
|
||||
cases: {
|
||||
name: string;
|
||||
placed: Tile[] | null;
|
||||
rack: { letters: number[]; blanks: number };
|
||||
mode: number;
|
||||
ignoreCrossWords: boolean;
|
||||
moves: GenMove[];
|
||||
}[];
|
||||
}
|
||||
|
||||
function buildBoard(placed: Tile[], cols: number): GenBoard {
|
||||
const grid: ({ letter: number; blank: boolean } | null)[] = new Array(cols * cols).fill(null);
|
||||
for (const t of placed) grid[t.row * cols + t.col] = { letter: t.letter, blank: t.blank };
|
||||
const inBounds = (r: number, c: number): boolean => r >= 0 && r < cols && c >= 0 && c < cols;
|
||||
return {
|
||||
rows: cols,
|
||||
cols,
|
||||
inBounds,
|
||||
filled: (r, c) => inBounds(r, c) && grid[r * cols + c] !== null,
|
||||
cellAt: (r, c) => grid[r * cols + c]!,
|
||||
isEmpty: () => placed.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function rulesetFor(fx: Fixture, ignoreCrossWords: boolean): Ruleset {
|
||||
const r = fx.ruleset;
|
||||
return {
|
||||
cols: r.cols,
|
||||
center: r.center,
|
||||
rackSize: r.rackSize,
|
||||
bingo: r.bingo,
|
||||
values: r.values,
|
||||
letterMult: (row, col) => r.letterMult[row][col],
|
||||
wordMult: (row, col) => r.wordMult[row][col],
|
||||
ignoreCrossWords,
|
||||
};
|
||||
}
|
||||
|
||||
function sig(m: GenMove): string {
|
||||
const ts = m.tiles.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||
return `${m.dir}#${m.score}#` + ts.map((t) => `${t.row},${t.col},${t.letter}${t.blank ? '*' : ''}`).join(';');
|
||||
}
|
||||
|
||||
describe.skipIf(!ready)('move generator parity vs Go on the real dictionaries', () => {
|
||||
for (const v of variants) {
|
||||
describe(v.variant, () => {
|
||||
// Guard the collection-time reads: the block is skipped when the dirs are unset, but
|
||||
// the describe callback still runs to register tests, so touch the filesystem only
|
||||
// when ready (otherwise join(undefined, …) would throw during a normal unit run).
|
||||
if (!ready) return;
|
||||
const fx = JSON.parse(readFileSync(join(goldDir!, `${v.variant}.movegen.json`), 'utf8')) as Fixture;
|
||||
const dawg = new Dawg(new Uint8Array(readFileSync(join(dawgDir!, `${v.dawg}.dawg`))));
|
||||
for (const c of fx.cases) {
|
||||
it(
|
||||
c.name,
|
||||
() => {
|
||||
const board = buildBoard(c.placed ?? [], fx.ruleset.cols);
|
||||
const rack = GenRack.from(fx.ruleset.size, c.rack.letters, c.rack.blanks);
|
||||
const rs = rulesetFor(fx, c.ignoreCrossWords);
|
||||
const got = generateMoves(dawg, board, rack, rs, c.mode as Mode);
|
||||
expect(got.map(sig)).toEqual(c.moves.map(sig));
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
// Local move generator: every legal play for a rack on a board, ranked by
|
||||
// descending score. Ported from the scrabble-solver engine — the Appel-Jacobson
|
||||
// two-phase algorithm (LeftPart then ExtendRight) over a plain left-to-right DAWG
|
||||
// (scrabble/gen_dawg.go, gen.go, crossset.go, solver.go, key.go). It walks the DAWG
|
||||
// with the cursor from dawg.ts and scores each play with evaluate() from validate.ts,
|
||||
// so the whole robot brain runs on-device. Faithfulness to the Go solver is pinned by
|
||||
// generate.parity.test.ts against golden fixtures (backend/cmd/movegen).
|
||||
//
|
||||
// Everything works in alphabet-index space, mirroring the Go engine. A letterSet is
|
||||
// a per-square cross-set (the letters that form a legal perpendicular word there);
|
||||
// it is a boolean membership array rather than a uint64, so alphabet indexes past
|
||||
// JS's 31-bit shift boundary (Russian has 33 letters) are handled exactly.
|
||||
|
||||
import { Dawg } from './dawg';
|
||||
import {
|
||||
evaluate,
|
||||
connected,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
type Board,
|
||||
type Ruleset,
|
||||
type Move,
|
||||
type Placement,
|
||||
type Direction,
|
||||
} from './validate';
|
||||
|
||||
/** Both generates across plays (on the board) and down plays (on its transpose). */
|
||||
export const Both = 0;
|
||||
/** OnlyHorizontal generates across plays only. */
|
||||
export const OnlyHorizontal = 1;
|
||||
/** OnlyVertical generates down plays only (Эрудит plays a single orientation per turn). */
|
||||
export const OnlyVertical = 2;
|
||||
export type Mode = typeof Both | typeof OnlyHorizontal | typeof OnlyVertical;
|
||||
|
||||
function modeIncludes(mode: Mode, dir: Direction): boolean {
|
||||
if (mode === Both) return true;
|
||||
if (mode === OnlyHorizontal) return dir === Horizontal;
|
||||
return dir === Vertical;
|
||||
}
|
||||
|
||||
/**
|
||||
* GenBoard is the board view the generator needs: the validator's read view plus the
|
||||
* dimensions it iterates over. The whole board is square, so a transposed view (below)
|
||||
* satisfies the same shape.
|
||||
*/
|
||||
export interface GenBoard extends Board {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GenRack is a rack as per-letter tile counts plus a blank slot, mirroring
|
||||
* scrabble-solver/rack. The generator mutates a single rack in place — removing a
|
||||
* tile, recursing, putting it back — so the operations are O(1) and allocation-free.
|
||||
*/
|
||||
export class GenRack {
|
||||
private readonly counts: Int32Array;
|
||||
|
||||
/** Construct an empty rack for an alphabet of the given size (a trailing blank slot). */
|
||||
constructor(size: number) {
|
||||
this.counts = new Int32Array(size + 1);
|
||||
}
|
||||
|
||||
/** from builds a rack from a multiset of letter indexes plus a blank count. */
|
||||
static from(size: number, letters: readonly number[], blanks: number): GenRack {
|
||||
const r = new GenRack(size);
|
||||
for (const l of letters) r.counts[l]++;
|
||||
r.counts[size] += blanks;
|
||||
return r;
|
||||
}
|
||||
|
||||
/** has reports whether at least one tile of the letter index is on the rack. */
|
||||
has(letter: number): boolean {
|
||||
return this.counts[letter] > 0;
|
||||
}
|
||||
/** blanks returns how many blank tiles are on the rack. */
|
||||
blanks(): number {
|
||||
return this.counts[this.counts.length - 1];
|
||||
}
|
||||
remove(letter: number): void {
|
||||
this.counts[letter]--;
|
||||
}
|
||||
add(letter: number): void {
|
||||
this.counts[letter]++;
|
||||
}
|
||||
removeBlank(): void {
|
||||
this.counts[this.counts.length - 1]--;
|
||||
}
|
||||
addBlank(): void {
|
||||
this.counts[this.counts.length - 1]++;
|
||||
}
|
||||
/** clone returns an independent copy. */
|
||||
clone(): GenRack {
|
||||
const c = new GenRack(this.counts.length - 1);
|
||||
c.counts.set(this.counts);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
// --- cross-sets ------------------------------------------------------------------
|
||||
//
|
||||
// A LetterSet is membership over alphabet letter indexes. Mirrors scrabble.letterSet
|
||||
// (a uint64) but as a Uint8Array so index 32 (Russian) is exact under JS bit ops.
|
||||
type LetterSet = Uint8Array;
|
||||
|
||||
function fullSet(size: number): LetterSet {
|
||||
return new Uint8Array(size).fill(1);
|
||||
}
|
||||
|
||||
// walk follows word (alphabet indexes) left to right from the root; -1 if it derails.
|
||||
function walk(dawg: Dawg, word: readonly number[]): number {
|
||||
let n = dawg.root();
|
||||
for (const l of word) {
|
||||
n = dawg.next(n, l);
|
||||
if (n < 0) return -1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// completers returns the letters X (< size) whose arc from state leads directly to an
|
||||
// accepting node — the deterministic cross-set primitive. Mirrors scrabble.completers.
|
||||
function completers(dawg: Dawg, state: number, size: number): LetterSet {
|
||||
const set = new Uint8Array(size);
|
||||
dawg.arcs(state, (label, _dest, final) => {
|
||||
if (final && label < size) set[label] = 1;
|
||||
return true;
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
// dawgCrossSet returns the letters X for which above·X·below is a stored word. Mirrors
|
||||
// scrabble.dawgCrossSet: a right extension (no tiles below) just completes the prefix
|
||||
// above; a left extension (tiles below) probes each X. above/below are letter indexes.
|
||||
function dawgCrossSet(dawg: Dawg, above: number[], below: number[], size: number): LetterSet {
|
||||
if (above.length === 0 && below.length === 0) return fullSet(size);
|
||||
if (below.length === 0) {
|
||||
const node = walk(dawg, above);
|
||||
if (node < 0) return new Uint8Array(size);
|
||||
return completers(dawg, node, size);
|
||||
}
|
||||
let node = dawg.root();
|
||||
if (above.length > 0) {
|
||||
node = walk(dawg, above);
|
||||
if (node < 0) return new Uint8Array(size);
|
||||
}
|
||||
const set = new Uint8Array(size);
|
||||
for (let x = 0; x < size; x++) {
|
||||
let m = dawg.next(node, x);
|
||||
if (m < 0) continue;
|
||||
let ok = true;
|
||||
for (const l of below) {
|
||||
m = dawg.next(m, l);
|
||||
if (m < 0) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok && dawg.final(m)) set[x] = 1;
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// columnContext returns the contiguous run of filled cells immediately above and below
|
||||
// the empty square (r, c), each top to bottom, as letter indexes — the tiles a
|
||||
// perpendicular word through (r, c) would include. Mirrors scrabble.columnContext.
|
||||
function columnContext(b: GenBoard, r: number, c: number): { above: number[]; below: number[] } {
|
||||
const above: number[] = [];
|
||||
let start = r;
|
||||
while (start - 1 >= 0 && b.filled(start - 1, c)) start--;
|
||||
for (let rr = start; rr < r; rr++) above.push(b.cellAt(rr, c).letter);
|
||||
|
||||
const below: number[] = [];
|
||||
let end = r;
|
||||
while (end + 1 < b.rows && b.filled(end + 1, c)) end++;
|
||||
for (let rr = r + 1; rr <= end; rr++) below.push(b.cellAt(rr, c).letter);
|
||||
return { above, below };
|
||||
}
|
||||
|
||||
// transpose returns a view of b with rows and columns swapped, so down-play generation
|
||||
// runs as across generation. Mirrors board.Transpose (as a lazy view).
|
||||
function transpose(b: GenBoard): GenBoard {
|
||||
return {
|
||||
rows: b.cols,
|
||||
cols: b.rows,
|
||||
inBounds: (r, c) => b.inBounds(c, r),
|
||||
filled: (r, c) => b.filled(c, r),
|
||||
cellAt: (r, c) => b.cellAt(c, r),
|
||||
isEmpty: () => b.isEmpty(),
|
||||
};
|
||||
}
|
||||
|
||||
// A tentatively placed left-part tile; its column is fixed only at record time.
|
||||
interface TileInfo {
|
||||
letter: number;
|
||||
blank: boolean;
|
||||
}
|
||||
|
||||
// AcrossGen carries one across-generation pass over a board. Mirrors scrabble.acrossGen.
|
||||
class AcrossGen {
|
||||
private row = 0;
|
||||
private readonly left: TileInfo[] = [];
|
||||
private readonly right: Placement[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly dawg: Dawg,
|
||||
private readonly bd: GenBoard,
|
||||
private readonly rk: GenRack,
|
||||
private readonly cross: (r: number, c: number) => LetterSet,
|
||||
private readonly emit: (placements: Placement[]) => void,
|
||||
) {}
|
||||
|
||||
generateRow(row: number, firstMove: boolean, centerRow: number, centerCol: number): void {
|
||||
this.row = row;
|
||||
let limit = 0;
|
||||
for (let col = 0; col < this.bd.cols; col++) {
|
||||
if (this.bd.filled(row, col)) {
|
||||
limit = 0;
|
||||
continue;
|
||||
}
|
||||
const anchor = firstMove ? row === centerRow && col === centerCol : this.hasFilledNeighbor(row, col);
|
||||
if (!anchor) {
|
||||
limit++;
|
||||
continue;
|
||||
}
|
||||
this.left.length = 0;
|
||||
this.right.length = 0;
|
||||
if (col > 0 && this.bd.filled(row, col - 1)) {
|
||||
const pre = this.walkPrefix(row, col);
|
||||
if (pre.ok) this.extendRight(pre.node, col, col);
|
||||
} else {
|
||||
this.leftPart(this.dawg.root(), col, limit);
|
||||
}
|
||||
limit = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private hasFilledNeighbor(r: number, c: number): boolean {
|
||||
return this.bd.filled(r - 1, c) || this.bd.filled(r + 1, c) || this.bd.filled(r, c - 1) || this.bd.filled(r, c + 1);
|
||||
}
|
||||
|
||||
// walkPrefix walks the DAWG through the filled run ending at col-1, returning the
|
||||
// node reached and whether that prefix exists. Mirrors scrabble.acrossGen.walkPrefix.
|
||||
private walkPrefix(row: number, col: number): { node: number; ok: boolean } {
|
||||
let start = col - 1;
|
||||
while (start - 1 >= 0 && this.bd.filled(row, start - 1)) start--;
|
||||
let node = this.dawg.root();
|
||||
for (let c = start; c < col; c++) {
|
||||
node = this.dawg.next(node, this.bd.cellAt(row, c).letter);
|
||||
if (node < 0) return { node, ok: false };
|
||||
}
|
||||
return { node, ok: true };
|
||||
}
|
||||
|
||||
// leftPart places left-part tiles from the rack (up to limit), calling extendRight
|
||||
// after each prefix. Mirrors scrabble.acrossGen.leftPart.
|
||||
private leftPart(node: number, anchorCol: number, limit: number): void {
|
||||
this.extendRight(node, anchorCol, anchorCol);
|
||||
if (limit === 0) return;
|
||||
this.dawg.arcs(node, (label, dest) => {
|
||||
if (this.rk.has(label)) {
|
||||
this.rk.remove(label);
|
||||
this.left.push({ letter: label, blank: false });
|
||||
this.leftPart(dest, anchorCol, limit - 1);
|
||||
this.left.pop();
|
||||
this.rk.add(label);
|
||||
}
|
||||
if (this.rk.blanks() > 0) {
|
||||
this.rk.removeBlank();
|
||||
this.left.push({ letter: label, blank: true });
|
||||
this.leftPart(dest, anchorCol, limit - 1);
|
||||
this.left.pop();
|
||||
this.rk.addBlank();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// extendRight extends the word rightward from col, placing rack tiles on empty
|
||||
// squares (constrained by cross-sets) and following board tiles. A word is recorded
|
||||
// only past the anchor. Mirrors scrabble.acrossGen.extendRight.
|
||||
private extendRight(node: number, col: number, anchorCol: number): void {
|
||||
if (col >= this.bd.cols) {
|
||||
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
|
||||
return;
|
||||
}
|
||||
if (this.bd.filled(this.row, col)) {
|
||||
const dest = this.dawg.next(node, this.bd.cellAt(this.row, col).letter);
|
||||
if (dest >= 0) this.extendRight(dest, col + 1, anchorCol);
|
||||
return;
|
||||
}
|
||||
|
||||
if (col > anchorCol && this.dawg.final(node)) this.record(anchorCol);
|
||||
const cross = this.cross(this.row, col);
|
||||
this.dawg.arcs(node, (label, dest) => {
|
||||
if (cross[label] !== 1) return true;
|
||||
if (this.rk.has(label)) {
|
||||
this.rk.remove(label);
|
||||
this.right.push({ row: this.row, col, letter: label, blank: false });
|
||||
this.extendRight(dest, col + 1, anchorCol);
|
||||
this.right.pop();
|
||||
this.rk.add(label);
|
||||
}
|
||||
if (this.rk.blanks() > 0) {
|
||||
this.rk.removeBlank();
|
||||
this.right.push({ row: this.row, col, letter: label, blank: true });
|
||||
this.extendRight(dest, col + 1, anchorCol);
|
||||
this.right.pop();
|
||||
this.rk.addBlank();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// record assembles the play (left part at fixed columns, then the right part) and
|
||||
// reports it, skipping plays that lay no new tile. Mirrors scrabble.acrossGen.record.
|
||||
private record(anchorCol: number): void {
|
||||
if (this.left.length + this.right.length === 0) return;
|
||||
const placements: Placement[] = [];
|
||||
const leftStart = anchorCol - this.left.length;
|
||||
for (let i = 0; i < this.left.length; i++) {
|
||||
placements.push({ row: this.row, col: leftStart + i, letter: this.left[i].letter, blank: this.left[i].blank });
|
||||
}
|
||||
for (const p of this.right) placements.push(p);
|
||||
this.emit(placements);
|
||||
}
|
||||
}
|
||||
|
||||
// runAcross generates all across plays on bd and reports each via emit in bd's
|
||||
// coordinates. Cross-sets are computed lazily (vertical words on bd) and cached.
|
||||
// Mirrors DAWGGenerator.runAcross.
|
||||
function runAcross(
|
||||
dawg: Dawg,
|
||||
bd: GenBoard,
|
||||
rk: GenRack,
|
||||
size: number,
|
||||
rs: Ruleset,
|
||||
centerRow: number,
|
||||
centerCol: number,
|
||||
emit: (placements: Placement[]) => void,
|
||||
): void {
|
||||
let crossFn: (r: number, c: number) => LetterSet;
|
||||
if (rs.ignoreCrossWords) {
|
||||
const full = fullSet(size);
|
||||
crossFn = () => full;
|
||||
} else {
|
||||
const cache = new Array<LetterSet | undefined>(bd.rows * bd.cols);
|
||||
crossFn = (r, c) => {
|
||||
const i = r * bd.cols + c;
|
||||
let s = cache[i];
|
||||
if (!s) {
|
||||
const { above, below } = columnContext(bd, r, c);
|
||||
s = dawgCrossSet(dawg, above, below, size);
|
||||
cache[i] = s;
|
||||
}
|
||||
return s;
|
||||
};
|
||||
}
|
||||
|
||||
const ag = new AcrossGen(dawg, bd, rk, crossFn, emit);
|
||||
const firstMove = bd.isEmpty();
|
||||
for (let row = 0; row < bd.rows; row++) {
|
||||
ag.generateRow(row, firstMove, centerRow, centerCol);
|
||||
}
|
||||
}
|
||||
|
||||
// moveKey is a canonical string identifying a play (direction plus its placed tiles),
|
||||
// used to de-duplicate and rank generated moves. Mirrors scrabble.moveKey.
|
||||
export function moveKey(dir: Direction, placements: readonly Placement[]): string {
|
||||
const ps = placements.slice().sort((a, b) => (a.row !== b.row ? a.row - b.row : a.col - b.col));
|
||||
let s = String(dir);
|
||||
for (const p of ps) s += `;${p.row},${p.col},${p.letter}${p.blank ? '*' : ''}`;
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* generateMoves returns every legal play for rack on board in the mode's orientations,
|
||||
* ranked by descending score (ties broken by the canonical move key). It walks the DAWG
|
||||
* with the cursor and scores each play with evaluate(); the alphabet size is taken from
|
||||
* the ruleset's value table. Mirrors (*Solver).GenerateMovesOpts (the ruleset carries
|
||||
* ignoreCrossWords for the single-word rule).
|
||||
*/
|
||||
export function generateMoves(dawg: Dawg, board: GenBoard, rack: GenRack, rs: Ruleset, mode: Mode = Both): Move[] {
|
||||
const size = rs.values.length;
|
||||
const rk = rack.clone(); // generation mutates the rack in place and restores it
|
||||
const centerRow = Math.floor(rs.center / rs.cols);
|
||||
const centerCol = rs.center % rs.cols;
|
||||
|
||||
const moves: Move[] = [];
|
||||
const seen = new Set<string>();
|
||||
const emit = (dir: Direction, placements: Placement[]): void => {
|
||||
const key = moveKey(dir, placements);
|
||||
if (seen.has(key)) return;
|
||||
const res = evaluate(board, rs, dir, placements);
|
||||
if (res.err || !res.move) return;
|
||||
seen.add(key);
|
||||
moves.push(res.move);
|
||||
};
|
||||
|
||||
if (modeIncludes(mode, Horizontal)) {
|
||||
runAcross(dawg, board, rk, size, rs, centerRow, centerCol, (p) => emit(Horizontal, p));
|
||||
}
|
||||
if (modeIncludes(mode, Vertical)) {
|
||||
const tb = transpose(board);
|
||||
runAcross(dawg, tb, rk, size, rs, centerCol, centerRow, (p) => {
|
||||
const rp = p.map((pl) => ({ row: pl.col, col: pl.row, letter: pl.letter, blank: pl.blank }));
|
||||
emit(Vertical, rp);
|
||||
});
|
||||
}
|
||||
|
||||
const kept = moves.filter((m) => connected(board, rs, m));
|
||||
kept.sort((a, b) => {
|
||||
if (a.score !== b.score) return b.score - a.score;
|
||||
const ka = moveKey(a.dir, a.tiles);
|
||||
const kb = moveKey(b.dir, b.tiles);
|
||||
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
// Browser orchestration for the offline-toggle readiness wait. Lazily imported by
|
||||
// offline.svelte.ts's requestOffline so the dict loader/generator it pulls in stays out of the main
|
||||
// bundle. It runs the same cache-first preload as the background warmup (preload.ts), but bounded by
|
||||
// a UI wait: it tells the toggle whether flipping to offline can succeed right now, and leaves the
|
||||
// fetch running on a timeout so a later flip is instant.
|
||||
|
||||
import type { Profile } from '../model';
|
||||
import { preloadDicts } from './preload';
|
||||
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||
import { raceOfflineReady } from '../offline';
|
||||
|
||||
/**
|
||||
* ensureOfflineDicts fetches the profile's enabled variants' dictionaries cache-first (instant when
|
||||
* already warm, a network fetch otherwise) and reports, within budgetMs, whether every one is
|
||||
* available — the offline toggle's readiness gate. With no enabled variant there is nothing to play
|
||||
* offline, so it is never ready. The fetch is not aborted on a timeout; it keeps warming the
|
||||
* on-device cache in the background so a later flip to offline succeeds immediately.
|
||||
*/
|
||||
export async function ensureOfflineDicts(prof: Profile, budgetMs: number): Promise<boolean> {
|
||||
if (prof.variantPreferences.length === 0) return false;
|
||||
const run = preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||
getDawg,
|
||||
disabled: dictLoadingDisabled,
|
||||
retries: 1,
|
||||
});
|
||||
return raceOfflineReady(run, budgetMs);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { preloadDicts } from './preload';
|
||||
import type { Variant } from '../model';
|
||||
import type { Dawg } from './dawg';
|
||||
|
||||
// A non-null stand-in for a loaded reader — preloadDicts only checks getDawg's result for null.
|
||||
const DAWG = {} as Dawg;
|
||||
const noSleep = (): Promise<void> => Promise.resolve();
|
||||
|
||||
describe('preloadDicts', () => {
|
||||
it('fetches every enabled variant that has a known version', async () => {
|
||||
const calls: string[] = [];
|
||||
const res = await preloadDicts({ scrabble_en: 'v1', scrabble_ru: 'v2', erudit_ru: 'v3' }, ['scrabble_en', 'erudit_ru'], {
|
||||
getDawg: async (v: Variant, ver: string) => {
|
||||
calls.push(`${v}@${ver}`);
|
||||
return DAWG;
|
||||
},
|
||||
disabled: () => false,
|
||||
sleep: noSleep,
|
||||
});
|
||||
expect(res.ok).toEqual(['scrabble_en', 'erudit_ru']);
|
||||
expect(res.failed).toEqual([]);
|
||||
expect(calls).toEqual(['scrabble_en@v1', 'erudit_ru@v3']);
|
||||
});
|
||||
|
||||
it('marks a variant with no known version as failed without fetching it', async () => {
|
||||
const calls: string[] = [];
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en', 'scrabble_ru'], {
|
||||
getDawg: async (v: Variant) => {
|
||||
calls.push(v);
|
||||
return DAWG;
|
||||
},
|
||||
disabled: () => false,
|
||||
sleep: noSleep,
|
||||
});
|
||||
expect(res.ok).toEqual(['scrabble_en']);
|
||||
expect(res.failed).toEqual(['scrabble_ru']);
|
||||
expect(calls).toEqual(['scrabble_en']);
|
||||
});
|
||||
|
||||
it('retries a transient failure with linear backoff, then succeeds', async () => {
|
||||
let attempts = 0;
|
||||
const waits: number[] = [];
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||
getDawg: async () => (++attempts >= 3 ? DAWG : null),
|
||||
disabled: () => false,
|
||||
sleep: async (ms: number) => void waits.push(ms),
|
||||
retries: 3,
|
||||
backoffMs: 100,
|
||||
});
|
||||
expect(res.ok).toEqual(['scrabble_en']);
|
||||
expect(attempts).toBe(3);
|
||||
expect(waits).toEqual([100, 200]);
|
||||
});
|
||||
|
||||
it('gives up a persistent failure after the retry budget', async () => {
|
||||
let attempts = 0;
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||
getDawg: async () => {
|
||||
attempts++;
|
||||
return null;
|
||||
},
|
||||
disabled: () => false,
|
||||
sleep: noSleep,
|
||||
retries: 2,
|
||||
});
|
||||
expect(res.failed).toEqual(['scrabble_en']);
|
||||
expect(res.ok).toEqual([]);
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
it('stops retrying once the session miss-breaker trips', async () => {
|
||||
let attempts = 0;
|
||||
const res = await preloadDicts({ scrabble_en: 'v1' }, ['scrabble_en'], {
|
||||
getDawg: async () => {
|
||||
attempts++;
|
||||
return null;
|
||||
},
|
||||
disabled: () => true,
|
||||
sleep: noSleep,
|
||||
retries: 5,
|
||||
});
|
||||
expect(res.failed).toEqual(['scrabble_en']);
|
||||
expect(attempts).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// Background dictionary preload for offline readiness. An installed PWA with a confirmed email
|
||||
// warms the dictionaries for the player's enabled variants while online, so a later switch to
|
||||
// deliberate offline mode has the data it needs. The pure preloadDicts here takes its side effects
|
||||
// (getDawg, the session miss-breaker, sleep) as dependencies, so it unit-tests in the node env. The
|
||||
// eligibility and once/online guard live in offline.svelte.ts (kickDictPreload); the browser
|
||||
// orchestration that supplies the real side effects and raises the in-lobby warning lives in
|
||||
// preloadrun.ts, which offline.svelte.ts imports dynamically so neither the loader nor the
|
||||
// generator is pulled into the main bundle.
|
||||
|
||||
import type { Variant } from '../model';
|
||||
import type { Dawg } from './dawg';
|
||||
|
||||
/** PreloadDeps injects preloadDicts's side effects so the logic stays pure and testable. */
|
||||
export interface PreloadDeps {
|
||||
/** getDawg resolves the (variant, version) reader, serving memory/IndexedDB before the network,
|
||||
* or null on any miss — mirrors the in-game loader. */
|
||||
getDawg: (variant: Variant, version: string) => Promise<Dawg | null>;
|
||||
/** disabled reports whether the session dictionary miss-breaker has tripped (too many network
|
||||
* misses this session); when it has, a network fetch will not recover, so retries stop. */
|
||||
disabled: () => boolean;
|
||||
/** sleep waits between retries; defaults to a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
/** retries is the number of extra attempts after the first (default 2). */
|
||||
retries?: number;
|
||||
/** backoffMs is the base linear backoff between attempts (default 800). */
|
||||
backoffMs?: number;
|
||||
}
|
||||
|
||||
/** PreloadResult reports which enabled variants ended up available (ok) and which are still
|
||||
* missing (failed) after the preload — the caller surfaces a warning when failed is non-empty. */
|
||||
export interface PreloadResult {
|
||||
ok: Variant[];
|
||||
failed: Variant[];
|
||||
}
|
||||
|
||||
/**
|
||||
* preloadDicts fetches, via getDawg, the dictionary for each enabled variant that has a known
|
||||
* version, retrying transient misses with linear backoff. A variant with no known version, or one
|
||||
* still missing after the retry budget, lands in failed; the rest in ok. It never throws and stops
|
||||
* retrying a variant once the session miss-breaker (disabled) trips, since the network will not
|
||||
* recover this session — a cached dictionary is still served by getDawg regardless.
|
||||
*/
|
||||
export async function preloadDicts(
|
||||
versions: Partial<Record<Variant, string>>,
|
||||
enabled: readonly Variant[],
|
||||
deps: PreloadDeps,
|
||||
): Promise<PreloadResult> {
|
||||
const sleep = deps.sleep ?? ((ms) => new Promise<void>((r) => setTimeout(r, ms)));
|
||||
const retries = deps.retries ?? 2;
|
||||
const backoffMs = deps.backoffMs ?? 800;
|
||||
const ok: Variant[] = [];
|
||||
const failed: Variant[] = [];
|
||||
for (const variant of enabled) {
|
||||
const version = versions[variant];
|
||||
if (!version) {
|
||||
failed.push(variant);
|
||||
continue;
|
||||
}
|
||||
let dawg: Dawg | null = null;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
dawg = await deps.getDawg(variant, version);
|
||||
if (dawg || deps.disabled()) break;
|
||||
if (attempt < retries) await sleep(backoffMs * (attempt + 1));
|
||||
}
|
||||
(dawg ? ok : failed).push(variant);
|
||||
}
|
||||
return { ok, failed };
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
// Browser-only orchestration for the offline dictionary preload. Kept apart from the pure
|
||||
// preloadDicts (preload.ts) — which unit-tests in the node env — and lazily imported by
|
||||
// offline.svelte.ts's kickDictPreload, so the dict loader/generator it pulls in stays out of the
|
||||
// main bundle. It supplies the real side effects (getDawg, the session miss-breaker) and raises the
|
||||
// in-lobby notice when a first-lobby preload cannot fetch every enabled variant's dictionary.
|
||||
|
||||
import type { Profile } from '../model';
|
||||
import { preloadDicts } from './preload';
|
||||
import { getDawg, dictLoadingDisabled } from '../dict';
|
||||
import { setDictPreloadWarning } from '../offline.svelte';
|
||||
|
||||
/**
|
||||
* runPreload warms the dictionaries for the profile's enabled variants (using the versions the
|
||||
* profile advertises) via the real dict loader, so a later switch to offline mode has the data.
|
||||
* When warnOnFail is set (the first lobby entry), it raises the in-lobby notice if a variant is
|
||||
* still missing afterwards, and clears it on a run where every variant is available.
|
||||
*/
|
||||
export async function runPreload(prof: Profile, warnOnFail: boolean): Promise<void> {
|
||||
const res = await preloadDicts(prof.dictVersions, prof.variantPreferences, {
|
||||
getDawg,
|
||||
disabled: dictLoadingDisabled,
|
||||
});
|
||||
if (warnOnFail) setDictPreloadWarning(res.failed.length > 0);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user