Compare commits
16 Commits
07815c5a30
..
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
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)
|
- name: E2E smoke (mock)
|
||||||
run: pnpm run test:e2e
|
run: pnpm run test:e2e
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
env:
|
|
||||||
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
|
||||||
|
|
||||||
# conformance proves the client's local move preview (the ported dawg reader +
|
# 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:
|
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||||
@@ -264,7 +252,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
|
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/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
|
- name: Set up Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -284,7 +271,6 @@ jobs:
|
|||||||
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
|
||||||
DICT_GOLD_DIR: /tmp/dictgold
|
DICT_GOLD_DIR: /tmp/dictgold
|
||||||
DICT_VALID_DIR: /tmp/validgold
|
DICT_VALID_DIR: /tmp/validgold
|
||||||
DICT_MOVEGEN_DIR: ${{ github.workspace }}/movegold
|
|
||||||
run: pnpm exec vitest run src/lib/dict/
|
run: pnpm exec vitest run src/lib/dict/
|
||||||
|
|
||||||
# gate is the single branch-protection required check. It always runs and passes
|
# 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.
|
# 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.
|
# 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
|
# 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
|
# some test jobs were path-skipped.
|
||||||
# UI side changed (e.g. a docs-only change): the contour image is unchanged, so
|
needs: [gate]
|
||||||
# there is nothing to redeploy. `changes` still defaults both to true when the
|
if: ${{ (github.event_name == 'push' && github.ref == 'refs/heads/development') || (github.event_name == 'pull_request' && github.base_ref == 'development') }}
|
||||||
# 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')) }}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
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.
|
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,7 +32,6 @@ import (
|
|||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/payments"
|
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
"scrabble/backend/internal/pushgrpc"
|
"scrabble/backend/internal/pushgrpc"
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
@@ -216,9 +216,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// Operator alert emails on new feedback / word complaints, coalesced into one digest
|
// Operator alert emails on new feedback / word complaints, coalesced into one digest
|
||||||
// per interval. Inert unless a distinct admin sender and recipient are configured.
|
// per interval. Inert unless a distinct admin sender and recipient are configured.
|
||||||
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
|
if cfg.SMTP.AdminFrom != "" && cfg.SMTP.AdminTo != "" {
|
||||||
// The alert digest carries no admin-console link on purpose — an admin URL must not
|
consoleURL := ""
|
||||||
// travel in an email (a mail provider could cache or index it); see adminalert.New.
|
if cfg.PublicBaseURL != "" {
|
||||||
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, logger)
|
consoleURL = strings.TrimRight(cfg.PublicBaseURL, "/") + "/_gm"
|
||||||
|
}
|
||||||
|
alerts := adminalert.New(mailer, feedbackSvc, games, cfg.SMTP.AdminFrom, cfg.SMTP.AdminTo, consoleURL, logger)
|
||||||
go alerts.Run(ctx, adminAlertInterval)
|
go alerts.Run(ctx, adminAlertInterval)
|
||||||
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
|
logger.Info("admin alert worker started", zap.Duration("interval", adminAlertInterval))
|
||||||
}
|
}
|
||||||
@@ -261,15 +263,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
// block and the banner admin console section.
|
// block and the banner admin console section.
|
||||||
adsSvc := ads.NewService(ads.NewStore(db))
|
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
|
// The image-render sidecar client for the PNG export artifact; nil (PNG
|
||||||
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
// download answers 404) when BACKEND_RENDERER_URL is unset.
|
||||||
var renderer *render.Client
|
var renderer *render.Client
|
||||||
@@ -297,7 +290,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
RateWatch: rateWatch,
|
RateWatch: rateWatch,
|
||||||
BanView: banView,
|
BanView: banView,
|
||||||
Ads: adsSvc,
|
Ads: adsSvc,
|
||||||
Payments: paymentsSvc,
|
|
||||||
Notifier: hub,
|
Notifier: hub,
|
||||||
ExportSignKey: cfg.ExportSignKey,
|
ExportSignKey: cfg.ExportSignKey,
|
||||||
Renderer: renderer,
|
Renderer: renderer,
|
||||||
|
|||||||
@@ -9,8 +9,7 @@
|
|||||||
// 1. start a postgres:17-alpine container via testcontainers-go
|
// 1. start a postgres:17-alpine container via testcontainers-go
|
||||||
// 2. open it with search_path=backend and apply the embedded goose migrations
|
// 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
|
// 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
|
// 4. run jet's PostgreSQL generator for schema=backend into internal/postgres/jet
|
||||||
// internal/postgres/jet (one subdirectory per schema)
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -37,7 +36,6 @@ const (
|
|||||||
superuserPassword = "scrabble"
|
superuserPassword = "scrabble"
|
||||||
superuserDatabase = "scrabble_backend"
|
superuserDatabase = "scrabble_backend"
|
||||||
backendSchema = "backend"
|
backendSchema = "backend"
|
||||||
paymentsSchema = "payments"
|
|
||||||
containerStartup = 90 * time.Second
|
containerStartup = 90 * time.Second
|
||||||
jetOutputDirSuffix = "internal/postgres/jet"
|
jetOutputDirSuffix = "internal/postgres/jet"
|
||||||
)
|
)
|
||||||
@@ -107,16 +105,11 @@ func run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("drop goose_db_version: %w", err)
|
return fmt.Errorf("drop goose_db_version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each schema generates into its own jet/<schema>/ subdirectory (the
|
if err := jetpostgres.GenerateDB(db, backendSchema, outputDir); err != nil {
|
||||||
// generator wipes only that subtree), so the two calls do not clobber each
|
return fmt.Errorf("jet generate: %w", err)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("jetgen: generated jet code into %s (schema=%s)", outputDir, backendSchema)
|
||||||
return nil
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -105,10 +105,9 @@ func (s *EmailService) allowSend(email string) bool {
|
|||||||
|
|
||||||
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
|
// issueCode generates a fresh confirm-code and one-tap deeplink token for (accountID,
|
||||||
// email), replaces any prior pending confirmation, and mails the branded code in
|
// email), replaces any prior pending confirmation, and mails the branded code in
|
||||||
// locale; purpose selects the email wording and what verifying does. omitLink drops the
|
// locale; purpose selects the email wording and what verifying does. Only the SHA-256
|
||||||
// one-tap deeplink from the email (account deletion, or a login requested from an installed
|
// hashes of the code and token are stored.
|
||||||
// PWA). Only the SHA-256 hashes of the code and token are stored.
|
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string) error {
|
||||||
func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email, purpose, locale string, omitLink bool) error {
|
|
||||||
code, codeHash, err := generateCode()
|
code, codeHash, err := generateCode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -120,12 +119,11 @@ func (s *EmailService) issueCode(ctx context.Context, accountID uuid.UUID, email
|
|||||||
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
if err := s.store.replacePendingConfirmation(ctx, accountID, email, codeHash, tokenHash, purpose, s.now().Add(emailCodeTTL)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Omit the one-tap deeplink when asked: for a login from an installed PWA (the link would
|
// Account deletion is never a one-tap link (a prefetch or a stray click must not delete
|
||||||
// open in a separate browser whose minted session cannot reach the PWA), or for account
|
// an account): the delete code is entered in the app only, so the email omits the
|
||||||
// deletion (a prefetch or stray click must not delete an account — the delete code is entered
|
// deeplink and ConfirmByToken refuses a delete token.
|
||||||
// in the app only, and ConfirmByToken refuses a delete token).
|
|
||||||
deeplink := s.confirmURL(token, locale)
|
deeplink := s.confirmURL(token, locale)
|
||||||
if purpose == purposeDelete || omitLink {
|
if purpose == purposeDelete {
|
||||||
deeplink = ""
|
deeplink = ""
|
||||||
}
|
}
|
||||||
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
msg, err := renderConfirmationEmail(purpose, code, deeplink, s.baseURL, locale)
|
||||||
@@ -177,7 +175,7 @@ func (s *EmailService) RequestCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
}
|
}
|
||||||
return ErrEmailTaken
|
return ErrEmailTaken
|
||||||
}
|
}
|
||||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
// ConfirmCode verifies code for accountID and email. On success it attaches a
|
||||||
@@ -223,11 +221,8 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
|
|||||||
// the ordinary returning-user login. The code is mailed to the address, so only its
|
// the ordinary returning-user login. The code is mailed to the address, so only its
|
||||||
// real owner can complete the login. On first contact browserTZ (the client's
|
// real owner can complete the login. On first contact browserTZ (the client's
|
||||||
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
// detected "±HH:MM" UTC offset) seeds the new account's time zone and language its UI
|
||||||
// language. When pwa is set (the request came from an installed PWA) the login email omits the
|
// language. It returns the target account id for the subsequent LoginWithCode.
|
||||||
// one-tap confirm link — it would open in a separate browser, out of the PWA's reach — so the
|
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string) (uuid.UUID, error) {
|
||||||
// code is entered in the same window. It returns the target account id for the subsequent
|
|
||||||
// LoginWithCode.
|
|
||||||
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, language string, pwa bool) (uuid.UUID, error) {
|
|
||||||
addr, err := normalizeEmail(email)
|
addr, err := normalizeEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
@@ -239,7 +234,7 @@ func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ, l
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language, pwa); err != nil {
|
if err := s.issueCode(ctx, acc.ID, addr, purposeLogin, language); err != nil {
|
||||||
return uuid.UUID{}, err
|
return uuid.UUID{}, err
|
||||||
}
|
}
|
||||||
return acc.ID, nil
|
return acc.ID, nil
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID,
|
|||||||
if !s.allowSend(addr) {
|
if !s.allowSend(addr) {
|
||||||
return ErrTooManyRequests
|
return ErrTooManyRequests
|
||||||
}
|
}
|
||||||
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID), false)
|
return s.issueCode(ctx, accountID, addr, purposeLink, s.accountLocale(ctx, accountID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
// ConfirmLink verifies code for (accountID, email) and reports the address's
|
||||||
@@ -140,7 +140,7 @@ func (s *EmailService) RequestChangeCode(ctx context.Context, accountID uuid.UUI
|
|||||||
if !s.allowSend(addr) {
|
if !s.allowSend(addr) {
|
||||||
return ErrTooManyRequests
|
return ErrTooManyRequests
|
||||||
}
|
}
|
||||||
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID), false)
|
return s.issueCode(ctx, accountID, addr, purposeChange, s.accountLocale(ctx, accountID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
|
// ConfirmChange verifies code for (accountID, newEmail) and atomically replaces the
|
||||||
@@ -199,7 +199,7 @@ func (s *EmailService) RequestDeleteCode(ctx context.Context, accountID uuid.UUI
|
|||||||
if !s.allowSend(addr) {
|
if !s.allowSend(addr) {
|
||||||
return ErrTooManyRequests
|
return ErrTooManyRequests
|
||||||
}
|
}
|
||||||
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID), false)
|
return s.issueCode(ctx, accountID, addr, purposeDelete, s.accountLocale(ctx, accountID))
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
|
// VerifyDeleteCode verifies the account-deletion code against the account's own email and
|
||||||
|
|||||||
@@ -33,21 +33,21 @@ type Notifier struct {
|
|||||||
complaints ComplaintCounter
|
complaints ComplaintCounter
|
||||||
from string
|
from string
|
||||||
to string
|
to string
|
||||||
|
consoleURL string
|
||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
last time.Time
|
last time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Notifier. from and to are the alert sender and recipient(s); log may be
|
// New constructs a Notifier. from and to are the alert sender and recipient(s); consoleURL,
|
||||||
// nil. The watermark starts at "now", so only items arriving after start-up are reported. The
|
// when non-empty, is the admin-console link included in the email. log may be nil. The
|
||||||
// digest deliberately carries no admin-console link — an admin URL must never travel in an
|
// watermark starts at "now", so only items arriving after start-up are reported.
|
||||||
// email, where a mail provider could cache or index it.
|
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to, consoleURL string, log *zap.Logger) *Notifier {
|
||||||
func New(mailer account.Mailer, fb FeedbackCounter, cp ComplaintCounter, from, to string, log *zap.Logger) *Notifier {
|
|
||||||
if log == nil {
|
if log == nil {
|
||||||
log = zap.NewNop()
|
log = zap.NewNop()
|
||||||
}
|
}
|
||||||
return &Notifier{
|
return &Notifier{
|
||||||
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to,
|
mailer: mailer, feedback: fb, complaints: cp, from: from, to: to, consoleURL: consoleURL,
|
||||||
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
|
clock: func() time.Time { return time.Now().UTC() }, log: log, last: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,9 +103,10 @@ func (n *Notifier) digest(fb, cp int) account.Message {
|
|||||||
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
|
parts = append(parts, fmt.Sprintf("%d new word complaint(s)", cp))
|
||||||
}
|
}
|
||||||
summary := strings.Join(parts, ", ")
|
summary := strings.Join(parts, ", ")
|
||||||
// No admin-console link in the body: an admin URL must never travel in an email (a mail
|
|
||||||
// provider could cache or index it). The operator opens the console directly.
|
|
||||||
text := summary + "."
|
text := summary + "."
|
||||||
|
if n.consoleURL != "" {
|
||||||
|
text += "\n\nOpen the admin console: " + n.consoleURL
|
||||||
|
}
|
||||||
return account.Message{
|
return account.Message{
|
||||||
From: n.from,
|
From: n.from,
|
||||||
To: n.to,
|
To: n.to,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func (m *recordingMailer) Send(_ context.Context, msg account.Message) error {
|
|||||||
|
|
||||||
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
|
func TestNotifierSkipsWhenNothingNew(t *testing.T) {
|
||||||
mailer := &recordingMailer{}
|
mailer := &recordingMailer{}
|
||||||
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", nil)
|
n := New(mailer, fbCounter{0}, cpCounter{0}, "alerts@erudit-game.ru", "op@x.ru", "", nil)
|
||||||
n.tick(context.Background())
|
n.tick(context.Background())
|
||||||
if len(mailer.sent) != 0 {
|
if len(mailer.sent) != 0 {
|
||||||
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
|
t.Fatalf("sent %d emails, want 0 when nothing is new", len(mailer.sent))
|
||||||
@@ -37,7 +37,7 @@ func TestNotifierSkipsWhenNothingNew(t *testing.T) {
|
|||||||
|
|
||||||
func TestNotifierDigestsNewItems(t *testing.T) {
|
func TestNotifierDigestsNewItems(t *testing.T) {
|
||||||
mailer := &recordingMailer{}
|
mailer := &recordingMailer{}
|
||||||
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", nil)
|
n := New(mailer, fbCounter{2}, cpCounter{1}, "alerts@erudit-game.ru", "op@x.ru, two@x.ru", "https://erudit-game.ru/_gm", nil)
|
||||||
n.tick(context.Background())
|
n.tick(context.Background())
|
||||||
if len(mailer.sent) != 1 {
|
if len(mailer.sent) != 1 {
|
||||||
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
|
t.Fatalf("sent %d emails, want 1 digest", len(mailer.sent))
|
||||||
@@ -49,9 +49,7 @@ func TestNotifierDigestsNewItems(t *testing.T) {
|
|||||||
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
|
if !strings.Contains(msg.Subject, "2 new feedback") || !strings.Contains(msg.Subject, "1 new word complaint") {
|
||||||
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
|
t.Errorf("digest subject = %q, want the feedback + complaint counts", msg.Subject)
|
||||||
}
|
}
|
||||||
// The digest must never carry an admin-console link — an admin URL in an email is a leak
|
if !strings.Contains(msg.Text, "/_gm") {
|
||||||
// (mail providers cache/index it).
|
t.Errorf("digest body = %q, want the console link", msg.Text)
|
||||||
if strings.Contains(msg.Text, "/_gm") || strings.Contains(strings.ToLower(msg.Text), "admin console") {
|
|
||||||
t.Errorf("digest body = %q, must not carry an admin-console link", msg.Text)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -193,24 +193,3 @@ code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
|
|||||||
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
.replay-log { margin: 0.4rem 0 0; padding-left: 1.4rem; max-height: 14rem; overflow: auto; font-size: 0.85rem; }
|
||||||
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
.replay-log li { color: var(--ink-dim); padding: 0.1rem 0; }
|
||||||
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
.replay-log li.cur { color: var(--ink); font-weight: 600; }
|
||||||
|
|
||||||
/* Banner colour override editor + live preview (banner_detail). The override
|
|
||||||
fieldsets group the enable toggle with the native colour swatches; the preview
|
|
||||||
renders a sample strip on both themes from those inputs (see the inline script). */
|
|
||||||
.ovr { border: 1px solid var(--line); border-radius: 6px; padding: 0.3rem 0.8rem 0.7rem; margin: 0.2rem 0; }
|
|
||||||
.ovr legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--ink); }
|
|
||||||
.ovr legend label { flex-direction: row; align-items: center; gap: 0.4rem; color: var(--ink); }
|
|
||||||
.ovr .note { margin: 0.2rem 0 0.4rem; }
|
|
||||||
.ovr .swatches { display: flex; flex-wrap: wrap; gap: 1rem; }
|
|
||||||
.ovr .swatches label { flex-direction: column; gap: 0.25rem; align-items: flex-start; }
|
|
||||||
.ovr input[type=color] { width: 3rem; height: 1.8rem; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; }
|
|
||||||
.ovr input[type=color]:disabled { opacity: 0.4; cursor: default; }
|
|
||||||
.ovr .hex { font-size: 0.72rem; color: var(--ink-dim); font-variant-numeric: tabular-nums; }
|
|
||||||
.banner-preview { display: flex; flex-wrap: wrap; gap: 0.8rem; margin: 0.7rem 0 0.2rem; }
|
|
||||||
.banner-preview .bp { flex: 1 1 18rem; }
|
|
||||||
.banner-preview .bp-label { display: block; font-size: 0.75rem; color: var(--ink-dim); margin-bottom: 0.25rem; }
|
|
||||||
.ad-frame { padding: 0.7rem; border-radius: 6px; border: 1px solid var(--line); }
|
|
||||||
.ad-frame.light { background: #f4f6f9; }
|
|
||||||
.ad-frame.dark { background: #0f1420; }
|
|
||||||
.ad-sample { padding: 0.35rem 0.7rem; font-size: 0.85rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.ad-sample .ad-link { text-decoration: underline; }
|
|
||||||
|
|||||||
@@ -11,72 +11,10 @@
|
|||||||
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
<label>Starts (UTC) <input type="datetime-local" name="starts_at" value="{{.StartsAt}}"></label>
|
||||||
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
<label>Ends (UTC) <input type="datetime-local" name="ends_at" value="{{.EndsAt}}"></label>
|
||||||
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
<label><input type="checkbox" name="enabled"{{if .Enabled}} checked{{end}}> Enabled</label>
|
||||||
<fieldset class="ovr">
|
|
||||||
<legend><label><input type="checkbox" name="urgent"{{if .Urgent}} checked{{end}}> Urgent</label></legend>
|
|
||||||
<p class="note">Shows to <em>everyone</em>, always — bypassing paid accounts, hint wallets and the no-banner role. While any urgent campaign is live it is the only thing the strip shows (other campaigns and the default are suppressed). For system alerts.</p>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="ovr" data-ovr-group>
|
|
||||||
<legend><label><input type="checkbox" name="override_all_on" data-ovr="all"{{if .OverrideAllOn}} checked{{end}}> Colour override — all themes</label></legend>
|
|
||||||
<div class="swatches">
|
|
||||||
<label>Background <input type="color" name="override_bg" value="{{.AllBg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
|
||||||
<label>Text <input type="color" name="override_fg" value="{{.AllFg}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
|
||||||
<label>Link <input type="color" name="override_link" value="{{.AllLink}}" data-ovr-color{{if not .OverrideAllOn}} disabled{{end}}><span class="hex"></span></label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
<fieldset class="ovr" data-ovr-group>
|
|
||||||
<legend><label><input type="checkbox" name="override_dark_on" data-ovr="dark"{{if .OverrideDarkOn}} checked{{end}}> Colour override — dark theme only</label></legend>
|
|
||||||
<div class="swatches">
|
|
||||||
<label>Background <input type="color" name="override_bg_dark" value="{{.DarkBg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
|
||||||
<label>Text <input type="color" name="override_fg_dark" value="{{.DarkFg}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
|
||||||
<label>Link <input type="color" name="override_link_dark" value="{{.DarkLink}}" data-ovr-color{{if not .OverrideDarkOn}} disabled{{end}}><span class="hex"></span></label>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
<div><button type="submit">Save</button></div>
|
<div><button type="submit">Save</button></div>
|
||||||
</form>
|
</form>
|
||||||
{{if not .IsDefault}}
|
{{if not .IsDefault}}
|
||||||
<div class="banner-preview">
|
|
||||||
<div class="bp"><span class="bp-label">Light theme</span><div class="ad-frame light"><div class="ad-sample" id="prev-light"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
|
||||||
<div class="bp"><span class="bp-label">Dark theme</span><div class="ad-frame dark"><div class="ad-sample" id="prev-dark"><span>Sample banner text — <span class="ad-link">a link</span></span></div></div></div>
|
|
||||||
</div>
|
|
||||||
<p class="note">Live preview of the strip on both themes. An empty override falls back to the neutral theme colours; the top/bottom border is derived from the background.</p>
|
|
||||||
<script>
|
|
||||||
(function(){
|
|
||||||
// Neutral fallbacks — mirror ui/src/app.css (--ad-bg / --text-muted / --accent).
|
|
||||||
var TOK={light:{bg:'#e3e7ee',fg:'#6b7280',link:'#2f6df6'},dark:{bg:'#272f3c',fg:'#9aa3b2',link:'#5b8cff'}};
|
|
||||||
function byName(n){return document.querySelector('[name="'+n+'"]');}
|
|
||||||
var allOn=byName('override_all_on'), darkOn=byName('override_dark_on');
|
|
||||||
if(!allOn||!darkOn){return;}
|
|
||||||
var f={ab:byName('override_bg'),af:byName('override_fg'),al:byName('override_link'),db:byName('override_bg_dark'),df:byName('override_fg_dark'),dl:byName('override_link_dark')};
|
|
||||||
var lightEl=document.getElementById('prev-light'), darkEl=document.getElementById('prev-dark');
|
|
||||||
function hexToRgb(h){h=h.replace('#','');return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
|
|
||||||
function pad(x){x=Math.max(0,Math.min(255,Math.round(x))).toString(16);return x.length<2?'0'+x:x;}
|
|
||||||
function rgbToHex(r){return '#'+pad(r[0])+pad(r[1])+pad(r[2]);}
|
|
||||||
function mix(a,b,t){return [a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t,a[2]+(b[2]-a[2])*t];}
|
|
||||||
function lum(r){return (0.2126*r[0]+0.7152*r[1]+0.0722*r[2])/255;}
|
|
||||||
// Derived border: nudge the background 14% toward black on a light bg, toward white on a dark bg.
|
|
||||||
function border(bg){var r=hexToRgb(bg);return rgbToHex(mix(r, lum(r)>0.5?[0,0,0]:[255,255,255], 0.14));}
|
|
||||||
function paint(el,c){
|
|
||||||
el.style.background=c.bg; el.style.color=c.fg;
|
|
||||||
el.style.borderTop='1px solid '+border(c.bg); el.style.borderBottom='1px solid '+border(c.bg);
|
|
||||||
var a=el.querySelector('.ad-link'); if(a){a.style.color=c.link;}
|
|
||||||
}
|
|
||||||
function resolve(){
|
|
||||||
var light=allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.light;
|
|
||||||
var dark=darkOn.checked?{bg:f.db.value,fg:f.df.value,link:f.dl.value}
|
|
||||||
:(allOn.checked?{bg:f.ab.value,fg:f.af.value,link:f.al.value}:TOK.dark);
|
|
||||||
paint(lightEl,light); paint(darkEl,dark);
|
|
||||||
document.querySelectorAll('.ovr .swatches label').forEach(function(lab){
|
|
||||||
var inp=lab.querySelector('input[type=color]'), hx=lab.querySelector('.hex');
|
|
||||||
if(inp&&hx){hx.textContent=inp.value;}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleGroup(chk){chk.closest('fieldset').querySelectorAll('[data-ovr-color]').forEach(function(inp){inp.disabled=!chk.checked;});}
|
|
||||||
[allOn,darkOn].forEach(function(chk){chk.addEventListener('change',function(){toggleGroup(chk);resolve();});});
|
|
||||||
Object.keys(f).forEach(function(k){f[k].addEventListener('input',resolve);});
|
|
||||||
resolve();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
<form class="form" method="post" action="/_gm/banners/{{.ID}}/delete" onsubmit="return confirm('Delete this campaign and its messages?')">
|
||||||
<button type="submit" class="danger">Delete campaign</button>
|
<button type="submit" class="danger">Delete campaign</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -498,30 +498,15 @@ type BannerCampaignRow struct {
|
|||||||
|
|
||||||
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
// BannerDetailView is the campaign detail/edit page. StartsAt/EndsAt are the
|
||||||
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
// "YYYY-MM-DDTHH:MM" (UTC) values for the datetime-local inputs, empty when open.
|
||||||
//
|
|
||||||
// The colour-override and urgent fields drive the non-default campaign's editor:
|
|
||||||
// OverrideAllOn/OverrideDarkOn report whether each colour set is active, and the
|
|
||||||
// six *Bg/*Fg/*Link values seed the native colour inputs — the stored override
|
|
||||||
// when a set is on, otherwise the neutral theme token so the picker starts from a
|
|
||||||
// sensible colour and the live preview shows the real fallback.
|
|
||||||
type BannerDetailView struct {
|
type BannerDetailView struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Weight int
|
Weight int
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt string
|
StartsAt string
|
||||||
EndsAt string
|
EndsAt string
|
||||||
Urgent bool
|
Messages []BannerMessageRow
|
||||||
OverrideAllOn bool
|
|
||||||
AllBg string
|
|
||||||
AllFg string
|
|
||||||
AllLink string
|
|
||||||
OverrideDarkOn bool
|
|
||||||
DarkBg string
|
|
||||||
DarkFg string
|
|
||||||
DarkLink string
|
|
||||||
Messages []BannerMessageRow
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
// BannerMessageRow is one bilingual message of a campaign. First/Last drive the
|
||||||
|
|||||||
+13
-77
@@ -30,15 +30,6 @@ var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modifi
|
|||||||
// window or message body).
|
// window or message body).
|
||||||
var ErrValidation = errors.New("ads: validation")
|
var ErrValidation = errors.New("ads: validation")
|
||||||
|
|
||||||
// ColorSet is an optional per-campaign colour override for the banner strip:
|
|
||||||
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
|
|
||||||
// are set together or the whole set is absent (a nil *ColorSet).
|
|
||||||
type ColorSet struct {
|
|
||||||
Bg string
|
|
||||||
Fg string
|
|
||||||
Link string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Campaign is one advertising placement order with its messages.
|
// Campaign is one advertising placement order with its messages.
|
||||||
type Campaign struct {
|
type Campaign struct {
|
||||||
ID uuid.UUID // uuid.Nil on create
|
ID uuid.UUID // uuid.Nil on create
|
||||||
@@ -49,16 +40,6 @@ type Campaign struct {
|
|||||||
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
StartsAt *time.Time // nil = open-ended start; always nil for the default
|
||||||
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
EndsAt *time.Time // nil = open-ended end; always nil for the default
|
||||||
Messages []Message
|
Messages []Message
|
||||||
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
|
|
||||||
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
|
|
||||||
// light ← all ?? token). Both are nil for the default campaign and for a
|
|
||||||
// campaign that keeps the neutral theme tokens. Non-default only.
|
|
||||||
OverrideAll *ColorSet
|
|
||||||
OverrideDark *ColorSet
|
|
||||||
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
|
|
||||||
// any urgent campaign is active, suppresses every non-urgent campaign and the
|
|
||||||
// default remainder. Non-default only; always false for the default campaign.
|
|
||||||
Urgent bool
|
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
@@ -89,15 +70,10 @@ type Timings struct {
|
|||||||
|
|
||||||
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
|
||||||
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
// its GCD-reduced show weight and its messages, already resolved to the viewer's
|
||||||
// language and in display (round-robin) order, plus the optional colour overrides
|
// language and in display (round-robin) order.
|
||||||
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
|
|
||||||
// carried here: it is resolved server-side into the set's membership and the
|
|
||||||
// eligibility bypass, so the client only ever renders what it is sent.
|
|
||||||
type ActiveCampaign struct {
|
type ActiveCampaign struct {
|
||||||
Weight int
|
Weight int
|
||||||
Messages []string
|
Messages []string
|
||||||
OverrideAll *ColorSet
|
|
||||||
OverrideDark *ColorSet
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eligible reports whether an account should be shown the advertising banner: a
|
// Eligible reports whether an account should be shown the advertising banner: a
|
||||||
@@ -109,20 +85,14 @@ func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
|
||||||
// at time now, in language lang, and reports whether the feed is an urgent one.
|
// at time now, in language lang. Campaigns outside their validity window, and
|
||||||
// Campaigns outside their validity window, and campaigns with no messages, are
|
// campaigns with no messages, are dropped. The default campaign's effective
|
||||||
// dropped.
|
// weight is the remainder up to 100% — max(0, 100 - sum of active timed
|
||||||
//
|
// weights) — so it fills unsold inventory and is dropped entirely when timed
|
||||||
// While any active campaign is urgent, the feed is the urgent campaigns alone —
|
// campaigns already reach 100%. Weights are then reduced by their GCD so the
|
||||||
// every non-urgent timed campaign and the default remainder are suppressed for
|
// fair rotation cycle stays short. The input is expected to be the enabled
|
||||||
// the duration (and the caller shows the feed to every viewer, bypassing
|
// campaigns (ActiveCampaigns); disabled ones must already be excluded.
|
||||||
// eligibility). Otherwise the default campaign's effective weight is the
|
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) []ActiveCampaign {
|
||||||
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
|
|
||||||
// unsold inventory and is dropped entirely when timed campaigns already reach
|
|
||||||
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
|
|
||||||
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
|
|
||||||
// disabled ones must already be excluded.
|
|
||||||
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
|
|
||||||
var timed []Campaign
|
var timed []Campaign
|
||||||
var def *Campaign
|
var def *Campaign
|
||||||
for i := range campaigns {
|
for i := range campaigns {
|
||||||
@@ -138,54 +108,20 @@ func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]Activ
|
|||||||
timed = append(timed, c)
|
timed = append(timed, c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if urgent := filterUrgent(timed); len(urgent) > 0 {
|
|
||||||
out := make([]ActiveCampaign, 0, len(urgent))
|
|
||||||
for _, c := range urgent {
|
|
||||||
out = append(out, activeFrom(c, lang))
|
|
||||||
}
|
|
||||||
reduceByGCD(out)
|
|
||||||
return out, true
|
|
||||||
}
|
|
||||||
sumTimed := 0
|
sumTimed := 0
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
sumTimed += c.Weight
|
sumTimed += c.Weight
|
||||||
}
|
}
|
||||||
out := make([]ActiveCampaign, 0, len(timed)+1)
|
out := make([]ActiveCampaign, 0, len(timed)+1)
|
||||||
for _, c := range timed {
|
for _, c := range timed {
|
||||||
out = append(out, activeFrom(c, lang))
|
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
||||||
}
|
}
|
||||||
if def != nil {
|
if def != nil {
|
||||||
if dw := 100 - sumTimed; dw > 0 {
|
if dw := 100 - sumTimed; dw > 0 {
|
||||||
a := activeFrom(*def, lang)
|
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
||||||
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
|
|
||||||
out = append(out, a)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
reduceByGCD(out)
|
reduceByGCD(out)
|
||||||
return out, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
|
|
||||||
// language-resolved messages and its colour overrides (carried through by
|
|
||||||
// reference, they are read-only).
|
|
||||||
func activeFrom(c Campaign, lang string) ActiveCampaign {
|
|
||||||
return ActiveCampaign{
|
|
||||||
Weight: c.Weight,
|
|
||||||
Messages: resolveBodies(c.Messages, lang),
|
|
||||||
OverrideAll: c.OverrideAll,
|
|
||||||
OverrideDark: c.OverrideDark,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
|
|
||||||
// preempt selector: a non-empty result makes the whole feed urgent.
|
|
||||||
func filterUrgent(cs []Campaign) []Campaign {
|
|
||||||
var out []Campaign
|
|
||||||
for _, c := range cs {
|
|
||||||
if c.Urgent {
|
|
||||||
out = append(out, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,19 +46,12 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
timed := func(name string, weight int, starts, ends *time.Time, msgs []Message) Campaign {
|
||||||
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
return Campaign{Name: name, Weight: weight, Enabled: true, StartsAt: starts, EndsAt: ends, Messages: msgs}
|
||||||
}
|
}
|
||||||
urgent := func(name string, weight int, msgs []Message) Campaign {
|
|
||||||
c := timed(name, weight, nil, nil, msgs)
|
|
||||||
c.Urgent = true
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
red := &ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
campaigns []Campaign
|
campaigns []Campaign
|
||||||
lang string
|
lang string
|
||||||
want []ActiveCampaign
|
want []ActiveCampaign
|
||||||
wantUrgent bool
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default only reduces to weight 1",
|
name: "default only reduces to weight 1",
|
||||||
@@ -159,71 +152,22 @@ func TestComputeActiveSet(t *testing.T) {
|
|||||||
lang: "en",
|
lang: "en",
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
want: []ActiveCampaign{{Weight: 1, Messages: []string{"one-en", "two-en"}}},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "urgent preempts default and normal timed",
|
|
||||||
campaigns: []Campaign{
|
|
||||||
def(msg("house")),
|
|
||||||
timed("promo", 40, nil, nil, msg("promo")),
|
|
||||||
urgent("alert", 50, msg("alert")),
|
|
||||||
},
|
|
||||||
lang: "en",
|
|
||||||
// only the urgent campaign survives; a lone weight reduces to 1.
|
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"alert-en"}}},
|
|
||||||
wantUrgent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "multiple urgent share the feed by gcd",
|
|
||||||
campaigns: []Campaign{
|
|
||||||
def(msg("house")),
|
|
||||||
urgent("a", 60, msg("a")),
|
|
||||||
urgent("b", 40, msg("b")),
|
|
||||||
},
|
|
||||||
lang: "en",
|
|
||||||
// default and any non-urgent dropped; gcd(60,40)=20 -> 3 and 2.
|
|
||||||
want: []ActiveCampaign{
|
|
||||||
{Weight: 3, Messages: []string{"a-en"}},
|
|
||||||
{Weight: 2, Messages: []string{"b-en"}},
|
|
||||||
},
|
|
||||||
wantUrgent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "out-of-window urgent does not preempt",
|
|
||||||
campaigns: []Campaign{
|
|
||||||
def(msg("house")),
|
|
||||||
func() Campaign { c := urgent("future", 50, msg("future")); c.StartsAt = &future; return c }(),
|
|
||||||
},
|
|
||||||
lang: "en",
|
|
||||||
// the urgent campaign is not yet live, so the normal default feed stands.
|
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"house-en"}}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "colour overrides ride the active campaign",
|
|
||||||
campaigns: []Campaign{
|
|
||||||
def(msg("house")),
|
|
||||||
func() Campaign { c := timed("promo", 100, nil, nil, msg("promo")); c.OverrideAll = red; return c }(),
|
|
||||||
},
|
|
||||||
lang: "en",
|
|
||||||
want: []ActiveCampaign{{Weight: 1, Messages: []string{"promo-en"}, OverrideAll: red}},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, gotUrgent := computeActiveSet(tt.campaigns, now, tt.lang)
|
got := computeActiveSet(tt.campaigns, now, tt.lang)
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
if !reflect.DeepEqual(got, tt.want) {
|
||||||
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
t.Errorf("computeActiveSet() =\n %#v\nwant\n %#v", got, tt.want)
|
||||||
}
|
}
|
||||||
if gotUrgent != tt.wantUrgent {
|
|
||||||
t.Errorf("computeActiveSet() urgent = %v, want %v", gotUrgent, tt.wantUrgent)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestComputeActiveSetEmpty(t *testing.T) {
|
func TestComputeActiveSetEmpty(t *testing.T) {
|
||||||
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
// No campaigns at all yields an empty (non-nil-or-nil) feed without panicking.
|
||||||
if got, urgent := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 || urgent {
|
if got := computeActiveSet(nil, time.Now(), "en"); len(got) != 0 {
|
||||||
t.Errorf("computeActiveSet(nil) = %#v urgent=%v, want empty non-urgent", got, urgent)
|
t.Errorf("computeActiveSet(nil) = %#v, want empty", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package ads
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -41,20 +40,17 @@ func NewService(store *Store) *Service { return &Service{store: store} }
|
|||||||
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
||||||
// (en/ru) together with the global display timings: the currently-active
|
// (en/ru) together with the global display timings: the currently-active
|
||||||
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
||||||
// lang, ready for the client's weighted round-robin. The bool result reports
|
// lang, ready for the client's weighted round-robin.
|
||||||
// whether the feed is urgent — an urgent feed is shown to every viewer
|
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
||||||
// regardless of eligibility (the caller skips the eligibility gate for it).
|
|
||||||
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, bool, error) {
|
|
||||||
campaigns, err := s.store.ActiveCampaigns(ctx)
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, false, err
|
return nil, Timings{}, err
|
||||||
}
|
}
|
||||||
timings, err := s.store.Settings(ctx)
|
timings, err := s.store.Settings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, Timings{}, false, err
|
return nil, Timings{}, err
|
||||||
}
|
}
|
||||||
set, urgent := computeActiveSet(campaigns, time.Now().UTC(), lang)
|
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
||||||
return set, timings, urgent, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListCampaigns returns every campaign with its messages, for the admin console.
|
// ListCampaigns returns every campaign with its messages, for the admin console.
|
||||||
@@ -80,13 +76,8 @@ func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, er
|
|||||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return uuid.Nil, err
|
return uuid.Nil, err
|
||||||
}
|
}
|
||||||
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
|
||||||
if err != nil {
|
|
||||||
return uuid.Nil, err
|
|
||||||
}
|
|
||||||
return s.store.CreateCampaign(ctx, Campaign{
|
return s.store.CreateCampaign(ctx, Campaign{
|
||||||
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
||||||
OverrideAll: all, OverrideDark: dark, Urgent: c.Urgent,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +99,6 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|||||||
upd.Enabled = true
|
upd.Enabled = true
|
||||||
upd.StartsAt = nil
|
upd.StartsAt = nil
|
||||||
upd.EndsAt = nil
|
upd.EndsAt = nil
|
||||||
// The default (house) campaign stays plain: no colour overrides, never urgent.
|
|
||||||
} else {
|
} else {
|
||||||
if err := validWeight(c.Weight); err != nil {
|
if err := validWeight(c.Weight); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -116,17 +106,10 @@ func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|||||||
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
all, dark, err := validOverrides(c.OverrideAll, c.OverrideDark)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
upd.Weight = c.Weight
|
upd.Weight = c.Weight
|
||||||
upd.Enabled = c.Enabled
|
upd.Enabled = c.Enabled
|
||||||
upd.StartsAt = c.StartsAt
|
upd.StartsAt = c.StartsAt
|
||||||
upd.EndsAt = c.EndsAt
|
upd.EndsAt = c.EndsAt
|
||||||
upd.OverrideAll = all
|
|
||||||
upd.OverrideDark = dark
|
|
||||||
upd.Urgent = c.Urgent
|
|
||||||
}
|
}
|
||||||
return s.store.UpdateCampaign(ctx, upd)
|
return s.store.UpdateCampaign(ctx, upd)
|
||||||
}
|
}
|
||||||
@@ -271,46 +254,6 @@ func validWindow(starts, ends *time.Time) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// hexColor matches a "#rrggbb" colour — the format the console's native colour
|
|
||||||
// input emits and the wire carries.
|
|
||||||
var hexColor = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
|
||||||
|
|
||||||
// validOverrides validates the two optional colour sets together and returns
|
|
||||||
// their normalised copies (nil when a set is absent), so a caller can store them
|
|
||||||
// directly.
|
|
||||||
func validOverrides(all, dark *ColorSet) (*ColorSet, *ColorSet, error) {
|
|
||||||
va, err := validColorSet(all)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
vd, err := validColorSet(dark)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
return va, vd, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validColorSet validates one optional colour override: a nil set passes (no
|
|
||||||
// override); otherwise all three colours must be present and "#rrggbb". It
|
|
||||||
// returns a trimmed, lower-cased copy.
|
|
||||||
func validColorSet(cs *ColorSet) (*ColorSet, error) {
|
|
||||||
if cs == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
bg := strings.TrimSpace(cs.Bg)
|
|
||||||
fg := strings.TrimSpace(cs.Fg)
|
|
||||||
link := strings.TrimSpace(cs.Link)
|
|
||||||
if bg == "" || fg == "" || link == "" {
|
|
||||||
return nil, fmt.Errorf("%w: a colour override needs all of background, text and link", ErrValidation)
|
|
||||||
}
|
|
||||||
for _, h := range []string{bg, fg, link} {
|
|
||||||
if !hexColor.MatchString(h) {
|
|
||||||
return nil, fmt.Errorf("%w: colours must be #rrggbb hex", ErrValidation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &ColorSet{Bg: strings.ToLower(bg), Fg: strings.ToLower(fg), Link: strings.ToLower(link)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validBodies trims and bounds both mandatory language bodies of a message.
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
||||||
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
||||||
en := strings.TrimSpace(bodyEn)
|
en := strings.TrimSpace(bodyEn)
|
||||||
|
|||||||
@@ -90,23 +90,15 @@ func (s *Store) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
|||||||
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
abg, afg, alink := colorCols(c.OverrideAll)
|
|
||||||
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
|
||||||
stmt := table.AdCampaigns.INSERT(
|
stmt := table.AdCampaigns.INSERT(
|
||||||
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
table.AdCampaigns.CampaignID, table.AdCampaigns.Name, table.AdCampaigns.Weight,
|
||||||
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
table.AdCampaigns.IsDefault, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
||||||
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
|
||||||
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
|
||||||
table.AdCampaigns.Urgent,
|
|
||||||
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
table.AdCampaigns.CreatedAt, table.AdCampaigns.UpdatedAt,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
postgres.UUID(id), postgres.String(c.Name), postgres.Int(int64(c.Weight)),
|
||||||
postgres.Bool(false), postgres.Bool(c.Enabled),
|
postgres.Bool(false), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
||||||
abg, afg, alink,
|
|
||||||
dbg, dfg, dlink,
|
|
||||||
postgres.Bool(c.Urgent),
|
|
||||||
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
postgres.TimestampzT(now), postgres.TimestampzT(now),
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
@@ -119,20 +111,12 @@ func (s *Store) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, erro
|
|||||||
// window. The default flag is never touched here. Returns ErrNotFound when no
|
// window. The default flag is never touched here. Returns ErrNotFound when no
|
||||||
// campaign matches.
|
// campaign matches.
|
||||||
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
func (s *Store) UpdateCampaign(ctx context.Context, c Campaign) error {
|
||||||
abg, afg, alink := colorCols(c.OverrideAll)
|
|
||||||
dbg, dfg, dlink := colorCols(c.OverrideDark)
|
|
||||||
stmt := table.AdCampaigns.UPDATE(
|
stmt := table.AdCampaigns.UPDATE(
|
||||||
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
table.AdCampaigns.Name, table.AdCampaigns.Weight, table.AdCampaigns.Enabled,
|
||||||
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt,
|
table.AdCampaigns.StartsAt, table.AdCampaigns.EndsAt, table.AdCampaigns.UpdatedAt,
|
||||||
table.AdCampaigns.OverrideBg, table.AdCampaigns.OverrideFg, table.AdCampaigns.OverrideLink,
|
|
||||||
table.AdCampaigns.OverrideBgDark, table.AdCampaigns.OverrideFgDark, table.AdCampaigns.OverrideLinkDark,
|
|
||||||
table.AdCampaigns.Urgent, table.AdCampaigns.UpdatedAt,
|
|
||||||
).SET(
|
).SET(
|
||||||
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
postgres.String(c.Name), postgres.Int(int64(c.Weight)), postgres.Bool(c.Enabled),
|
||||||
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt),
|
tsOrNull(c.StartsAt), tsOrNull(c.EndsAt), postgres.TimestampzT(time.Now().UTC()),
|
||||||
abg, afg, alink,
|
|
||||||
dbg, dfg, dlink,
|
|
||||||
postgres.Bool(c.Urgent), postgres.TimestampzT(time.Now().UTC()),
|
|
||||||
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
).WHERE(table.AdCampaigns.CampaignID.EQ(postgres.UUID(c.ID)))
|
||||||
return execOne(ctx, s.db, stmt, "update campaign")
|
return execOne(ctx, s.db, stmt, "update campaign")
|
||||||
}
|
}
|
||||||
@@ -273,40 +257,18 @@ func execOne(ctx context.Context, db qrm.Executable, stmt postgres.Statement, wh
|
|||||||
|
|
||||||
func modelToCampaign(r model.AdCampaigns) Campaign {
|
func modelToCampaign(r model.AdCampaigns) Campaign {
|
||||||
return Campaign{
|
return Campaign{
|
||||||
ID: r.CampaignID,
|
ID: r.CampaignID,
|
||||||
Name: r.Name,
|
Name: r.Name,
|
||||||
Weight: int(r.Weight),
|
Weight: int(r.Weight),
|
||||||
IsDefault: r.IsDefault,
|
IsDefault: r.IsDefault,
|
||||||
Enabled: r.Enabled,
|
Enabled: r.Enabled,
|
||||||
StartsAt: r.StartsAt,
|
StartsAt: r.StartsAt,
|
||||||
EndsAt: r.EndsAt,
|
EndsAt: r.EndsAt,
|
||||||
OverrideAll: colorSetFrom(r.OverrideBg, r.OverrideFg, r.OverrideLink),
|
CreatedAt: r.CreatedAt,
|
||||||
OverrideDark: colorSetFrom(r.OverrideBgDark, r.OverrideFgDark, r.OverrideLinkDark),
|
UpdatedAt: r.UpdatedAt,
|
||||||
Urgent: r.Urgent,
|
|
||||||
CreatedAt: r.CreatedAt,
|
|
||||||
UpdatedAt: r.UpdatedAt,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// colorSetFrom rebuilds an optional ColorSet from three nullable colour columns.
|
|
||||||
// The all-or-nothing CHECK keeps the trio consistent, so a nil in any one means
|
|
||||||
// the set is absent.
|
|
||||||
func colorSetFrom(bg, fg, link *string) *ColorSet {
|
|
||||||
if bg == nil || fg == nil || link == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &ColorSet{Bg: *bg, Fg: *fg, Link: *link}
|
|
||||||
}
|
|
||||||
|
|
||||||
// colorCols renders a *ColorSet as its three column value-expressions in
|
|
||||||
// bg, fg, link order — NULL for each when the set is absent.
|
|
||||||
func colorCols(cs *ColorSet) (bg, fg, link postgres.Expression) {
|
|
||||||
if cs == nil {
|
|
||||||
return postgres.NULL, postgres.NULL, postgres.NULL
|
|
||||||
}
|
|
||||||
return postgres.String(cs.Bg), postgres.String(cs.Fg), postgres.String(cs.Link)
|
|
||||||
}
|
|
||||||
|
|
||||||
func modelToMessage(r model.AdMessages) Message {
|
func modelToMessage(r model.AdMessages) Message {
|
||||||
return Message{
|
return Message{
|
||||||
ID: r.MessageID,
|
ID: r.MessageID,
|
||||||
|
|||||||
@@ -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) {
|
func TestAllowedTimeout(t *testing.T) {
|
||||||
if !allowedTimeout(24 * time.Hour) {
|
if !allowedTimeout(24 * time.Hour) {
|
||||||
t.Error("24h must be allowed")
|
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)
|
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)
|
// Hint reveals the top-scoring legal play for the requesting player on their
|
||||||
// before the idle hint unlocks. Mirrors the offline client (lib/hints HINT_GATE_MS = 30 min).
|
// turn, spending one hint from their per-game allowance and then their profile
|
||||||
const hintIdleWindow = 30 * time.Minute
|
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||||
|
// appropriate.
|
||||||
// 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.
|
|
||||||
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (HintResult, error) {
|
||||||
pre, err := svc.store.GetGame(ctx, gameID)
|
pre, err := svc.store.GetGame(ctx, gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1101,26 +1080,6 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
|
|||||||
if !pre.HintsAllowed {
|
if !pre.HintsAllowed {
|
||||||
return HintResult{}, ErrHintsDisabled
|
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)
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HintResult{}, err
|
return HintResult{}, err
|
||||||
@@ -1249,8 +1208,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
|
|||||||
BagLen: g.BagLen(),
|
BagLen: g.BagLen(),
|
||||||
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
|
||||||
WalletBalance: 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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,10 +65,6 @@ var (
|
|||||||
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
ErrNoHintsLeft = errors.New("game: no hints remaining")
|
||||||
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
// ErrNoHintAvailable is returned when the player has no legal play to reveal.
|
||||||
ErrNoHintAvailable = errors.New("game: no legal move to suggest")
|
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
|
// ErrGameLimitReached is returned when an account already holds MaxActiveQuickGames
|
||||||
// simultaneous quick games and tries to create another game (quick or by invitation).
|
// simultaneous quick games and tries to create another game (quick or by invitation).
|
||||||
ErrGameLimitReached = errors.New("game: simultaneous game limit reached")
|
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
|
// 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.
|
// it in with the per-game allowance), so the client keeps the wallet live across games.
|
||||||
WalletBalance int
|
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.
|
// HistoryMove is one decoded journal row, independent of any dictionary.
|
||||||
|
|||||||
@@ -192,9 +192,8 @@ func TestBannerMessageOwnership(t *testing.T) {
|
|||||||
type profileBanner struct {
|
type profileBanner struct {
|
||||||
Banner *struct {
|
Banner *struct {
|
||||||
Campaigns []struct {
|
Campaigns []struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
OverrideBg string `json:"override_bg"`
|
|
||||||
} `json:"campaigns"`
|
} `json:"campaigns"`
|
||||||
Timings struct {
|
Timings struct {
|
||||||
HoldMs int `json:"hold_ms"`
|
HoldMs int `json:"hold_ms"`
|
||||||
@@ -275,142 +274,3 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
|
|||||||
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
t.Fatalf("profile update dropped the banner block: %v", p.Banner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestBannerConsoleColorsAndUrgent drives the colour-override + urgent editor over
|
|
||||||
// HTTP against real Postgres: an incomplete or malformed colour set is refused, a
|
|
||||||
// full two-set override + urgent round-trips through the store (exercising the new
|
|
||||||
// columns and CHECK constraints), clearing the sets removes the override, and the
|
|
||||||
// default campaign stays plain (colours + urgent are ignored for it).
|
|
||||||
func TestBannerConsoleColorsAndUrgent(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
srv, adsSvc := bannerServer(t)
|
|
||||||
h := srv.Handler()
|
|
||||||
base := "http://admin.test/_gm"
|
|
||||||
const origin = "http://admin.test"
|
|
||||||
|
|
||||||
name := "Brand-" + uuid.NewString()[:8]
|
|
||||||
if code, body := consoleDo(h, http.MethodPost, base+"/banners", "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
|
|
||||||
t.Fatalf("create = %d, has Created=%v", code, strings.Contains(body, "Created"))
|
|
||||||
}
|
|
||||||
id := findCampaign(t, adsSvc, name)
|
|
||||||
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, id) })
|
|
||||||
detail := base + "/banners/" + id.String()
|
|
||||||
|
|
||||||
// A partial colour set (a missing colour) is refused by validation.
|
|
||||||
partial := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=%23aa0000&override_fg=&override_link=%23ffdd00"
|
|
||||||
if code, b := consoleDo(h, http.MethodPost, detail, partial, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
|
||||||
t.Fatalf("partial colour = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
|
||||||
}
|
|
||||||
// A malformed hex is refused.
|
|
||||||
badHex := "name=" + name + "&weight=20&enabled=on&override_all_on=on&override_bg=red&override_fg=%23ffffff&override_link=%23ffdd00"
|
|
||||||
if code, b := consoleDo(h, http.MethodPost, detail, badHex, origin); code != http.StatusOK || !strings.Contains(b, "Not saved") {
|
|
||||||
t.Fatalf("bad hex = %d, has 'Not saved'=%v", code, strings.Contains(b, "Not saved"))
|
|
||||||
}
|
|
||||||
// Both colour sets + urgent persist and round-trip through the store.
|
|
||||||
full := "name=" + name + "&weight=20&enabled=on&urgent=on" +
|
|
||||||
"&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00" +
|
|
||||||
"&override_dark_on=on&override_bg_dark=%23330000&override_fg_dark=%23eeeeee&override_link_dark=%23ffcc00"
|
|
||||||
if code, b := consoleDo(h, http.MethodPost, detail, full, origin); code != http.StatusOK || !strings.Contains(b, "Saved") {
|
|
||||||
t.Fatalf("full save = %d, has Saved=%v", code, strings.Contains(b, "Saved"))
|
|
||||||
}
|
|
||||||
cm, err := adsSvc.Campaign(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read campaign: %v", err)
|
|
||||||
}
|
|
||||||
if cm.OverrideAll == nil || cm.OverrideAll.Bg != "#aa0000" || cm.OverrideAll.Fg != "#ffffff" || cm.OverrideAll.Link != "#ffdd00" {
|
|
||||||
t.Fatalf("override all = %+v", cm.OverrideAll)
|
|
||||||
}
|
|
||||||
if cm.OverrideDark == nil || cm.OverrideDark.Bg != "#330000" || cm.OverrideDark.Fg != "#eeeeee" || cm.OverrideDark.Link != "#ffcc00" {
|
|
||||||
t.Fatalf("override dark = %+v", cm.OverrideDark)
|
|
||||||
}
|
|
||||||
if !cm.Urgent {
|
|
||||||
t.Fatal("urgent not persisted")
|
|
||||||
}
|
|
||||||
// Clearing the sets (both checkboxes off, urgent off) removes the override.
|
|
||||||
if code, _ := consoleDo(h, http.MethodPost, detail, "name="+name+"&weight=20&enabled=on", origin); code != http.StatusOK {
|
|
||||||
t.Fatalf("clear = %d", code)
|
|
||||||
}
|
|
||||||
if cm, _ := adsSvc.Campaign(ctx, id); cm.OverrideAll != nil || cm.OverrideDark != nil || cm.Urgent {
|
|
||||||
t.Fatalf("override not cleared: all=%v dark=%v urgent=%v", cm.OverrideAll, cm.OverrideDark, cm.Urgent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The default campaign stays plain: submitted colours + urgent are ignored for it.
|
|
||||||
def := defaultCampaign(t, adsSvc)
|
|
||||||
defForm := "name=Default+%28house%29&urgent=on&override_all_on=on&override_bg=%23aa0000&override_fg=%23ffffff&override_link=%23ffdd00"
|
|
||||||
if code, _ := consoleDo(h, http.MethodPost, base+"/banners/"+def.ID.String(), defForm, origin); code != http.StatusOK {
|
|
||||||
t.Fatalf("update default = %d", code)
|
|
||||||
}
|
|
||||||
if again := defaultCampaign(t, adsSvc); again.OverrideAll != nil || again.OverrideDark != nil || again.Urgent {
|
|
||||||
t.Fatalf("default not plain: all=%v dark=%v urgent=%v", again.OverrideAll, again.OverrideDark, again.Urgent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestBannerUrgentBypassesEligibility checks the urgent flag at the profile level:
|
|
||||||
// an urgent campaign preempts the feed (only it shows, with its colours) and reaches
|
|
||||||
// every viewer — including the no_banner role and a non-empty hint wallet that
|
|
||||||
// otherwise suppress the banner.
|
|
||||||
func TestBannerUrgentBypassesEligibility(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
srv, adsSvc := bannerServer(t)
|
|
||||||
accounts := account.NewStore(testDB)
|
|
||||||
id := provisionAccount(t)
|
|
||||||
|
|
||||||
urgentID, err := adsSvc.CreateCampaign(ctx, ads.Campaign{
|
|
||||||
Name: "Alert-" + uuid.NewString()[:8], Weight: 10, Enabled: true, Urgent: true,
|
|
||||||
OverrideAll: &ads.ColorSet{Bg: "#aa0000", Fg: "#ffffff", Link: "#ffdd00"},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create urgent: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = adsSvc.DeleteCampaign(ctx, urgentID) })
|
|
||||||
if _, err := adsSvc.AddMessage(ctx, urgentID, "System maintenance tonight", "Ночью тех.работы"); err != nil {
|
|
||||||
t.Fatalf("add urgent message: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
get := func() profileBanner {
|
|
||||||
t.Helper()
|
|
||||||
rec := userGet(t, srv, "/api/v1/user/profile", id)
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("profile = %d", rec.Code)
|
|
||||||
}
|
|
||||||
var p profileBanner
|
|
||||||
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
|
|
||||||
t.Fatalf("decode: %v", err)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
assertUrgentOnly := func(what string, p profileBanner) {
|
|
||||||
t.Helper()
|
|
||||||
if p.Banner == nil {
|
|
||||||
t.Fatalf("%s: no banner", what)
|
|
||||||
}
|
|
||||||
if len(p.Banner.Campaigns) != 1 {
|
|
||||||
t.Fatalf("%s: %d campaigns, want only the urgent one (default preempted)", what, len(p.Banner.Campaigns))
|
|
||||||
}
|
|
||||||
c := p.Banner.Campaigns[0]
|
|
||||||
if len(c.Messages) != 1 || c.Messages[0] != "System maintenance tonight" {
|
|
||||||
t.Fatalf("%s: messages = %v, want only the urgent one", what, c.Messages)
|
|
||||||
}
|
|
||||||
if c.OverrideBg != "#aa0000" {
|
|
||||||
t.Fatalf("%s: override_bg = %q, want #aa0000", what, c.OverrideBg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A normal viewer sees only the urgent campaign (the default is preempted).
|
|
||||||
assertUrgentOnly("eligible", get())
|
|
||||||
|
|
||||||
// The no_banner role no longer suppresses it — urgent reaches everyone.
|
|
||||||
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
|
|
||||||
t.Fatalf("grant role: %v", err)
|
|
||||||
}
|
|
||||||
assertUrgentOnly("no_banner", get())
|
|
||||||
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
|
|
||||||
t.Fatalf("revoke role: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A non-empty hint wallet no longer suppresses it either.
|
|
||||||
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
|
|
||||||
t.Fatalf("grant hints: %v", err)
|
|
||||||
}
|
|
||||||
assertUrgentOnly("hints", get())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
||||||
email := "login-" + uuid.NewString() + "@example.com"
|
email := "login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en", false)
|
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00", "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
t.Fatalf("request login code: %v", err)
|
||||||
}
|
}
|
||||||
@@ -233,7 +233,7 @@ func TestEmailLoginFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A second login for the same email is the returning user: same account.
|
// A second login for the same email is the returning user: same account.
|
||||||
if _, err := svc.RequestLoginCode(ctx, email, "", "ru", false); err != nil {
|
if _, err := svc.RequestLoginCode(ctx, email, "", "ru"); err != nil {
|
||||||
t.Fatalf("second request: %v", err)
|
t.Fatalf("second request: %v", err)
|
||||||
}
|
}
|
||||||
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
|
||||||
@@ -255,7 +255,7 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
|||||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
email := "squat-" + uuid.NewString() + "@example.com"
|
email := "squat-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login code: %v", err)
|
t.Fatalf("request login code: %v", err)
|
||||||
}
|
}
|
||||||
@@ -279,34 +279,6 @@ func TestEmailLoginProvisionsGuestUntilConfirmed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestEmailLoginPWAOmitsLink: a login requested from an installed PWA (pwa=true) mails only the
|
|
||||||
// code — no one-tap confirm link, which would otherwise open in a separate browser out of the
|
|
||||||
// PWA's reach. A non-PWA request keeps the link.
|
|
||||||
func TestEmailLoginPWAOmitsLink(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
mailer := &capturingMailer{}
|
|
||||||
svc := account.NewEmailService(account.NewStore(testDB), mailer, "https://erudit-game.ru")
|
|
||||||
|
|
||||||
pwaEmail := "pwa-login-" + uuid.NewString() + "@example.com"
|
|
||||||
if _, err := svc.RequestLoginCode(ctx, pwaEmail, "", "en", true); err != nil {
|
|
||||||
t.Fatalf("pwa request login: %v", err)
|
|
||||||
}
|
|
||||||
if sixDigit.FindString(mailer.lastBody) == "" {
|
|
||||||
t.Errorf("pwa login mail must still carry the code, body %q", mailer.lastBody)
|
|
||||||
}
|
|
||||||
if confirmToken.MatchString(mailer.lastBody) {
|
|
||||||
t.Errorf("pwa login mail must omit the one-tap confirm link, body %q", mailer.lastBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
webEmail := "web-login-" + uuid.NewString() + "@example.com"
|
|
||||||
if _, err := svc.RequestLoginCode(ctx, webEmail, "", "en", false); err != nil {
|
|
||||||
t.Fatalf("web request login: %v", err)
|
|
||||||
}
|
|
||||||
if !confirmToken.MatchString(mailer.lastBody) {
|
|
||||||
t.Errorf("non-pwa login mail must keep the one-tap confirm link, body %q", mailer.lastBody)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
|
// confirmToken extracts the one-tap deeplink token from the /app/#/confirm/<token> link
|
||||||
// the branded email carries.
|
// the branded email carries.
|
||||||
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
|
var confirmToken = regexp.MustCompile(`/confirm/([A-Za-z0-9_-]+)`)
|
||||||
@@ -329,7 +301,7 @@ func TestConfirmByTokenLogin(t *testing.T) {
|
|||||||
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
|
||||||
email := "tok-login-" + uuid.NewString() + "@example.com"
|
email := "tok-login-" + uuid.NewString() + "@example.com"
|
||||||
|
|
||||||
id, err := svc.RequestLoginCode(ctx, email, "", "en", false)
|
id, err := svc.RequestLoginCode(ctx, email, "", "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login: %v", err)
|
t.Fatalf("request login: %v", err)
|
||||||
}
|
}
|
||||||
@@ -410,7 +382,7 @@ func TestEmailAccountSeedsDisplayName(t *testing.T) {
|
|||||||
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
|
svc := account.NewEmailService(store, &capturingMailer{}, "https://erudit-game.ru")
|
||||||
local := "kaya-" + uuid.NewString()[:8]
|
local := "kaya-" + uuid.NewString()[:8]
|
||||||
|
|
||||||
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en", false)
|
id, err := svc.RequestLoginCode(ctx, local+"@example.com", "", "en")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("request login: %v", err)
|
t.Fatalf("request login: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -13,20 +13,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AdCampaigns struct {
|
type AdCampaigns struct {
|
||||||
CampaignID uuid.UUID `sql:"primary_key"`
|
CampaignID uuid.UUID `sql:"primary_key"`
|
||||||
Name string
|
Name string
|
||||||
Weight int32
|
Weight int32
|
||||||
IsDefault bool
|
IsDefault bool
|
||||||
Enabled bool
|
Enabled bool
|
||||||
StartsAt *time.Time
|
StartsAt *time.Time
|
||||||
EndsAt *time.Time
|
EndsAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
OverrideBg *string
|
|
||||||
OverrideFg *string
|
|
||||||
OverrideLink *string
|
|
||||||
OverrideBgDark *string
|
|
||||||
OverrideFgDark *string
|
|
||||||
OverrideLinkDark *string
|
|
||||||
Urgent bool
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,4 @@ type FeedbackMessages struct {
|
|||||||
ReplyReadAt *time.Time
|
ReplyReadAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Lang *string
|
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
|
|
||||||
}
|
|
||||||
@@ -17,22 +17,15 @@ type adCampaignsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
CampaignID postgres.ColumnString
|
CampaignID postgres.ColumnString
|
||||||
Name postgres.ColumnString
|
Name postgres.ColumnString
|
||||||
Weight postgres.ColumnInteger
|
Weight postgres.ColumnInteger
|
||||||
IsDefault postgres.ColumnBool
|
IsDefault postgres.ColumnBool
|
||||||
Enabled postgres.ColumnBool
|
Enabled postgres.ColumnBool
|
||||||
StartsAt postgres.ColumnTimestampz
|
StartsAt postgres.ColumnTimestampz
|
||||||
EndsAt postgres.ColumnTimestampz
|
EndsAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
UpdatedAt postgres.ColumnTimestampz
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
OverrideBg postgres.ColumnString
|
|
||||||
OverrideFg postgres.ColumnString
|
|
||||||
OverrideLink postgres.ColumnString
|
|
||||||
OverrideBgDark postgres.ColumnString
|
|
||||||
OverrideFgDark postgres.ColumnString
|
|
||||||
OverrideLinkDark postgres.ColumnString
|
|
||||||
Urgent postgres.ColumnBool
|
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -74,47 +67,33 @@ func newAdCampaignsTable(schemaName, tableName, alias string) *AdCampaignsTable
|
|||||||
|
|
||||||
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
func newAdCampaignsTableImpl(schemaName, tableName, alias string) adCampaignsTable {
|
||||||
var (
|
var (
|
||||||
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
CampaignIDColumn = postgres.StringColumn("campaign_id")
|
||||||
NameColumn = postgres.StringColumn("name")
|
NameColumn = postgres.StringColumn("name")
|
||||||
WeightColumn = postgres.IntegerColumn("weight")
|
WeightColumn = postgres.IntegerColumn("weight")
|
||||||
IsDefaultColumn = postgres.BoolColumn("is_default")
|
IsDefaultColumn = postgres.BoolColumn("is_default")
|
||||||
EnabledColumn = postgres.BoolColumn("enabled")
|
EnabledColumn = postgres.BoolColumn("enabled")
|
||||||
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
StartsAtColumn = postgres.TimestampzColumn("starts_at")
|
||||||
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
EndsAtColumn = postgres.TimestampzColumn("ends_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
OverrideBgColumn = postgres.StringColumn("override_bg")
|
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
OverrideFgColumn = postgres.StringColumn("override_fg")
|
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
OverrideLinkColumn = postgres.StringColumn("override_link")
|
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||||
OverrideBgDarkColumn = postgres.StringColumn("override_bg_dark")
|
|
||||||
OverrideFgDarkColumn = postgres.StringColumn("override_fg_dark")
|
|
||||||
OverrideLinkDarkColumn = postgres.StringColumn("override_link_dark")
|
|
||||||
UrgentColumn = postgres.BoolColumn("urgent")
|
|
||||||
allColumns = postgres.ColumnList{CampaignIDColumn, NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
|
||||||
mutableColumns = postgres.ColumnList{NameColumn, WeightColumn, IsDefaultColumn, EnabledColumn, StartsAtColumn, EndsAtColumn, CreatedAtColumn, UpdatedAtColumn, OverrideBgColumn, OverrideFgColumn, OverrideLinkColumn, OverrideBgDarkColumn, OverrideFgDarkColumn, OverrideLinkDarkColumn, UrgentColumn}
|
|
||||||
defaultColumns = postgres.ColumnList{IsDefaultColumn, EnabledColumn, CreatedAtColumn, UpdatedAtColumn, UrgentColumn}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return adCampaignsTable{
|
return adCampaignsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
CampaignID: CampaignIDColumn,
|
CampaignID: CampaignIDColumn,
|
||||||
Name: NameColumn,
|
Name: NameColumn,
|
||||||
Weight: WeightColumn,
|
Weight: WeightColumn,
|
||||||
IsDefault: IsDefaultColumn,
|
IsDefault: IsDefaultColumn,
|
||||||
Enabled: EnabledColumn,
|
Enabled: EnabledColumn,
|
||||||
StartsAt: StartsAtColumn,
|
StartsAt: StartsAtColumn,
|
||||||
EndsAt: EndsAtColumn,
|
EndsAt: EndsAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
OverrideBg: OverrideBgColumn,
|
|
||||||
OverrideFg: OverrideFgColumn,
|
|
||||||
OverrideLink: OverrideLinkColumn,
|
|
||||||
OverrideBgDark: OverrideBgDarkColumn,
|
|
||||||
OverrideFgDark: OverrideFgDarkColumn,
|
|
||||||
OverrideLinkDark: OverrideLinkDarkColumn,
|
|
||||||
Urgent: UrgentColumn,
|
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ type feedbackMessagesTable struct {
|
|||||||
ReplyReadAt postgres.ColumnTimestampz
|
ReplyReadAt postgres.ColumnTimestampz
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
Lang postgres.ColumnString
|
Lang postgres.ColumnString
|
||||||
AppVersion postgres.ColumnString
|
|
||||||
BrowserTz postgres.ColumnString
|
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -88,10 +86,8 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
|||||||
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
LangColumn = postgres.StringColumn("lang")
|
LangColumn = postgres.StringColumn("lang")
|
||||||
AppVersionColumn = postgres.StringColumn("app_version")
|
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
||||||
BrowserTzColumn = postgres.StringColumn("browser_tz")
|
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
|
||||||
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}
|
|
||||||
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
defaultColumns = postgres.ColumnList{CreatedAtColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -113,8 +109,6 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
|
|||||||
ReplyReadAt: ReplyReadAtColumn,
|
ReplyReadAt: ReplyReadAtColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
Lang: LangColumn,
|
Lang: LangColumn,
|
||||||
AppVersion: AppVersionColumn,
|
|
||||||
BrowserTz: BrowserTzColumn,
|
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
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
|
// 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.
|
// this method only once at the beginning of the program.
|
||||||
func UseSchema(schema string) {
|
func UseSchema(schema string) {
|
||||||
AccountBestMove = AccountBestMove.FromSchema(schema)
|
|
||||||
AccountRoles = AccountRoles.FromSchema(schema)
|
AccountRoles = AccountRoles.FromSchema(schema)
|
||||||
AccountStats = AccountStats.FromSchema(schema)
|
AccountStats = AccountStats.FromSchema(schema)
|
||||||
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
AccountSuspensions = AccountSuspensions.FromSchema(schema)
|
||||||
@@ -36,8 +35,6 @@ func UseSchema(schema string) {
|
|||||||
Games = Games.FromSchema(schema)
|
Games = Games.FromSchema(schema)
|
||||||
Identities = Identities.FromSchema(schema)
|
Identities = Identities.FromSchema(schema)
|
||||||
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
RetainedIdentities = RetainedIdentities.FromSchema(schema)
|
||||||
RobotBlocks = RobotBlocks.FromSchema(schema)
|
|
||||||
RobotFriendRequests = RobotFriendRequests.FromSchema(schema)
|
|
||||||
Sessions = Sessions.FromSchema(schema)
|
Sessions = Sessions.FromSchema(schema)
|
||||||
SuspensionReasons = SuspensionReasons.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,60 +0,0 @@
|
|||||||
-- Per-campaign banner colour overrides and the urgent (always-show) flag.
|
|
||||||
--
|
|
||||||
-- ad_campaigns gains two optional colour sets and an urgent flag, all for
|
|
||||||
-- non-default campaigns only:
|
|
||||||
-- override_bg/fg/link — paints the strip on every theme
|
|
||||||
-- override_bg/fg/link_dark — further overrides the dark theme
|
|
||||||
-- urgent — force the banner on every viewer (bypassing
|
|
||||||
-- eligibility) and suppress all non-urgent
|
|
||||||
-- campaigns while any urgent one is active
|
|
||||||
--
|
|
||||||
-- Each colour set is all-or-nothing (bg+fg+link together, or absent) and every
|
|
||||||
-- colour is a "#rrggbb" hex string. The default (house) campaign stays plain: no
|
|
||||||
-- colours, never urgent.
|
|
||||||
--
|
|
||||||
-- Expand-contract: additive (nullable columns plus one NOT NULL boolean with a
|
|
||||||
-- default), so a backend image rollback stays DB-safe — older code ignores them.
|
|
||||||
-- The ad_campaigns table shape changes, so its generated go-jet model is
|
|
||||||
-- regenerated (by hand here, to keep the diff to this one table).
|
|
||||||
|
|
||||||
-- +goose Up
|
|
||||||
ALTER TABLE backend.ad_campaigns
|
|
||||||
ADD COLUMN override_bg text,
|
|
||||||
ADD COLUMN override_fg text,
|
|
||||||
ADD COLUMN override_link text,
|
|
||||||
ADD COLUMN override_bg_dark text,
|
|
||||||
ADD COLUMN override_fg_dark text,
|
|
||||||
ADD COLUMN override_link_dark text,
|
|
||||||
ADD COLUMN urgent boolean DEFAULT false NOT NULL,
|
|
||||||
ADD CONSTRAINT ad_campaigns_override_all_chk CHECK (
|
|
||||||
(override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL)
|
|
||||||
OR (override_bg IS NOT NULL AND override_fg IS NOT NULL AND override_link IS NOT NULL)
|
|
||||||
),
|
|
||||||
ADD CONSTRAINT ad_campaigns_override_dark_chk CHECK (
|
|
||||||
(override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL)
|
|
||||||
OR (override_bg_dark IS NOT NULL AND override_fg_dark IS NOT NULL AND override_link_dark IS NOT NULL)
|
|
||||||
),
|
|
||||||
ADD CONSTRAINT ad_campaigns_override_hex_chk CHECK (
|
|
||||||
(override_bg IS NULL OR override_bg ~ '^#[0-9a-fA-F]{6}$')
|
|
||||||
AND (override_fg IS NULL OR override_fg ~ '^#[0-9a-fA-F]{6}$')
|
|
||||||
AND (override_link IS NULL OR override_link ~ '^#[0-9a-fA-F]{6}$')
|
|
||||||
AND (override_bg_dark IS NULL OR override_bg_dark ~ '^#[0-9a-fA-F]{6}$')
|
|
||||||
AND (override_fg_dark IS NULL OR override_fg_dark ~ '^#[0-9a-fA-F]{6}$')
|
|
||||||
AND (override_link_dark IS NULL OR override_link_dark ~ '^#[0-9a-fA-F]{6}$')
|
|
||||||
),
|
|
||||||
ADD CONSTRAINT ad_campaigns_default_plain_chk CHECK (
|
|
||||||
(NOT is_default)
|
|
||||||
OR (override_bg IS NULL AND override_fg IS NULL AND override_link IS NULL
|
|
||||||
AND override_bg_dark IS NULL AND override_fg_dark IS NULL AND override_link_dark IS NULL
|
|
||||||
AND NOT urgent)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- +goose Down
|
|
||||||
ALTER TABLE backend.ad_campaigns
|
|
||||||
DROP COLUMN override_bg,
|
|
||||||
DROP COLUMN override_fg,
|
|
||||||
DROP COLUMN override_link,
|
|
||||||
DROP COLUMN override_bg_dark,
|
|
||||||
DROP COLUMN override_fg_dark,
|
|
||||||
DROP COLUMN override_link_dark,
|
|
||||||
DROP COLUMN urgent;
|
|
||||||
@@ -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/account"
|
||||||
"scrabble/backend/internal/ads"
|
"scrabble/backend/internal/ads"
|
||||||
"scrabble/backend/internal/engine"
|
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,20 +22,10 @@ type bannerDTO struct {
|
|||||||
|
|
||||||
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
|
||||||
// weight (for the client's smooth weighted round-robin) and its messages, in
|
// weight (for the client's smooth weighted round-robin) and its messages, in
|
||||||
// display order, already resolved to one language. The override_* colours are
|
// display order, already resolved to one language.
|
||||||
// present only for a campaign that carries a colour override: override_bg/fg/link
|
|
||||||
// paint every theme, and the *_dark trio further overrides the dark theme (the
|
|
||||||
// client resolves the cascade). They are absent for a plain campaign, which keeps
|
|
||||||
// the neutral theme tokens.
|
|
||||||
type bannerCampaignDTO struct {
|
type bannerCampaignDTO struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
OverrideBg string `json:"override_bg,omitempty"`
|
|
||||||
OverrideFg string `json:"override_fg,omitempty"`
|
|
||||||
OverrideLink string `json:"override_link,omitempty"`
|
|
||||||
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
|
||||||
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
|
||||||
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
||||||
@@ -57,34 +46,9 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
|
|||||||
r := profileResponseFor(acc)
|
r := profileResponseFor(acc)
|
||||||
r.Banner = s.bannerFor(ctx, acc)
|
r.Banner = s.bannerFor(ctx, acc)
|
||||||
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
s.fillLinkedIdentities(ctx, &r, acc.ID)
|
||||||
r.DictVersions = s.currentDictVersions()
|
|
||||||
return r
|
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
|
// 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 /
|
// 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
|
// change-email controls. A read failure leaves them zero (no controls), logged as a
|
||||||
@@ -119,27 +83,22 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
|||||||
if s.ads == nil {
|
if s.ads == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// An urgent campaign is shown to every viewer; otherwise the normal eligibility
|
|
||||||
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
|
|
||||||
// hides the banner).
|
|
||||||
if !urgent {
|
|
||||||
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
|
|
||||||
if err != nil {
|
|
||||||
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
||||||
for _, c := range set {
|
for _, c := range set {
|
||||||
campaigns = append(campaigns, bannerCampaignFromActive(c))
|
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
||||||
}
|
}
|
||||||
return &bannerDTO{
|
return &bannerDTO{
|
||||||
Campaigns: campaigns,
|
Campaigns: campaigns,
|
||||||
@@ -154,20 +113,6 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
|
|
||||||
// projecting each optional colour set into its three "#rrggbb" fields (empty when
|
|
||||||
// the set is absent, so JSON omitempty drops them).
|
|
||||||
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
|
|
||||||
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
|
|
||||||
if c.OverrideAll != nil {
|
|
||||||
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
|
|
||||||
}
|
|
||||||
if c.OverrideDark != nil {
|
|
||||||
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// bannerLang resolves the message language for a viewer: their interface language
|
// bannerLang resolves the message language for a viewer: their interface language
|
||||||
// (Russian, or English by default).
|
// (Russian, or English by default).
|
||||||
func bannerLang(acc account.Account) string {
|
func bannerLang(acc account.Account) string {
|
||||||
|
|||||||
@@ -63,20 +63,6 @@ type profileResponse struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
TelegramLinked bool `json:"telegram_linked"`
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
VkLinked bool `json:"vk_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.
|
// 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
|
// 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.
|
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
|
||||||
type stateDTO struct {
|
type stateDTO struct {
|
||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
|
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
||||||
// 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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchDTO reports whether the caller has been paired into a game.
|
// 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
|
return stateDTO{}, err
|
||||||
}
|
}
|
||||||
dto := stateDTO{
|
dto := stateDTO{
|
||||||
Game: gameDTOFromGame(v.Game),
|
Game: gameDTOFromGame(v.Game),
|
||||||
Seat: v.Seat,
|
Seat: v.Seat,
|
||||||
Rack: rack,
|
Rack: rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
HintsRemaining: v.HintsRemaining,
|
||||||
WalletBalance: v.WalletBalance,
|
WalletBalance: v.WalletBalance,
|
||||||
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
|
|
||||||
}
|
}
|
||||||
if includeAlphabet {
|
if includeAlphabet {
|
||||||
tab, err := engine.AlphabetTable(v.Game.Variant)
|
tab, err := engine.AlphabetTable(v.Game.Variant)
|
||||||
|
|||||||
@@ -239,9 +239,6 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "no_hint_available"
|
return http.StatusConflict, "no_hint_available"
|
||||||
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
|
||||||
return http.StatusConflict, "hint_unavailable"
|
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):
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
||||||
|
|||||||
@@ -19,15 +19,6 @@ const bannersBack = "/_gm/banners"
|
|||||||
// bound; the operator's entry is interpreted as UTC.
|
// bound; the operator's entry is interpreted as UTC.
|
||||||
const localTimeLayout = "2006-01-02T15:04"
|
const localTimeLayout = "2006-01-02T15:04"
|
||||||
|
|
||||||
// Neutral banner theme tokens, mirrored from ui/src/app.css (--ad-bg,
|
|
||||||
// --text-muted, --accent for the light and dark themes). They seed the console's
|
|
||||||
// colour pickers and the preview fallback when a campaign has no override, so the
|
|
||||||
// operator always edits against the real neutral colours.
|
|
||||||
const (
|
|
||||||
tokenLightBg, tokenLightFg, tokenLightLink = "#e3e7ee", "#6b7280", "#2f6df6"
|
|
||||||
tokenDarkBg, tokenDarkFg, tokenDarkLink = "#272f3c", "#9aa3b2", "#5b8cff"
|
|
||||||
)
|
|
||||||
|
|
||||||
// consoleBanners lists the advertising campaigns (default first), each with its
|
// consoleBanners lists the advertising campaigns (default first), each with its
|
||||||
// weight, validity window, enabled flag, message count and live-now status.
|
// weight, validity window, enabled flag, message count and live-now status.
|
||||||
func (s *Server) consoleBanners(c *gin.Context) {
|
func (s *Server) consoleBanners(c *gin.Context) {
|
||||||
@@ -65,19 +56,14 @@ func (s *Server) consoleBannerDetail(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := adminconsole.BannerDetailView{
|
view := adminconsole.BannerDetailView{
|
||||||
ID: cm.ID.String(),
|
ID: cm.ID.String(),
|
||||||
Name: cm.Name,
|
Name: cm.Name,
|
||||||
Weight: cm.Weight,
|
Weight: cm.Weight,
|
||||||
IsDefault: cm.IsDefault,
|
IsDefault: cm.IsDefault,
|
||||||
Enabled: cm.Enabled,
|
Enabled: cm.Enabled,
|
||||||
StartsAt: localInput(cm.StartsAt),
|
StartsAt: localInput(cm.StartsAt),
|
||||||
EndsAt: localInput(cm.EndsAt),
|
EndsAt: localInput(cm.EndsAt),
|
||||||
Urgent: cm.Urgent,
|
|
||||||
OverrideAllOn: cm.OverrideAll != nil,
|
|
||||||
OverrideDarkOn: cm.OverrideDark != nil,
|
|
||||||
}
|
}
|
||||||
view.AllBg, view.AllFg, view.AllLink = seedColors(cm.OverrideAll, tokenLightBg, tokenLightFg, tokenLightLink)
|
|
||||||
view.DarkBg, view.DarkFg, view.DarkLink = seedColors(cm.OverrideDark, tokenDarkBg, tokenDarkFg, tokenDarkLink)
|
|
||||||
for i, m := range cm.Messages {
|
for i, m := range cm.Messages {
|
||||||
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
|
||||||
ID: m.ID.String(),
|
ID: m.ID.String(),
|
||||||
@@ -125,15 +111,12 @@ func (s *Server) consoleUpdateBanner(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
|
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: trimForm(c, "name"),
|
Name: trimForm(c, "name"),
|
||||||
Weight: atoiForm(c, "weight"),
|
Weight: atoiForm(c, "weight"),
|
||||||
Enabled: c.PostForm("enabled") != "",
|
Enabled: c.PostForm("enabled") != "",
|
||||||
StartsAt: starts,
|
StartsAt: starts,
|
||||||
EndsAt: ends,
|
EndsAt: ends,
|
||||||
Urgent: c.PostForm("urgent") != "",
|
|
||||||
OverrideAll: colorSetForm(c, "override_all_on", "override_bg", "override_fg", "override_link"),
|
|
||||||
OverrideDark: colorSetForm(c, "override_dark_on", "override_bg_dark", "override_fg_dark", "override_link_dark"),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.bannerError(c, err, back)
|
s.bannerError(c, err, back)
|
||||||
@@ -338,29 +321,3 @@ func atoiForm(c *gin.Context, name string) int {
|
|||||||
n, _ := strconv.Atoi(trimForm(c, name))
|
n, _ := strconv.Atoi(trimForm(c, name))
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedColors returns the three colour-input seed values for a campaign's optional
|
|
||||||
// override: the stored colours when the set is present, otherwise the given
|
|
||||||
// neutral theme tokens (so the native picker starts sensibly and the preview
|
|
||||||
// shows the real fallback).
|
|
||||||
func seedColors(cs *ads.ColorSet, tokenBg, tokenFg, tokenLink string) (bg, fg, link string) {
|
|
||||||
if cs == nil {
|
|
||||||
return tokenBg, tokenFg, tokenLink
|
|
||||||
}
|
|
||||||
return cs.Bg, cs.Fg, cs.Link
|
|
||||||
}
|
|
||||||
|
|
||||||
// colorSetForm reads an optional colour override from the form: the on flag gates
|
|
||||||
// it (an unchecked set is absent, nil), otherwise the three posted colours are
|
|
||||||
// returned for the ads service to validate. The colour inputs are disabled in the
|
|
||||||
// browser while the set is off, so they are not posted then.
|
|
||||||
func colorSetForm(c *gin.Context, on, bg, fg, link string) *ads.ColorSet {
|
|
||||||
if c.PostForm(on) == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &ads.ColorSet{
|
|
||||||
Bg: trimForm(c, bg),
|
|
||||||
Fg: trimForm(c, fg),
|
|
||||||
Link: trimForm(c, link),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -174,10 +174,6 @@ type emailRequest struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
// Pwa marks a request from an installed PWA (standalone display mode): the login email then
|
|
||||||
// omits the one-tap confirm link so the code is typed in the same window (the link would open
|
|
||||||
// in a separate browser whose session cannot reach the PWA). See EmailService.RequestLoginCode.
|
|
||||||
Pwa bool `json:"pwa"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
// handleEmailRequest issues a login confirm-code to the email. It always reports
|
||||||
@@ -189,7 +185,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
|
|||||||
abortBadRequest(c, "email is required")
|
abortBadRequest(c, "email is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language, req.Pwa); err != nil {
|
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ, req.Language); err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import (
|
|||||||
"scrabble/backend/internal/link"
|
"scrabble/backend/internal/link"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/payments"
|
|
||||||
"scrabble/backend/internal/ratewatch"
|
"scrabble/backend/internal/ratewatch"
|
||||||
"scrabble/backend/internal/render"
|
"scrabble/backend/internal/render"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
@@ -94,11 +93,6 @@ type Deps struct {
|
|||||||
// profile.get banner block, plus the banner admin console section. A nil Ads
|
// profile.get banner block, plus the banner admin console section. A nil Ads
|
||||||
// omits the banner block and disables the banner console.
|
// omits the banner block and disables the banner console.
|
||||||
Ads *ads.Service
|
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
|
// Notifier publishes live-event intents — here the banner-eligibility re-poll
|
||||||
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
// signal the banner/hint/role console actions emit. A nil Notifier discards
|
||||||
// them (notify.Nop).
|
// them (notify.Nop).
|
||||||
@@ -135,7 +129,6 @@ type Server struct {
|
|||||||
ratewatch *ratewatch.Watch
|
ratewatch *ratewatch.Watch
|
||||||
banview *banview.View
|
banview *banview.View
|
||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
payments *payments.Service
|
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
exportKey []byte
|
exportKey []byte
|
||||||
@@ -188,7 +181,6 @@ func New(addr string, deps Deps) *Server {
|
|||||||
ratewatch: deps.RateWatch,
|
ratewatch: deps.RateWatch,
|
||||||
banview: deps.BanView,
|
banview: deps.BanView,
|
||||||
ads: deps.Ads,
|
ads: deps.Ads,
|
||||||
payments: deps.Payments,
|
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
renderer: deps.Renderer,
|
renderer: deps.Renderer,
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
|
|||||||
@@ -86,7 +86,7 @@
|
|||||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
|
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /scrabble.edge.v1.Gateway/*
|
||||||
handle @gateway {
|
handle @gateway {
|
||||||
reverse_proxy gateway:8081 {
|
reverse_proxy gateway:8081 {
|
||||||
header_up -X-Scrabble-Honeypot
|
header_up -X-Scrabble-Honeypot
|
||||||
|
|||||||
@@ -56,22 +56,6 @@
|
|||||||
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 24 },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
"targets": [{ "refId": "A", "expr": "sum(rate(local_eval_preview_total[1h])) by (path)", "legendFormat": "{{path}}" }]
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Unsupported-engine screens (cumulative by reason)",
|
|
||||||
"description": "Clients turned away by the index.html boot guard because the engine cannot run the app (an old Android System WebView), by reason: no_bigint / no_proxy (a missing unpolyfillable primitive) or boot_error (an uncaught startup failure). Deduped per device/version, so this counts distinct blocked installs, not launches. The effective floor is Chrome 67 (BigInt).",
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 32 },
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"targets": [{ "refId": "A", "expr": "sum(unsupported_engine_total) by (reason)", "legendFormat": "{{reason}}" }]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "timeseries",
|
|
||||||
"title": "Unsupported engines by Chromium (rate)",
|
|
||||||
"description": "The same blocked clients by reported Chromium major version — which old in-app WebViews are still in the wild. \"other\" is an unparseable or out-of-range version.",
|
|
||||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 32 },
|
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
|
||||||
"targets": [{ "refId": "A", "expr": "sum(rate(unsupported_engine_total[1h])) by (chromium)", "legendFormat": "Chromium {{chromium}}" }]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-104
@@ -244,9 +244,7 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
256-bit token stored only as its SHA-256, 12-hour TTL): a login mints a session in the
|
||||||
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
browser that opens it (magic-link), a link confirms the identity and emits a `notify`
|
||||||
profile-refresh to the in-app session, a change switches the account's email in place,
|
profile-refresh to the in-app session, a change switches the account's email in place,
|
||||||
and a would-be merge is deferred to the interactive flow. A login requested from an installed **PWA** omits the deeplink
|
and a would-be merge is deferred to the interactive flow. The confirm runs on load; the token rides the URL fragment (never
|
||||||
entirely — its email carries the code only, typed in the same window, since the link would open
|
|
||||||
in a separate browser whose minted session cannot reach the PWA. The confirm runs on load; the token rides the URL fragment (never
|
|
||||||
sent to the server), so a plain link prefetch cannot reach it, and the manual
|
sent to the server), so a plain link prefetch cannot reach it, and the manual
|
||||||
six-digit code is the fallback if an aggressive scanner runs the page. An
|
six-digit code is the fallback if an aggressive scanner runs the page. An
|
||||||
**email-login** account is created flagged `is_guest` and stays reapable until the
|
**email-login** account is created flagged `is_guest` and stays reapable until the
|
||||||
@@ -982,25 +980,10 @@ edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are o
|
|||||||
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
||||||
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
||||||
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
||||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. A
|
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session. The same
|
||||||
non-default campaign may also carry an optional **colour override** (background, text, link) in two
|
|
||||||
sets — one for every theme, one for the dark theme only — plus an **urgent** flag. The colours ride
|
|
||||||
the same block (six optional strings appended to each `BannerCampaign` on the wire — trailing,
|
|
||||||
backward-compatible); the client resolves the cascade (dark ← dark ?? all, light ← all) and derives
|
|
||||||
the strip's border from the background in JS (no CSS `color-mix`, for the old-WebView floor).
|
|
||||||
**Urgent is resolved entirely server-side, with no wire field**: while any enabled, in-window urgent
|
|
||||||
campaign exists, `computeActiveSet` returns *only* the urgent campaigns (preempting the timed set and
|
|
||||||
the default remainder) and `bannerFor` **skips the eligibility gate** for that feed, so an urgent
|
|
||||||
notice reaches every viewer — paid, hint-holding or `no_banner` included. There is no instant
|
|
||||||
broadcast on an urgent toggle (the notify path is per-user); an urgent campaign appears on each
|
|
||||||
viewer's next `profile.get`. The default campaign stays plain (no colours, never urgent), enforced by
|
|
||||||
both the service and a DB CHECK. The same
|
|
||||||
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
|
mechanism carries a **`profile`** sub-kind — a payload-free re-fetch signal emitted when a viewer's
|
||||||
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
|
own account changed out of band (an email confirmed through the one-tap deeplink opened in another
|
||||||
browser), so an open in-app session reflects it at once. The live stream is single-shot with no
|
browser), so an open in-app session reflects it at once.
|
||||||
replay, so a **backgrounded Mini App** — suspended while the user is in their mail app tapping the
|
|
||||||
link — misses the event; while an add-email confirmation is pending the client therefore also
|
|
||||||
**polls `profile.get`** (and on foreground regain) as a fallback until the address lands.
|
|
||||||
|
|
||||||
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
||||||
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||||
@@ -1043,9 +1026,7 @@ link — misses the event; while an add-email confirmation is pending the client
|
|||||||
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
|
(`internal/adminalert`, started from `main`) emails the operator (`ADMIN_EMAIL`, from a
|
||||||
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
|
distinct `SMTP_RELAY_ADMIN_FROM`) when new player feedback or word complaints arrive,
|
||||||
**coalescing** a burst into one digest per interval; both recipients may be several
|
**coalescing** a burst into one digest per interval; both recipients may be several
|
||||||
comma-separated addresses. The digest carries **no admin-console link** — an admin URL must
|
comma-separated addresses. Both paths are inert unless configured.
|
||||||
never travel in an email, where a mail provider could cache or index it; the operator opens the
|
|
||||||
console directly. Both paths are inert unless configured.
|
|
||||||
- Per-request server-side timing via gin middleware from day one (the access log
|
- Per-request server-side timing via gin middleware from day one (the access log
|
||||||
carries method, route, status, latency and the active trace id). A
|
carries method, route, status, latency and the active trace id). A
|
||||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
client-measured RTT piggybacked on the next request is a later enhancement.
|
||||||
@@ -1089,17 +1070,6 @@ link — misses the event; while an add-email confirmation is pending the client
|
|||||||
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
it fills IndexedDB — and is surfaced on the **Scrabble — Users** dashboard. Lossy by
|
||||||
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
design (a dropped beacon just retries its batch on the next flush); it is telemetry, never
|
||||||
a game input.
|
a game input.
|
||||||
- **Unsupported-engine screens (client-reported):** the ES5 boot guard in `index.html` (§13)
|
|
||||||
shows a full-screen "your device's OS or browser can't run the app" screen — instead of a white
|
|
||||||
screen — when the engine lacks an unpolyfillable essential (`BigInt`, the 64-bit FlatBuffers
|
|
||||||
decode; or `Proxy`, Svelte 5 runes) or an uncaught error aborts boot; the effective floor is
|
|
||||||
Chrome 67. It then fires one fire-and-forget beacon (`POST /telemetry/unsupported` —
|
|
||||||
unauthenticated, since the client never booted, but per-IP public-limited and body-capped),
|
|
||||||
deduped in `localStorage` by app version + reason + Chromium so a user reopening the app is one
|
|
||||||
report. The gateway folds it into `unsupported_engine_total` (`reason` =
|
|
||||||
no_bigint/no_proxy/boot_error/other; `chromium` = the major version reduced to a bounded range so
|
|
||||||
a spoofed beacon cannot inflate cardinality) and logs the full user agent (not a label). Surfaced
|
|
||||||
on the **Scrabble — Users** dashboard beside app opens.
|
|
||||||
- **Rate-limit observability:** every limiter rejection increments the gateway
|
- **Rate-limit observability:** every limiter rejection increments the gateway
|
||||||
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
counter `gateway_rate_limited_total` (`class` = user/public/email/admin — aggregate
|
||||||
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
only, honouring the no-per-user-label discipline above) and logs one **Debug** line;
|
||||||
@@ -1237,76 +1207,7 @@ origin, so the test contour canonicalises to production instead of being indexed
|
|||||||
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
|
separate site; the SPA shell is `noindex`, and the landing always boots in Russian (no
|
||||||
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
browser-language detection — crawlers render with arbitrary languages). The favicon set,
|
||||||
`og-image.png` and `robots.txt` ship unhashed from `ui/public/` (generated by
|
`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**:
|
`assets/icons/`, same tile design as the VK loader). Hash-named `/assets/*` are served
|
||||||
`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
|
|
||||||
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
|
`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
|
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||||
|
|||||||
+8
-76
@@ -21,8 +21,7 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
|
|||||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
A public **landing page** at the site root introduces the game, switches language and
|
A public **landing page** at the site root introduces the game, switches language and
|
||||||
theme, and links to the Telegram game channel, the VK Mini App and the **web version**
|
theme, and links to the Telegram game channel and the VK Mini App; the game itself runs at
|
||||||
(`/app/`) — each under a caption (Telegram / VK / Веб-версия); the game itself runs at
|
|
||||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||||
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
(it follows the system scheme, not the saved preference); its language choice is saved. The
|
||||||
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
landing always opens in **Russian** — the browser language is never auto-detected (crawlers
|
||||||
@@ -32,11 +31,6 @@ nondeterministic); a saved 🌐 choice still wins. The page carries a static Rus
|
|||||||
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
|
||||||
shell is `noindex` — the landing is the only indexable page.
|
shell is `noindex` — the landing is the only indexable page.
|
||||||
|
|
||||||
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
|
||||||
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
|
||||||
home screen / desktop in one tap where the browser supports it (Chromium), or shows manual
|
|
||||||
Add-to-Home-Screen steps on iOS Safari; it is hidden once installed and inside the Mini Apps.
|
|
||||||
|
|
||||||
### First-run onboarding
|
### First-run onboarding
|
||||||
The first time a player opens the app it walks them through the interface with a light
|
The first time a player opens the app it walks them through the interface with a light
|
||||||
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
|
||||||
@@ -76,9 +70,7 @@ per address), so a mistyped address or an impatient tap cannot flood an inbox. T
|
|||||||
also carries a **one-tap link** that confirms the address — or signs the player straight
|
also carries a **one-tap link** that confirms the address — or signs the player straight
|
||||||
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
in — in a single tap; opening it in another browser reflects in the app at once, and a
|
||||||
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
mail scanner that merely fetches the link cannot reach it — the token rides the URL
|
||||||
fragment, which is never sent to the server. A sign-in requested from an **installed PWA** omits
|
fragment, which is never sent to the server.
|
||||||
this link: the email carries only the code, entered in the same window, since the link would open
|
|
||||||
in a separate browser the app cannot reach.
|
|
||||||
Telegram runs a **single bot**: every player uses
|
Telegram runs a **single bot**: every player uses
|
||||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||||
@@ -117,9 +109,7 @@ two accounts share a game still in progress.
|
|||||||
|
|
||||||
The profile lists the account's **sign-in methods**. On the web a player can add
|
The profile lists the account's **sign-in methods**. On the web a player can add
|
||||||
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
|
||||||
back); inside a Mini App the host platform is already linked, and its own sign-in-method
|
back); inside a Mini App the host platform is already linked. Linking a provider that
|
||||||
row is hidden — the platform the player is currently signed in through cannot be unlinked
|
|
||||||
from there, only the other provider's row is shown. Linking a provider that
|
|
||||||
already belongs to another account offers the same irreversible **merge** as email
|
already belongs to another account offers the same irreversible **merge** as email
|
||||||
linking. A linked provider can be **unlinked** — except the last
|
linking. A linked provider can be **unlinked** — except the last
|
||||||
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
remaining sign-in method, which is refused so the account stays reachable. **Email is
|
||||||
@@ -215,16 +205,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
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
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
|
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
|
personal hint wallet once the per-game allowance is spent. The game ends when the
|
||||||
(**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
|
|
||||||
bag empties and a player clears their rack, after 6 consecutive scoreless turns,
|
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
|
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
|
24 hours): a missed turn auto-resigns, except while the player is inside their
|
||||||
@@ -265,44 +246,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
|
add-friend are off. AI games are **practice** — they never count toward a player's
|
||||||
statistics.
|
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
|
### Social: friends, block, chat, nudge
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
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
|
digits, valid for twelve hours), or send a **request to someone you have played
|
||||||
@@ -540,19 +483,8 @@ through**, regardless of their interface language. The client rotates the eligib
|
|||||||
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||||||
and a campaign with several messages shows them in turn.
|
and a campaign with several messages shows them in turn.
|
||||||
|
|
||||||
A non-default campaign can carry its own **colours** — background, text and link — so a sponsored or
|
|
||||||
operational message stands out from the neutral strip. Colours come in two sets: one that applies on
|
|
||||||
**every theme**, and an optional second that overrides the **dark theme** only, so a campaign reads
|
|
||||||
well on both; the strip's border is derived from the background automatically. A campaign can also be
|
|
||||||
marked **urgent**: an urgent campaign is shown to **everyone** — even paid players, hint holders and
|
|
||||||
`no_banner` users — and, while it is live, it is the **only** thing the strip shows (ordinary
|
|
||||||
campaigns and the house default step aside). Urgent is for genuine system notices (maintenance,
|
|
||||||
outages), not advertising.
|
|
||||||
|
|
||||||
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||||||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), pick a
|
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
|
||||||
non-default campaign's optional override colours (a native colour picker with a live light-and-dark
|
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
||||||
preview of the strip) and mark it urgent, and set the global display **timings** (how long a message
|
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
||||||
holds, the scroll of an over-long message, and the fade-out → gap → fade-in transition between
|
keeps at least one message.
|
||||||
messages). The default campaign cannot be deleted, keeps at least one message, and stays plain (no
|
|
||||||
colours, never urgent).
|
|
||||||
|
|||||||
+7
-76
@@ -22,8 +22,7 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||||
тему и ведёт в Telegram-канал игры, в VK Mini App и в **веб-версию** (`/app/`) — каждая под
|
тему и ведёт в Telegram-канал игры и в VK Mini App; сама игра живёт по адресам
|
||||||
подписью (Telegram / VK / Веб-версия); сама игра живёт по адресам
|
|
||||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||||
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
системной настройки, а не из сохранённой), выбор языка сохраняется. Страница всегда
|
||||||
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
открывается на **русском** — язык браузера не определяется автоматически (роботы
|
||||||
@@ -34,11 +33,6 @@ Open Graph, которую используют превью ссылок в Tel
|
|||||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
`noindex` — индексируется только посадочная страница.
|
`noindex` — индексируется только посадочная страница.
|
||||||
|
|
||||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
|
||||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
|
||||||
(домашний экран) там, где браузер это умеет (Chromium), либо показывает ручные шаги
|
|
||||||
«На экран „Домой“» в Safari на iOS; он скрыт после установки и внутри Mini App.
|
|
||||||
|
|
||||||
### Первый запуск: онбординг
|
### Первый запуск: онбординг
|
||||||
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
|
||||||
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
|
||||||
@@ -80,9 +74,7 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
не завалили почтовый ящик. В письме также есть **ссылка одного нажатия**, которая подтверждает
|
||||||
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
адрес — или сразу выполняет вход — в один тап; открытие её в другом браузере тут же отражается в
|
||||||
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
приложении, а почтовый сканер, который просто загружает ссылку, до токена не дотянется —
|
||||||
он едет в URL-фрагменте, который не уходит на сервер. Вход, запрошенный из **установленного
|
он едет в URL-фрагменте, который не уходит на сервер.
|
||||||
PWA**, эту ссылку не содержит: в письме только код, который вводится в том же окне (ссылка
|
|
||||||
открылась бы в отдельном браузере, недоступном приложению).
|
|
||||||
Telegram держит **единого бота**: все игроки пользуются одним
|
Telegram держит **единого бота**: все игроки пользуются одним
|
||||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||||
@@ -121,9 +113,7 @@ Telegram держит **единого бота**: все игроки поль
|
|||||||
|
|
||||||
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
|
||||||
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
|
||||||
обратно); внутри Mini App платформа-хозяин уже привязана, и её собственная плашка способа
|
обратно); внутри Mini App платформа-хозяин уже привязана. Привязка провайдера, уже
|
||||||
входа скрыта — платформу, через которую игрок сейчас вошёл, отсюда отвязать нельзя,
|
|
||||||
показывается только плашка другого провайдера. Привязка провайдера, уже
|
|
||||||
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
принадлежащего другому аккаунту, предлагает то же необратимое **слияние**, что и привязка
|
||||||
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
email. Привязанного провайдера можно **отвязать** — кроме последнего
|
||||||
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
оставшегося способа входа: он не отвязывается, чтобы аккаунт оставался достижимым.
|
||||||
@@ -222,16 +212,7 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Партия
|
||||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
|
||||||
анти-фрустрация: подсказка открывается, только когда игрок **застрял на ходу ~30 минут**
|
|
||||||
(отсчёт от последнего хода робота; самый первый ход, до хода робота, исключён). Пока гейт
|
|
||||||
закрыт, кнопка подсказки несёт маленький **🔒 замок**, а тап показывает, сколько осталось;
|
|
||||||
замок снимается вживую в нужный момент. Один и тот же гейт работает **онлайн и офлайн**: отсчёт
|
|
||||||
идёт по **ровному внутриигровому таймеру**, а не по системным часам, поэтому их перевод его не
|
|
||||||
собьёт, а повторный вход перечитывает остаток — застрявший ход не забывается. Онлайн гейт
|
|
||||||
**принуждает сервер** по своим часам (их игрок не тронет); vs_ai-подсказка не учитывается ни в
|
|
||||||
одной статистике подсказок. Партия
|
|
||||||
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
завершается, когда мешок пуст и игрок выложил стойку, после 6 подряд бесплодных
|
||||||
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
ходов, по сдаче, либо по таймауту хода (от 5 минут до 24 часов, дефолт 24 часа):
|
||||||
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
пропущенный ход означает авто-сдачу, кроме как когда игрок внутри своего
|
||||||
@@ -270,45 +251,6 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
паузы), сохраняет ту же силу (по-прежнему играет на победу лишь примерно в 40% партий, с теми же
|
||||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
### Офлайн-режим
|
|
||||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
|
||||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
|
||||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
|
||||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
|
||||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
|
||||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
|
||||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
|
||||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
|
||||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
|
||||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
|
||||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
|
||||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
|
||||||
сохраняется между запусками.
|
|
||||||
|
|
||||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
|
||||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
|
||||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
|
||||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
|
||||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
|
||||||
дать любому месту **свой необязательный PIN**. Во время игры доска видна всегда, но место с PIN
|
|
||||||
вместо стойки показывает кнопку **«Разблокировать»**, пока владелец не введёт PIN, — так устройство
|
|
||||||
передаётся из рук в руки, и только защищённое место прячет свои буквы; на каждом ходу замок
|
|
||||||
возвращается. **Ведущий** (кнопка ведущего в игре) может по паролю ведущего **пропустить** ход
|
|
||||||
текущего игрока, **исключить** игрока или **завершить игру досрочно** — досрочное завершение стирает
|
|
||||||
партию без победителей и проигравших. Подсказки и чат в игре по очереди отключены. Нормально
|
|
||||||
завершённая партия сохраняется в офлайн-лобби как любая другая; удаление любой игры по очереди —
|
|
||||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
|
||||||
своего хода.
|
|
||||||
|
|
||||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
|
||||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
|
||||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
|
||||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
|
||||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
|
||||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
|
||||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
|
||||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
другой игрок (шесть цифр, действует двенадцать часов), либо отправить **заявку
|
||||||
@@ -552,19 +494,8 @@ high-rate флага. С карточки пользователя операт
|
|||||||
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||||
кампания с несколькими сообщениями показывает их по очереди.
|
кампания с несколькими сообщениями показывает их по очереди.
|
||||||
|
|
||||||
Недефолтная кампания может нести собственные **цвета** — фон, текст и ссылку — чтобы рекламное или
|
|
||||||
служебное сообщение выделялось на фоне нейтральной ленты. Цвета задаются двумя наборами: один
|
|
||||||
применяется на **любой теме**, а необязательный второй переопределяет только **тёмную тему**, чтобы
|
|
||||||
кампания хорошо читалась на обеих; рамка ленты выводится из фона автоматически. Кампанию можно также
|
|
||||||
пометить как **срочную (urgent)**: срочная кампания показывается **всем** — даже платным игрокам,
|
|
||||||
владельцам подсказок и пользователям с ролью `no_banner` — и, пока она активна, лента показывает
|
|
||||||
**только её** (обычные кампании и домашняя дефолтная уступают место). Срочность — для настоящих
|
|
||||||
системных уведомлений (тех.работы, сбои), а не для рекламы.
|
|
||||||
|
|
||||||
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||||
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||||
их), выбирает необязательные цвета недефолтной кампании (нативный color-picker с живым превью ленты
|
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
||||||
на светлой и тёмной темах) и помечает её срочной, а также задаёт общие **тайминги** показа (сколько
|
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
||||||
держится сообщение, прокрутка слишком длинного, и переход fade-out → пауза → fade-in между
|
всегда остаётся хотя бы одно сообщение.
|
||||||
сообщениями). Дефолтную кампанию нельзя удалить, в ней всегда остаётся хотя бы одно сообщение, и она
|
|
||||||
остаётся простой (без цветов, никогда не срочная).
|
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
invitations section, the stats screen, profile editing, and the export chooser's
|
||||||
finished-only visibility + its signed-URL download flow (route-intercepted). The
|
finished-only visibility + its signed-URL download flow (route-intercepted).
|
||||||
**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.
|
|
||||||
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
`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
|
real self-played 35-move game) must rasterize to a plausible PNG
|
||||||
|
|||||||
@@ -315,19 +315,6 @@ left. A **viewport size change** (e.g. a portrait↔landscape rotation) re-measu
|
|||||||
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||||||
and a long message does not scroll.
|
and a long message does not scroll.
|
||||||
|
|
||||||
**Per-campaign colours.** A campaign may override the strip's colours (background, text, link). The
|
|
||||||
engine carries the current message's campaign colours to the host, and `AdBanner` resolves them for
|
|
||||||
the **rendered theme** (`lib/bannerColors`: dark ← dark-set ?? all-set, light ← all-set) and applies
|
|
||||||
them as inline CSS variables scoped to `.ad` (`--ad-bg`, `--text-muted`, `--accent`, and a derived
|
|
||||||
`--ad-border`) — so an override never leaks past the strip, and a campaign with no override keeps the
|
|
||||||
neutral tokens. The border is computed from the background in JS (a luminance-aware nudge toward
|
|
||||||
black/white — no CSS `color-mix`, which the old Android WebView floor lacks), matching the admin
|
|
||||||
console's live preview. The resolution re-runs when the theme flips (a `[data-theme]` /
|
|
||||||
`prefers-color-scheme` observer), so switching light↔dark repaints an overridden strip at once. An
|
|
||||||
**urgent** campaign has no client-specific styling — it simply arrives (with its colours) as the only
|
|
||||||
campaign in the feed; the preempt-and-show-everyone behaviour is entirely server-side (ARCHITECTURE
|
|
||||||
§10).
|
|
||||||
|
|
||||||
## Result / status iconography (`lib/result.ts`)
|
## Result / status iconography (`lib/result.ts`)
|
||||||
|
|
||||||
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ 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
|
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
|
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.
|
**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).
|
|
||||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
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;
|
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
|
in the deployed contour the front caddy owns `/_gm` (see
|
||||||
|
|||||||
@@ -42,15 +42,6 @@ type ProfileResp struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
TelegramLinked bool `json:"telegram_linked"`
|
TelegramLinked bool `json:"telegram_linked"`
|
||||||
VkLinked bool `json:"vk_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
|
// BannerResp is the advertising-banner block of an eligible viewer's profile: the
|
||||||
@@ -62,18 +53,9 @@ type BannerResp struct {
|
|||||||
|
|
||||||
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
// BannerCampaignResp is one campaign in the rotation feed: a GCD-reduced show
|
||||||
// weight and its messages, in display order, already resolved to one language.
|
// weight and its messages, in display order, already resolved to one language.
|
||||||
// The override_* colours are the optional per-campaign palette (empty when the
|
|
||||||
// campaign keeps the neutral theme tokens); they ride through to the client
|
|
||||||
// verbatim, mirroring the backend DTO.
|
|
||||||
type BannerCampaignResp struct {
|
type BannerCampaignResp struct {
|
||||||
Weight int `json:"weight"`
|
Weight int `json:"weight"`
|
||||||
Messages []string `json:"messages"`
|
Messages []string `json:"messages"`
|
||||||
OverrideBg string `json:"override_bg,omitempty"`
|
|
||||||
OverrideFg string `json:"override_fg,omitempty"`
|
|
||||||
OverrideLink string `json:"override_link,omitempty"`
|
|
||||||
OverrideBgDark string `json:"override_bg_dark,omitempty"`
|
|
||||||
OverrideFgDark string `json:"override_fg_dark,omitempty"`
|
|
||||||
OverrideLinkDark string `json:"override_link_dark,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerTimingsResp mirrors the backend's global display timings.
|
// BannerTimingsResp mirrors the backend's global display timings.
|
||||||
@@ -181,14 +163,13 @@ type AlphabetEntryJSON struct {
|
|||||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
// StateResp is a player's view of a game. Rack carries wire alphabet indices;
|
||||||
// Alphabet is present only when the request asked for it.
|
// Alphabet is present only when the request asked for it.
|
||||||
type StateResp struct {
|
type StateResp struct {
|
||||||
Game GameResp `json:"game"`
|
Game GameResp `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []int `json:"rack"`
|
Rack []int `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
WalletBalance int `json:"wallet_balance"`
|
WalletBalance int `json:"wallet_balance"`
|
||||||
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
|
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResp reports an auto-match outcome.
|
// MatchResp reports an auto-match outcome.
|
||||||
@@ -299,11 +280,10 @@ func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp,
|
|||||||
|
|
||||||
// EmailRequest asks the backend to mail a login code, provisioning the account on
|
// EmailRequest asks the backend to mail a login code, provisioning the account on
|
||||||
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
|
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
|
||||||
// account's time zone, since the email account is created here, not at login. pwa marks a
|
// account's time zone, since the email account is created here, not at login.
|
||||||
// request from an installed PWA, so the backend omits the one-tap confirm link from the email.
|
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string) error {
|
||||||
func (c *Client) EmailRequest(ctx context.Context, email, browserTz, language string, pwa bool) error {
|
|
||||||
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
|
||||||
map[string]any{"email": email, "browser_tz": browserTz, "language": language, "pwa": pwa}, nil)
|
map[string]string{"email": email, "browser_tz": browserTz, "language": language}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailLogin verifies a login code and mints a session.
|
// EmailLogin verifies a login code and mints a session.
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ type serverMetrics struct {
|
|||||||
localColdStart metric.Int64Counter
|
localColdStart metric.Int64Counter
|
||||||
localDictLoad metric.Int64Counter
|
localDictLoad metric.Int64Counter
|
||||||
localPreview metric.Int64Counter
|
localPreview metric.Int64Counter
|
||||||
// Clients turned away by the unsupported-engine boot screen (see unsupportedEngineHandler).
|
|
||||||
unsupportedEngine metric.Int64Counter
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
// newServerMetrics builds the instruments on meter (nil selects a no-op meter),
|
||||||
@@ -46,13 +44,7 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
}
|
}
|
||||||
h, err := meter.Float64Histogram("edge_request_duration",
|
h, err := meter.Float64Histogram("edge_request_duration",
|
||||||
metric.WithUnit("s"),
|
metric.WithUnit("s"),
|
||||||
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."),
|
metric.WithDescription("Seconds to serve one Connect Execute call, by message type and result."))
|
||||||
// Explicit second-scale buckets. The durations are recorded in seconds, so the SDK's
|
|
||||||
// default millisecond-calibrated boundaries (first boundary 5) would bin every sub-5s
|
|
||||||
// request into one bucket — making histogram_quantile(0.99) interpolate to ~4.95s
|
|
||||||
// regardless of the real latency, which flapped the >1s edge-latency alert. These
|
|
||||||
// boundaries straddle the 1s SLO so the p99 reflects real (mostly sub-second) latency.
|
|
||||||
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram("edge_request_duration")
|
||||||
}
|
}
|
||||||
@@ -71,8 +63,6 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
|||||||
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
||||||
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
||||||
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
||||||
unsupportedEngine: counterOf(meter, "unsupported_engine_total",
|
|
||||||
"Clients that hit the unsupported-engine boot screen (the app cannot run), by reason (no_bigint, no_proxy, boot_error, other) and Chromium major — a deduped beacon from the index.html boot guard; the full user agent is logged, not labelled."),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
gauge, err := meter.Int64ObservableGauge("active_users",
|
gauge, err := meter.Int64ObservableGauge("active_users",
|
||||||
@@ -118,16 +108,6 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
|||||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
|
|
||||||
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
|
|
||||||
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
|
|
||||||
func (m *serverMetrics) recordUnsupportedEngine(ctx context.Context, reason, chromium string) {
|
|
||||||
m.unsupportedEngine.Add(ctx, 1, metric.WithAttributes(
|
|
||||||
attribute.String("reason", reason),
|
|
||||||
attribute.String("chromium", chromium),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
|
// localEvalReport is the client-reported local move-preview telemetry batch — deltas since
|
||||||
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
// the client's previous report. It backs the adoption dashboard: app cold starts vs cached
|
||||||
// dictionaries vs on-device previews.
|
// dictionaries vs on-device previews.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ func TestEdgeMetric(t *testing.T) {
|
|||||||
|
|
||||||
type key struct{ messageType, result string }
|
type key struct{ messageType, result string }
|
||||||
counts := map[key]uint64{}
|
counts := map[key]uint64{}
|
||||||
var bounds []float64
|
|
||||||
for _, sm := range rm.ScopeMetrics {
|
for _, sm := range rm.ScopeMetrics {
|
||||||
for _, md := range sm.Metrics {
|
for _, md := range sm.Metrics {
|
||||||
if md.Name != "edge_request_duration" {
|
if md.Name != "edge_request_duration" {
|
||||||
@@ -43,7 +42,6 @@ func TestEdgeMetric(t *testing.T) {
|
|||||||
mt, _ := dp.Attributes.Value(attribute.Key("message_type"))
|
mt, _ := dp.Attributes.Value(attribute.Key("message_type"))
|
||||||
res, _ := dp.Attributes.Value(attribute.Key("result"))
|
res, _ := dp.Attributes.Value(attribute.Key("result"))
|
||||||
counts[key{mt.AsString(), res.AsString()}] += dp.Count
|
counts[key{mt.AsString(), res.AsString()}] += dp.Count
|
||||||
bounds = dp.Bounds
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,19 +51,6 @@ func TestEdgeMetric(t *testing.T) {
|
|||||||
if got := counts[key{"auth.guest", "domain"}]; got != 1 {
|
if got := counts[key{"auth.guest", "domain"}]; got != 1 {
|
||||||
t.Errorf("edge auth.guest/domain = %d, want 1", got)
|
t.Errorf("edge auth.guest/domain = %d, want 1", got)
|
||||||
}
|
}
|
||||||
// The buckets must be second-scaled. The default (millisecond-calibrated) boundaries have no
|
|
||||||
// boundary between 0 and 5, so every sub-5s request bins into one bucket and p99 interpolates
|
|
||||||
// to ~4.95s, flapping the >1s alert. Require at least one sub-second boundary.
|
|
||||||
subSecond := false
|
|
||||||
for _, b := range bounds {
|
|
||||||
if b > 0 && b < 1 {
|
|
||||||
subSecond = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !subSecond {
|
|
||||||
t.Errorf("edge_request_duration bounds = %v, want sub-second boundaries (seconds-scaled)", bounds)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRateLimitedMetric records limiter rejections through a manual reader and
|
// TestRateLimitedMetric records limiter rejections through a manual reader and
|
||||||
@@ -143,70 +128,3 @@ func TestBannedMetric(t *testing.T) {
|
|||||||
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
|
t.Errorf("banned counts = %v, want tripwire=2 honeytoken=1", counts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnsupportedEngineMetric records unsupported-engine beacons through a manual reader and asserts
|
|
||||||
// unsupported_engine_total splits by reason and Chromium major.
|
|
||||||
func TestUnsupportedEngineMetric(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
reader := sdkmetric.NewManualReader()
|
|
||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
|
||||||
m := newServerMetrics(meter)
|
|
||||||
|
|
||||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
|
||||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
|
||||||
m.recordUnsupportedEngine(ctx, "boot_error", "74")
|
|
||||||
|
|
||||||
var rm metricdata.ResourceMetrics
|
|
||||||
if err := reader.Collect(ctx, &rm); err != nil {
|
|
||||||
t.Fatalf("collect: %v", err)
|
|
||||||
}
|
|
||||||
type key struct{ reason, chromium string }
|
|
||||||
counts := map[key]int64{}
|
|
||||||
for _, sm := range rm.ScopeMetrics {
|
|
||||||
for _, md := range sm.Metrics {
|
|
||||||
if md.Name != "unsupported_engine_total" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sum, ok := md.Data.(metricdata.Sum[int64])
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("unsupported_engine_total is not an int64 sum")
|
|
||||||
}
|
|
||||||
for _, dp := range sum.DataPoints {
|
|
||||||
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
|
|
||||||
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
|
|
||||||
counts[key{reason.AsString(), chromium.AsString()}] += dp.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := counts[key{"no_bigint", "66"}]; got != 2 {
|
|
||||||
t.Errorf("unsupported no_bigint/66 = %d, want 2", got)
|
|
||||||
}
|
|
||||||
if got := counts[key{"boot_error", "74"}]; got != 1 {
|
|
||||||
t.Errorf("unsupported boot_error/74 = %d, want 1", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNormalizeUnsupported checks that a beacon's reason and Chromium are reduced to bounded label
|
|
||||||
// values, so a spoofed beacon cannot explode the metric cardinality.
|
|
||||||
func TestNormalizeUnsupported(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
reason, chromium string
|
|
||||||
wantReason, wantChromium string
|
|
||||||
}{
|
|
||||||
{"no_bigint", "66", "no_bigint", "66"},
|
|
||||||
{"no_proxy", " 74 ", "no_proxy", "74"},
|
|
||||||
{"boot_error", "105", "boot_error", "105"},
|
|
||||||
{"garbage", "66", "other", "66"},
|
|
||||||
{"", "", "other", "other"},
|
|
||||||
{"no_bigint", "not-a-number", "no_bigint", "other"},
|
|
||||||
{"no_bigint", "9999", "no_bigint", "other"},
|
|
||||||
{"no_bigint", "0", "no_bigint", "other"},
|
|
||||||
{"no_bigint", "-5", "no_bigint", "other"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
gotR, gotC := normalizeUnsupported(c.reason, c.chromium)
|
|
||||||
if gotR != c.wantReason || gotC != c.wantChromium {
|
|
||||||
t.Errorf("normalizeUnsupported(%q,%q) = %q,%q; want %q,%q", c.reason, c.chromium, gotR, gotC, c.wantReason, c.wantChromium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -198,9 +197,6 @@ func (s *Server) HTTPHandler() http.Handler {
|
|||||||
mux.Handle("/dl/", s.exportDownloadHandler())
|
mux.Handle("/dl/", s.exportDownloadHandler())
|
||||||
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
// The client posts its local-move-preview adoption telemetry here (session-gated).
|
||||||
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
|
||||||
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
|
|
||||||
// screen (the app cannot run). Unauthenticated — the client never booted — but rate-limited.
|
|
||||||
mux.Handle("/telemetry/unsupported", s.unsupportedEngineHandler())
|
|
||||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||||
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||||
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||||
@@ -586,76 +582,6 @@ func clampReport(r *localEvalReport) {
|
|||||||
clamp(&r.PreviewNetwork)
|
clamp(&r.PreviewNetwork)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsupportedEngineBeacon is the small fire-and-forget report the index.html boot guard sends when
|
|
||||||
// it turns a client away on the unsupported-engine screen (no BigInt/Proxy, or an uncaught boot
|
|
||||||
// error). It is deduped client-side (one per device / app version / reason).
|
|
||||||
type unsupportedEngineBeacon struct {
|
|
||||||
Reason string `json:"reason"`
|
|
||||||
Chromium string `json:"chromium"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
UA string `json:"ua"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// unsupportedEngineHandler folds one unsupported-engine beacon into the edge counter. It is
|
|
||||||
// unauthenticated (the client never booted, so it carries no session) but per-IP rate-limited with
|
|
||||||
// the public limiter and body-capped. reason and the Chromium major are reduced to bounded label
|
|
||||||
// sets (normalizeUnsupported) so a spoofed beacon cannot inflate the metric cardinality; the full
|
|
||||||
// user agent is logged, not labelled. Only POST; the reply is always 204.
|
|
||||||
func (s *Server) unsupportedEngineHandler() http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ip := peerIP(r.RemoteAddr, r.Header)
|
|
||||||
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
|
|
||||||
s.noteRateLimited(r.Context(), classPublic, ip, "unsupported")
|
|
||||||
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var b unsupportedEngineBeacon
|
|
||||||
if err := json.NewDecoder(io.LimitReader(r.Body, 2048)).Decode(&b); err != nil {
|
|
||||||
http.Error(w, "bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reason, chromium := normalizeUnsupported(b.Reason, b.Chromium)
|
|
||||||
s.metrics.recordUnsupportedEngine(r.Context(), reason, chromium)
|
|
||||||
s.log.Info("unsupported engine",
|
|
||||||
zap.String("reason", reason),
|
|
||||||
zap.String("chromium", chromium),
|
|
||||||
zap.String("app_version", truncate(b.Version, 40)),
|
|
||||||
zap.String("user_agent", truncate(b.UA, 400)),
|
|
||||||
)
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// normalizeUnsupported reduces a beacon's reason and Chromium fields to bounded label values, so a
|
|
||||||
// spoofed beacon cannot explode the metric cardinality: reason is allow-listed, and Chromium is
|
|
||||||
// parsed as a major version kept only within a plausible range, otherwise "other".
|
|
||||||
func normalizeUnsupported(reason, chromium string) (string, string) {
|
|
||||||
switch reason {
|
|
||||||
case "no_bigint", "no_proxy", "boot_error":
|
|
||||||
// a recognised reason — keep as-is
|
|
||||||
default:
|
|
||||||
reason = "other"
|
|
||||||
}
|
|
||||||
major := "other"
|
|
||||||
if n, err := strconv.Atoi(strings.TrimSpace(chromium)); err == nil && n >= 1 && n <= 199 {
|
|
||||||
major = strconv.Itoa(n)
|
|
||||||
}
|
|
||||||
return reason, major
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncate bounds a logged, client-supplied string to n bytes (a spoofed beacon field is not
|
|
||||||
// trusted to be small).
|
|
||||||
func truncate(s string, n int) string {
|
|
||||||
if len(s) > n {
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolve extracts and resolves the Authorization bearer token to an account id
|
// resolve extracts and resolves the Authorization bearer token to an account id
|
||||||
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
// and its guest flag, returning a Connect Unauthenticated error when it is missing
|
||||||
// or unknown.
|
// or unknown.
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
package connectsrv_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
|
||||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
|
||||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
|
||||||
|
|
||||||
"scrabble/gateway/internal/config"
|
|
||||||
"scrabble/gateway/internal/connectsrv"
|
|
||||||
"scrabble/gateway/internal/ratelimit"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestUnsupportedEngineHandler drives the /telemetry/unsupported beacon route end to end: a POST
|
|
||||||
// increments unsupported_engine_total with the normalised labels and replies 204; a GET is 405.
|
|
||||||
func TestUnsupportedEngineHandler(t *testing.T) {
|
|
||||||
reader := sdkmetric.NewManualReader()
|
|
||||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
|
||||||
Limiter: ratelimit.New(),
|
|
||||||
RateLimit: config.DefaultRateLimit(),
|
|
||||||
Meter: meter,
|
|
||||||
})
|
|
||||||
srv := httptest.NewServer(edge.HTTPHandler())
|
|
||||||
defer srv.Close()
|
|
||||||
url := srv.URL + "/telemetry/unsupported"
|
|
||||||
|
|
||||||
// A GET is rejected.
|
|
||||||
getResp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("get: %v", err)
|
|
||||||
}
|
|
||||||
getResp.Body.Close()
|
|
||||||
if getResp.StatusCode != http.StatusMethodNotAllowed {
|
|
||||||
t.Errorf("GET status = %d, want 405", getResp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A POST is accepted (204) and folded into the counter with normalised labels.
|
|
||||||
body := `{"reason":"no_bigint","chromium":"66","version":"v1.2.3","ua":"Mozilla/5.0 ... Chrome/66"}`
|
|
||||||
resp, err := http.Post(url, "application/json", bytes.NewBufferString(body))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("post: %v", err)
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
|
||||||
t.Errorf("POST status = %d, want 204", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
var rm metricdata.ResourceMetrics
|
|
||||||
if err := reader.Collect(context.Background(), &rm); err != nil {
|
|
||||||
t.Fatalf("collect: %v", err)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
for _, sm := range rm.ScopeMetrics {
|
|
||||||
for _, md := range sm.Metrics {
|
|
||||||
if md.Name != "unsupported_engine_total" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sum, ok := md.Data.(metricdata.Sum[int64])
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("unsupported_engine_total is not an int64 sum")
|
|
||||||
}
|
|
||||||
for _, dp := range sum.DataPoints {
|
|
||||||
reason, _ := dp.Attributes.Value(attribute.Key("reason"))
|
|
||||||
chromium, _ := dp.Attributes.Value(attribute.Key("chromium"))
|
|
||||||
if reason.AsString() != "no_bigint" || chromium.AsString() != "66" {
|
|
||||||
t.Errorf("labels = %s/%s, want no_bigint/66", reason.AsString(), chromium.AsString())
|
|
||||||
}
|
|
||||||
total += dp.Value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if total != 1 {
|
|
||||||
t.Errorf("unsupported_engine_total = %d, want 1", total)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -95,7 +95,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
banner = encodeBanner(b, *p.Banner)
|
banner = encodeBanner(b, *p.Banner)
|
||||||
}
|
}
|
||||||
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
|
||||||
dictVersions := encodeDictVersions(b, p.DictVersions)
|
|
||||||
fb.ProfileStart(b)
|
fb.ProfileStart(b)
|
||||||
fb.ProfileAddUserId(b, uid)
|
fb.ProfileAddUserId(b, uid)
|
||||||
fb.ProfileAddDisplayName(b, name)
|
fb.ProfileAddDisplayName(b, name)
|
||||||
@@ -112,9 +111,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
fb.ProfileAddEmail(b, email)
|
fb.ProfileAddEmail(b, email)
|
||||||
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
fb.ProfileAddTelegramLinked(b, p.TelegramLinked)
|
||||||
fb.ProfileAddVkLinked(b, p.VkLinked)
|
fb.ProfileAddVkLinked(b, p.VkLinked)
|
||||||
if dictVersions != 0 {
|
|
||||||
fb.ProfileAddDictVersions(b, dictVersions)
|
|
||||||
}
|
|
||||||
if p.Banner != nil {
|
if p.Banner != nil {
|
||||||
fb.ProfileAddBanner(b, banner)
|
fb.ProfileAddBanner(b, banner)
|
||||||
}
|
}
|
||||||
@@ -122,48 +118,6 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
|||||||
return b.FinishedBytes()
|
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 {
|
|
||||||
if s == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return b.CreateString(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// addOptString adds an optional string slot only when it was created (a 0 offset
|
|
||||||
// leaves the field absent). add is the generated BannerCampaignAdd* setter.
|
|
||||||
func addOptString(b *flatbuffers.Builder, add func(*flatbuffers.Builder, flatbuffers.UOffsetT), off flatbuffers.UOffsetT) {
|
|
||||||
if off != 0 {
|
|
||||||
add(b, off)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeBanner builds a BannerInfo table from the resolved banner block and
|
// encodeBanner builds a BannerInfo table from the resolved banner block and
|
||||||
// returns its offset. It is bottom-up: each campaign's messages vector and table
|
// returns its offset. It is bottom-up: each campaign's messages vector and table
|
||||||
// are built first, then the campaigns vector, then the BannerInfo table. The
|
// are built first, then the campaigns vector, then the BannerInfo table. The
|
||||||
@@ -180,23 +134,9 @@ func encodeBanner(b *flatbuffers.Builder, banner backendclient.BannerResp) flatb
|
|||||||
b.PrependUOffsetT(msgOffsets[j])
|
b.PrependUOffsetT(msgOffsets[j])
|
||||||
}
|
}
|
||||||
msgs := b.EndVector(len(msgOffsets))
|
msgs := b.EndVector(len(msgOffsets))
|
||||||
// The optional colour strings must be created before the table starts; an
|
|
||||||
// empty colour is omitted (offset 0), so the client reads it as absent.
|
|
||||||
obg := optString(b, c.OverrideBg)
|
|
||||||
ofg := optString(b, c.OverrideFg)
|
|
||||||
olink := optString(b, c.OverrideLink)
|
|
||||||
obgD := optString(b, c.OverrideBgDark)
|
|
||||||
ofgD := optString(b, c.OverrideFgDark)
|
|
||||||
olinkD := optString(b, c.OverrideLinkDark)
|
|
||||||
fb.BannerCampaignStart(b)
|
fb.BannerCampaignStart(b)
|
||||||
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
fb.BannerCampaignAddWeight(b, int32(c.Weight))
|
||||||
fb.BannerCampaignAddMessages(b, msgs)
|
fb.BannerCampaignAddMessages(b, msgs)
|
||||||
addOptString(b, fb.BannerCampaignAddOverrideBg, obg)
|
|
||||||
addOptString(b, fb.BannerCampaignAddOverrideFg, ofg)
|
|
||||||
addOptString(b, fb.BannerCampaignAddOverrideLink, olink)
|
|
||||||
addOptString(b, fb.BannerCampaignAddOverrideBgDark, obgD)
|
|
||||||
addOptString(b, fb.BannerCampaignAddOverrideFgDark, ofgD)
|
|
||||||
addOptString(b, fb.BannerCampaignAddOverrideLinkDark, olinkD)
|
|
||||||
campOffsets[i] = fb.BannerCampaignEnd(b)
|
campOffsets[i] = fb.BannerCampaignEnd(b)
|
||||||
}
|
}
|
||||||
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
fb.BannerInfoStartCampaignsVector(b, len(campOffsets))
|
||||||
@@ -299,14 +239,13 @@ func toWireState(s backendclient.StateResp) wire.StateView {
|
|||||||
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
alphabet[i] = wire.AlphabetEntry{Index: e.Index, Letter: e.Letter, Value: e.Value}
|
||||||
}
|
}
|
||||||
return wire.StateView{
|
return wire.StateView{
|
||||||
Game: toWireGame(s.Game),
|
Game: toWireGame(s.Game),
|
||||||
Seat: s.Seat,
|
Seat: s.Seat,
|
||||||
Rack: s.Rack,
|
Rack: s.Rack,
|
||||||
BagLen: s.BagLen,
|
BagLen: s.BagLen,
|
||||||
HintsRemaining: s.HintsRemaining,
|
HintsRemaining: s.HintsRemaining,
|
||||||
WalletBalance: s.WalletBalance,
|
WalletBalance: s.WalletBalance,
|
||||||
HintUnlockLeftSeconds: s.HintUnlockLeftSeconds,
|
Alphabet: alphabet,
|
||||||
Alphabet: alphabet,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
|
|||||||
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
func authEmailRequestHandler(backend *backendclient.Client) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
|
||||||
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language()), in.Pwa()); err != nil {
|
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz()), string(in.Language())); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return encodeAck(true), nil
|
return encodeAck(true), nil
|
||||||
|
|||||||
@@ -61,54 +61,6 @@ func TestProfileGetEncodesBanner(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProfileGetEncodesBannerColors verifies the gateway carries a campaign's
|
|
||||||
// optional colour overrides into the FlatBuffers payload, and leaves an absent
|
|
||||||
// colour (or an absent set) empty so the client falls back to the theme tokens.
|
|
||||||
func TestProfileGetEncodesBannerColors(t *testing.T) {
|
|
||||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"en",` +
|
|
||||||
`"banner":{"campaigns":[` +
|
|
||||||
`{"weight":1,"messages":["promo-en"],` +
|
|
||||||
`"override_bg":"#aa0000","override_fg":"#ffffff","override_link":"#ffdd00",` +
|
|
||||||
`"override_bg_dark":"#330000","override_fg_dark":"#eeeeee","override_link_dark":"#ffcc00"},` +
|
|
||||||
`{"weight":1,"messages":["plain-en"]}],` +
|
|
||||||
`"timings":{"hold_ms":60000,"edge_pause_ms":5000,"scroll_px_per_sec":40,` +
|
|
||||||
`"fade_out_ms":1000,"gap_ms":250,"fade_in_ms":1000}}}`))
|
|
||||||
})
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
reg := transcode.NewRegistry(backend, nil)
|
|
||||||
op, _ := reg.Lookup(transcode.MsgProfileGet)
|
|
||||||
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("handler: %v", err)
|
|
||||||
}
|
|
||||||
banner := fb.GetRootAsProfile(payload, 0).Banner(nil)
|
|
||||||
if banner == nil {
|
|
||||||
t.Fatal("profile carries no banner block")
|
|
||||||
}
|
|
||||||
var c fb.BannerCampaign
|
|
||||||
banner.Campaigns(&c, 0)
|
|
||||||
if got := string(c.OverrideBg()); got != "#aa0000" {
|
|
||||||
t.Errorf("override_bg = %q, want #aa0000", got)
|
|
||||||
}
|
|
||||||
if got := string(c.OverrideLink()); got != "#ffdd00" {
|
|
||||||
t.Errorf("override_link = %q, want #ffdd00", got)
|
|
||||||
}
|
|
||||||
if got := string(c.OverrideBgDark()); got != "#330000" {
|
|
||||||
t.Errorf("override_bg_dark = %q, want #330000", got)
|
|
||||||
}
|
|
||||||
if got := string(c.OverrideLinkDark()); got != "#ffcc00" {
|
|
||||||
t.Errorf("override_link_dark = %q, want #ffcc00", got)
|
|
||||||
}
|
|
||||||
// The second campaign has no override: every colour field must be absent (nil).
|
|
||||||
banner.Campaigns(&c, 1)
|
|
||||||
if c.OverrideBg() != nil || c.OverrideFg() != nil || c.OverrideLink() != nil ||
|
|
||||||
c.OverrideBgDark() != nil || c.OverrideFgDark() != nil || c.OverrideLinkDark() != nil {
|
|
||||||
t.Error("plain campaign unexpectedly carries a colour override")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
// TestProfileGetNoBanner verifies an ineligible viewer's profile carries no
|
||||||
// banner block (the backend omits it).
|
// banner block (the backend omits it).
|
||||||
func TestProfileGetNoBanner(t *testing.T) {
|
func TestProfileGetNoBanner(t *testing.T) {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
if r.URL.Path != "/api/v1/user/games/g-1/state" {
|
||||||
t.Errorf("unexpected path %q", r.URL.Path)
|
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()
|
defer cleanup()
|
||||||
|
|
||||||
@@ -77,8 +77,8 @@ func TestGameStateRoundTripForwardsUserID(t *testing.T) {
|
|||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
st := fb.GetRootAsStateView(payload, 0)
|
st := fb.GetRootAsStateView(payload, 0)
|
||||||
if st.BagLen() != 80 || st.RackLength() != 2 || st.HintsRemaining() != 4 || st.WalletBalance() != 3 || st.HintUnlockLeftSeconds() != 1200 {
|
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 unlockLeft=%d", st.BagLen(), st.RackLength(), st.HintsRemaining(), st.WalletBalance(), st.HintUnlockLeftSeconds())
|
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)
|
game := st.Game(nil)
|
||||||
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
if game == nil || string(game.Id()) != "g-1" || string(game.Variant()) != "scrabble_en" || game.ToMove() != 1 {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package webui
|
|||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"mime"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -36,25 +35,13 @@ func distFS() fs.FS {
|
|||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
// init registers the MIME type for .webmanifest, which Go's built-in table lacks and the
|
// Handler serves the embedded UI. An existing file is served directly (hash-named assets get
|
||||||
// distroless runtime image has no /etc/mime.types to supply. Without it the PWA Web App Manifest
|
// an immutable cache); every other path falls back to indexName (the SPA shell) so a
|
||||||
// served under /app/ would be content-sniffed to text/plain, which some browsers reject.
|
// client-side deep link still loads. When stripPrefix is non-empty it is removed from the
|
||||||
func init() {
|
// request path before lookup, so the same build serves under a sub-path (e.g. "/app/" or
|
||||||
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
// "/telegram/").
|
||||||
}
|
|
||||||
|
|
||||||
// Handler serves the compile-time embedded UI build over the public edge — the game SPA under
|
|
||||||
// /app/, /telegram/ and /vk/. It delegates to handlerFor over the embedded dist/.
|
|
||||||
func Handler(stripPrefix, indexName string) http.Handler {
|
func Handler(stripPrefix, indexName string) http.Handler {
|
||||||
return handlerFor(distFS(), stripPrefix, indexName)
|
content := distFS()
|
||||||
}
|
|
||||||
|
|
||||||
// handlerFor serves content as the UI: an existing file is served directly (hash-named assets get
|
|
||||||
// an immutable cache); every other path falls back to indexName (the SPA shell) so a client-side
|
|
||||||
// deep link still loads. When stripPrefix is non-empty it is removed from the request path before
|
|
||||||
// lookup, so the same build serves under a sub-path (e.g. "/app/" or "/telegram/"). Split from
|
|
||||||
// Handler so it can be exercised over an in-memory fs.FS in tests.
|
|
||||||
func handlerFor(content fs.FS, stripPrefix, indexName string) http.Handler {
|
|
||||||
files := http.FileServer(http.FS(content))
|
files := http.FileServer(http.FS(content))
|
||||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||||
@@ -71,11 +58,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.
|
// app (notably a relaunched Telegram Mini App) is a cache hit, not a re-download.
|
||||||
if strings.HasPrefix(name, "assets/") {
|
if strings.HasPrefix(name, "assets/") {
|
||||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
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)
|
files.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// get drives the handler with a GET for the given path and returns the response.
|
// get drives the handler with a GET for the given path and returns the response.
|
||||||
@@ -56,60 +55,3 @@ func TestAppMountServesShellStripsPrefixAndCachesAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testFS mirrors what Vite emits into dist/: the SPA shell, the PWA manifest + service worker at
|
|
||||||
// the root (copied from ui/public), and a hash-named asset. The compile-time embedded dist/ holds
|
|
||||||
// only a placeholder shell, so the manifest/sw.js serving is exercised over this in-memory FS.
|
|
||||||
func testFS() fstest.MapFS {
|
|
||||||
return fstest.MapFS{
|
|
||||||
"index.html": {Data: []byte("<!doctype html><title>shell</title>")},
|
|
||||||
"manifest.webmanifest": {Data: []byte(`{"name":"Эрудит"}`)},
|
|
||||||
"sw.js": {Data: []byte("self.addEventListener('fetch', () => {});")},
|
|
||||||
"assets/app-abc123.js": {Data: []byte("export const x = 1;")},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlerServesManifestWithManifestType: the PWA manifest is served with the manifest MIME
|
|
||||||
// type (registered in init, since the distroless image has no /etc/mime.types), not sniffed to
|
|
||||||
// text/plain, which some browsers reject.
|
|
||||||
func TestHandlerServesManifestWithManifestType(t *testing.T) {
|
|
||||||
h := handlerFor(testFS(), "/app/", "index.html")
|
|
||||||
resp := get(t, h, "/app/manifest.webmanifest")
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/manifest+json") {
|
|
||||||
t.Errorf("Content-Type = %q, want application/manifest+json", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHandlerServesServiceWorkerAsJavaScript: sw.js is served as JavaScript from the root, so its
|
|
||||||
// registration (scope /app/) succeeds.
|
|
||||||
func TestHandlerServesServiceWorkerAsJavaScript(t *testing.T) {
|
|
||||||
h := handlerFor(testFS(), "/app/", "index.html")
|
|
||||||
resp := get(t, h, "/app/sw.js")
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
// into dist/ falls back to the SPA shell as text/html, on which SW registration would fail. The two
|
|
||||||
// tests above pin that the real files are served instead of the shell.
|
|
||||||
func TestHandlerFallsBackToShellForUnknownManifestPath(t *testing.T) {
|
|
||||||
h := handlerFor(testFS(), "/app/", "index.html")
|
|
||||||
resp := get(t, h, "/app/not-emitted.webmanifest")
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
|
||||||
t.Errorf("Content-Type = %q, want text/html (SPA shell fallback)", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-34
@@ -138,11 +138,6 @@ table EmailRequestRequest {
|
|||||||
email:string;
|
email:string;
|
||||||
browser_tz:string;
|
browser_tz:string;
|
||||||
language:string;
|
language:string;
|
||||||
// Set when the request originates from an installed PWA (standalone display mode): the
|
|
||||||
// backend then omits the one-tap confirm link from the login email so the code is typed in
|
|
||||||
// the same window, avoiding a link that opens in a separate browser (a different storage
|
|
||||||
// context) where the minted session could not reach the PWA.
|
|
||||||
pwa:bool;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
// EmailLoginRequest logs in to the account owning email (provisioned at the
|
||||||
@@ -185,20 +180,10 @@ table Ack {
|
|||||||
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
// BannerCampaign is one campaign in the rotation feed: a GCD-reduced show weight
|
||||||
// (the client runs a smooth weighted round-robin over campaigns by this weight)
|
// (the client runs a smooth weighted round-robin over campaigns by this weight)
|
||||||
// and its messages in display order, each already resolved to the viewer's bot
|
// and its messages in display order, each already resolved to the viewer's bot
|
||||||
// language (the stored en/ru pair is picked server-side). The override_* colours
|
// language (the stored en/ru pair is picked server-side).
|
||||||
// are an optional per-campaign palette: override_bg/fg/link paint the strip on
|
|
||||||
// every theme, and the *_dark trio further overrides the dark theme (the client
|
|
||||||
// resolves dark ← dark ?? all ?? token, light ← all ?? token). Empty means the
|
|
||||||
// campaign keeps the neutral theme tokens (all added trailing — backward-compatible).
|
|
||||||
table BannerCampaign {
|
table BannerCampaign {
|
||||||
weight:int;
|
weight:int;
|
||||||
messages:[string];
|
messages:[string];
|
||||||
override_bg:string;
|
|
||||||
override_fg:string;
|
|
||||||
override_link:string;
|
|
||||||
override_bg_dark:string;
|
|
||||||
override_fg_dark:string;
|
|
||||||
override_link_dark:string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
// BannerInfo is the advertising-banner block of an eligible viewer's profile: the
|
||||||
@@ -223,13 +208,6 @@ table BannerInfo {
|
|||||||
// suppresses out-of-app platform push, leaving only the in-app live stream. banner
|
// 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
|
// carries the advertising-banner block for an eligible viewer, absent otherwise
|
||||||
// (all added trailing — backward-compatible).
|
// (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 {
|
table Profile {
|
||||||
user_id:string;
|
user_id:string;
|
||||||
display_name:string;
|
display_name:string;
|
||||||
@@ -252,10 +230,6 @@ table Profile {
|
|||||||
email:string;
|
email:string;
|
||||||
telegram_linked:bool;
|
telegram_linked:bool;
|
||||||
vk_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
|
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation
|
||||||
@@ -316,13 +290,6 @@ table StateView {
|
|||||||
// the wallet is a single global figure the client keeps live across games (added trailing —
|
// the wallet is a single global figure the client keeps live across games (added trailing —
|
||||||
// backward-compatible).
|
// backward-compatible).
|
||||||
wallet_balance:int;
|
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).
|
// GameActionRequest carries just a game id (pass / resign / hint / history).
|
||||||
|
|||||||
@@ -70,56 +70,8 @@ func (rcv *BannerCampaign) MessagesLength() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *BannerCampaign) OverrideBg() []byte {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *BannerCampaign) OverrideFg() []byte {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *BannerCampaign) OverrideLink() []byte {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *BannerCampaign) OverrideBgDark() []byte {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *BannerCampaign) OverrideFgDark() []byte {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(16))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *BannerCampaign) OverrideLinkDark() []byte {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
func BannerCampaignStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(8)
|
builder.StartObject(2)
|
||||||
}
|
}
|
||||||
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
func BannerCampaignAddWeight(builder *flatbuffers.Builder, weight int32) {
|
||||||
builder.PrependInt32Slot(0, weight, 0)
|
builder.PrependInt32Slot(0, weight, 0)
|
||||||
@@ -130,24 +82,6 @@ func BannerCampaignAddMessages(builder *flatbuffers.Builder, messages flatbuffer
|
|||||||
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
func BannerCampaignStartMessagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
|
||||||
return builder.StartVector(4, numElems, 4)
|
return builder.StartVector(4, numElems, 4)
|
||||||
}
|
}
|
||||||
func BannerCampaignAddOverrideBg(builder *flatbuffers.Builder, overrideBg flatbuffers.UOffsetT) {
|
|
||||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(overrideBg), 0)
|
|
||||||
}
|
|
||||||
func BannerCampaignAddOverrideFg(builder *flatbuffers.Builder, overrideFg flatbuffers.UOffsetT) {
|
|
||||||
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(overrideFg), 0)
|
|
||||||
}
|
|
||||||
func BannerCampaignAddOverrideLink(builder *flatbuffers.Builder, overrideLink flatbuffers.UOffsetT) {
|
|
||||||
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(overrideLink), 0)
|
|
||||||
}
|
|
||||||
func BannerCampaignAddOverrideBgDark(builder *flatbuffers.Builder, overrideBgDark flatbuffers.UOffsetT) {
|
|
||||||
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(overrideBgDark), 0)
|
|
||||||
}
|
|
||||||
func BannerCampaignAddOverrideFgDark(builder *flatbuffers.Builder, overrideFgDark flatbuffers.UOffsetT) {
|
|
||||||
builder.PrependUOffsetTSlot(6, flatbuffers.UOffsetT(overrideFgDark), 0)
|
|
||||||
}
|
|
||||||
func BannerCampaignAddOverrideLinkDark(builder *flatbuffers.Builder, overrideLinkDark flatbuffers.UOffsetT) {
|
|
||||||
builder.PrependUOffsetTSlot(7, flatbuffers.UOffsetT(overrideLinkDark), 0)
|
|
||||||
}
|
|
||||||
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func BannerCampaignEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -65,20 +65,8 @@ func (rcv *EmailRequestRequest) Language() []byte {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rcv *EmailRequestRequest) Pwa() bool {
|
|
||||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(10))
|
|
||||||
if o != 0 {
|
|
||||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rcv *EmailRequestRequest) MutatePwa(n bool) bool {
|
|
||||||
return rcv._tab.MutateBoolSlot(10, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
|
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(4)
|
builder.StartObject(3)
|
||||||
}
|
}
|
||||||
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
|
||||||
@@ -89,9 +77,6 @@ func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz fla
|
|||||||
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
|
func EmailRequestRequestAddLanguage(builder *flatbuffers.Builder, language flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
|
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(language), 0)
|
||||||
}
|
}
|
||||||
func EmailRequestRequestAddPwa(builder *flatbuffers.Builder, pwa bool) {
|
|
||||||
builder.PrependBoolSlot(3, pwa, false)
|
|
||||||
}
|
|
||||||
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,28 +211,8 @@ func (rcv *Profile) MutateVkLinked(n bool) bool {
|
|||||||
return rcv._tab.MutateBoolSlot(34, n)
|
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) {
|
func ProfileStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(17)
|
builder.StartObject(16)
|
||||||
}
|
}
|
||||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
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) {
|
func ProfileAddVkLinked(builder *flatbuffers.Builder, vkLinked bool) {
|
||||||
builder.PrependBoolSlot(15, vkLinked, false)
|
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 {
|
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
return builder.EndObject()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,20 +156,8 @@ func (rcv *StateView) MutateWalletBalance(n int32) bool {
|
|||||||
return rcv._tab.MutateInt32Slot(16, n)
|
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) {
|
func StateViewStart(builder *flatbuffers.Builder) {
|
||||||
builder.StartObject(8)
|
builder.StartObject(7)
|
||||||
}
|
}
|
||||||
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
func StateViewAddGame(builder *flatbuffers.Builder, game flatbuffers.UOffsetT) {
|
||||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(game), 0)
|
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) {
|
func StateViewAddWalletBalance(builder *flatbuffers.Builder, walletBalance int32) {
|
||||||
builder.PrependInt32Slot(6, walletBalance, 0)
|
builder.PrependInt32Slot(6, walletBalance, 0)
|
||||||
}
|
}
|
||||||
func StateViewAddHintUnlockLeftSeconds(builder *flatbuffers.Builder, hintUnlockLeftSeconds int32) {
|
|
||||||
builder.PrependInt32Slot(7, hintUnlockLeftSeconds, 0)
|
|
||||||
}
|
|
||||||
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
func StateViewEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||||
return builder.EndObject()
|
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
|
// (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.
|
// recipient may not have cached the variant's display table yet.
|
||||||
type StateView struct {
|
type StateView struct {
|
||||||
Game GameView
|
Game GameView
|
||||||
Seat int
|
Seat int
|
||||||
Rack []int
|
Rack []int
|
||||||
BagLen int
|
BagLen int
|
||||||
HintsRemaining int
|
HintsRemaining int
|
||||||
WalletBalance int
|
WalletBalance int
|
||||||
HintUnlockLeftSeconds int
|
Alphabet []AlphabetEntry
|
||||||
Alphabet []AlphabetEntry
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountRef is a referenced account with its display name resolved.
|
// 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.StateViewAddBagLen(b, int32(s.BagLen))
|
||||||
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
fb.StateViewAddHintsRemaining(b, int32(s.HintsRemaining))
|
||||||
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
fb.StateViewAddWalletBalance(b, int32(s.WalletBalance))
|
||||||
fb.StateViewAddHintUnlockLeftSeconds(b, int32(s.HintUnlockLeftSeconds))
|
|
||||||
if hasAlphabet {
|
if hasAlphabet {
|
||||||
fb.StateViewAddAlphabet(b, alphabet)
|
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,59 +0,0 @@
|
|||||||
import { expect, test } from './fixtures';
|
|
||||||
|
|
||||||
// The install CTA (components/InstallApp.svelte) is platform-adaptive: a one-tap button where the
|
|
||||||
// browser offers a beforeinstallprompt (Chromium), a manual instructions modal on iOS Safari, and
|
|
||||||
// nothing elsewhere / once installed / inside a Mini App. The native OS install dialog is
|
|
||||||
// browser-level and not drivable from Playwright, so these assert the CTA's presence and branch.
|
|
||||||
|
|
||||||
const IPHONE_SAFARI =
|
|
||||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
|
||||||
|
|
||||||
test('web: a captured install prompt shows the one-tap CTA and clicking it prompts', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
// The web login screen is shown (an ordinary browser tab, not a Mini App).
|
|
||||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
|
||||||
|
|
||||||
// Hidden until the browser offers installation (Playwright never fires beforeinstallprompt).
|
|
||||||
await expect(page.getByRole('button', { name: /Install the app/i })).toHaveCount(0);
|
|
||||||
|
|
||||||
// Simulate Chromium offering the install: dispatch a beforeinstallprompt the app can capture.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
const e = new Event('beforeinstallprompt') as Event & {
|
|
||||||
prompt?: () => Promise<void>;
|
|
||||||
userChoice?: Promise<unknown>;
|
|
||||||
};
|
|
||||||
(window as unknown as { __promptCalled: boolean }).__promptCalled = false;
|
|
||||||
e.prompt = () => {
|
|
||||||
(window as unknown as { __promptCalled: boolean }).__promptCalled = true;
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
e.userChoice = Promise.resolve({ outcome: 'accepted', platform: '' });
|
|
||||||
window.dispatchEvent(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
const cta = page.getByRole('button', { name: /Install the app/i });
|
|
||||||
await expect(cta).toBeVisible();
|
|
||||||
await cta.click();
|
|
||||||
// Clicking calls the captured prompt (the native dialog itself is not observable here).
|
|
||||||
await expect
|
|
||||||
.poll(() => page.evaluate(() => (window as unknown as { __promptCalled: boolean }).__promptCalled))
|
|
||||||
.toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('iOS Safari', () => {
|
|
||||||
test.use({ userAgent: IPHONE_SAFARI });
|
|
||||||
|
|
||||||
test('shows manual Add-to-Home-Screen instructions (no programmatic install)', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
|
|
||||||
|
|
||||||
// On iOS Safari the CTA shows at once (no beforeinstallprompt is ever fired there).
|
|
||||||
const cta = page.getByRole('button', { name: /Install the app/i });
|
|
||||||
await expect(cta).toBeVisible();
|
|
||||||
await cta.click();
|
|
||||||
|
|
||||||
// It opens the app's own instructions modal (not a native / Telegram popup).
|
|
||||||
await expect(page.getByRole('heading', { name: 'Add to Home Screen' })).toBeVisible();
|
|
||||||
await expect(page.getByText(/Tap the Share button/)).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -46,15 +46,3 @@ test('the landing is a normal scrolling document (the SPA document-pin does not
|
|||||||
expect(state.shell).toBe(false);
|
expect(state.shell).toBe(false);
|
||||||
expect(state.bodyPosition).not.toBe('fixed');
|
expect(state.bodyPosition).not.toBe('fixed');
|
||||||
});
|
});
|
||||||
|
|
||||||
// The web-version entry (/app/) is always present, alongside the build-var-gated Telegram/VK ones
|
|
||||||
// (absent here, since e2e sets no VITE_* vars), each with a caption.
|
|
||||||
test('the landing shows a web-version entry linking /app/, with a caption', async ({ page }) => {
|
|
||||||
await page.goto('/landing.html');
|
|
||||||
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
|
|
||||||
|
|
||||||
const web = page.getByRole('link', { name: 'Играть в браузере' });
|
|
||||||
await expect(web).toBeVisible();
|
|
||||||
expect(await web.getAttribute('href')).toContain('/app/');
|
|
||||||
await expect(page.getByText('Веб-версия')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user