Compare commits
79 Commits
e3c5cff7b7
...
v1.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| aaf162de40 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| ed79c30d2a | |||
| 471fadc6a4 | |||
| 564abdcf88 | |||
| c522e77599 | |||
| 28afcff551 | |||
| c34e2a8dbf | |||
| 25381f70a3 | |||
| 5ce9f7e9b4 | |||
| 522beec82e | |||
| 3b485883ee | |||
| efeed17abc | |||
| cc34622630 | |||
| f6d256215a | |||
| fe5a3d6d3b | |||
| 7e142d471c | |||
| dbe2c4cb94 | |||
| 8c5b659ddf | |||
| a067a2fd01 | |||
| 8becdb45e4 | |||
| 25b5ed5516 | |||
| de5ab9186c | |||
| 7df078f5ed | |||
| 1d77ee83b3 | |||
| 5a4c460268 | |||
| 807edff327 | |||
| 0f3b4dbcff | |||
| 3456a0b3ff | |||
| 93ccc8c449 | |||
| e0360c98e2 | |||
| a41281c495 | |||
| 11f89f6477 | |||
| 2d1fadb50c | |||
| 09e05eef18 | |||
| 73baf58002 | |||
| eec225c4ee | |||
| ca2c6487cf | |||
| e7cb60c996 | |||
| 49c53794f4 | |||
| 4a0689a4ac | |||
| 0eb72ba955 | |||
| e077258567 | |||
| a035edfb54 | |||
| bcd5a1d02d | |||
| a57fd355ba | |||
| 2ea91a8354 | |||
| de003e862a | |||
| aaf2825260 | |||
| 0f5db0ee91 | |||
| 53b33073ac | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -28,6 +28,57 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
|
|||||||
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
||||||
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
||||||
|
|
||||||
|
## Native Android build (Capacitor)
|
||||||
|
|
||||||
|
The native Android app is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
||||||
|
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
|
||||||
|
|
||||||
|
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
|
||||||
|
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
|
||||||
|
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
|
||||||
|
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
|
||||||
|
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
|
||||||
|
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
|
||||||
|
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
|
||||||
|
`android-36.1` does NOT satisfy compileSdk 36.
|
||||||
|
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
|
||||||
|
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
|
||||||
|
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
|
||||||
|
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
|
||||||
|
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
|
||||||
|
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
|
||||||
|
(dodges the corepack flake).
|
||||||
|
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
|
||||||
|
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
|
||||||
|
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
|
||||||
|
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
|
||||||
|
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
|
||||||
|
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
|
||||||
|
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
|
||||||
|
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
|
||||||
|
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
|
||||||
|
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
|
||||||
|
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
|
||||||
|
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
|
||||||
|
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
|
||||||
|
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
|
||||||
|
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
|
||||||
|
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
|
||||||
|
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
|
||||||
|
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
|
||||||
|
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
|
||||||
|
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
|
||||||
|
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
|
||||||
|
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
|
||||||
|
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
|
||||||
|
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
|
||||||
|
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
|
||||||
|
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
|
||||||
|
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
|
||||||
|
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
|
||||||
|
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
|
||||||
|
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
|
||||||
|
|
||||||
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
||||||
|
|
||||||
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
||||||
@@ -84,6 +135,20 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
|
|||||||
eyeball per-variant tiles.
|
eyeball per-variant tiles.
|
||||||
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||||||
plugin-config gotcha in wiring them).
|
plugin-config gotcha in wiring them).
|
||||||
|
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
|
||||||
|
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
|
||||||
|
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
|
||||||
|
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
|
||||||
|
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
|
||||||
|
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
|
||||||
|
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
|
||||||
|
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
|
||||||
|
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
|
||||||
|
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
|
||||||
|
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
|
||||||
|
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
|
||||||
|
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
|
||||||
|
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
|
||||||
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
||||||
Use **testcontainers** for container-backed tests.
|
Use **testcontainers** for container-backed tests.
|
||||||
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
||||||
@@ -169,9 +234,9 @@ on it**, and prefer the authoritative docs where they overlap. Add to this file
|
|||||||
`--pg1-user=scrabble`.
|
`--pg1-user=scrabble`.
|
||||||
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
|
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
|
||||||
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
|
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
|
||||||
offline-first bundles the dictionaries (see `ANDROID_PLAN.md`).
|
offline-first bundles the dictionaries.
|
||||||
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
||||||
App, server-side confidential code exchange.
|
App, server-side confidential code exchange.
|
||||||
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||||||
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
|
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||||||
offline-first, RuStore).
|
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
|
||||||
|
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
|
||||||
|
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||||
|
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||||
|
#
|
||||||
|
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||||
|
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||||
|
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
|
||||||
|
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
|
||||||
|
# nothing pre-installed.
|
||||||
|
name: android-build
|
||||||
|
run-name: "android build ${{ github.sha }}"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
confirm:
|
||||||
|
description: 'Type "build" to confirm an APK build from master.'
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
NO_COLOR: "1"
|
||||||
|
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
|
||||||
|
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
|
||||||
|
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||||
|
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
|
||||||
|
VITE_PAYMENTS_DISABLED: "1"
|
||||||
|
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
|
||||||
|
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
|
||||||
|
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
|
||||||
|
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
|
||||||
|
# lives elsewhere (the default matches deploy/README.md's install path).
|
||||||
|
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||||
|
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
|
||||||
|
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
|
||||||
|
# missing — this doubles as the runner-access check (deploy/README.md).
|
||||||
|
- name: Verify the host Android SDK
|
||||||
|
run: |
|
||||||
|
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||||
|
if [ ! -x "$sm" ]; then
|
||||||
|
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
|
||||||
|
fi
|
||||||
|
for pkg in "platforms/android-36" "build-tools"; do
|
||||||
|
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
|
||||||
|
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
|
||||||
|
|
||||||
|
- name: Compute version + native build env
|
||||||
|
id: prep
|
||||||
|
env:
|
||||||
|
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||||
|
run: |
|
||||||
|
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
|
||||||
|
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
|
||||||
|
# rather than derive a versionCode from a "-N-gSHA" describe.
|
||||||
|
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
|
||||||
|
case "$desc" in
|
||||||
|
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||||
|
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
v="${desc#v}"
|
||||||
|
IFS=. read -r MA MI PA <<< "$v"
|
||||||
|
# 10# forces base-10 so a zero-padded part is never read as octal.
|
||||||
|
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
|
||||||
|
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
|
||||||
|
# trailing slash so the Connect endpoint never doubles it.
|
||||||
|
gateway="${PUBLIC_BASE_URL%/}"
|
||||||
|
{
|
||||||
|
echo "tag=$desc"
|
||||||
|
echo "name=$v"
|
||||||
|
echo "code=$code"
|
||||||
|
echo "gateway=$gateway"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
|
||||||
|
|
||||||
|
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
|
||||||
|
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
|
||||||
|
- 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: Set up Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.0.9
|
||||||
|
|
||||||
|
- name: Install deps
|
||||||
|
working-directory: ui
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build the SPA (native flavour)
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
|
||||||
|
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
|
||||||
|
# (after the build, before cap sync copies dist/ into the native assets).
|
||||||
|
- name: Bundle the dictionaries
|
||||||
|
working-directory: ui
|
||||||
|
env:
|
||||||
|
DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
run: node scripts/bundle-dicts.mjs
|
||||||
|
|
||||||
|
- name: Set up JDK 21
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: "21"
|
||||||
|
|
||||||
|
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
|
||||||
|
- name: Sync the native project
|
||||||
|
working-directory: ui
|
||||||
|
run: node_modules/.bin/cap sync android
|
||||||
|
|
||||||
|
- name: Decode the release keystore
|
||||||
|
id: keystore
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||||
|
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||||
|
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "keystore decoded -> signed release build"
|
||||||
|
else
|
||||||
|
echo "file=" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Assemble the release APK
|
||||||
|
working-directory: ui/android
|
||||||
|
env:
|
||||||
|
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||||
|
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||||
|
|
||||||
|
- name: Upload the APK artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: erudit-${{ steps.prep.outputs.name }}-apk
|
||||||
|
path: ui/android/app/build/outputs/apk/release/*.apk
|
||||||
|
if-no-files-found: error
|
||||||
@@ -353,6 +353,11 @@ jobs:
|
|||||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
|
||||||
|
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
|
||||||
|
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
|
||||||
|
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||||
|
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||||
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||||
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||||
@@ -517,10 +522,21 @@ jobs:
|
|||||||
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
||||||
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
||||||
# the marker with nothing, so "pricing_template" must be absent either way.
|
# the marker with nothing, so "pricing_template" must be absent either way.
|
||||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
|
||||||
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
|
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
|
||||||
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
|
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
|
||||||
else
|
# the same deploy.
|
||||||
|
ok=
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
out="$(docker run --rm --network edge alpine:3.20 wget -q -T 10 -O - http://scrabble/offer/ 2>&1 || true)"
|
||||||
|
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
|
||||||
|
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
|
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
|
||||||
docker logs --tail 50 scrabble-renderer || true
|
docker logs --tail 50 scrabble-renderer || true
|
||||||
docker logs --tail 50 scrabble-backend || true
|
docker logs --tail 50 scrabble-backend || true
|
||||||
@@ -528,6 +544,35 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Probe the /privacy/ and /eula/ legal pages are served
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
|
||||||
|
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
|
||||||
|
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
|
||||||
|
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
|
||||||
|
# (head -c 4096; the canonical link sits in the first bytes): the check is then
|
||||||
|
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
|
||||||
|
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
|
||||||
|
# landing/gateway/backend probe to ride out that window.
|
||||||
|
for route in privacy eula; do
|
||||||
|
ok=
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
|
||||||
|
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
|
||||||
|
echo "ok: /${route}/ serves the rendered legal page"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
|
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
|
||||||
|
docker logs --tail 50 scrabble-renderer || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
- name: Probe the /pay/ callback route reaches the gateway
|
- name: Probe the /pay/ callback route reaches the gateway
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ jobs:
|
|||||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
|
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
|
||||||
|
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
|
||||||
|
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
|
||||||
|
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
|
||||||
|
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||||
|
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||||
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
|
||||||
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ installs away cleanly, and (c) opens offline-first as a soft guest with local pl
|
|||||||
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
|
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
|
||||||
| appId (permanent) | **`ru.eruditgame.app`** |
|
| appId (permanent) | **`ru.eruditgame.app`** |
|
||||||
| App display name | **`Эрудит`** (Cyrillic) |
|
| App display name | **`Эрудит`** (Cyrillic) |
|
||||||
|
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
|
||||||
|
|
||||||
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
|
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
|
||||||
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
|
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
|
||||||
@@ -57,23 +58,236 @@ client-only (no server change).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Progress (as-built)
|
||||||
|
|
||||||
|
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
|
||||||
|
|
||||||
|
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
|
||||||
|
>
|
||||||
|
> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
|
||||||
|
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
|
||||||
|
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
|
||||||
|
>
|
||||||
|
> **Branches / PRs (current working branch: `feature/android-release-prep`).**
|
||||||
|
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
|
||||||
|
> test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN.
|
||||||
|
> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged**
|
||||||
|
> (the owner chose to hold the merge). It is branched off `development`, so it also contains the
|
||||||
|
> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the
|
||||||
|
> agent cannot self-approve).
|
||||||
|
>
|
||||||
|
> **Done this session.**
|
||||||
|
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
|
||||||
|
> `ui/legal/{privacy,eula,offer}_{ru,en}.md`; served as **bilingual (RU/EN)** standalone pages by the
|
||||||
|
> render sidecar at `/privacy/`, `/eula/`, `/offer/` via the shared `ui/src/lib/offer.ts`
|
||||||
|
> `renderLegalHtml`, with a client-side **language + theme switcher** (🌐 + ☼/☾, no "back", default
|
||||||
|
> Russian + persisted, mirroring the landing). The offer's EN view **transliterates** the Russian
|
||||||
|
> product names and **translates** the price-list section headings + column headers (`PRICE_TR`).
|
||||||
|
> Edge: `@legal` caddy matcher (`/offer/ /privacy/ /eula/`) + CI probes (size-independent head-only
|
||||||
|
> for the legal pages, retry for the offer). Landing footer links: Пользовательское соглашение |
|
||||||
|
> Политика конфиденциальности | Публичная оферта | Обратная связь (→ the Telegram bot). Docs baked
|
||||||
|
> into `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `renderer/README.md`.
|
||||||
|
> **Locked legal decisions — do NOT re-litigate:** seller **INN `290210610742`**; EULA §14 leads
|
||||||
|
> with **RF law + jurisdiction** as residence tiers (France / England&Wales retained as foreign
|
||||||
|
> tiers); the "only the English version is legally binding" clause is **removed**; data collection is
|
||||||
|
> kept but **scoped to voluntary/support provision** (passport/payment/ID only when the user provides
|
||||||
|
> them); «Компания» is not shout-cased; the offer's EN catalog product names are **transliterated**
|
||||||
|
> (owner-chosen), while its headings/columns are translated.
|
||||||
|
> - **Release-prep hygiene (PR #254):** de-anchored `ANDROID_PLAN.md` from all code/docs — **no
|
||||||
|
> reference to this file remains anywhere outside it**; **re-enabled the `android-build` workflow**
|
||||||
|
> (`.gitea/workflows/android-build.yaml.disabled` → `.yaml`; it is `workflow_dispatch`-only + gated
|
||||||
|
> `if master`, so it never auto-runs; the CI host already has the Android SDK at `/opt/android-sdk`);
|
||||||
|
> added [`docs/ICONS.md`](docs/ICONS.md) (the single-master icon/logo format reference).
|
||||||
|
>
|
||||||
|
> **Remaining to release (owner-gated unless the agent is named).**
|
||||||
|
> 1. **Icon rebrand — DONE.** The SVG-tracer export failed, so the icon was designed **collaboratively
|
||||||
|
> from scratch** (wooden «Э» tile + ✻ subscript, Spectral Bold). The construction is fully specified
|
||||||
|
> in fractions of the side in [`docs/ICON_BRANDBOOK.md`](docs/ICON_BRANDBOOK.md), with the reference
|
||||||
|
> generator + pinned font + masters in [`assets/icons/brand/`](assets/icons/brand/). One master →
|
||||||
|
> the whole set sliced by `brand/build-set.mjs` (+ `build-android-res.mjs`): web (favicon light+dark,
|
||||||
|
> apple-touch, PWA any + maskable, og-image reskinned to «Эрудит» / Игра в слова), the Capacitor
|
||||||
|
> layers `ui/assets/{icon,icon-foreground,icon-background}.png`, the Android launcher res (all
|
||||||
|
> densities, **no inset**, **+ monochrome** themed layer), and manual-upload art (`brand/{vk,tg,store}`).
|
||||||
|
> Primary variant **light**; dark where the platform can theme (favicon.svg). The old LiberationSans
|
||||||
|
> generator (`assets/icons/build/`) is removed; targets are mapped in `docs/ICONS.md`.
|
||||||
|
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
|
||||||
|
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
|
||||||
|
> `X-Client-Version` header).
|
||||||
|
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
|
||||||
|
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
|
||||||
|
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
|
||||||
|
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
|
||||||
|
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an
|
||||||
|
> UNSIGNED APK (a dry run still proves the pipeline).
|
||||||
|
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
|
||||||
|
> publication, not the build.
|
||||||
|
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
|
||||||
|
> `tea`, watch to green, retrieve the signed APK artifact (verify Gitea 1.26 `upload-artifact@v4` at
|
||||||
|
> the dispatch).
|
||||||
|
> 6. **On-device smoke.** OWNER installs the signed APK and runs the airplane-mode first-launch
|
||||||
|
> checklist (`docs/TESTING.md`). The AGENT can pre-run the emulator smoke.
|
||||||
|
> 7. **Store listing.** Reserve `ru.eruditgame.app`, title «Эрудит», RU description, phone screenshots,
|
||||||
|
> icon / feature graphic, age rating, the **privacy-policy URL `https://erudit-game.ru/privacy/`**
|
||||||
|
> (now available), the content declaration.
|
||||||
|
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
|
||||||
|
> the store deep-link so the update button works on future gated releases (empty until then — the
|
||||||
|
> gate is dormant in the MVP).
|
||||||
|
|
||||||
|
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
|
||||||
|
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
|
||||||
|
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build` → `cap sync` → `./gradlew assembleDebug`
|
||||||
|
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
|
||||||
|
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
|
||||||
|
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
|
||||||
|
**Launcher icon:** the temporary placeholder was **superseded by the icon rebrand** — the launcher
|
||||||
|
now uses the two-layer adaptive icon (background + foreground + monochrome) generated from the brand
|
||||||
|
master; see the RESUME block (item 1), `docs/ICON_BRANDBOOK.md` and `docs/ICONS.md`.
|
||||||
|
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
|
||||||
|
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
|
||||||
|
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
|
||||||
|
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
|
||||||
|
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
|
||||||
|
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
|
||||||
|
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
|
||||||
|
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
|
||||||
|
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
|
||||||
|
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
|
||||||
|
- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
|
||||||
|
(parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
|
||||||
|
gate in `connectsrv` — `Execute` returns `result_code="update_required"` before the registry lookup,
|
||||||
|
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
|
||||||
|
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
|
||||||
|
`UpdateOverlay.svelte` (native → `VITE_RUSTORE_URL`, web → reload), `retry.ts` maps
|
||||||
|
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
|
||||||
|
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
|
||||||
|
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
|
||||||
|
store-update SDK noted Out of scope.
|
||||||
|
- **D. Offline-first — ✅ CODE-COMPLETE, e2e-verified & on-device-smoke-verified (D.1–D.6 done, 2026-07-12).**
|
||||||
|
The offline path is proven on-device (Pixel_10 / API 37, airplane mode): cold-boot → offline guest lobby, a
|
||||||
|
full local vs_ai turn (bundled dawg — Hint placed "FEZ", the robot replied "NEEDFIRE"), New Game offering
|
||||||
|
both modes; the smoke also surfaced + fixed a native-chrome edge-to-edge safe-area bug (below). Remaining for
|
||||||
|
D: the **reconcile-online leg on-device** (deferred — it hits prod, minting a guest) and the **deferred
|
||||||
|
local-game-visibility decision** (below). D.5 is also covered by `e2e/native.spec.ts` (vs_ai move + hotseat).
|
||||||
|
- **D.1 + D.2 (foundations) — ✅ DONE & committed `bcd5a1d` (2026-07-12).** `ui/scripts/bundle-dicts.mjs`
|
||||||
|
(release DAWGs → `dist/dict/<variant>@<version>.dawg`, keyed on `VITE_DICT_VERSION`, `OUT_DIR` override
|
||||||
|
for the e2e); the `dict/loader.ts` **bundled tier** (between IndexedDB and network, **native-gated** —
|
||||||
|
see the §D.1 correction); `__DICT_VERSION__` vite define + declaration; `lib/localguest.ts` (persisted
|
||||||
|
device-local guest id, no DB row) + `common.guest` i18n; `NewGame.svelte` offline vs_ai/hotseat creates
|
||||||
|
fall back to `__DICT_VERSION__` and `localGuestId()`/`t('common.guest')` when there is no session.
|
||||||
|
- **D.3 (cold boot) + D.4 (reconciliation + silent seam) — ✅ DONE & verified (2026-07-12).** The native
|
||||||
|
no-session cold boot lands in the offline lobby as a device-local guest (`app.svelte.ts` bootstrap
|
||||||
|
`else if (native)` branch — `localGuestId()` + `setOfflineMode(true,false)` + `scheduleRecovery(0)`);
|
||||||
|
lazy `reconcileServerGuest()` mints + adopts a server guest and clears the auto-offline when the gateway
|
||||||
|
is reachable (kicked at boot + by the recovery poll + the online event). Silent seam:
|
||||||
|
`transport.ts` `exec` gained `{ silent, allowOffline }` (suppress the update overlay **and** bypass the
|
||||||
|
kill switch), plus `authGuestSilent(locale)` on the `GatewayClient` interface, the real transport and the
|
||||||
|
mock. `native.ts` `initNativeShell` made bridge-tolerant. New `e2e/native.spec.ts` (Capacitor injected;
|
||||||
|
boot→offline-lobby, local vs_ai move, hotseat start, reconcile→online) + `playwright.config.ts` bundles
|
||||||
|
the dawgs into `dist-e2e/dict/`. `svelte-check` 0, `vitest` 591, web + native builds clean; e2e runs in CI.
|
||||||
|
See §D.3/§D.4 for the as-built corrections (shouldBootOffline **not** changed; `allowOffline` +
|
||||||
|
checkReachable-token findings; the `__native.reconcile` e2e hook).
|
||||||
|
- **D.6 (Profile tg/vk hide on native) — ✅ DONE & verified (2026-07-12).** `Profile.svelte` gates
|
||||||
|
`telegramLinkable`/`vkLinkable` on `!nativeShell` (`clientChannel()` android/ios), so the Telegram + VK
|
||||||
|
LINK buttons are hidden on the native build; email + account management (incl. any existing link's UNLINK,
|
||||||
|
a redirect-free gateway call) stay. Covered by the `e2e/native.spec.ts` reconcile test (Profile → no Link
|
||||||
|
Telegram / Link VK buttons, email present).
|
||||||
|
- **Native chrome: Android 15+ edge-to-edge safe-area — ✅ FIXED & on-device-verified (2026-07-12, owner-confirmed).**
|
||||||
|
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system bars; the app chrome overlapped the
|
||||||
|
status bar (top nav, untappable) and the gesture-nav home indicator (the game's centre Hint button — side
|
||||||
|
buttons fine). Two-part CSS fix consuming the SystemBars core plugin's injected `--safe-area-inset-*`:
|
||||||
|
the **bottom** via the `--tg-safe-*` token (`app.css`), the **header top** via a native-only `.bar` rule
|
||||||
|
(`Header.svelte` — its top inset had been Telegram-fullscreen-scoped only, so the token alone didn't reach
|
||||||
|
it). No dep/config (SystemBars ships in `@capacitor/core` v8). Verified on Pixel_10 / API 37 by live-WebView
|
||||||
|
CDP measurement (header title y=11→65, `--safe-area-inset-*` = 54/24px) **and** owner re-test on their device.
|
||||||
|
Full gotcha + CDP recipe in `.claude/CLAUDE.md`. Commits `4a0689a` (bottom) + `49c5379` (top).
|
||||||
|
- **DEFERRED DECISION (owner-agreed 2026-07-12) — native local-game visibility.** The online lobby lists
|
||||||
|
only server games (`Lobby.svelte:42/54`), so a native guest's **device-local vs_ai/hotseat games hide from
|
||||||
|
the lobby once reconciled online** (the pre-existing offline↔online split). For native this IS the primary
|
||||||
|
flow, so the behaviour **must change** — a native guest should still see / resume their local games when
|
||||||
|
online. Owner agreed it needs changing but wants to design it **at the very end of the Android work (before
|
||||||
|
release — see §G)**; out of D.3/D.4 scope, tracked here so it is not lost.
|
||||||
|
- **E. CI — signed APK artifact — ✅ CODE-COMPLETE & locally-proven (2026-07-12).** New manual workflow
|
||||||
|
`.gitea/workflows/android-build.yaml` (`workflow_dispatch` + `confirm=build`, `if` gated to `master` —
|
||||||
|
mirrors `prod-deploy.yaml`; never on a PR) builds the native-flavoured SPA, bundles the offline dicts, and
|
||||||
|
assembles a release APK uploaded as a run artifact. Like `prod-deploy`, it is **provable only by a dispatch
|
||||||
|
from `master`** (that is §G), so E was verified as far as possible now: locally via `assembleDebug` + a
|
||||||
|
**keyless** `assembleRelease` (→ `app-release-unsigned.apk`, `versionCode 1017000` / `versionName 1.17.0`
|
||||||
|
for a `v1.17.0` tag — confirmed in `output-metadata.json`), `svelte-check` 0, YAML structure checked.
|
||||||
|
As-built:
|
||||||
|
- **`ui/android/app/build.gradle`:** `versionCode`/`versionName` read `-PversionCode`/`-PversionName` (defaults
|
||||||
|
keep local `assembleDebug` building). **CORRECTION vs the plan snippet — use `=` assignment**
|
||||||
|
(`versionCode = (…).toInteger()`), NOT the command form `versionCode (…).toInteger()`: the latter binds
|
||||||
|
`.toInteger()` to the DSL setter's null return (`> Value is null`). A guarded `signingConfigs.release` reads
|
||||||
|
the keystore from env and is attached **only when the keystore file exists** — so **no keystore ⇒ an UNSIGNED
|
||||||
|
release APK, not a failure** (a dispatch proves the pipeline before the keystore exists).
|
||||||
|
- **Toolchain:** the **CI runner is a host-executor on a remote Debian host**. JDK 21 comes from
|
||||||
|
`actions/setup-java`; the **Android SDK is host-provisioned** (pre-installed at `ANDROID_SDK_DIR`, default
|
||||||
|
`/opt/android-sdk`), with a fail-fast **Verify the host Android SDK** pre-flight that also checks the `runner`
|
||||||
|
user's read/exec access (`platforms;android-36` + `build-tools`). Unit tests need no new tooling (they ride
|
||||||
|
the existing `unit`/`ui` jobs).
|
||||||
|
- **Env:** `VITE_GATEWAY_URL` reuses `vars.PROD_PUBLIC_BASE_URL` (trailing slash stripped); `VITE_DICT_VERSION` /
|
||||||
|
`DICT_VERSION` = the `DICT_VERSION` var; `VITE_PAYMENTS_DISABLED=1`. **`VITE_STORE_URL` was renamed to
|
||||||
|
`VITE_RUSTORE_URL`** (owner) — left **empty** via the (unset) `vars.ANDROID_RUSTORE_URL`, so the update button
|
||||||
|
no-ops until publication (`UpdateOverlay.svelte` already guards `if (url)`; the gate is dormant in the MVP).
|
||||||
|
- **versionCode from an exact tag:** the workflow refuses anything but a clean `git describe --exact-match`
|
||||||
|
`vX.Y.Z` (deterministic + monotonic store versionCode); §G tags before dispatch.
|
||||||
|
- **Delivery:** `actions/upload-artifact@v4` (assumes Gitea 1.26 artifact support — verify at the §G dispatch).
|
||||||
|
Secrets to set before a **signed** build (§G / publication): `ANDROID_KEYSTORE_BASE64`,
|
||||||
|
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
|
||||||
|
- **F. Docs — ✅ DONE (2026-07-12).** Baked the Android work into the live docs: `docs/ARCHITECTURE.md` §2
|
||||||
|
(the client-version gate + the frozen wire contract + the gate × offline rule), §3 (the local-guest /
|
||||||
|
server-guest / reconciliation identity model) and §13 (the native Capacitor build — bundle model, bundled
|
||||||
|
dicts, `versionCode` scheme, `file://` origin, RuStore); `docs/FUNCTIONAL.md` (+ `_ru` mirror) a new **Native
|
||||||
|
app (Android)** domain (offline-first guest launch, email soft-registration, no TG/VK login, hidden
|
||||||
|
purchases, update-required); `docs/TESTING.md` (the `clientver`/gate Go tests, the `native`/`update` e2e +
|
||||||
|
the `retry` mapping, and the manual **on-device Android smoke checklist**); `deploy/README.md` (the
|
||||||
|
`GATEWAY_MIN_CLIENT_VERSION` var + the wire-break bump discipline + the Android build/release runbook —
|
||||||
|
keystore, secrets, dispatch, RuStore upload); `ui/README.md` (the native `VITE_*` build vars). Every
|
||||||
|
referenced test file was confirmed to exist.
|
||||||
|
- **G — pending; gated on the offline-model redesign (was G-step-0).** The deferred native
|
||||||
|
local-game-visibility decision grew into an owner-approved cross-cutting change (web + native + a small
|
||||||
|
additive backend/wire bit): remove the explicit offline toggle → one `netState` machine, unify the lobby,
|
||||||
|
two-tier version gate. **Designed + staged (O1–O7)** in the "Offline-model redesign" section below.
|
||||||
|
**➡ Next actionable work: implement O1** (the pure `netState` reducer, test-first) via `stage-implementation`,
|
||||||
|
then O2–O7; the release chain (PR→development→contour→master→tag→dispatch `android-build`) follows. None of
|
||||||
|
O1–O7 is started. Branch `feature/android-native`, local/unpushed.
|
||||||
|
|
||||||
|
Icon rebrand — **DONE** (was "one master → every icon"): a single Spectral «Э» master now drives web
|
||||||
|
(favicon light+dark / PWA any + maskable / apple-touch / og), the Capacitor layers and the Android
|
||||||
|
adaptive res (no inset, + monochrome), plus VK/TG/store art. Specced in `docs/ICON_BRANDBOOK.md`,
|
||||||
|
mapped in `docs/ICONS.md`, generated from `assets/icons/brand/`. `~/.claude/bin/gitea-ci-watch.py` is present.
|
||||||
|
RuStore publication prerequisites gate release only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Prerequisites to start DEVELOPMENT (on the other device)
|
## Prerequisites to start DEVELOPMENT (on the other device)
|
||||||
|
|
||||||
Have all of these before writing code:
|
Have all of these before writing code:
|
||||||
|
|
||||||
- **JDK 17** (Temurin/OpenJDK).
|
- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets
|
||||||
|
`sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with
|
||||||
|
`invalid source release: 21`. (Homebrew: `brew install openjdk@21`.)
|
||||||
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
|
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
|
||||||
cmdline-tools. Install: `platform-tools`, `platforms;android-34`, `build-tools;34.0.0`.
|
cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install:
|
||||||
|
`platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as
|
||||||
|
`36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the
|
||||||
|
wrapper `cap add android` generates.
|
||||||
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
|
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
|
||||||
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
|
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
|
||||||
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
|
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
|
||||||
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
|
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
|
||||||
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
|
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
|
||||||
sibling `scrabble-solver` next to it. The sibling holds the committed dictionaries
|
sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
|
||||||
(`scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`) that offline-first bundles, and
|
`go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
|
||||||
the solver library the backend/CI use.
|
production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
|
||||||
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. Pixel,
|
`scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
|
||||||
API 34).
|
job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
|
||||||
|
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
|
||||||
|
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
|
||||||
|
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
|
||||||
|
commit, not the versioned production set.)
|
||||||
|
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
|
||||||
|
Pixel AVD; any recent API image).
|
||||||
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
|
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
|
||||||
is only needed to exercise the version gate locally (optional).
|
is only needed to exercise the version gate locally (optional).
|
||||||
|
|
||||||
@@ -152,7 +366,7 @@ it and stay a local guest (do not overlay). Implementation seam: the reconciliat
|
|||||||
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
|
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
|
||||||
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
|
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
|
||||||
|
|
||||||
### A. Capacitor scaffolding
|
### A. Capacitor scaffolding — ✅ DONE
|
||||||
|
|
||||||
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
|
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
|
||||||
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
|
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
|
||||||
@@ -190,13 +404,13 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
|
|||||||
```
|
```
|
||||||
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
|
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
|
||||||
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
|
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
|
||||||
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
|
- **Launcher icon:** DONE — generated from the brand master into the Capacitor layers
|
||||||
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
|
`ui/assets/{icon,icon-foreground,icon-background}.png` and the Android res; see `docs/ICONS.md`.
|
||||||
|
|
||||||
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
|
||||||
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
|
||||||
|
|
||||||
### B. Native web-build correctness (file:// origin)
|
### B. Native web-build correctness (file:// origin) — ✅ DONE
|
||||||
|
|
||||||
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
1. **One origin helper** — new `ui/src/lib/origin.ts`:
|
||||||
```ts
|
```ts
|
||||||
@@ -228,15 +442,20 @@ is: scaffold → native-correct → gate → offline-first → CI → docs → r
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
|
||||||
hide the buy actions (grep `isGooglePlayBuild` under `ui/src`). Keep the "go to RuStore" stub
|
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
|
||||||
`isGooglePlayBuild()`-only. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
|
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
|
||||||
|
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
|
||||||
|
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
|
||||||
|
same note can replace the RuStore stub in the later Google Play anti-steering variant.
|
||||||
|
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
|
||||||
|
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_RUSTORE_URL?: string`,
|
||||||
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
|
||||||
4. **Native env matrix** — see Build & env.
|
4. **Native env matrix** — see Build & env.
|
||||||
|
|
||||||
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
|
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
|
||||||
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
||||||
|
|
||||||
### C. The client-version gate
|
### C. The client-version gate — ✅ DONE
|
||||||
|
|
||||||
#### C1. Backend (gateway)
|
#### C1. Backend (gateway)
|
||||||
|
|
||||||
@@ -313,96 +532,194 @@ purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
|
|||||||
before the throw.
|
before the throw.
|
||||||
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
|
||||||
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
|
||||||
- **Reconciliation exception:** provide a variant used by background guest-reconciliation that does
|
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
|
||||||
NOT call `reportUpdateRequired()` (it swallows the code and stays offline). Simplest: a boolean
|
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
|
||||||
on `exec`/a dedicated `authGuestSilent` path guarded by a flag; the executing session picks the
|
there with its caller rather than speculatively in C. In C every foreground call that gets
|
||||||
seam. Foreground `auth.*` keep the overlay behaviour.
|
`update_required` raises the overlay; offline play never trips it (the network kill switch).
|
||||||
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
|
||||||
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
|
||||||
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
|
||||||
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
|
||||||
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
`window.open(import.meta.env.VITE_RUSTORE_URL, '_system')`; web → `location.reload()`. i18n keys
|
||||||
`update.title/body/action` (add siblings to the maintenance keys).
|
`update.title/body/action` (add siblings to the maintenance keys).
|
||||||
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
|
||||||
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
|
||||||
- **Tests:** `update.svelte.test.ts`; extend `retry.test.ts` (`FailedPrecondition → 'update_required'`);
|
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
|
||||||
Playwright `update.spec.ts` driving `__update.on()`.
|
Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
|
||||||
|
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
|
||||||
|
`*.svelte.ts` store it is covered by the e2e, not a unit test.
|
||||||
|
|
||||||
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
|
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
|
||||||
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
|
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
|
||||||
|
|
||||||
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation)
|
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — ✅ CODE-COMPLETE
|
||||||
|
|
||||||
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player
|
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player
|
||||||
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
|
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
|
||||||
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
|
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
|
||||||
with no network**.
|
with no network**.
|
||||||
|
|
||||||
1. **Bundle dictionaries in the APK.**
|
**Status:** D.1–D.6 are **done & verified** (foundations committed `bcd5a1d`; boot + reconciliation +
|
||||||
- New build step `ui/scripts/bundle-dicts.mjs`: copy `../scrabble-solver/dawg/{ru_scrabble,ru_erudit,en_sowpods}.dawg`
|
Profile + the Android edge-to-edge safe-area fix committed on `feature/android-native`, 2026-07-12), with
|
||||||
→ `ui/dist/dict/<variant>@<DICT_VERSION>.dawg` (dictKey naming). Run only in the native pipeline
|
`e2e/native.spec.ts` green (chromium + webkit) AND an **on-device emulator smoke** (Pixel_10 / API 37,
|
||||||
(after `pnpm build`, before `cap sync`). Web builds stay slim.
|
airplane mode): cold-boot → offline guest lobby, offline vs_ai played end-to-end (bundled dawg — Hint
|
||||||
- Vite `define`: add `__DICT_VERSION__` from `VITE_DICT_VERSION` (mirrors `__APP_VERSION__`);
|
placed "FEZ", the robot replied "NEEDFIRE"), New Game offers both modes. The smoke surfaced a native-chrome
|
||||||
declare it in `vite-env.d.ts`.
|
bug on **Android 15+ edge-to-edge** (WebView < 140 reports `env(safe-area-inset-*)`=0 → the top nav draws
|
||||||
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: between the IndexedDB tier (line 73-85)
|
under the status bar and the game's centre Hint button under the gesture-nav home indicator) — **fixed** by
|
||||||
and the network tier (line 87), add a **bundled tier** that `fetch('./dict/'+key+'.dawg')`
|
consuming the SystemBars core plugin's injected `--safe-area-inset-*`: the bottom via the `--tg-safe-*` token
|
||||||
(relative — served from app assets on native, 404 on web → falls through). On a hit, build the
|
(`app.css`), and the header's top inset via a native-only `.bar` rule (`Header.svelte` — its top inset was
|
||||||
`Dawg`, cache in memory, seed IndexedDB (`idbPutDawg`), and return. This is the only change that
|
Telegram-fullscreen-scoped only, so the token alone did not reach it). No dep/config (the plugin ships in
|
||||||
lets a never-online device obtain a dictionary.
|
`@capacitor/core` v8); full story in `.claude/CLAUDE.md`. Remaining for D: the reconcile-online leg
|
||||||
- Offline local-game creation must request the **bundled version**: where `NewGame.svelte`
|
on-device (hits prod — a guest mint) and the deferred local-game-visibility decision (§G). **Verify every
|
||||||
offline-create and `localgame` resolve the dict version, use `__DICT_VERSION__` so the requested
|
line ref below against current code.**
|
||||||
`(variant, version)` matches the bundled file (grep the offline `localSource.create` calls in
|
|
||||||
`NewGame.svelte:83-96,289-296` and the dict-version resolution in `gamesource.ts`/`localgame`).
|
|
||||||
2. **Local-guest identity** — new `ui/src/lib/localguest.ts`: a device-local id + default display
|
|
||||||
name (e.g. localized "Гость"), persisted (IndexedDB `scrabble` DB / localStorage). Used as the
|
|
||||||
human seat's account id in a local vs_ai game when there is no server session (today that id comes
|
|
||||||
from the session — see `localgame/source.list.test.ts:64`). Hotseat seats stay independent local
|
|
||||||
identities (already so).
|
|
||||||
3. **Cold offline-first boot** — `ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`:
|
|
||||||
- Extend `shouldBootOffline` (offline.ts:85) so that on a **native** channel, no session may still
|
|
||||||
boot offline as the local guest (dicts are bundled). Keep the current web rule intact (web still
|
|
||||||
needs a prior online session).
|
|
||||||
- In `bootstrap()` (app.svelte.ts): when native and (no cached session) and (offline preference on
|
|
||||||
OR the network is unreachable at boot), set `setOfflineMode(true, …)`, establish the local guest,
|
|
||||||
preload the bundled dicts, and land in the **lobby** (not `Login`). Preserve the existing online
|
|
||||||
path (guest/email) when reachable.
|
|
||||||
- `offlinePreloadEligible` (offline.ts:69): treat a native channel as eligible (drop the
|
|
||||||
`standalone && hasEmail` web gate for native), or bypass preload entirely on native since the
|
|
||||||
dicts are bundled.
|
|
||||||
4. **Lazy server-guest reconciliation** — when native and online and no server session: background
|
|
||||||
`auth.guest` (the silent variant from C2), cache the session, adopt it; unlock online features.
|
|
||||||
Guard against duplicates (only when no cached session). Local games remain device-only. On
|
|
||||||
`update_required`, stay offline silently (no overlay).
|
|
||||||
5. **Ensure both offline modes render for the local guest.** `NewGame.svelte` gates the offline
|
|
||||||
flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
|
|
||||||
the "quick" (vs_ai) and "with friends" (hotseat) flows show. Verify the vs_ai human seat uses the
|
|
||||||
local-guest id.
|
|
||||||
6. **Soft registration** stays: the local/server guest can register by email → upgrade (existing
|
|
||||||
flow), adopting the server guest first if present.
|
|
||||||
|
|
||||||
**Tests:** extend the local-game/offline unit tests for the bundled-dict tier (a `fetch` mock
|
**Decisions locked this session (owner-approved) — bake these before implementing:**
|
||||||
returning a blob) and the local-guest boot decision (`shouldBootOffline` native-no-session); a
|
- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
|
||||||
Playwright offline-first spec that boots with the network hook off and plays a local vs_ai move and
|
online-session rule (they still need a prior session). The native channel always lands the user in the
|
||||||
starts a hotseat game. (Follow `docs/TESTING.md` layers; the mock e2e bypasses the codec, so keep the
|
lobby, online or offline.
|
||||||
gate's server path in Go tests.)
|
- **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it
|
||||||
|
already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D.
|
||||||
|
- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page
|
||||||
|
redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI),
|
||||||
|
and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no
|
||||||
|
redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a
|
||||||
|
**separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram
|
||||||
|
login in the native build".
|
||||||
|
- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest").
|
||||||
|
|
||||||
|
1. **Bundle dictionaries in the APK. ✅ DONE.**
|
||||||
|
- `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release**
|
||||||
|
(dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `<OUT_DIR|dist>/dict/<variant>@<version>.dawg`.
|
||||||
|
`dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps
|
||||||
|
`scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`).
|
||||||
|
Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at
|
||||||
|
`dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip
|
||||||
|
it and stay slim.
|
||||||
|
- Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`)
|
||||||
|
+ declared in `vite-env.d.ts`.
|
||||||
|
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB
|
||||||
|
tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is
|
||||||
|
**native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative
|
||||||
|
`fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route**
|
||||||
|
(a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in
|
||||||
|
a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return
|
||||||
|
(no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests).
|
||||||
|
- Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and
|
||||||
|
hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less
|
||||||
|
local guest gets the bundled `(variant, version)`.
|
||||||
|
2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a
|
||||||
|
device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the
|
||||||
|
old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')`
|
||||||
|
at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat
|
||||||
|
(`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and
|
||||||
|
`name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities
|
||||||
|
(`buildSeats` — unchanged).
|
||||||
|
3. **Cold offline-first boot — ✅ DONE & verified (2026-07-12)** (`ui/src/lib/app.svelte.ts`). **High
|
||||||
|
blast-radius: app startup for every platform — every change is native-gated; web / PWA / TG / VK stay
|
||||||
|
byte-for-byte.** As built:
|
||||||
|
- The blocking Login was **`bootstrap()`'s `else` of `if (saved)`** (no cached session, formerly
|
||||||
|
`navigate('/login')`). On native it is now the offline-first entry: `localGuestId()` +
|
||||||
|
`setOfflineMode(true, false)` (**non-sticky** auto-offline, so reconciliation may clear it) + land in
|
||||||
|
the lobby (`navigate('/')` only if on login/confirm) + `scheduleRecovery(0)` to kick reconciliation.
|
||||||
|
Web keeps the `navigate('/login')` else. `app.ready = true`.
|
||||||
|
- **CORRECTION — `shouldBootOffline` was NOT changed.** The native-no-session case is fully caught in
|
||||||
|
that `else` branch (there `hasSession` is provably false and `saved`'s `if`-block — the web
|
||||||
|
canOffline logic — is skipped), so a `native` param would be unused in the load-bearing path. The
|
||||||
|
plan's "add a native-no-session path to `shouldBootOffline`" is dropped, and the matching unit test
|
||||||
|
with it (the boot decision is exercised by `e2e/native.spec.ts`).
|
||||||
|
- **`offlinePreloadEligible` was NOT changed either** — it already returns false off a standalone web
|
||||||
|
PWA (native is not `isStandalone()`), so the server preload is already skipped on native; no edit needed.
|
||||||
|
- The lobby renders without a session/profile: `Lobby.svelte`/`NewGame.svelte` use optional chaining
|
||||||
|
and the offline branch loads only device-local games (no session-gated `gamesList`). `NewGame` gates
|
||||||
|
its offline flows on `guest || offlineMode.active`, both true for the local guest.
|
||||||
|
4. **Lazy server-guest reconciliation + the silent seam — ✅ DONE & verified (2026-07-12).** As built:
|
||||||
|
- **Silent seam:** `exec` gained `opts?: { silent?: boolean; allowOffline?: boolean }`.
|
||||||
|
**CORRECTION — `silent` alone was not enough:** reconciliation runs while the app is still in
|
||||||
|
auto-offline, so `exec`'s `assertOnline()` kill switch would refuse it — hence **`allowOffline`**
|
||||||
|
(bypass the kill switch, like the reachability probe). `silent` gates the two `exec`
|
||||||
|
`reportUpdateRequired()` sites (result-code + catch); the `subscribe` catch keeps the overlay
|
||||||
|
(reconciliation never subscribes). New `authGuestSilent(locale)` on the `GatewayClient` interface
|
||||||
|
(`lib/client.ts`), the real transport (`{ silent: true, allowOffline: true }`) and the mock.
|
||||||
|
- **Reconciliation — `reconcileServerGuest()` (`app.svelte.ts`):** native + no session → `authGuestSilent`,
|
||||||
|
`setOfflineMode(false)` **before** `adoptSession` (so adopt's `profileGet`/stream ride the online
|
||||||
|
transport, not the kill switch), then adopt. Guarded by `reconcileInFlight` + `app.session` (never a
|
||||||
|
second guest). `update_required`/unreachable are swallowed (stay a local guest). Local games stay
|
||||||
|
device-only. Fired at boot and by the recovery poll + the online event.
|
||||||
|
- **CORRECTION — `checkReachable` cannot gate a session-less guest:** its authenticated probe rejects
|
||||||
|
with no token, so it never reports "reachable" for a local guest. The recovery poll (`scheduleRecovery`)
|
||||||
|
therefore routes the **no-session** case straight through `reconcileServerGuest` (the mint attempt IS
|
||||||
|
the reachability check); the session case keeps `checkReachable`.
|
||||||
|
- **`scheduleRecovery` is now mock-guarded** (like `initNetworkReactivity`) so the e2e's boot does not
|
||||||
|
race a mock reconcile to online before the spec observes the offline lobby; the e2e drives
|
||||||
|
reconciliation via a new **`window.__native.reconcile()`** hook (mirrors `__conn`/`__maint`/`__update`).
|
||||||
|
5. **Both offline modes render for the local guest — ✅ DONE & verified (2026-07-12).** `NewGame` gates the
|
||||||
|
offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
|
||||||
|
"quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id
|
||||||
|
(D.2). Verified: `e2e/native.spec.ts` plays a vs_ai move + starts a hotseat game, and the on-device smoke
|
||||||
|
(Pixel_10 / API 37) played a full offline vs_ai turn (Hint "FEZ", robot "NEEDFIRE") and reached New Game
|
||||||
|
offering both modes.
|
||||||
|
6. **Soft registration = reuse the `Profile` screen — ✅ DONE & verified (2026-07-12).** As built:
|
||||||
|
- **Reaching Profile needed no change:** guests keep the Profile tab (`SettingsHub.svelte:27` only hides
|
||||||
|
friends/wallet for guests; `:33` hides profile/friends/wallet in **offline** mode), so once the native
|
||||||
|
guest is online and reconciled (D.4 clears the auto-offline) Profile shows and email sign-in works.
|
||||||
|
Offline you cannot register anyway (every method needs the server), so the offline-hidden behaviour is
|
||||||
|
correct.
|
||||||
|
- **Hid tg/vk on native:** `Profile.svelte` gates `telegramLinkable = loginWidgetAvailable() && !nativeShell`
|
||||||
|
and `vkLinkable = vkWebLinkAvailable() && !nativeShell` (`nativeShell = clientChannel()` android/ios), so
|
||||||
|
both LINK buttons are hidden on native while email + account management (and an existing link's UNLINK — a
|
||||||
|
redirect-free `link.unlink` call) stay. Verified in `e2e/native.spec.ts` (the reconcile test opens Profile
|
||||||
|
and asserts no Link Telegram / Link VK, email present; `loginWidgetAvailable`/`vkWebLinkAvailable` are true
|
||||||
|
under the mock, so the buttons show on web — the native count-0 proves the gate).
|
||||||
|
|
||||||
|
**Tests — ✅ DONE for D.3/D.4 (2026-07-12):**
|
||||||
|
- **Unit (vitest, node):** no NEW unit test was added for D.3/D.4 — the boot decision, reconciliation and
|
||||||
|
silent seam live in `$state`/transport modules the plugin-less vitest cannot import (cf. `update.svelte.ts`),
|
||||||
|
and the `shouldBootOffline` native path was dropped (§D.3). `localguest` + the bundled-dict tier are D.1/D.2
|
||||||
|
coverage (a bundled-tier `fetch`-mock unit test remains a D-close nicety if wanted). `vitest` stays 591.
|
||||||
|
- **Playwright — new `e2e/native.spec.ts`** (NOT an extension of `offline.spec`): **GOTCHA — inject
|
||||||
|
`window.androidBridge`, NOT `window.Capacitor.getPlatform`.** `@capacitor/core` (loaded during boot by
|
||||||
|
`initNativeShell`) derives the platform from `window.androidBridge` / `window.webkit` and **replaces** any
|
||||||
|
pre-set `window.Capacitor` with its own shim whose `getPlatform()` is `web` — so a bare injected
|
||||||
|
`window.Capacitor = { getPlatform: () => 'android' }` is clobbered and the boot falls to `/login`.
|
||||||
|
`simulateNative` injects `window.androidBridge = { postMessage(){} }` (+ a Capacitor stub for the pre-core
|
||||||
|
window), which makes `clientChannel()` resolve to `android`. Two tests: (1) boot → **assert the offline guest
|
||||||
|
lobby (not login)**, play a local vs_ai move (bundled dawg), Back, `window.__native.reconcile()` → assert
|
||||||
|
**online lights up** (offline tint gone, the seeded online game appears, Stats re-enabled); (2) boot → start a
|
||||||
|
2-player hotseat game. The `playwright.config.ts` webServer now also runs `DICT_DIR="${E2E_DICT_DIR:-…}"
|
||||||
|
OUT_DIR=dist-e2e node scripts/bundle-dicts.mjs` → `dist-e2e/dict/<variant>@<version>.dawg` (version flows from
|
||||||
|
`VITE_DICT_VERSION`, default `dev`, matching `__DICT_VERSION__`). **Ran locally green (chromium + webkit, 4/4)
|
||||||
|
against the installed browsers + the sibling `scrabble-solver/dawg`; also runs in CI.**
|
||||||
|
- **GOTCHA resolved:** `initNativeShell` was made **bridge-tolerant** (`try/catch` around the `@capacitor/app`
|
||||||
|
import + `addListener`) rather than stubbing a fake Capacitor bridge — cleaner and defensive for a real WebView
|
||||||
|
that lacks the App plugin.
|
||||||
|
|
||||||
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
|
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
|
||||||
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
|
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
|
||||||
silently establishes a server guest and lights up online play.
|
silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md`
|
||||||
|
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
|
||||||
|
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
|
||||||
|
|
||||||
### E. CI — signed APK artifact
|
### E. CI — signed APK artifact — ✅ CODE-COMPLETE (workflow written + `build.gradle` wired + locally proven; the signed dispatch is §G)
|
||||||
|
|
||||||
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
|
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
|
||||||
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
|
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
|
||||||
1. Checkout this repo **and the sibling `scrabble-solver`** — copy the checkout + Node/pnpm setup
|
1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
|
||||||
block from `.gitea/workflows/ci.yaml` (the `ui` job). The sibling is needed for the bundled dicts.
|
Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl …
|
||||||
|
scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` +
|
||||||
|
untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not
|
||||||
|
the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed
|
||||||
|
by the Android build: `ui` is a Node project outside `go.work`.)
|
||||||
2. `pnpm install --frozen-lockfile`.
|
2. `pnpm install --frozen-lockfile`.
|
||||||
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
|
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
|
||||||
`VITE_DICT_VERSION` = the release dict version.
|
`VITE_DICT_VERSION` = the release dict version.
|
||||||
4. `node ui/scripts/bundle-dicts.mjs` (copy the `.dawg` into `ui/dist/dict/`).
|
4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
|
||||||
5. Setup JDK 17 (`actions/setup-java@v4`, temurin 17).
|
files into `ui/dist/dict/`).
|
||||||
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-34`,
|
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
|
||||||
`build-tools;34.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
|
||||||
|
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
|
||||||
|
*As built:* the runner is a **host-executor** on a remote Debian host, so the **SDK is host-provisioned**
|
||||||
|
(`ANDROID_SDK_DIR` var, default `/opt/android-sdk`) and a **Verify the host Android SDK** step fail-fasts on a
|
||||||
|
missing package / no runner-user access; JDK 21 still comes from `actions/setup-java`.
|
||||||
7. `npx cap sync android`.
|
7. `npx cap sync android`.
|
||||||
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
|
||||||
tag (scheme below).
|
tag (scheme below).
|
||||||
@@ -417,11 +734,14 @@ for the MVP.
|
|||||||
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
|
||||||
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
|
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
|
||||||
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
Wire in `ui/android/app/build.gradle` `defaultConfig`:
|
||||||
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
|
`versionCode = (project.findProperty('versionCode') ?: '1').toInteger()`,
|
||||||
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
|
`versionName = (project.findProperty('versionName') ?: '0.0.0').toString()`, plus a guarded
|
||||||
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
`signingConfigs.release` reading env/props. **Use `=` assignment, not the command form**
|
||||||
|
`versionCode (…).toInteger()` — the latter binds `.toInteger()` to the DSL setter's null return
|
||||||
|
(`> Value is null`). The signing block attaches only when `ANDROID_KEYSTORE_FILE` exists, so a keyless
|
||||||
|
`assembleRelease` (and every `assembleDebug`) builds UNSIGNED instead of failing.
|
||||||
|
|
||||||
### F. Docs (bake in the same PR)
|
### F. Docs (bake in the same PR) — ✅ DONE
|
||||||
|
|
||||||
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
|
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
|
||||||
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
|
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
|
||||||
@@ -434,14 +754,31 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
|||||||
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
|
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
|
||||||
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
|
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
|
||||||
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
|
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
|
||||||
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
|
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_RUSTORE_URL`,
|
||||||
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
|
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value. Gitea CI vars/secrets
|
||||||
|
for the APK build: `ANDROID_RUSTORE_URL` (var, empty until publish), `PROD_PUBLIC_BASE_URL` (reused as the
|
||||||
|
gateway origin), and the release-signing secrets `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` /
|
||||||
|
`ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`.
|
||||||
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
|
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
|
||||||
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
|
||||||
ships an incompatible wire change.
|
ships an incompatible wire change.
|
||||||
|
|
||||||
### G. Release + owner handoff
|
### G. Release + owner handoff
|
||||||
|
|
||||||
|
> **Status 2026-07-13 — see the ▶ RESUME block at the top of §Progress.** Since this chain was
|
||||||
|
> written, a **legal-documents track** was added and merged (PR #253 — the RuStore hosted
|
||||||
|
> privacy-policy URL and the `/privacy/ /eula/ /offer/` pages), and a **release-prep hygiene** patch is
|
||||||
|
> in flight (PR #254, open), and the **icon rebrand is DONE** (brand master → full set; see the RESUME
|
||||||
|
> block). The remaining gates are the **keystore** and the **RuStore account** (owner-side). The steps
|
||||||
|
> below are the original release chain; the resume
|
||||||
|
> block supersedes them with the current, exhaustive state.
|
||||||
|
|
||||||
|
0. **Land the offline-model redesign — ✅ DONE (O1–O7, 2026-07-13; staged detail below).** Resolved the deferred
|
||||||
|
native local-game-visibility decision — a native guest now sees / resumes their device-local games once online
|
||||||
|
(the **unified lobby**, O5) — as part of the owner-approved change: the explicit offline toggle is gone, offline
|
||||||
|
is an implicit **net-state machine** (O1/O2), the lobby is unified (O5), and the **two-tier** version gate is in
|
||||||
|
(O4). Cross-cutting (web + native + a small additive backend/wire bit); **contour-safe** (both version vars empty
|
||||||
|
⇒ dormant; the wire add is additive). The remaining G steps below are the release chain.
|
||||||
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
|
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
|
||||||
2. Promote `development` → `master`; tag `vX.Y.0`.
|
2. Promote `development` → `master`; tag `vX.Y.0`.
|
||||||
3. Dispatch `android-build`; watch green; retrieve the signed APK.
|
3. Dispatch `android-build`; watch green; retrieve the signed APK.
|
||||||
@@ -450,6 +787,324 @@ reading env/props (guarded so a local `assembleDebug` still builds unsigned).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Offline-model redesign (design — resolves G-step-0)
|
||||||
|
|
||||||
|
> Owner-approved design (brainstormed 2026-07-12). Cross-cutting web + native + a small **additive**
|
||||||
|
> backend/wire bit. This is the Android release's G-step-0 and its own staged work; `writing-plans` turns it
|
||||||
|
> into the implementation plan. English throughout; the FBS change is additive-only (§frozen contract).
|
||||||
|
|
||||||
|
### Goal & locked decisions
|
||||||
|
|
||||||
|
Remove the explicit online/offline toggle. Offline becomes an **implicit, detected** state driven by a single
|
||||||
|
**net-state machine**. vs_ai stays as-is (offline → local, online → server; the app decides by the detected
|
||||||
|
state). The lobby becomes **unified**. The version gate gains a **soft** tier and its hard tier degrades to
|
||||||
|
offline instead of a terminal lockout. TG/VK stay **always-online**. Client-only except the additive
|
||||||
|
soft-tier threshold + signal.
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Offline trigger | Implicit — detected connectivity + version, **no user toggle** |
|
||||||
|
| State model | **Single net-state machine** (replaces the two-layer `connection` / `offlineMode` split) |
|
||||||
|
| Switch UX | **Auto + a toast**, no dialog; hysteresis against flap; self-heal to online |
|
||||||
|
| Server vs_ai | **Kept** — offline → local, online → server; identical create flow, app decides by state |
|
||||||
|
| With-friends create | Explicit online/offline choice; offline **disabled (greyed)** when no network; create always works (→ local hotseat) |
|
||||||
|
| Lobby offline | Unified; server games **greyed from the last cache**, local games active |
|
||||||
|
| Version — critical (hard) | Terminal lockout → now a **notice "Update / Play offline"**, then forced offline |
|
||||||
|
| Version — recommended (soft) | **Built now** — notify but still play online (`GATEWAY_RECOMMENDED_CLIENT_VERSION` + an additive signal) |
|
||||||
|
| TG/VK | **Always online** (exempt); no local lobby / offline states |
|
||||||
|
| Native net signal | Add **`@capacitor/network`** as a hint; web keeps `navigator` + gateway probes |
|
||||||
|
| Migration | Clear the persisted `offlinePref` (nobody stuck without a toggle) |
|
||||||
|
|
||||||
|
### The net-state machine
|
||||||
|
|
||||||
|
One reactive `netState` absorbs `connection.svelte.ts` + `offline.svelte.ts`; the ~14 readers of
|
||||||
|
`offlineMode.active` migrate to a derived `offline` boolean and the "Connecting…" readers to `connecting`.
|
||||||
|
Written as a pure reducer (reusable for a future iOS shell).
|
||||||
|
|
||||||
|
**States**
|
||||||
|
- `online` — gateway reachable, version accepted. Full features. May carry an orthogonal dismissible
|
||||||
|
**`updateRecommended`** nudge (soft tier); play continues.
|
||||||
|
- `connecting` — a call/probe is currently failing but the hysteresis window has not elapsed. "Connecting…";
|
||||||
|
chrome stays online. Transient — the anti-flap buffer. **Not** offline (no kill-switch).
|
||||||
|
- `offlineNoNetwork` — sustained unreachable (hysteresis exceeded). Offline mode (blue chrome, local-only
|
||||||
|
active lobby, transport kill-switch). Self-heals.
|
||||||
|
- `offlineVersionLocked` — gateway reachable but version < min (critical). Offline mode + the
|
||||||
|
"Update / Play offline" notice. **Sticky** — exits only via an app update (fresh version next boot).
|
||||||
|
|
||||||
|
`offline` (kill-switch / blue chrome / local play) = `offlineNoNetwork || offlineVersionLocked`.
|
||||||
|
|
||||||
|
**Events:** `callFailed` · `callOk`/`probeOk` · `probeFailed` · `osOffline`/`osOnline` (navigator /
|
||||||
|
`@capacitor/network` hints) · `versionRejected` (update_required) · `versionRecommended` (soft) · `boot`.
|
||||||
|
|
||||||
|
**Transitions**
|
||||||
|
| From | Event | To | Side-effect |
|
||||||
|
|---|---|---|---|
|
||||||
|
| online | callFailed / osOffline | connecting | start hysteresis (probe + timer) |
|
||||||
|
| connecting | probeOk / callOk | online | — |
|
||||||
|
| connecting | hysteresis exceeded (K fails / `OFFLINE_DEBOUNCE_MS`) | offlineNoNetwork | toast "offline" |
|
||||||
|
| offlineNoNetwork | osOnline | (probe) | trigger a probe; stay until it wins |
|
||||||
|
| offlineNoNetwork | probeOk / callOk | online | toast "back online" |
|
||||||
|
| **any** | versionRejected | offlineVersionLocked | show the Update/Offline notice (supersedes the soft nudge) |
|
||||||
|
| offlineVersionLocked | probe (still rejected) | offlineVersionLocked | stays; only an app update exits |
|
||||||
|
| online | versionRecommended | online (+ nudge) | dismissible "update available" banner |
|
||||||
|
| boot (no net) | — | connecting → offlineNoNetwork | offline-first |
|
||||||
|
| boot (version too old) | versionRejected | offlineVersionLocked | notice |
|
||||||
|
|
||||||
|
**Hysteresis / anti-flap (explicit):** `online → offline` only after the probe fails **and** (K consecutive
|
||||||
|
failures **or** `connecting` held ≥ `OFFLINE_DEBOUNCE_MS`); a single blip lives entirely in `connecting`
|
||||||
|
(spinner only). Recovery to `online` is immediate on the first `probeOk`. `osOnline` never flips to online by
|
||||||
|
itself — it only **triggers** a probe (navigator / `@capacitor/network` can lie; the probe decides). Reuses
|
||||||
|
the existing probe watcher + backoff.
|
||||||
|
|
||||||
|
**Session-less guest (from D):** the probe is authenticated, so a not-yet-reconciled local guest cannot probe;
|
||||||
|
there the **reconcile attempt (`auth.guest`) IS the connectivity signal** — success → online + adopt;
|
||||||
|
failure / `update_required` → stays offline (swallowed, as D already does).
|
||||||
|
|
||||||
|
### Edge cases (each gets a test)
|
||||||
|
1. Rapid flap (fail→ok inside the window) → never leaves `connecting`; no chrome thrash.
|
||||||
|
2. Cold boot, no network → `offlineNoNetwork`, offline-first lobby.
|
||||||
|
3. Cold boot, gateway up but version < min → `offlineVersionLocked` + notice.
|
||||||
|
4. Cold boot, session-less guest → reconcile = probe; ok→online, fail→offline.
|
||||||
|
5. Mid-session min-version bump → next call `versionRejected` → `offlineVersionLocked` mid-play.
|
||||||
|
6. Captive portal: `osOnline` fires but the gateway is down → probe fails → stays offline.
|
||||||
|
7. Recovery race: `probeOk` + a queued `callOk` together → one idempotent transition to online.
|
||||||
|
8. Soft nudge, then a hard reject → escalate to `offlineVersionLocked` (notice supersedes the banner).
|
||||||
|
9. `offlineVersionLocked` on plain web with no cached dicts → offline but create disabled → the notice is the only action.
|
||||||
|
10. Offline → create local vs_ai → back online → the local game persists and shows in the unified lobby.
|
||||||
|
11. Persisted stale `offlinePref` on upgrade → ignored/cleared; never stuck offline.
|
||||||
|
12. TG/VK → the machine is inert (always `online`); no offline states, no local lobby.
|
||||||
|
|
||||||
|
### Version gate — two tiers (backend + wire, additive)
|
||||||
|
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`** (exists): `version < min` → `update_required` (Execute
|
||||||
|
result_code / Subscribe `FailedPrecondition`). Client reaction **changes**: no terminal overlay →
|
||||||
|
`offlineVersionLocked` + a **notice** with "Update" (native → `VITE_RUSTORE_URL`, web → reload) and
|
||||||
|
"Play offline" (dismiss → stay offline). Background reconcile stays silent (D).
|
||||||
|
- **Soft (recommended) — NEW `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` → a
|
||||||
|
**non-blocking** signal. Wire: **additive** — a **response header** `X-Update-Recommended: 1` on gated
|
||||||
|
responses (headers are the version-tolerant layer, like `X-Client-Version`); the call still succeeds and
|
||||||
|
never carries a breaking payload change. Client shows a **dismissible** "update available" banner and plays
|
||||||
|
online. Empty ⇒ soft tier off.
|
||||||
|
- Config: `recommended` must be ≥ `min` (validated at load); both empty ⇒ the gate is fully dormant (web
|
||||||
|
unchanged). Frozen-contract compliance: the soft signal is additive/trailing; the `update_required`
|
||||||
|
sentinel is unchanged.
|
||||||
|
|
||||||
|
### Unified lobby
|
||||||
|
Merge `localSource.list()` + `gateway.gamesList()`. Offline: local games active + **server games greyed from
|
||||||
|
the last `lobbycache` snapshot** (un-openable, an "offline" hint). Identity: recognise **both** the
|
||||||
|
`server user id` and the `localGuestId` as "you" (they differ after reconciliation) for turn / medal logic.
|
||||||
|
One `lobbysort`; ids never collide. Removes the `loadSeq` mode-exclusive branch — both sources feed one list.
|
||||||
|
TG/VK: server-only (unchanged).
|
||||||
|
|
||||||
|
### Create flows
|
||||||
|
- **vs_ai (quick):** unchanged logic — offline → `localSource.create`, online → `lobbyEnqueue` — driven by
|
||||||
|
`netState`. **Dict guard:** if the variant's dawg is unavailable offline, disable create with a reason.
|
||||||
|
- **With-friends:** a new **online/offline segmented control** (online = friend invite, offline = hotseat;
|
||||||
|
both forms exist). No network → the online segment is disabled ("needs network"), offline preselected.
|
||||||
|
|
||||||
|
### Settings / chrome / migration / platform
|
||||||
|
Remove the offline-toggle section from `Settings.svelte` (+ `requestOffline`, the `offlineEligible` gate);
|
||||||
|
`SettingsHub` keeps disabling Profile/Friends/Wallet while offline (now from `netState`). On upgrade,
|
||||||
|
ignore/clear the persisted `offlinePref` (never strand a deliberate-offline user). Implicit offline applies to
|
||||||
|
**native (always) + web (plain tab + PWA)**; **TG/VK exempt**. Plain-web offline is best-effort (local play
|
||||||
|
only for variants whose dawg is cached). No server / data migration (server vs_ai + all server games
|
||||||
|
untouched).
|
||||||
|
|
||||||
|
### Test matrix (exhaustive — owner requirement)
|
||||||
|
- **vitest (pure):** the net-state machine as a pure reducer — **every** transition, the hysteresis/debounce
|
||||||
|
(blip vs sustained), the session-less-guest reconcile-as-probe branch, the two-tier version decision
|
||||||
|
(min / recommended ordering + all three bands), the dict guard, the identity "self" test (both ids). Every
|
||||||
|
edge case #1–#12 gets a case.
|
||||||
|
- **Go:** `clientver`/config extended for `GATEWAY_RECOMMENDED_CLIENT_VERSION` (parse + ordering validation);
|
||||||
|
`connectsrv` for the soft signal (`v < min` ⇒ hard, `min ≤ v < recommended` ⇒ soft, `v ≥ recommended` ⇒
|
||||||
|
none, absent/garbled ⇒ fail-open).
|
||||||
|
- **Playwright (mock):** auto online→offline (toast) + self-heal; `offlineVersionLocked` notice → "Play
|
||||||
|
offline" → local play; unified lobby (greyed server games offline; both active online); with-friends toggle
|
||||||
|
(online disabled offline); the soft nudge banner; update `native.spec.ts`.
|
||||||
|
- **Docs (revise the F bake):** ARCHITECTURE §2 gate → two tiers + graceful degrade; §3 offline model;
|
||||||
|
FUNCTIONAL (+`_ru`) offline + "update required"; TESTING; deploy/README the two version vars.
|
||||||
|
|
||||||
|
### Scope / sequencing
|
||||||
|
Client refactor + a small **additive** backend/wire bit; **contour-safe** (both version vars empty ⇒ dormant;
|
||||||
|
the wire add is additive). It is **G-step-0** and gates the Android release. After it lands: the normal G
|
||||||
|
chain (PR → development → contour verify → master → tag → dispatch `android-build`).
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
Dropping server vs_ai (kept). TG/VK offline. In-app store-update SDK. The iOS shell (the machine is written to
|
||||||
|
be reusable for it; iOS is not built here).
|
||||||
|
|
||||||
|
### Implementation stages
|
||||||
|
|
||||||
|
Executed via `stage-implementation` (interview on every fork, tests at each layer, bake docs). Ordered so each
|
||||||
|
stage leaves the app working and independently testable. **TDD:** the pure machine (O1) is written test-first.
|
||||||
|
Standing constraints (from the design): one `netState` machine; hysteresis explicit; **server vs_ai
|
||||||
|
untouched**; wire changes **additive-only**; **TG/VK inert** (always online); **contour-safe** (both version
|
||||||
|
vars empty ⇒ dormant). Minimal-diff: keep `connection`/`offline` module paths as thin **derived shims** over
|
||||||
|
`netState` so the ~14 `offlineMode.active` consumers are not mass-renamed (single source of truth underneath).
|
||||||
|
|
||||||
|
- **O1 — Pure net-state reducer + exhaustive unit tests (test-first). — ✅ DONE (2026-07-12).**
|
||||||
|
- Files — Create: `ui/src/lib/netstate.ts`, `ui/src/lib/netstate.test.ts`.
|
||||||
|
- Interfaces (as built) — Produces: `type NetState = 'online'|'connecting'|'offlineNoNetwork'|'offlineVersionLocked'`;
|
||||||
|
`type NetEvent` (`boot`/`callOk`/`callFailed`/`probeOk`/`probeFailed`/`osOnline`/`osOffline`/`versionRejected`/
|
||||||
|
`versionRecommended`); `reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig) => NetResult` where
|
||||||
|
`NetInput = { type: NetEvent; at: number }` (**time-on-the-event** — owner-chosen option A: the reducer stays
|
||||||
|
a pure function of `(state, event)` and decides *both* hysteresis branches; O2 stamps `Date.now()`),
|
||||||
|
`NetSnapshot = { state; fails; connectingSince }` (the hysteresis bookkeeping is carried in the reduced value
|
||||||
|
so `reduce` is pure yet the whole anti-flap is unit-tested), `NetResult = { next: NetSnapshot; effects: Effect[] }`,
|
||||||
|
`Effect = {kind:'toast',toast:'offline'|'online'} | {kind:'startProbe'} | {kind:'showVersionNotice'} | {kind:'setNudge'}`,
|
||||||
|
`NetConfig = { k; debounceMs }` (K + `OFFLINE_DEBOUNCE_MS`), plus `INITIAL` (optimistic pre-boot `online`).
|
||||||
|
Consumes: nothing (pure). **As-built semantics:** entry via `callFailed`/`probeFailed` counts as `fails 1`;
|
||||||
|
`osOffline` is a hint (enters `connecting` with `fails 0`, only `startProbe`) — so a single blip stays `connecting`
|
||||||
|
for K≥2; `boot` resets from any state (the sole exit from the sticky lock) → `connecting` + `startProbe`;
|
||||||
|
`offlineVersionLocked` is sticky (no connectivity event exits it, notice shown once on entry); the soft nudge is
|
||||||
|
**effect-only** (`setNudge` from `online`), latched later in O4, not stored in the snapshot.
|
||||||
|
- Acceptance: every transition-table row + all 12 edge cases pass as pure `reduce` assertions; the blip vs
|
||||||
|
sustained hysteresis (a single fail→ok stays `connecting`; K/debounce → `offlineNoNetwork`); `versionRejected`
|
||||||
|
from **any** state → `offlineVersionLocked`; `versionRecommended` sets the nudge only from `online`.
|
||||||
|
- Targeted tests: `netstate.test.ts` (vitest, node — pure, no jsdom). **31 assertions, green** (transition table,
|
||||||
|
hysteresis blip/K/debounce, two-tier version gate, edge cases #1–#12, purity).
|
||||||
|
|
||||||
|
- **O2 — Runtime store + wire the events; migrate the two modules. — ✅ DONE (2026-07-12).**
|
||||||
|
- Files (as built) — Create: `ui/src/lib/netstate.svelte.ts` (the `$state` store running `reduce`; the derived
|
||||||
|
`state`/`online`/`connecting`/`offline`/`versionLocked` getters; one self-rescheduling probe/recovery watcher —
|
||||||
|
backoff while `connecting`, a 4 s poll while `offlineNoNetwork`, idle otherwise; the OS-signal wiring). Modify:
|
||||||
|
`ui/src/lib/connection.svelte.ts` + `ui/src/lib/offline.svelte.ts` (thin shims over `netState` — `connection.online`
|
||||||
|
= the online state, `offlineMode.active` = the machine `offline`; `reportOffline`→`callFailed` **once** from online,
|
||||||
|
`reportOnline`→`callOk`), `ui/src/lib/update.svelte.ts` (`reportUpdateRequired` also emits `versionRejected`),
|
||||||
|
`ui/src/lib/native.ts` (+`initNativeNetwork` — `@capacitor/network` → `osOnline`/`osOffline`),
|
||||||
|
`ui/src/lib/app.svelte.ts` (the boot/recovery-seam rewrite), `ui/src/lib/gateway.ts` (the mock `__net` hook),
|
||||||
|
`ui/src/App.svelte` (dialog removal), `ui/src/lib/i18n/{en,ru}.ts` (`net.offline`/`net.online` toast), `ui/package.json`
|
||||||
|
(+`@capacitor/network@8.0.1`). **Owner-confirmed scope A (full migration):** the store subsumes the old
|
||||||
|
`scheduleRecovery` poll + `initNetworkReactivity` listeners + the cold-boot `promptOfflineChoice` **dialog** (removed —
|
||||||
|
"auto + toast, no dialog"); the native offline-first boot + the web unreachable-cached boot now enter via `bootOffline()`.
|
||||||
|
- Interfaces (as built) — Produces: `netState` (`.state`/`.online`/`.connecting`/`.offline`/`.versionLocked`),
|
||||||
|
`emit(type)` (stamps `Date.now()`), `bootOffline(kickNow?)`, `checkReachable`, `resetNetState`, `initNetSignals`, and a
|
||||||
|
**register-hook inversion** so the store stays a leaf module (no cycle with `app.svelte.ts`): `registerProbe`
|
||||||
|
(transport's reachability read), `registerRecovery` (the app's smart reconcile-or-reachability), `registerNetToast`
|
||||||
|
(i18n toast). `NetConfig = { k: 3, debounceMs: 15000 }` (owner-confirmed). **Wiring:** `reportOffline` enters
|
||||||
|
`connecting` once (only from online) and the **probe decides** offline via K/debounce; the session-less native
|
||||||
|
reconcile IS the probe (`recover()` → `reconcileServerGuest` → `emit('callOk')` before adopt). **`boot` is not
|
||||||
|
emitted** — a real relaunch reloads the bundle to `INITIAL` online, which also clears the sticky version lock; O1's
|
||||||
|
`boot` handling stays valid but unused here. **TG/VK inertness falls out for free:** the registrations run only past
|
||||||
|
the Telegram/VK early-returns in `bootstrap`, so those channels are never fed an offline signal (they can show
|
||||||
|
`connecting` but never reach `offline`).
|
||||||
|
- Acceptance: airplane-mode → `offlineNoNetwork` → back → `online` via the machine; native uses `@capacitor/network`,
|
||||||
|
web `navigator`; the derived `offline` matches the old `offlineMode.active` for every consumer (no regression). **Verified.**
|
||||||
|
- Targeted tests: `e2e/offline.spec.ts` **rewritten** (implicit offline via the mock `__net` hook — no toggle — + the
|
||||||
|
toast + local play + persist + self-heal), `e2e/hotseat.spec.ts` migrated off the toggle, `e2e/native.spec.ts` green
|
||||||
|
(the native boot/offline/reconcile IS the store's e2e). **Full suite 119/119 on chromium + webkit; svelte-check 0/0;
|
||||||
|
vitest 622; `cap sync` registers `@capacitor/network`.**
|
||||||
|
|
||||||
|
- **O3 — Remove the explicit offline toggle + migrate the pref. — ✅ DONE (2026-07-12).**
|
||||||
|
- Files (as built) — Modify: `ui/src/screens/Settings.svelte` (deleted the whole "Play mode" Online/Offline segment +
|
||||||
|
`offlineEligible` + `goOffline` + the `checking`/`needsData` state + the now-unused `isStandalone`/`insideVK`/offline
|
||||||
|
imports + the `.onote` CSS), `ui/src/lib/offline.svelte.ts` (dropped the inert `setOfflineMode`/`requestOffline`/
|
||||||
|
`TOGGLE_READY_BUDGET_MS` stubs + the dead `offlineMode.auto` getter — `offlineMode.active` = `netState.offline` stays),
|
||||||
|
`ui/src/lib/offline.ts` (dropped `loadOfflinePref`/`saveOfflinePref`/`shouldBootOffline`/`offlineReady`/`missingDicts`/
|
||||||
|
`raceOfflineReady`; **new** `clearOfflinePref`; only `offlinePreloadEligible` + the cleanup remain), `ui/src/lib/app.svelte.ts`
|
||||||
|
(calls `clearOfflinePref()` once at boot, before the Mini-App branches), `ui/src/lib/i18n/{en,ru}.ts` (pruned 7 orphaned
|
||||||
|
keys each: `settings.{offlineMode,online,offlineChecking,offlineNeedsData}` + `offline.prompt{Title,Yes,No}`). **Deleted**
|
||||||
|
`ui/src/lib/dict/offlineready.ts` (orphaned once `requestOffline` went). Tests: `ui/src/lib/offline.test.ts` slimmed to
|
||||||
|
`offlinePreloadEligible` + `clearOfflinePref`; `ui/e2e/offline.spec.ts` +1 case.
|
||||||
|
- **Kept, not changed:** `settings.offline` (the Header offline chip still uses it) + `offline.preloadWarning` (the lobby
|
||||||
|
dict-preload notice). **`SettingsHub.svelte` needed no change** — its tab-disable already reads `offlineMode.active`,
|
||||||
|
which is `netState.offline` (the O2 shim); the plan's "tab-disable from `netState`" is satisfied without a mass-rename.
|
||||||
|
- Acceptance: no toggle in Settings; a seeded stale `offlinePref` boots **online** (nobody stuck) and is cleared; offline
|
||||||
|
chrome/tab-gating still driven from `netState`. **Verified.**
|
||||||
|
- Targeted tests: `e2e/offline.spec.ts` new case (a stale `scrabble.offlineMode` → boots online, key cleared, Settings has
|
||||||
|
no `Offline` toggle even in the eligible context); `offline.test.ts` (`clearOfflinePref`). **Full suite 120/120 on
|
||||||
|
chromium + webkit; svelte-check 0/0; vitest 616.**
|
||||||
|
|
||||||
|
- **O4 — Two-tier version gate. — ✅ DONE (2026-07-13).**
|
||||||
|
- Files (as built) — Backend: `gateway/internal/config/config.go` (+`RecommendedClientVersion` from
|
||||||
|
`GATEWAY_RECOMMENDED_CLIENT_VERSION`; validate parseable + `≥ MinClientVersion`, an empty min imposing no lower bound),
|
||||||
|
`gateway/internal/connectsrv/server.go` (soft-tier fields `recClient`/`recOn`; `clientUpdateRecommended` = `min ≤ v <
|
||||||
|
recommended`, fail-open; Execute sets `X-Update-Recommended: 1` on any served response in the band via a named-return
|
||||||
|
`defer`; hard band + Subscribe unchanged), `gateway/cmd/gateway/main.go` (dep). Client: `ui/src/lib/transport.ts` (a
|
||||||
|
Connect-ES **interceptor** reads `x-update-recommended` on unary responses ⇒ `reportUpdateRecommended`),
|
||||||
|
`ui/src/lib/update.svelte.ts` (dropped the terminal `updateRequired` latch — `reportUpdateRequired` now only emits
|
||||||
|
`versionRejected`; new `updateRecommended` store + `latchUpdateNudge`/`dismissUpdateNudge`/`reportUpdateRecommended` +
|
||||||
|
shared `openUpdate`), `ui/src/lib/netstate.svelte.ts` (`registerNetNudge`; the `setNudge` effect fires it),
|
||||||
|
`ui/src/lib/app.svelte.ts` (`registerNetNudge(latchUpdateNudge)`), `ui/src/lib/gateway.ts` (mock `__update.recommend`),
|
||||||
|
`ui/src/lib/i18n/{en,ru}.ts` (`update.playOffline` + `update.available`). Rework: `ui/src/components/UpdateOverlay.svelte`
|
||||||
|
(terminal → dismissable notice reading `netState.versionLocked`, "Update" + "Play offline"). New:
|
||||||
|
`ui/src/components/UpdateNudge.svelte` (the soft banner, rendered **bottom of the lobby, above the tab bar** — shown only
|
||||||
|
while `online && updateRecommended.active && !dismissed`).
|
||||||
|
- Owner-chosen (interview): the hard notice keeps the **full-screen** form (now dismissable); the soft nudge is a
|
||||||
|
**bottom-of-lobby** banner (not in-game); the header rides **Execute only** (Subscribe untouched); read via a transport
|
||||||
|
interceptor; nudge dismiss is **per-session**; the nudge latches via `setNudge → registerNetNudge` (machine is the source,
|
||||||
|
"only from online"). `update.body` copy kept as-is (owner).
|
||||||
|
- Acceptance: `v<min` → notice → "Play offline" → offline lobby; `min≤v<recommended` → dismissible banner, online continues;
|
||||||
|
`v≥recommended`/both empty → nothing; fail-open on an absent/garbled header. **Verified.**
|
||||||
|
- Targeted tests: Go `config_test` (`TestLoadRecommendedClientVersion`) + `server_test` (`TestExecuteUpdateRecommended` — 3
|
||||||
|
bands + at-min/at-recommended boundaries + fail-open + recommended-alone + dormant); `e2e/update.spec.ts` rewritten (notice
|
||||||
|
+ both actions; Update reloads; Play offline → offline lobby; the soft nudge shows in the lobby + dismisses). **Full gateway
|
||||||
|
go-test/build/vet/gofmt clean; e2e 122/122 chromium + webkit; svelte-check 0/0; vitest 616.**
|
||||||
|
|
||||||
|
- **O5 — Unified lobby. — ✅ DONE (2026-07-13).**
|
||||||
|
- Files (as built) — `ui/src/screens/Lobby.svelte` (`load()` merges `localSource.list()` + `gateway.gamesList()`;
|
||||||
|
online ⇒ `[...local, ...server]`; offline ⇒ `[...localFresh, ...cachedServer]` greyed; the mode-exclusive branch is
|
||||||
|
gone; `grey(g) = offline && !isLocalGameId` drives a `.rowwrap.greyed` dim + an un-openable `openGame` that toasts
|
||||||
|
`net.offline`; **invitations hidden offline** — owner addition — via `{#if invitations.length && !offline}`), `ui/src/lib/lobbysort.ts`
|
||||||
|
+ `ui/src/lib/result.ts` (`myId: string` → `myIds: readonly string[]` self-set across `isMyTurn`/`scoreStanding`/
|
||||||
|
`gamePhase`/`groupGames`/`resultBadge`; game seat-matching uses `myIds.includes(...)`), `ui/src/lib/lobbycache.ts`
|
||||||
|
(dropped the `offline` snapshot key — **one unified snapshot**; `getLobby()` no arg; offline reuses its server games),
|
||||||
|
`ui/src/screens/NewGame.svelte` (`getLobby()` + filter to server games — device-local games do not count toward the
|
||||||
|
server per-kind limit), `ui/src/lib/localguest`/`telegram`/`vk` imports in the lobby.
|
||||||
|
- **Identity split:** game functions take the self-set `myIds = [session.userId, localGuestId()]` (a reconciled
|
||||||
|
guest's local games seat you under `localGuestId`, server games under `userId`, ids never collide); **invitations
|
||||||
|
keep `session.userId`** (a server concept). **TG/VK server-only:** the local merge is gated on
|
||||||
|
`insideTelegram()`/`insideVK()` (the shared-origin local store must not leak into a mini-app).
|
||||||
|
- Acceptance: online ⇒ local + server both listed; offline ⇒ local active + server greyed-from-cache (un-openable);
|
||||||
|
a reconciled guest's local games stay visible online (**closes G-step-0**); invitations hidden offline; TG/VK
|
||||||
|
server-only. **Verified.**
|
||||||
|
- Targeted tests: `lobbysort.test.ts` + `result.test.ts` extended (two-id "self" recognition), `lobbycache.test.ts`
|
||||||
|
reworked (unified snapshot, mode-aware tests removed); `e2e/native.spec.ts` proves the local vs_ai game stays listed
|
||||||
|
online (G-step-0); `e2e/offline.spec.ts` + `hotseat.spec.ts` + `update.spec.ts` updated for greyed-from-cache server
|
||||||
|
games offline. **Full suite 122/122 on chromium + webkit; svelte-check 0/0; vitest 617.**
|
||||||
|
|
||||||
|
- **O6 — Create flows. — ✅ DONE (2026-07-13).**
|
||||||
|
- Files (as built) — `ui/src/screens/NewGame.svelte`: **with-friends** now carries a `friendsMode` segmented control
|
||||||
|
(`new.playRemote` = online invite / `new.playLocal` = offline hotseat); the online segment is `disabled` when
|
||||||
|
`offlineMode.active` and an `$effect` forces `friendsMode='offline'` there (a `new.needsNetwork` note explains it);
|
||||||
|
the friends markup is restructured from the mode-exclusive `{:else if offlineMode.active}` chain into
|
||||||
|
`{:else} <segment> {#if friendsMode==='offline'} hotseat {:else if !friends} noFriends {:else} invite`. **vs_ai
|
||||||
|
unchanged** (verified: `find()` still offline→`localSource.create`, online→`lobbyEnqueue`). **Dict guard** (owner-chosen
|
||||||
|
**async getDawg**): a `dawgReady`/`dictBlocked` pair — an `$effect` awaits `getDawg(guardVariant, version)` while
|
||||||
|
offline (`guardVariant` = the quick pick or the hotseat pick) and disables create + shows a `new.dictUnavailable` note
|
||||||
|
only on a **confirmed** miss (`dawgReady === false`, never while `null`/checking, so a still-warming dawg does not
|
||||||
|
false-block). `ui/src/lib/i18n/{en,ru}.ts` (`new.{playRemote,playLocal,needsNetwork,dictUnavailable}`).
|
||||||
|
- Owner-chosen (interview): the dict guard uses the **async getDawg** precheck (no reliable sync signal; native bundles
|
||||||
|
every variant so it mostly guards web offline); **guest gating unchanged** — an online guest still sees vs_ai only (no
|
||||||
|
new online-guest hotseat); a guest offline keeps its hotseat.
|
||||||
|
- Acceptance: with-friends online = invite / offline = hotseat; no network ⇒ online disabled, offline preselected,
|
||||||
|
create works; vs_ai offline→local / online→server unchanged; create disabled with a reason on an unavailable offline
|
||||||
|
dawg. **Verified.**
|
||||||
|
- Targeted tests: `e2e/hotseat.spec.ts` asserts the offline segment (online disabled, hotseat forced); `offline.spec.ts`
|
||||||
|
+ `native.spec.ts` exercise vs_ai-local + hotseat create through the new segment. **Full suite 122/122 on chromium +
|
||||||
|
webkit; svelte-check 0/0; vitest 617.**
|
||||||
|
|
||||||
|
- **O7 — Docs + native e2e. — ✅ DONE (2026-07-13).**
|
||||||
|
- Files (as built) — `docs/ARCHITECTURE.md`: **§2** rewrote the connectivity signal into the **net-state machine**
|
||||||
|
(four states, implicit offline, hysteresis, self-heal, TG/VK-exempt), the version gate into the **two tiers** (hard →
|
||||||
|
`offlineVersionLocked` + dismissable notice, *not* terminal; soft → `X-Update-Recommended` nudge), and the gate×offline
|
||||||
|
rule; **§13** (PWA/offline) rewrote the offline model from the deliberate toggle/dialog to implicit detection + the
|
||||||
|
**unified lobby** (greyed cached server games) + the with-friends **segment** + the dict guard. `docs/FUNCTIONAL.md`
|
||||||
|
(+`_ru` mirror): rewrote *Offline mode* (implicit, unified lobby, segment) and added a *Staying up to date* /
|
||||||
|
*«Актуальная версия»* story (Update/Play-offline notice + the soft nudge). `docs/TESTING.md`: the `netstate.test.ts`
|
||||||
|
reducer layer, the `__net`-driven offline spec, the two-tier `update.spec`, the soft-tier Go server/config tests, and
|
||||||
|
the native G-step-0 assertion. `deploy/README.md`: a **`GATEWAY_RECOMMENDED_CLIENT_VERSION`** row (soft tier;
|
||||||
|
validated ≥ `MIN`). `ui/e2e/native.spec.ts` already reflects the new boot/offline (done in O5).
|
||||||
|
- Acceptance: the docs describe both tiers + the implicit model + the unified lobby; `deploy/README` lists
|
||||||
|
`GATEWAY_RECOMMENDED_CLIENT_VERSION`. **Verified** (a full-doc sweep leaves no stale deliberate-offline / terminal /
|
||||||
|
toggle / dialog reference). Code unchanged from O6 (docs only): gateway Go green, svelte-check 0/0, vitest 617,
|
||||||
|
e2e 122/122 chromium + webkit.
|
||||||
|
|
||||||
|
**Offline-model redesign COMPLETE (O1–O7).** G-step-0 is resolved — the redesign is contour-safe (both version vars empty
|
||||||
|
⇒ dormant; the wire add is additive). Next is the normal G chain: PR → `development` → contour verify → `master` → tag →
|
||||||
|
dispatch `android-build`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Google Play variant (later — design now so we don't repaint)
|
## Google Play variant (later — design now so we don't repaint)
|
||||||
|
|
||||||
One codebase, two build variants selected by flags:
|
One codebase, two build variants selected by flags:
|
||||||
@@ -472,8 +1127,10 @@ target, built later.
|
|||||||
## Build & env matrix
|
## Build & env matrix
|
||||||
|
|
||||||
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
|
||||||
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
|
- `VITE_GATEWAY_URL` — absolute gateway origin; the workflow reuses `vars.PROD_PUBLIC_BASE_URL`
|
||||||
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
|
(`https://erudit-game.ru`, trailing slash stripped). The native-critical var.
|
||||||
|
- `VITE_RUSTORE_URL` — the update-overlay store target. **Empty in the MVP** (set the `ANDROID_RUSTORE_URL`
|
||||||
|
Gitea variable once published); the gate is dormant so the button never fires yet.
|
||||||
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
|
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
|
||||||
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
|
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
|
||||||
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
|
||||||
@@ -516,7 +1173,10 @@ breaking release deliberately sets the min version.
|
|||||||
|
|
||||||
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
|
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
|
||||||
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
|
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
|
||||||
the native build, a soft "recommended update" tier, native splash/status-bar plugins, push
|
the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
|
||||||
|
Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
|
||||||
|
action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
|
||||||
|
plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
|
||||||
notifications (FCM), and automated RuStore-API upload.
|
notifications (FCM), and automated RuStore-API upload.
|
||||||
|
|
||||||
## End-to-end verification
|
## End-to-end verification
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
# Erudit — site icons + Open Graph card
|
# Icons
|
||||||
|
|
||||||
The favicon set and the `og:image` link-preview card for the public landing
|
The whole shipped icon set is sliced from **one master** — see
|
||||||
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
[`brand/`](brand/) for the reference generator, the pinned font and the committed
|
||||||
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
|
||||||
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
mark is constructed. The per-platform target map (which file goes where) is in
|
||||||
|
[`../../docs/ICONS.md`](../../docs/ICONS.md).
|
||||||
| Output (committed to `ui/public/`) | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
|
|
||||||
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
|
|
||||||
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
|
|
||||||
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
|
|
||||||
character) from LiberationSans (Arial-metric, the game's font stack) with their
|
|
||||||
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
|
|
||||||
plain SVG from those outlines — no font is needed at generation time — writes
|
|
||||||
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
|
|
||||||
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
|
|
||||||
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
|
|
||||||
installed chromium version; the SVG sources are deterministic.
|
|
||||||
|
|
||||||
## Regenerate
|
|
||||||
|
|
||||||
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
|
|
||||||
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
|
|
||||||
needs no extra packages**.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd assets/icons
|
cd brand
|
||||||
|
npm i opentype.js
|
||||||
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
|
||||||
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||||
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
|
||||||
|
|
||||||
# 2. regenerate everything in ui/public/:
|
|
||||||
node build/generate.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
|
||||||
|
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
|
||||||
|
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Erudit icon — brand master
|
||||||
|
|
||||||
|
The reference construction of the Erudit app icon. The authoritative, human-readable
|
||||||
|
spec lives in [`docs/ICON_BRANDBOOK.md`](../../../docs/ICON_BRANDBOOK.md); this folder
|
||||||
|
holds the machine reproduction of exactly what that document describes.
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|------|------|
|
||||||
|
| `build-icon.mjs` | reference generator — every dimension is a fraction of the side, matching the brand book |
|
||||||
|
| `render-png.mjs` | rasterises an icon SVG to PNG via the `ui` package's Playwright chromium |
|
||||||
|
| `Spectral-Bold.ttf` | the «Э» typeface (SIL OFL, `Spectral-OFL.txt`) — pinned so the letter never drifts |
|
||||||
|
| `icon-light.svg` / `.png` | the light master (1024) |
|
||||||
|
| `icon-dark.svg` / `.png` | the dark master (1024) |
|
||||||
|
| `icon-construction.svg` / `.png` | the percent-annotated construction scheme (figure in the brand book) |
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i opentype.js # the only extra dep; render uses ui's chromium
|
||||||
|
|
||||||
|
node build-icon.mjs --variant light > icon-light.svg
|
||||||
|
node build-icon.mjs --variant dark > icon-dark.svg
|
||||||
|
node build-icon.mjs --variant light --scheme > icon-construction.svg
|
||||||
|
|
||||||
|
node render-png.mjs icon-light.svg icon-light.png 1024
|
||||||
|
node render-png.mjs icon-dark.svg icon-dark.png 1024
|
||||||
|
node render-png.mjs icon-construction.svg icon-construction.png 2048
|
||||||
|
```
|
||||||
|
|
||||||
|
The SVGs are resolution-free; render at any size. Downstream slicing (favicon / PWA /
|
||||||
|
iOS / Android) is a separate step — see [`docs/ICONS.md`](../../../docs/ICONS.md).
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Spectral Project Authors (https://github.com/productiontype/Spectral)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
'use strict';
|
||||||
|
// Generate the Android launcher resources from the committed layers, at every
|
||||||
|
// density, with NO inset (our layers already fill the 108 dp canvas) and a
|
||||||
|
// monochrome (themed) layer. Run build-set.mjs first (it writes the layer PNGs).
|
||||||
|
//
|
||||||
|
// npm i opentype.js # (only build-icon/build-set need it; this reads PNGs)
|
||||||
|
// node build-set.mjs && node build-android-res.mjs
|
||||||
|
//
|
||||||
|
// Writes ui/android/app/src/main/res/mipmap-*/ic_launcher{,_round,_foreground,
|
||||||
|
// _background,_monochrome}.png. The two mipmap-anydpi-v26/*.xml are committed by hand.
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(DIR, '..', '..', '..');
|
||||||
|
const RES = join(ROOT, 'ui', 'android', 'app', 'src', 'main', 'res');
|
||||||
|
const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
|
||||||
|
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||||
|
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||||
|
const MONO = uri(join(DIR, 'android-monochrome.png'));
|
||||||
|
|
||||||
|
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
|
||||||
|
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
|
||||||
|
|
||||||
|
const pw = await import(join(ROOT, 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ deviceScaleFactor: 1 });
|
||||||
|
const im = (u, s) => `<img src="${u}" width="${s}" height="${s}" style="display:block">`;
|
||||||
|
async function shot(html, s, transparent) {
|
||||||
|
await page.setViewportSize({ width: s, height: s });
|
||||||
|
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}#r{width:${s}px;height:${s}px}</style><div id=r>${html}</div>`, { waitUntil: 'networkidle' });
|
||||||
|
return page.locator('#r').screenshot({ omitBackground: !!transparent });
|
||||||
|
}
|
||||||
|
for (const [d, [fg, lg]] of Object.entries(D)) {
|
||||||
|
const dir = join(RES, `mipmap-${d}`);
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||||
|
const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||||
|
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
|
||||||
|
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||||
|
console.log('mipmap-' + d, 'ok');
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
'use strict';
|
||||||
|
// Erudit app-icon — the reference generator. Every dimension below is a FRACTION
|
||||||
|
// of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the
|
||||||
|
// icon reproduces at any resolution. Emits one variant/layer's SVG to stdout.
|
||||||
|
//
|
||||||
|
// node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg
|
||||||
|
//
|
||||||
|
// Layers (for Android adaptive icons):
|
||||||
|
// full the standalone master (rounded tile + mark) — iOS / PWA / favicon
|
||||||
|
// background wood + grain + light/shadow, full-bleed square (the OS applies the mask)
|
||||||
|
// foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below)
|
||||||
|
//
|
||||||
|
// Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled).
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import opentype from 'opentype.js';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const has = f => args.includes('--' + f);
|
||||||
|
const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; };
|
||||||
|
|
||||||
|
// ---- the design, in fractions of the side ----
|
||||||
|
const S = 1024; // reference side (the icon is resolution-free)
|
||||||
|
const RADIUS = 0.17; // tile corner radius (full master)
|
||||||
|
const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius
|
||||||
|
const STAR_SPOKES = 6;
|
||||||
|
const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4;
|
||||||
|
const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right
|
||||||
|
const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right
|
||||||
|
|
||||||
|
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
|
||||||
|
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
|
||||||
|
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
|
||||||
|
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
|
||||||
|
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
|
||||||
|
|
||||||
|
const PALETTE = {
|
||||||
|
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||||
|
dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layers:
|
||||||
|
// full rounded tile + master mark (favicon.svg — browser rounds nothing)
|
||||||
|
// flat square tile + master mark (apple-touch, PWA "any" — OS rounds)
|
||||||
|
// background square tile, no mark (Android adaptive background)
|
||||||
|
// foreground mark only, transparent (Android adaptive foreground)
|
||||||
|
// maskable square tile + foreground mark (PWA maskable — safe-zone aware)
|
||||||
|
// monochrome foreground mark only, flat colour (Android 13+ themed layer)
|
||||||
|
const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome'];
|
||||||
|
const variant = val('variant', 'light');
|
||||||
|
const layer = val('layer', 'full');
|
||||||
|
const P = PALETTE[variant];
|
||||||
|
if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); }
|
||||||
|
if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); }
|
||||||
|
|
||||||
|
const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer);
|
||||||
|
const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer);
|
||||||
|
const drawMark = layer !== 'background';
|
||||||
|
const MARK = usesForegroundPlacement ? FOREGROUND : MASTER;
|
||||||
|
const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink;
|
||||||
|
const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners
|
||||||
|
const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer);
|
||||||
|
|
||||||
|
// «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc.
|
||||||
|
function letterSvg() {
|
||||||
|
const g = font.charToGlyph('Э');
|
||||||
|
const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })();
|
||||||
|
const scale = (MARK.lh * S) / h0;
|
||||||
|
const p = g.getPath(0, 0, 1000 * scale);
|
||||||
|
const bb = p.getBoundingBox();
|
||||||
|
const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1;
|
||||||
|
const tx = MARK.lc[0] * S - (bb.x1 + w / 2);
|
||||||
|
const ty = MARK.lc[1] * S - (bb.y1 + h / 2);
|
||||||
|
return { svg: `<path transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)})" d="${p.toPathData(2)}" fill="${inkColour}"/>`,
|
||||||
|
box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends
|
||||||
|
// in a round bulb; a central disc equal to the bulb fuses them.
|
||||||
|
function starPath() {
|
||||||
|
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2;
|
||||||
|
const rb = Rout * STAR_BULB, R = Rout - rb;
|
||||||
|
const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R);
|
||||||
|
const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)];
|
||||||
|
let d = '';
|
||||||
|
for (let k = 0; k < STAR_SPOKES; k++) {
|
||||||
|
const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES;
|
||||||
|
const u = [Math.cos(a), Math.sin(a)];
|
||||||
|
const d1 = rot(u, theta), d2 = rot(u, -theta);
|
||||||
|
const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]];
|
||||||
|
d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `;
|
||||||
|
}
|
||||||
|
d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`;
|
||||||
|
return `<path d="${d}" fill="${inkColour}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grain() {
|
||||||
|
const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S;
|
||||||
|
const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2;
|
||||||
|
let s = '';
|
||||||
|
for (let i = 1; i <= GRAIN_COLS; i++) {
|
||||||
|
const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2;
|
||||||
|
s += `<rect x="${x.toFixed(2)}" y="${yTop.toFixed(2)}" width="${sw.toFixed(2)}" height="${(yBot - yTop).toFixed(2)}" fill="${P.grain}" transform="rotate(${GRAIN_TILT} ${cx.toFixed(2)} ${cy.toFixed(2)})"/>`;
|
||||||
|
}
|
||||||
|
return `<g clip-path="url(#tile)">${s}</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
const L = letterSvg();
|
||||||
|
if (drawBg) { // tile + grain + light/shadow (the background)
|
||||||
|
body += `<rect width="${S}" height="${S}" rx="${RX}" fill="${P.wood}"/>`
|
||||||
|
+ `<defs><clipPath id="tile"><rect width="${S}" height="${S}" rx="${RX}"/></clipPath>`
|
||||||
|
+ `<linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${SHEEN}"/></linearGradient>`
|
||||||
|
+ `<linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#000" stop-opacity="${SHADE}"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>`
|
||||||
|
+ `</defs>`
|
||||||
|
+ grain()
|
||||||
|
+ `<rect width="${S}" height="${S}" fill="url(#sheen)" clip-path="url(#tile)"/>`
|
||||||
|
+ `<rect width="${S}" height="${S}" fill="url(#shade)" clip-path="url(#tile)"/>`;
|
||||||
|
}
|
||||||
|
if (drawMark) body += L.svg + starPath(); // the mark
|
||||||
|
if (has('scheme')) body += scheme(L);
|
||||||
|
|
||||||
|
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${body}</svg>\n`);
|
||||||
|
|
||||||
|
// Percent-based construction overlay for the brand book (full master only).
|
||||||
|
function scheme(L) {
|
||||||
|
const pc = n => (100 * n / S).toFixed(1) + '%';
|
||||||
|
const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488';
|
||||||
|
const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"';
|
||||||
|
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S;
|
||||||
|
let g = `<g font-family="'Helvetica Neue',Arial,sans-serif">`;
|
||||||
|
for (let f = 1; f <= 3; f++) {
|
||||||
|
const p = f * S / 4;
|
||||||
|
g += `<line x1="${p}" y1="0" x2="${p}" y2="${S}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||||
|
g += `<line x1="0" y1="${p}" x2="${S}" y2="${p}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||||
|
g += `<text x="${p + 4}" y="20" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||||
|
g += `<text x="4" y="${p - 4}" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||||
|
}
|
||||||
|
g += `<line x1="${0.14 * S}" y1="${0.86 * S}" x2="${0.86 * S}" y2="${0.14 * S}" stroke="${AC}" stroke-width="3" stroke-dasharray="3 8" stroke-linecap="round"/>`;
|
||||||
|
g += `<circle cx="${0.86 * S}" cy="${0.14 * S}" r="6" fill="${AC}"/><circle cx="${0.14 * S}" cy="${0.86 * S}" r="6" fill="${AC}"/>`;
|
||||||
|
g += `<text x="${0.86 * S - 6}" y="${0.14 * S - 12}" text-anchor="end" font-size="18" font-weight="700" fill="${AC}" ${halo}>light: white 0%→15%</text>`;
|
||||||
|
g += `<text x="${0.14 * S + 10}" y="${0.86 * S + 26}" font-size="18" font-weight="700" fill="${AC}" ${halo}>shadow: black 15%→0%</text>`;
|
||||||
|
const { x, y, w, h } = L.box;
|
||||||
|
g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<line x1="${MARK.lc[0] * S}" y1="${MARK.lc[1] * S - 16}" x2="${MARK.lc[0] * S}" y2="${MARK.lc[1] * S + 16}" stroke="${BX}" stroke-width="2.5"/><line x1="${MARK.lc[0] * S - 16}" y1="${MARK.lc[1] * S}" x2="${MARK.lc[0] * S + 16}" y2="${MARK.lc[1] * S}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<text x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" font-size="19" font-weight="700" fill="${BX}" ${halo}>«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}</text>`;
|
||||||
|
g += `<text x="${x.toFixed(1)}" y="${(y + h + 24).toFixed(1)}" font-size="18" font-weight="700" fill="${BX}" ${halo}>box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}</text>`;
|
||||||
|
g += `<rect x="${cx - sd / 2}" y="${cy - sd / 2}" width="${sd}" height="${sd}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<line x1="${cx}" y1="${cy - 14}" x2="${cx}" y2="${cy + 14}" stroke="${BX}" stroke-width="2.5"/><line x1="${cx - 14}" y1="${cy}" x2="${cx + 14}" y2="${cy}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<text x="${(cx + sd / 2).toFixed(1)}" y="${(cy - sd / 2 - 10).toFixed(1)}" text-anchor="end" font-size="18" font-weight="700" fill="${BX}" ${halo}>✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}</text>`;
|
||||||
|
g += `<text x="${RX + 14}" y="${RX - 6}" font-size="17" font-weight="700" fill="${GC}" ${halo}>corner r = 17%</text>`;
|
||||||
|
g += `<path d="M4 ${RX} A ${RX} ${RX} 0 0 1 ${RX} 4" fill="none" stroke="${GC}" stroke-width="2" stroke-dasharray="5 5"/>`;
|
||||||
|
g += `<rect x="${0.03 * S}" y="${S - 96}" width="${0.94 * S}" height="74" rx="12" fill="#0f172a" fill-opacity="0.82"/>`;
|
||||||
|
g += `<text x="${0.055 * S}" y="${S - 64}" font-size="18" font-weight="600" fill="#fff">Wood grain: 6 equal columns; a vertical stripe on each column's right edge,</text>`;
|
||||||
|
g += `<text x="${0.055 * S}" y="${S - 40}" font-size="18" font-weight="600" fill="#fff">width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.</text>`;
|
||||||
|
return g + `</g>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use strict';
|
||||||
|
// Slice the whole shipped icon set from the brand master (build-icon.mjs). Renders
|
||||||
|
// with the ui package's Playwright chromium. See docs/ICONS.md for the target map.
|
||||||
|
//
|
||||||
|
// npm i opentype.js && node build-set.mjs
|
||||||
|
//
|
||||||
|
// Writes: ui/public/* (web), ui/assets/* (Capacitor layers), ./vk/* and ./store/*
|
||||||
|
// (manual-upload art). Wiring (manifest, html, Android res) is done separately.
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const UI = join(DIR, '..', '..', '..', 'ui');
|
||||||
|
const PUB = join(UI, 'public'), ASSETS = join(UI, 'assets');
|
||||||
|
const VK = join(DIR, 'vk'), STORE = join(DIR, 'store'), TG = join(DIR, 'tg');
|
||||||
|
[VK, STORE, TG].forEach(d => mkdirSync(d, { recursive: true }));
|
||||||
|
|
||||||
|
// one SVG string for a variant+layer from the reference generator
|
||||||
|
const svgFor = (variant, layer) =>
|
||||||
|
execFileSync('node', [join(DIR, 'build-icon.mjs'), '--variant', variant, '--layer', layer], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
const pw = await import(join(UI, 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
async function pngFromSvg(svg, size, transparent) {
|
||||||
|
await page.setViewportSize({ width: size, height: size });
|
||||||
|
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||||
|
return page.locator('svg').screenshot({ omitBackground: !!transparent });
|
||||||
|
}
|
||||||
|
async function shootHtml(html, w, h) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||||
|
return page.screenshot();
|
||||||
|
}
|
||||||
|
function icoFromPNG(png, sizePx) {
|
||||||
|
const h = Buffer.alloc(22);
|
||||||
|
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4);
|
||||||
|
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7);
|
||||||
|
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12);
|
||||||
|
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18);
|
||||||
|
return Buffer.concat([h, png]);
|
||||||
|
}
|
||||||
|
const write = (p, buf) => { writeFileSync(p, buf); console.log('wrote', p.replace(join(DIR, '..', '..', '..'), '.'), buf.length, 'b'); };
|
||||||
|
|
||||||
|
// ---- pre-render the SVG sources we need ----
|
||||||
|
const S = {
|
||||||
|
fullLight: svgFor('light', 'full'), fullDark: svgFor('dark', 'full'),
|
||||||
|
flatLight: svgFor('light', 'flat'), flatDark: svgFor('dark', 'flat'),
|
||||||
|
maskLight: svgFor('light', 'maskable'), maskDark: svgFor('dark', 'maskable'),
|
||||||
|
bgLight: svgFor('light', 'background'), fgLight: svgFor('light', 'foreground'),
|
||||||
|
monoLight: svgFor('light', 'monochrome'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- favicon.svg: light + dark in one file, toggled by prefers-color-scheme ----
|
||||||
|
const inner = svg => svg.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
|
||||||
|
const faviconSvg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">`
|
||||||
|
+ `<style>.d{display:none}@media(prefers-color-scheme:dark){.l{display:none}.d{display:inline}}</style>`
|
||||||
|
+ `<g class="l">${inner(S.fullLight)}</g><g class="d">${inner(S.fullDark)}</g></svg>\n`;
|
||||||
|
write(join(PUB, 'favicon.svg'), Buffer.from(faviconSvg));
|
||||||
|
|
||||||
|
// ---- web rasters ----
|
||||||
|
write(join(PUB, 'favicon.ico'), icoFromPNG(await pngFromSvg(S.flatLight, 32, false), 32));
|
||||||
|
write(join(PUB, 'apple-touch-icon.png'), await pngFromSvg(S.flatLight, 180, false));
|
||||||
|
write(join(PUB, 'icon-192.png'), await pngFromSvg(S.flatLight, 192, false));
|
||||||
|
write(join(PUB, 'icon-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||||
|
write(join(PUB, 'icon-192-dark.png'), await pngFromSvg(S.flatDark, 192, false));
|
||||||
|
write(join(PUB, 'icon-512-dark.png'), await pngFromSvg(S.flatDark, 512, false));
|
||||||
|
write(join(PUB, 'icon-maskable-512.png'), await pngFromSvg(S.maskLight, 512, false));
|
||||||
|
write(join(PUB, 'icon-maskable-512-dark.png'), await pngFromSvg(S.maskDark, 512, false));
|
||||||
|
|
||||||
|
// ---- Capacitor layers (ui/assets) ----
|
||||||
|
write(join(ASSETS, 'icon.png'), await pngFromSvg(S.flatLight, 1024, false)); // legacy single
|
||||||
|
write(join(ASSETS, 'icon-foreground.png'), await pngFromSvg(S.fgLight, 1024, true)); // adaptive fg
|
||||||
|
write(join(ASSETS, 'icon-background.png'), await pngFromSvg(S.bgLight, 1024, false)); // adaptive bg
|
||||||
|
write(join(DIR, 'android-monochrome.png'), await pngFromSvg(S.monoLight, 1024, true)); // themed layer (wired manually)
|
||||||
|
|
||||||
|
// ---- VK (no rounding — flat light) ----
|
||||||
|
for (const px of [576, 278, 150, 32]) write(join(VK, `vk-${px}.png`), await pngFromSvg(S.flatLight, px, false));
|
||||||
|
|
||||||
|
// ---- Telegram bot: square, no rounding (TG crops the avatar to a circle) ----
|
||||||
|
write(join(TG, 'tg-512.png'), await pngFromSvg(S.flatLight, 512, false)); // bot avatar + Mini App icon
|
||||||
|
|
||||||
|
// ---- store art ----
|
||||||
|
write(join(STORE, 'rustore-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||||
|
|
||||||
|
// ---- wordmark banner (board-green bg + master tile + «Эрудит» / Игра в слова) ----
|
||||||
|
const b64 = readFileSync(join(DIR, 'Spectral-Bold.ttf')).toString('base64');
|
||||||
|
const banner = (w, h, tile, t1, t2, gap, pad) => `<!doctype html><meta charset=utf8><style>
|
||||||
|
@font-face{font-family:Spectral;src:url(data:font/ttf;base64,${b64});font-weight:700}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{width:${w}px;height:${h}px;background:#2a3330;display:flex;align-items:center;gap:${gap}px;padding:0 ${pad}px;font-family:Spectral}
|
||||||
|
.tile{width:${tile}px;height:${tile}px;flex:none}.tile svg{width:${tile}px;height:${tile}px;display:block}
|
||||||
|
.wm{color:#e7ece8;text-align:center}.t1{font-weight:700;font-size:${t1}px;line-height:1.02;letter-spacing:-2px}
|
||||||
|
.t2{font-weight:700;font-size:${t2}px;line-height:1.1;color:#cdd6cf;margin-top:6px}
|
||||||
|
</style><div class=tile>${S.fullLight}</div><div class=wm><div class=t1>«Эрудит»</div><div class=t2>Игра в слова</div></div>`;
|
||||||
|
write(join(PUB, 'og-image.png'), await shootHtml(banner(1200, 630, 300, 150, 74, 64, 96), 1200, 630));
|
||||||
|
write(join(TG, 'tg-demo-640x360.png'), await shootHtml(banner(640, 360, 150, 72, 38, 30, 44), 640, 360));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('done');
|
||||||
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 29 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#f2d9a0"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#f2d9a0"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 405 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#f2d9a0"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#f2d9a0"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 290 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#5a3a12"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#5a3a12"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#5a3a12"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#5a3a12"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "erudit-icon-brand",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Reference generator for the Erudit app-icon set (see docs/ICON_BRANDBOOK.md).",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build-set.mjs && node build-android-res.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"opentype.js": "^1.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
// Rasterise an icon SVG to PNG at a given size, using the `ui` package's cached
|
||||||
|
// Playwright chromium (no extra install). Usage:
|
||||||
|
// node render-png.mjs <in.svg> <out.png> [size=1024]
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pw = await import(join(DIR, '..', '..', '..', 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
|
||||||
|
const [, , svgPath, pngPath, size] = process.argv;
|
||||||
|
const S = Number(size) || 1024;
|
||||||
|
const svg = readFileSync(svgPath, 'utf8');
|
||||||
|
const b = await chromium.launch();
|
||||||
|
const pg = await b.newPage({ viewport: { width: S, height: S }, deviceScaleFactor: 1 });
|
||||||
|
await pg.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||||
|
writeFileSync(pngPath, await pg.locator('svg').screenshot({ omitBackground: true }));
|
||||||
|
await b.close();
|
||||||
|
console.log('wrote', pngPath, S + 'px');
|
||||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -1,78 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
|
||||||
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
|
||||||
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
|
||||||
// plus the advance width so generate.js can lay out words without the font.
|
|
||||||
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
|
||||||
const opentype = require('opentype.js');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
|
||||||
const b = fs.readFileSync(FONT);
|
|
||||||
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
|
||||||
const FS = 1000; // em scale
|
|
||||||
|
|
||||||
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
|
||||||
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
|
||||||
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
|
||||||
|
|
||||||
function glyphData(ch) {
|
|
||||||
const g = font.charToGlyph(ch);
|
|
||||||
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
|
||||||
const contours = [];
|
|
||||||
let cur = null, prev = null;
|
|
||||||
for (const c of p.commands) {
|
|
||||||
if (c.type === 'M') {
|
|
||||||
if (cur) contours.push(cur);
|
|
||||||
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'L') {
|
|
||||||
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'C') {
|
|
||||||
cur[cur.length - 1].o = [c.x1, c.y1];
|
|
||||||
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'Q') {
|
|
||||||
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
|
||||||
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
|
||||||
cur[cur.length - 1].o = c1;
|
|
||||||
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'Z') {
|
|
||||||
if (cur && cur.length > 1) {
|
|
||||||
const last = cur[cur.length - 1], first = cur[0];
|
|
||||||
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
|
||||||
first.i = last.i; // fold the duplicate closing point into the first
|
|
||||||
cur.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) { contours.push(cur); cur = null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) contours.push(cur);
|
|
||||||
|
|
||||||
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
|
||||||
const out = contours.map(ct => {
|
|
||||||
const v = [], i = [], o = [];
|
|
||||||
ct.forEach(pt => {
|
|
||||||
v.push(pt.v);
|
|
||||||
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
|
||||||
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
|
||||||
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
|
||||||
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
|
||||||
});
|
|
||||||
return { i, o, v, c: true };
|
|
||||||
});
|
|
||||||
const bbox = out.length
|
|
||||||
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
|
||||||
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
|
||||||
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
|
||||||
}
|
|
||||||
|
|
||||||
const glyphs = {};
|
|
||||||
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
|
||||||
|
|
||||||
const out = __dirname + '/glyphs.json';
|
|
||||||
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
|
||||||
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Site icons + the Open Graph card for the public landing, drawn from the same
|
|
||||||
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
|
||||||
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
|
||||||
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
|
||||||
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
|
||||||
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
|
||||||
// ui/public/:
|
|
||||||
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
|
||||||
// favicon.ico 32x32 PNG-in-ICO render of the same
|
|
||||||
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
|
||||||
// og-image.png 1200x630 card: tile + wordmark on the board green
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
|
||||||
const UI = path.resolve(__dirname, '../../../ui');
|
|
||||||
const OUT = path.join(UI, 'public');
|
|
||||||
|
|
||||||
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
|
||||||
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
|
||||||
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
|
||||||
|
|
||||||
// ---- glyph outlines -> SVG path data ----------------------------------------
|
|
||||||
const r2 = n => Math.round(n * 100) / 100;
|
|
||||||
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
|
||||||
function pathD(g, sc, tx, ty) {
|
|
||||||
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
|
||||||
const Z = [0, 0];
|
|
||||||
return g.contours.map(ct => {
|
|
||||||
const n = ct.v.length;
|
|
||||||
let d = `M${pt(ct.v[0], Z)}`;
|
|
||||||
for (let k = 1; k <= n; k++) {
|
|
||||||
const a = k - 1, b = k % n;
|
|
||||||
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
|
||||||
}
|
|
||||||
return d + 'Z';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
|
||||||
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
|
||||||
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
|
||||||
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
|
||||||
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
|
||||||
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
|
||||||
}
|
|
||||||
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
|
||||||
// the capital height (measured on «Э»). Returns the combined path + the width.
|
|
||||||
function textLine(str, capPx, x, y, colour) {
|
|
||||||
const sc = capPx / G.glyphs['Э'].bbox.h;
|
|
||||||
let d = '', w = 0;
|
|
||||||
for (const ch of str) {
|
|
||||||
const g = G.glyphs[ch];
|
|
||||||
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
|
||||||
w += g.adv * sc;
|
|
||||||
}
|
|
||||||
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
|
||||||
}
|
|
||||||
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
|
||||||
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
|
||||||
function tile(cx, cy, size, withScore) {
|
|
||||||
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
|
||||||
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
|
||||||
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
|
||||||
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
|
||||||
|
|
||||||
// ---- favicon.svg (the committed vector master) -------------------------------
|
|
||||||
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
|
||||||
|
|
||||||
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
|
||||||
const appleTouch = svg(180, 180,
|
|
||||||
`<rect width="180" height="180" fill="${FACE}"/>` +
|
|
||||||
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
|
||||||
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
|
||||||
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
|
||||||
|
|
||||||
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
|
||||||
function ogImage() {
|
|
||||||
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
|
||||||
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
|
||||||
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
|
||||||
const textW = Math.max(l1.width, l2.width);
|
|
||||||
const left = (W - (tileSize + gap + textW)) / 2;
|
|
||||||
const tx = left + tileSize + gap;
|
|
||||||
// Two baselines around the vertical centre; the tile centre sits between them.
|
|
||||||
const b1 = 295, b2 = 408;
|
|
||||||
const body =
|
|
||||||
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
|
||||||
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
|
||||||
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
|
||||||
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
|
||||||
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
|
||||||
return svg(W, H, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
|
||||||
async function shoot(page, markup, w, h, transparent) {
|
|
||||||
await page.setViewportSize({ width: w, height: h });
|
|
||||||
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
|
||||||
return page.screenshot({ omitBackground: transparent });
|
|
||||||
}
|
|
||||||
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
|
||||||
function icoFromPNG(png, sizePx) {
|
|
||||||
const h = Buffer.alloc(22);
|
|
||||||
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
|
||||||
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
|
||||||
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
|
||||||
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
|
||||||
return Buffer.concat([h, png]);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
|
||||||
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
|
||||||
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
|
||||||
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
|
||||||
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
|
||||||
await browser.close();
|
|
||||||
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
|
||||||
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -21,7 +21,17 @@
|
|||||||
<div><button type="submit">Add</button></div>
|
<div><button type="submit">Add</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||||
|
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||||
|
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||||
|
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||||
|
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Products</h2>
|
<section class="panel"><h2>Products</h2>
|
||||||
|
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -677,6 +677,15 @@ type FeedbackDetailView struct {
|
|||||||
// CatalogView is the product-catalog list page.
|
// CatalogView is the product-catalog list page.
|
||||||
type CatalogView struct {
|
type CatalogView struct {
|
||||||
Products []ProductRow
|
Products []ProductRow
|
||||||
|
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||||
|
// toggle); it drives the toggle link and the list heading.
|
||||||
|
ShowAll bool
|
||||||
|
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||||
|
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||||
|
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||||
|
RewardPayout int
|
||||||
|
RewardDailyCap int
|
||||||
|
RewardHourlyCap int
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
all, err := pay.AdminCatalog(context.Background())
|
all, err := pay.AdminCatalog(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("admin catalog: %v", err)
|
t.Fatalf("admin catalog: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||||
}
|
}
|
||||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||||
t.Fatalf("catalog: %v", err)
|
t.Fatalf("catalog: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||||
|
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||||
|
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const catalog = "http://admin.test/_gm/catalog"
|
||||||
|
|
||||||
|
// An active value and an archived one (created without the active flag).
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create active failed")
|
||||||
|
}
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create archived failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: active only.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?all=1: active and archived.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||||
|
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||||
|
func TestConsoleRewardConfig(t *testing.T) {
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const reward = "http://admin.test/_gm/catalog/reward"
|
||||||
|
|
||||||
|
// Set the payout and caps.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||||
|
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||||
|
}
|
||||||
|
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||||
|
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog page renders the form pre-filled with the current payout.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||||
|
t.Error("catalog page did not render the reward form with the current payout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative value is refused (the service validates non-negativity).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||||
|
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
|||||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||||
// no method, so only values show; buying is gated server-side regardless.
|
// no method, so only values show; buying is gated server-side regardless.
|
||||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||||
|
// Present products in the canonical listing order shared with the offer and the admin console
|
||||||
|
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||||
|
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||||
|
// it does not reuse.
|
||||||
|
sortCatalogEntries(entries)
|
||||||
var out CatalogView
|
var out CatalogView
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isPack := false
|
isPack := false
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||||
return s.store.adminCatalog(ctx)
|
// the catalog tables.
|
||||||
|
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
return s.store.adminCatalog(ctx, includeInactive)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||||
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
|||||||
// products the same as active ones (the admin list shows both).
|
// products the same as active ones (the admin list shows both).
|
||||||
func SortAdminCatalog(products []AdminProduct) {
|
func SortAdminCatalog(products []AdminProduct) {
|
||||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||||
if pa {
|
|
||||||
return -1 // packs (sales) before values (chip exchange)
|
|
||||||
}
|
|
||||||
return 1
|
|
||||||
} else if pa {
|
|
||||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
}
|
|
||||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||||
|
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||||
|
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||||
|
// row sorts last rather than leading).
|
||||||
|
type catalogRank struct {
|
||||||
|
pack bool
|
||||||
|
group int
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||||
|
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||||
|
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||||
|
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||||
|
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||||
|
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||||
|
func compareCatalogRank(a, b catalogRank) int {
|
||||||
|
if a.pack != b.pack {
|
||||||
|
if a.pack {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !a.pack {
|
||||||
|
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.amount, b.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||||
|
func entryRank(e catalogEntry) catalogRank {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRank builds the canonical rank of an admin catalog product.
|
||||||
|
func adminRank(p AdminProduct) catalogRank {
|
||||||
|
if adminIsPack(p) {
|
||||||
|
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||||
|
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||||
|
func sortCatalogEntries(entries []catalogEntry) {
|
||||||
|
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||||
|
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||||
|
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||||
|
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||||
|
// console, regardless of catalog creation order.
|
||||||
|
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||||
|
pack := func(title string, rub int64) catalogEntry {
|
||||||
|
return catalogEntry{
|
||||||
|
id: uuid.New(),
|
||||||
|
title: title,
|
||||||
|
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||||
|
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||||
|
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||||
|
for _, a := range atoms {
|
||||||
|
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
pack("packDear", 30000),
|
||||||
|
value("bundle", 500, "hints", "noads_days"),
|
||||||
|
pack("packCheap", 10000),
|
||||||
|
value("adsOnly", 150, "noads_days"),
|
||||||
|
value("hintsBig", 200, "hints"),
|
||||||
|
value("hintsSmall", 50, "hints"),
|
||||||
|
}
|
||||||
|
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
if len(got.Products) != len(want) {
|
||||||
|
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range got.Products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productTitles extracts the projected product titles for a failure message.
|
||||||
|
func productTitles(products []CatalogProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package payments
|
package payments
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
|||||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||||
func projectOfferPricing(entries []catalogEntry) string {
|
func projectOfferPricing(entries []catalogEntry) string {
|
||||||
|
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||||
|
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||||
|
// caller's slice is left untouched.
|
||||||
|
sorted := slices.Clone(entries)
|
||||||
|
sortCatalogEntries(sorted)
|
||||||
var packs, values []catalogEntry
|
var packs, values []catalogEntry
|
||||||
for _, e := range entries {
|
for _, e := range sorted {
|
||||||
if isPackEntry(e) {
|
if isPackEntry(e) {
|
||||||
packs = append(packs, e)
|
packs = append(packs, e)
|
||||||
} else {
|
} else {
|
||||||
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
|
||||||
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
|
||||||
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
|
||||||
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
|
||||||
})
|
|
||||||
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
|
||||||
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
|
||||||
})
|
|
||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
if len(packs) > 0 {
|
if len(packs) > 0 {
|
||||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
|||||||
return payout, err
|
return payout, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||||
|
// the per-day and per-hour caps.
|
||||||
|
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||||
|
return s.store.rewardConfig(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||||
|
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||||
|
// offer stale.
|
||||||
|
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||||
|
return errors.New("payments: reward payout and caps must be non-negative")
|
||||||
|
}
|
||||||
|
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||||
|
}
|
||||||
|
|
||||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||||
|
|||||||
@@ -19,11 +19,17 @@ import (
|
|||||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||||
|
|
||||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// archived products to the active ones; with it false only active products are returned.
|
||||||
|
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
where := table.Product.Active.IS_TRUE()
|
||||||
|
if includeInactive {
|
||||||
|
where = postgres.Bool(true)
|
||||||
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
if err := postgres.SELECT(table.Product.AllColumns).
|
if err := postgres.SELECT(table.Product.AllColumns).
|
||||||
FROM(table.Product).
|
FROM(table.Product).
|
||||||
|
WHERE(where).
|
||||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||||
QueryContext(ctx, s.db, &prods); err != nil {
|
QueryContext(ctx, s.db, &prods); err != nil {
|
||||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||||
|
|||||||
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
|||||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||||
|
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||||
|
// and also enforced by the config CHECK constraints.
|
||||||
|
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||||
|
payout, dailyCap, hourlyCap); err != nil {
|
||||||
|
return fmt.Errorf("payments: set reward config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||||
|
|||||||
@@ -33,10 +33,12 @@ var priceFields = []struct {
|
|||||||
{"price_chip", "", payments.CurrencyChip},
|
{"price_chip", "", payments.CurrencyChip},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||||
// flag, and the inline create form.
|
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||||
|
// active/all toggle).
|
||||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
showAll := c.Query("all") == "1"
|
||||||
|
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -44,13 +46,32 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
|||||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||||
payments.SortAdminCatalog(products)
|
payments.SortAdminCatalog(products)
|
||||||
var view adminconsole.CatalogView
|
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||||
|
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||||
|
// refused by the service.
|
||||||
|
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||||
|
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||||
|
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||||
|
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||||
|
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||||||
|
}
|
||||||
|
|
||||||
// catalogRow projects a product into its list row.
|
// catalogRow projects a product into its list row.
|
||||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||||
@@ -69,7 +90,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -242,7 +263,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
|||||||
// bundles, including archived ones — chips and tournament products are excluded).
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
products, err := s.payments.AdminCatalog(ctx)
|
products, err := s.payments.AdminCatalog(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
gm.GET("/catalog", s.consoleCatalog)
|
gm.GET("/catalog", s.consoleCatalog)
|
||||||
gm.POST("/catalog", s.consoleCreateProduct)
|
gm.POST("/catalog", s.consoleCreateProduct)
|
||||||
|
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
|
|
||||||
# --- UI build args (baked into the gateway image) ---------------------------
|
# --- UI build args (baked into the gateway image) ---------------------------
|
||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
|||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|
||||||
|
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
|
||||||
|
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
|
||||||
|
# variables, shared across contours; in the test contour the stamped client version is a commit hash
|
||||||
|
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
|
||||||
|
GATEWAY_MIN_CLIENT_VERSION=
|
||||||
|
GATEWAY_RECOMMENDED_CLIENT_VERSION=
|
||||||
|
|
||||||
# --- VK Mini App ------------------------------------------------------------
|
# --- VK Mini App ------------------------------------------------------------
|
||||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||||
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
|
||||||
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||||
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||||
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
@@ -118,6 +118,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
||||||
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
||||||
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
||||||
|
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
|
||||||
|
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
|
||||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
@@ -236,6 +238,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
|
|||||||
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
||||||
that survives losing the host.
|
that survives losing the host.
|
||||||
|
|
||||||
|
## Android app build & release (RuStore)
|
||||||
|
|
||||||
|
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
|
||||||
|
web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release runbook follows.
|
||||||
|
|
||||||
|
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
|
||||||
|
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
|
||||||
|
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
|
||||||
|
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
|
||||||
|
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
|
||||||
|
**unsigned** release APK otherwise (a dry run still proves the pipeline).
|
||||||
|
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned** —
|
||||||
|
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
|
||||||
|
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
|
||||||
|
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
|
||||||
|
the SDK is missing or unreadable.
|
||||||
|
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
||||||
|
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
||||||
|
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||||
|
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||||
|
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
||||||
|
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
||||||
|
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||||
|
away cleanly instead of failing blind.
|
||||||
|
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
|
||||||
|
|
||||||
## Point-in-time recovery (PITR)
|
## Point-in-time recovery (PITR)
|
||||||
|
|
||||||
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
||||||
|
|||||||
@@ -107,12 +107,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# The public offer page is rendered by the render sidecar: it splices the live catalog price
|
# The public legal pages are rendered by the render sidecar: the offer (with the live catalog
|
||||||
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
|
# price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA
|
||||||
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
|
# (both static markdown). Only these paths are exposed here — the sidecar's /render stays off this
|
||||||
# disjoint from the landing/app paths so the catch-all below never shadows it.
|
# allow-list, internal-only. Kept disjoint from the landing/app paths so the catch-all below never
|
||||||
@offer path /offer /offer/*
|
# shadows them.
|
||||||
handle @offer {
|
@legal path /offer /offer/* /privacy /privacy/* /eula /eula/*
|
||||||
|
handle @legal {
|
||||||
reverse_proxy renderer:8090
|
reverse_proxy renderer:8090
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ services:
|
|||||||
GATEWAY_HTTP_ADDR: ":8081"
|
GATEWAY_HTTP_ADDR: ":8081"
|
||||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||||
|
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
|
||||||
|
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
|
||||||
|
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
|
||||||
|
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
|
||||||
|
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
|
||||||
|
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
|
||||||
# Telegram auth validates against the home validator (plaintext, internal).
|
# Telegram auth validates against the home validator (plaintext, internal).
|
||||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
|
|||||||
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
export LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
export DICT_VERSION='$DICT_VERSION'
|
||||||
export APP_VERSION='$APP_VERSION'
|
export APP_VERSION='$APP_VERSION'
|
||||||
|
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
|
||||||
|
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
|
||||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||||
|
|||||||
@@ -100,15 +100,35 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
auth operations are unauthenticated and return the minted token. A unary
|
auth operations are unauthenticated and return the minted token. A unary
|
||||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||||
response was lost — its button is disabled while offline and the player re-issues it on
|
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
|
||||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
|
||||||
|
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
|
||||||
|
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||||
|
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||||
|
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||||
|
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||||
|
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
|
||||||
|
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||||
|
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||||
|
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||||
|
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** —
|
||||||
|
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
|
||||||
|
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
|
||||||
|
switch and no device-local create paths. The machine still tracks reachability there, though: a
|
||||||
|
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
||||||
|
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
||||||
|
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||||
|
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
|
||||||
|
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
||||||
|
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
||||||
|
look-up hidden.
|
||||||
**Edge hardening:** every request body on the public listener is capped at
|
**Edge hardening:** every request body on the public listener is capped at
|
||||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||||
@@ -140,6 +160,40 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||||
platform-native push via the platform side-service.
|
platform-native push via the platform side-service.
|
||||||
|
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
|
||||||
|
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
|
||||||
|
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
|
||||||
|
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
|
||||||
|
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
|
||||||
|
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
|
||||||
|
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
|
||||||
|
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
|
||||||
|
vars empty ⇒ off, web behaviour unchanged).
|
||||||
|
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
|
||||||
|
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
|
||||||
|
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
|
||||||
|
`offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
|
||||||
|
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
|
||||||
|
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
|
||||||
|
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
|
||||||
|
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
|
||||||
|
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
|
||||||
|
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
|
||||||
|
(validated at config load); empty ⇒ the soft tier is off.
|
||||||
|
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
|
||||||
|
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
|
||||||
|
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
|
||||||
|
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
|
||||||
|
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
|
||||||
|
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
|
||||||
|
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
|
||||||
|
rollout (see [`../deploy/README.md`](../deploy/README.md)).
|
||||||
|
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
|
||||||
|
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
|
||||||
|
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
|
||||||
|
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
|
||||||
|
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
|
||||||
|
rather than interrupting local play.
|
||||||
|
|
||||||
## 3. Authentication & sessions
|
## 3. Authentication & sessions
|
||||||
|
|
||||||
@@ -224,6 +278,19 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||||
Platform and email users are auto-provisioned **durable** accounts with an
|
Platform and email users are auto-provisioned **durable** accounts with an
|
||||||
identity.
|
identity.
|
||||||
|
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
|
||||||
|
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
|
||||||
|
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
|
||||||
|
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
|
||||||
|
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
|
||||||
|
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
|
||||||
|
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
|
||||||
|
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
|
||||||
|
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
|
||||||
|
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
|
||||||
|
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
|
||||||
|
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
|
||||||
|
prior online-session rule.
|
||||||
|
|
||||||
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
||||||
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
||||||
@@ -909,10 +976,14 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
|
|||||||
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||||
image option is not offered.
|
image option is not offered.
|
||||||
|
|
||||||
The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed
|
The same sidecar also serves the **public legal pages** — the offer at `/offer/`, the privacy
|
||||||
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
|
policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
|
||||||
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
|
here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
|
||||||
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
|
`renderLegalHtml` (one renderer, no drift) over their owner-edited `ui/legal/*_{ru,en}.md` — each is
|
||||||
|
**bilingual (RU + EN)** with a client-side language + theme switcher (default Russian, persisted;
|
||||||
|
the offer's EN view transliterates the Russian product names) — baked into the image. `/privacy/`
|
||||||
|
and `/eula/` are static; the offer additionally splices in the **live price list** (§4.4) into both
|
||||||
|
languages: it
|
||||||
fetches the two catalog tables as markdown from the backend's internal
|
fetches the two catalog tables as markdown from the backend's internal
|
||||||
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
|
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
|
||||||
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
|
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
|
||||||
@@ -1307,7 +1378,9 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
|
|||||||
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||||
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||||
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||||
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
|
parameters from the URL and the gateway verifies them in-process — §12; on that path without a
|
||||||
|
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
|
||||||
|
a throwaway guest); a stray hit on the
|
||||||
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
@@ -1332,13 +1405,18 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
|||||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
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
|
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
|
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
|
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
|
||||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
|
||||||
|
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
|
||||||
|
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
|
||||||
|
hotseat create is guarded when
|
||||||
|
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||||
|
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`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
|
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
|
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||||
@@ -1366,36 +1444,27 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
|||||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
— 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
|
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
|
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
|
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||||
mode** boots from the persisted session and
|
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
||||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
||||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
||||||
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
|
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
|
||||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
|
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
|
||||||
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
|
legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar — the offer with
|
||||||
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
|
the live catalog price list spliced in) go to the render sidecar; and the catch-all — notably the
|
||||||
|
landing at `/` — goes to the landing
|
||||||
container. The
|
container. The
|
||||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||||
@@ -1468,6 +1537,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
||||||
contour activates; the prod main host omits it.
|
contour activates; the prod main host omits it.
|
||||||
|
|
||||||
|
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
|
||||||
|
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
|
||||||
|
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
|
||||||
|
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
|
||||||
|
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
|
||||||
|
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
|
||||||
|
the cache). It is **offline-first**:
|
||||||
|
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
|
||||||
|
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
|
||||||
|
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
|
||||||
|
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
|
||||||
|
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
|
||||||
|
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
|
||||||
|
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
|
||||||
|
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
|
||||||
|
with a host-provisioned Android SDK; RuStore upload is manual. Runbook:
|
||||||
|
[`../deploy/README.md`](../deploy/README.md).
|
||||||
|
|
||||||
## 14. CI & branches
|
## 14. CI & branches
|
||||||
|
|
||||||
- **Two long-lived branches**: **`development`** is the integration
|
- **Two long-lived branches**: **`development`** is the integration
|
||||||
|
|||||||
@@ -30,11 +30,13 @@ render with arbitrary languages, so detection would make the indexed content
|
|||||||
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
|
||||||
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
|
||||||
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. The footer links to the **public
|
shell is `noindex` — the landing is the only indexable page. The footer links to the three **legal
|
||||||
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
|
documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
|
||||||
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
|
**public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
|
||||||
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
|
the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
|
||||||
the published prices always match what is currently on sale — no redeploy to update them.
|
from its `ui/legal/*_{ru,en}.md` sources by the render sidecar; the offer additionally splices in its
|
||||||
|
**price list** (§4.4) generated from the live product catalog, so the published prices always match
|
||||||
|
what is currently on sale — no redeploy to update them.
|
||||||
|
|
||||||
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
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
|
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
||||||
@@ -70,7 +72,10 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
|
|||||||
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||||||
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
||||||
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
||||||
"couldn't load" screen applies inside VK.
|
"couldn't load" screen applies inside VK. Opening a Mini App link directly in an ordinary browser —
|
||||||
|
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
|
||||||
|
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
|
||||||
|
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
|
||||||
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
||||||
(en/ru), no images — to the address; entering it signs the player in. A new address
|
(en/ru), no images — to the address; entering it signs the player in. A new address
|
||||||
is provisioned on the first request but only becomes a durable account once the code
|
is provisioned on the first request but only becomes a durable account once the code
|
||||||
@@ -224,7 +229,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server
|
|||||||
local dictionary is unavailable. The dictionary check tool is
|
local dictionary is unavailable. The dictionary check tool is
|
||||||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||||||
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. While offline in an online game the check runs against the game's own
|
||||||
|
dictionary on the device — the same verdict as the server when that dictionary is available,
|
||||||
|
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
|
||||||
|
need the network, are hidden. 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. Against the robot
|
||||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||||
@@ -277,22 +285,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
|||||||
statistics.
|
statistics.
|
||||||
|
|
||||||
### Offline mode
|
### Offline mode
|
||||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
native apps only). When it cannot reach the
|
||||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||||
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
app never touches the network.
|
||||||
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
|
The **lobby stays unified**: your device-local games are active, while your server games are still
|
||||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
|
||||||
|
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
|
||||||
|
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
|
||||||
|
backend; local games are kept on the device 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, or on the native app) launches from bundled/precached data even
|
||||||
|
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
|
||||||
|
a short note.
|
||||||
|
|
||||||
|
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
|
||||||
|
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
|
||||||
|
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
|
||||||
|
In pass-and-play 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 —
|
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
|
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**.
|
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||||
@@ -305,14 +319,42 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
|
|||||||
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
|
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.
|
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
|
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
||||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
||||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
||||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
offline, so you are never interrupted for a hiccup.
|
||||||
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
|
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||||||
|
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||||||
|
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||||
|
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||||||
|
board as a draft. Nothing
|
||||||
|
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||||||
|
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||||||
|
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||||||
|
on-device dictionary.
|
||||||
|
|
||||||
|
### Staying up to date
|
||||||
|
When a new client version is required, the app does not lock you out. On the **native** app a
|
||||||
|
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||||||
|
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
|
||||||
|
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
|
||||||
|
required — version exists; you can update or dismiss it, and play continues either way.
|
||||||
|
|
||||||
|
### Native app (Android)
|
||||||
|
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
|
||||||
|
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
|
||||||
|
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
|
||||||
|
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
|
||||||
|
network is available the app quietly establishes a guest in the background and online features (random
|
||||||
|
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
|
||||||
|
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
|
||||||
|
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
|
||||||
|
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
|
||||||
|
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
|
||||||
|
this appears only on an online action, never while you are playing offline.
|
||||||
|
|
||||||
### 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
|
||||||
@@ -407,9 +449,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
|
|||||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||||
|
|
||||||
### Telegram support chat
|
### Telegram support chat
|
||||||
A user can also reach the operators straight from the Telegram bot: anything they send the bot
|
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
|
||||||
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
|
with the support desk's working hours and what to include (a description and, when possible,
|
||||||
private support group, grouped into a per-user thread. The user sees no automatic reply; an
|
screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
|
||||||
|
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
|
||||||
|
user sees no automatic reply to a forwarded message; an
|
||||||
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
||||||
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
||||||
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||||||
@@ -512,7 +556,8 @@ at any time (games already lost stay lost).
|
|||||||
|
|
||||||
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
||||||
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
||||||
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
|
||||||
|
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||||||
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
||||||
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||||||
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||||||
|
|||||||
@@ -32,12 +32,13 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||||
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
|
`noindex` — индексируется только посадочная страница. В подвале — ссылки на три **юридических
|
||||||
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
|
документа** — **пользовательское соглашение** (`/eula/`), **политику конфиденциальности**
|
||||||
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
|
(`/privacy/`) и **публичную оферту** (`/offer/`) — и рядом на **обратную связь** (тот самый
|
||||||
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
|
Telegram-бот, который документы указывают как контакт Продавца). Каждый — **двуязычный (RU/EN)** с
|
||||||
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
|
переключателем языка и темы, рендерится из исходников `ui/legal/*_{ru,en}.md` сайдкаром-рендерером;
|
||||||
сейчас в продаже — без передеплоя.
|
оферта дополнительно подставляет **перечень стоимости** (§4.4), формируемый из живого каталога
|
||||||
|
товаров, поэтому опубликованные цены всегда совпадают с тем, что сейчас в продаже — без передеплоя.
|
||||||
|
|
||||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||||
@@ -76,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
тихого повтора «не удалось
|
тихого повтора «не удалось
|
||||||
загрузить» действует и внутри VK.
|
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||||
|
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||||
|
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||||
|
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
@@ -232,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||||
|
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||||
|
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
@@ -283,22 +289,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
### Офлайн-режим
|
### Офлайн-режим
|
||||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
приложениям). Когда оно не может достучаться
|
||||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
сети.
|
||||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
|
||||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
|
||||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
|
||||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
|
||||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
|
||||||
сохраняется между запусками.
|
|
||||||
|
|
||||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||||
|
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||||
|
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||||
|
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||||
|
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||||
|
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||||
|
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||||
|
|
||||||
|
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||||
|
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||||
|
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||||
|
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||||
@@ -312,14 +323,42 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||||
своего хода.
|
своего хода.
|
||||||
|
|
||||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
вас не прерывают из-за короткой икоты.
|
||||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
|
||||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||||
|
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||||
|
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||||
|
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||||
|
Ничего не отправляется;
|
||||||
|
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||||
|
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||||
|
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||||
|
|
||||||
|
### Актуальная версия
|
||||||
|
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||||
|
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||||
|
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||||
|
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||||
|
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||||
|
|
||||||
|
### Нативное приложение (Android)
|
||||||
|
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||||
|
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
||||||
|
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
||||||
|
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
||||||
|
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
||||||
|
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
||||||
|
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
||||||
|
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
||||||
|
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
||||||
|
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
||||||
|
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
||||||
|
офлайн-игры.
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
@@ -418,9 +457,11 @@ Telegram. На сенсорном устройстве в настройках
|
|||||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||||
|
|
||||||
### Чат поддержки в Telegram
|
### Чат поддержки в Telegram
|
||||||
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
|
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
|
||||||
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
|
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
|
||||||
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
|
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
|
||||||
|
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
|
||||||
|
Пользователь не получает автоответа на пересланное сообщение; оператор
|
||||||
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
||||||
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
||||||
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
||||||
@@ -526,7 +567,9 @@ high-rate флага. С карточки пользователя операт
|
|||||||
|
|
||||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||||
|
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||||
|
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Icon & logo asset map
|
||||||
|
|
||||||
|
> The whole shipped icon set is sliced from **one master**. This file is the per-platform
|
||||||
|
> **target map** (which file goes where, at what size). How the mark itself is constructed
|
||||||
|
> — proportions, palette, wood grain, the light/shadow gradients, the «Э» + ✻ placement,
|
||||||
|
> all in fractions of the side — is specified in [`ICON_BRANDBOOK.md`](ICON_BRANDBOOK.md).
|
||||||
|
> The reference generator, the pinned Spectral font and the committed masters live in
|
||||||
|
> [`../assets/icons/brand/`](../assets/icons/brand/).
|
||||||
|
|
||||||
|
## The master (one source, six layers)
|
||||||
|
|
||||||
|
`brand/build-icon.mjs` emits any `--variant light|dark` in any `--layer`:
|
||||||
|
|
||||||
|
| Layer | What | Feeds |
|
||||||
|
|---|---|---|
|
||||||
|
| `full` | rounded tile + master mark | `favicon.svg` |
|
||||||
|
| `flat` | square tile + master mark (OS rounds) | apple-touch, PWA "any", VK, TG, store |
|
||||||
|
| `background` | square tile only, full-bleed | Android adaptive background |
|
||||||
|
| `foreground` | mark only, transparent, re-placed for the round mask | Android adaptive foreground |
|
||||||
|
| `maskable` | square tile + foreground-placed mark | PWA `maskable` |
|
||||||
|
| `monochrome` | foreground-placed silhouette, flat colour | Android 13+ themed layer |
|
||||||
|
|
||||||
|
`brand/build-set.mjs` renders every raster below; `brand/build-android-res.mjs` renders
|
||||||
|
the Android launcher resources.
|
||||||
|
|
||||||
|
## Generated targets
|
||||||
|
|
||||||
|
### Web — `ui/public/` (via `brand/build-set.mjs`)
|
||||||
|
|
||||||
|
| File | Size | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `favicon.svg` | vector | **light + dark in one file** via `prefers-color-scheme` |
|
||||||
|
| `favicon.ico` | 32×32 | PNG-in-ICO; answers the blind `/favicon.ico` probe |
|
||||||
|
| `apple-touch-icon.png` | 180×180 | opaque full-bleed (iOS masks its own corners) |
|
||||||
|
| `icon-192.png` / `icon-512.png` | 192 / 512 | PWA `any`, light |
|
||||||
|
| `icon-192-dark.png` / `icon-512-dark.png` | 192 / 512 | dark variants (generated; see PWA note) |
|
||||||
|
| `icon-maskable-512.png` | 512×512 | PWA `maskable`, light |
|
||||||
|
| `icon-maskable-512-dark.png` | 512×512 | dark variant (generated; see PWA note) |
|
||||||
|
| `og-image.png` | 1200×630 | board-green card: master tile + «Эрудит» / Игра в слова |
|
||||||
|
|
||||||
|
### PWA — `ui/public/manifest.webmanifest`
|
||||||
|
|
||||||
|
Referenced icons are the **light** set (`icon-192`, `icon-512` = `any`;
|
||||||
|
`icon-maskable-512` = `maskable`). The manifest has **no reliable way to pick an icon by
|
||||||
|
colour scheme**, so the dark PNGs are **generated but not referenced** — theming the
|
||||||
|
in-browser tab is handled by `favicon.svg` instead. Wire the dark files in only if a
|
||||||
|
concrete installer target is known to honour them.
|
||||||
|
|
||||||
|
### Android — `ui/android/app/src/main/res/` (via `brand/build-android-res.mjs`)
|
||||||
|
|
||||||
|
Capacitor layer inputs are `ui/assets/{icon,icon-foreground,icon-background}.png`. The
|
||||||
|
resources are **hand-generated from the layers**, not `capacitor-assets` — our layers
|
||||||
|
already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
|
||||||
|
full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
|
||||||
|
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
|
||||||
|
|
||||||
|
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
|
||||||
|
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
|
||||||
|
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
|
||||||
|
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no `<inset>`**, with
|
||||||
|
`<background>` + `<foreground>` + `<monochrome>` (committed by hand)
|
||||||
|
|
||||||
|
### iOS / iPhone / iPad — FUTURE
|
||||||
|
|
||||||
|
No iOS shell today. When one exists, `flat` (opaque, no alpha, no rounded corners; the OS
|
||||||
|
masks) drives the asset catalog — a single 1024×1024 marketing icon (Xcode derives the
|
||||||
|
rest), plus light/dark/tinted where the catalog supports it.
|
||||||
|
|
||||||
|
### VK / Telegram / store — manual upload (`brand/{vk,tg,store}/`)
|
||||||
|
|
||||||
|
Square, **no rounding** (each platform crops/rounds itself), light `flat`:
|
||||||
|
|
||||||
|
| Folder | Files | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `vk/` | `vk-576/278/150/32.png` | VK community / app icons |
|
||||||
|
| `tg/` | `tg-512.png` | Telegram bot avatar + Mini App icon (512, square; TG crops circular) |
|
||||||
|
| `tg/` | `tg-demo-640x360.png` | BotFather `/newapp` demo banner (tile + wordmark) |
|
||||||
|
| `store/` | `rustore-512.png` | RuStore app icon |
|
||||||
|
| RuStore | screenshots / feature graphic | per the RuStore listing spec (uploaded by hand) |
|
||||||
|
| Google Play (future) | icon / feature graphic | 512×512 / 1024×500 |
|
||||||
|
| App Store (future) | marketing icon / screenshots | 1024×1024 / per device class |
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i # opentype.js (rasterisation reuses the ui package's chromium)
|
||||||
|
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/ + og
|
||||||
|
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||||
|
```
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# Erudit app-icon — brand book
|
||||||
|
|
||||||
|
The single source of truth for **how the Erudit icon is constructed**. It is written to
|
||||||
|
be reproducible *from words alone*: a designer with this document and the Spectral font
|
||||||
|
can rebuild the icon exactly, at any size, without pixel-matching anything.
|
||||||
|
|
||||||
|
Two rules make that possible:
|
||||||
|
|
||||||
|
1. **The icon is a square.** Every size and position below is a **fraction of the
|
||||||
|
square's side** (equivalently, a percentage). Nothing is in pixels. Draw the square
|
||||||
|
at whatever resolution you need and scale each value to it.
|
||||||
|
2. **The origin is the top-left corner.** `x` grows right, `y` grows down. So "center
|
||||||
|
`48.1% / 49.2%`" means a point `0.481·side` from the left and `0.492·side` from the top.
|
||||||
|
|
||||||
|
The companion generator in [`../assets/icons/brand/`](../assets/icons/brand/) is the
|
||||||
|
machine version of this exact spec (`build-icon.mjs`, where each constant is the same
|
||||||
|
fraction printed here). The `docs/ICONS.md` map covers the *opposite* concern — which
|
||||||
|
files/sizes we slice this master into for each platform.
|
||||||
|
|
||||||
|
## Anatomy
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Bottom-to-top the icon is six layers: **tile → wood grain → light sheen → shadow →
|
||||||
|
letter «Э» → star ✻**. The letter and star are always drawn last, at full ink colour;
|
||||||
|
the grain and the two gradients only ever touch the background.
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
|
||||||
|
The icon ships as a **light** and a **dark** variant (a matched pair — the light tile
|
||||||
|
carries dark ink, the dark tile carries light ink). Each variant uses four colours.
|
||||||
|
|
||||||
|
| Role | Light | Dark |
|
||||||
|
|------|-------|------|
|
||||||
|
| **Tile** — the wooden body | `#e6b053` | `#4a3316` |
|
||||||
|
| **Ink** — the «Э» and the ✻ | `#5a3a12` | `#f2d9a0` |
|
||||||
|
| **Grain** — the vertical stripes | `#d8a54e` | `#432e14` |
|
||||||
|
| **Sheen** — diagonal light | white, α 0 → 15 % | white, α 0 → 15 % |
|
||||||
|
| **Shadow** — diagonal dark | black, α 15 → 0 % | black, α 15 → 0 % |
|
||||||
|
|
||||||
|
The grain colour is intentionally close to the tile — a hair darker — so the stripes
|
||||||
|
read as wood texture, not as stripes.
|
||||||
|
|
||||||
|
## 1 — Tile
|
||||||
|
|
||||||
|
A **square with rounded corners**, corner radius **17 %** of the side, filled with the
|
||||||
|
**Tile** colour. This is the whole background; there is no separate outer frame or
|
||||||
|
border. Corners are baked at 17 %; platform masks (iOS/Android) may round further on top.
|
||||||
|
|
||||||
|
## 2 — Wood grain
|
||||||
|
|
||||||
|
Subtle vertical planks over the tile:
|
||||||
|
|
||||||
|
- Divide the width into **6 equal columns** (each `1/6` of the side wide).
|
||||||
|
- On the **right edge of each column**, draw one **vertical stripe**, width **`1/32` of
|
||||||
|
the side** (≈ 3.1 %), running the full height.
|
||||||
|
- Move the **whole set of six stripes left** by **`1/16` of the side** (= two stripe
|
||||||
|
widths).
|
||||||
|
- **Tilt every stripe 4°** (clockwise, about its own centre).
|
||||||
|
- Draw the stripes **past the top and bottom edges** so the tilt leaves no empty corners,
|
||||||
|
then **clip the whole set to the tile** (so the rounded corners stay clean).
|
||||||
|
- Colour: **Grain**.
|
||||||
|
|
||||||
|
## 3 — Light sheen · 4 — Shadow
|
||||||
|
|
||||||
|
Two linear gradients along the **same diagonal**, from the **bottom-left corner to the
|
||||||
|
top-right corner**, each clipped to the tile and sitting **above the grain, below the
|
||||||
|
letter**:
|
||||||
|
|
||||||
|
- **Sheen** — white. Fully transparent at the bottom-left, rising to **15 % opacity** at
|
||||||
|
the top-right. Brightens the top-right.
|
||||||
|
- **Shadow** — black. **15 % opacity** at the bottom-left, fading to transparent at the
|
||||||
|
top-right. Deepens the bottom-left.
|
||||||
|
|
||||||
|
Together they give the tile a lit-from-the-top-right, resting-in-shadow-at-the-bottom-left
|
||||||
|
sense of depth. Both percentages are a tuning knob (`--sheen`, `--shade`).
|
||||||
|
|
||||||
|
## 5 — The letter «Э»
|
||||||
|
|
||||||
|
- Typeface: **Spectral, Bold** (SIL OFL; pinned in the brand folder).
|
||||||
|
- Glyph: the Cyrillic capital **«Э»** (U+042D).
|
||||||
|
- Size: scale the glyph so its **ink bounding box is 60.5 % of the side tall** (its width
|
||||||
|
then follows the font — about 53.2 %).
|
||||||
|
- Position: place the **center of that bounding box** at **48.1 % / 49.2 %** (just up and
|
||||||
|
left of the icon center).
|
||||||
|
- Colour: **Ink**.
|
||||||
|
|
||||||
|
## 6 — The star ✻
|
||||||
|
|
||||||
|
A **six-spoke teardrop asterisk** (the "score" mark that replaces a tile's point number):
|
||||||
|
|
||||||
|
- Each spoke tapers to a **point at the center** and ends in a **round bulb**; a central
|
||||||
|
disc equal to a bulb fuses the six spokes.
|
||||||
|
- Outer diameter: **17.3 % of the side**.
|
||||||
|
- Bulb radius (and the central disc): **22 % of the star's radius**.
|
||||||
|
- Position: center at **78.8 % / 78.8 %** — tucked to the lower-right of the «Э», like a
|
||||||
|
subscript, with a clear gap from the letter.
|
||||||
|
- Colour: **Ink**.
|
||||||
|
|
||||||
|
Spectral has no ✻ glyph, so the star is **drawn geometrically**, not set as type. The exact
|
||||||
|
construction is in `build-icon.mjs` (`starPath`).
|
||||||
|
|
||||||
|
## Composition intent
|
||||||
|
|
||||||
|
The «Э» and the ✻ are treated as **one group**. Its combined bounding box measures
|
||||||
|
about **65.9 % × 68.6 %** of the side, and it is placed so its **bottom-right corner sits
|
||||||
|
at 87.5 % / 87.5 %** of the icon (the lower-right corner of an inner `12.5 % … 87.5 %`
|
||||||
|
safe square). That is what pushes the composition down-and-right and leaves the star room
|
||||||
|
in the corner. When re-deriving positions, this is the anchor to preserve.
|
||||||
|
|
||||||
|
> This bottom-right bias suits a **full-bleed** icon (iOS / PWA / favicon, which show
|
||||||
|
> almost the whole square). Android crops more, so it uses a **separate two-layer build**
|
||||||
|
> — see the next section. The master itself is never moved for Android.
|
||||||
|
|
||||||
|
## Android adaptive icon (two layers)
|
||||||
|
|
||||||
|
Android icons are **108×108 dp** and the OS applies its own mask (circle, squircle,
|
||||||
|
rounded square…). Only the **inner 66 dp = 61 % of the side, centred** is guaranteed
|
||||||
|
visible under every mask; the outer ~1/6 ring is used for the mask and motion effects.
|
||||||
|
So Android is **not** the full-bleed master — it is built as two layers:
|
||||||
|
|
||||||
|
- **Background** — the tile with wood grain and the light/shadow gradients, **full-bleed
|
||||||
|
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
|
||||||
|
layer, so the sacrificial outer ring is just more wood.
|
||||||
|
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
||||||
|
the mask:
|
||||||
|
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
|
||||||
|
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
|
||||||
|
tucked closer to the letter.
|
||||||
|
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
|
||||||
|
centred under the round mask, and the whole group fits within the **61 % safe zone**.
|
||||||
|
|
||||||
|
Everything else (palette, grain, gradients, star construction) is identical to the master.
|
||||||
|
|
||||||
|
## Variants in use
|
||||||
|
|
||||||
|
The **light** variant is the primary icon (launcher, favicon, apple-touch, PWA). The
|
||||||
|
**dark** variant is shipped in addition wherever the platform can pick by appearance
|
||||||
|
(e.g. an SVG favicon via `prefers-color-scheme`). Where a platform allows only one icon,
|
||||||
|
use **light**.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i opentype.js
|
||||||
|
# full master (iOS / PWA / favicon), either variant:
|
||||||
|
node build-icon.mjs --variant light --layer full > icon-light.svg
|
||||||
|
# Android adaptive layers:
|
||||||
|
node build-icon.mjs --variant light --layer background > icon-light-android-background.svg
|
||||||
|
node build-icon.mjs --variant light --layer foreground > icon-light-android-foreground.svg
|
||||||
|
# the construction figure:
|
||||||
|
node build-icon.mjs --variant light --scheme > icon-construction.svg
|
||||||
|
node render-png.mjs icon-light.svg icon-light.png 1024 # any size
|
||||||
|
```
|
||||||
|
|
||||||
|
`--layer` is `full` (default), `background`, or `foreground`. All outputs are committed
|
||||||
|
alongside the generator in [`../assets/icons/brand/`](../assets/icons/brand/).
|
||||||
|
|
||||||
|
## Masters
|
||||||
|
|
||||||
|
| | Light | Dark |
|
||||||
|
|-|-------|------|
|
||||||
|
| **Full** (iOS / PWA / favicon) |  |  |
|
||||||
|
| **Android background** |  |  |
|
||||||
|
| **Android foreground** |  |  |
|
||||||
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||||
|
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||||
|
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||||
|
|||||||
@@ -17,21 +17,43 @@ tests or touching CI.
|
|||||||
- **UI** — Vitest (unit) + Playwright
|
- **UI** — Vitest (unit) + Playwright
|
||||||
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
|
||||||
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
|
||||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
derivation, the GCG share/copy/download choice and the **net-state reducer**
|
||||||
|
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
|
||||||
|
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
|
||||||
|
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). The
|
||||||
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
|
**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
|
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
|
||||||
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
|
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
|
||||||
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
|
Stats tab), self-heals back online, 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
|
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
|
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
|
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`
|
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
|
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
|
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
|
||||||
production build.
|
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
|
||||||
|
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
|
||||||
|
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
|
||||||
|
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
|
||||||
|
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
|
||||||
|
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
|
||||||
|
**unified lobby** — and Profile hides the Telegram/VK link buttons.
|
||||||
|
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
|
||||||
|
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
|
||||||
|
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
|
||||||
|
update_required` mapping.
|
||||||
|
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
|
||||||
|
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
|
||||||
|
assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
|
||||||
|
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
|
||||||
|
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
|
||||||
|
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
|
||||||
|
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
|
||||||
|
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
|
||||||
|
minimum.
|
||||||
- **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
|
||||||
@@ -175,6 +197,16 @@ tests or touching CI.
|
|||||||
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
|
||||||
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
|
||||||
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
|
||||||
|
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
|
||||||
|
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
|
||||||
|
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
|
||||||
|
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
|
||||||
|
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
|
||||||
|
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
|
||||||
|
lights up online play (a server guest is established); Profile offers **email** sign-in but no
|
||||||
|
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
|
||||||
|
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
|
||||||
|
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
|
||||||
|
|
||||||
## Principles
|
## Principles
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
|
|||||||
VITE_APP_VERSION=$VITE_APP_VERSION \
|
VITE_APP_VERSION=$VITE_APP_VERSION \
|
||||||
VITE_ADS_STUB=$VITE_ADS_STUB
|
VITE_ADS_STUB=$VITE_ADS_STUB
|
||||||
|
|
||||||
# Install with the lockfile first (the workspace file carries pnpm's build-script
|
# Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
|
||||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
# --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
|
||||||
|
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
|
||||||
|
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
|
||||||
|
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
|
||||||
|
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
|
||||||
|
# installs (where android:assets does need sharp built).
|
||||||
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||||
COPY ui ./
|
COPY ui ./
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ web code exchange via `internal/vkid`, and both forward the trusted `external_id
|
|||||||
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
|
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
|
||||||
300/min·user (burst 80, sized for multi-device play), admin
|
300/min·user (burst 80, sized for multi-device play), admin
|
||||||
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
|
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
|
||||||
email-code 5/10 min·IP (burst 2).
|
email-code 5/10 min·IP (burst 4, covering request + a mistyped code + the
|
||||||
|
correct one; defence-in-depth over the backend's per-code 5-attempt/15-min cap).
|
||||||
|
|
||||||
Every rejection increments `gateway_rate_limited_total{class}`
|
Every rejection increments `gateway_rate_limited_total{class}`
|
||||||
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
||||||
|
|||||||
@@ -221,22 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
registry := transcode.NewRegistry(backend, validator, regOpts...)
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Backend: backend,
|
Backend: backend,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Tracker: tracker,
|
Tracker: tracker,
|
||||||
Banlist: banlist,
|
Banlist: banlist,
|
||||||
Blocklist: blocklist,
|
Blocklist: blocklist,
|
||||||
Honeytoken: cfg.Abuse.Honeytoken,
|
Honeytoken: cfg.Abuse.Honeytoken,
|
||||||
VKAppSecret: cfg.VKAppSecret,
|
VKAppSecret: cfg.VKAppSecret,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
RateLimit: cfg.RateLimit,
|
RateLimit: cfg.RateLimit,
|
||||||
Heartbeat: cfg.PushHeartbeatInterval,
|
Heartbeat: cfg.PushHeartbeatInterval,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
AdminProxy: adminProxy,
|
AdminProxy: adminProxy,
|
||||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
||||||
MaxBodyBytes: cfg.MaxBodyBytes,
|
MaxBodyBytes: cfg.MaxBodyBytes,
|
||||||
|
MinClientVersion: cfg.MinClientVersion,
|
||||||
|
RecommendedClientVersion: cfg.RecommendedClientVersion,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
|
||||||
|
// version string so the edge can turn away a build too old to speak the current wire
|
||||||
|
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
|
||||||
|
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
|
||||||
|
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
|
||||||
|
// (the caller fails open — an absent or garbled header is never treated as too old).
|
||||||
|
package clientver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
|
||||||
|
// ordering; any pre-release or build suffix is dropped at parse time.
|
||||||
|
type Version struct {
|
||||||
|
Major, Minor, Patch int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
|
||||||
|
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
|
||||||
|
// reports ok=false when s has fewer than three numeric components or any component is not
|
||||||
|
// an integer, so the caller can distinguish a real version from a dev/empty string.
|
||||||
|
func Parse(s string) (Version, bool) {
|
||||||
|
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
|
||||||
|
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||||
|
s = s[:i]
|
||||||
|
}
|
||||||
|
p := strings.SplitN(s, ".", 4)
|
||||||
|
if len(p) < 3 {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
var v Version
|
||||||
|
var err error
|
||||||
|
if v.Major, err = strconv.Atoi(p[0]); err != nil {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
|
||||||
|
return Version{}, false
|
||||||
|
}
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
|
||||||
|
// are not Less than each other, so a client exactly at the minimum passes the gate.
|
||||||
|
func Less(a, b Version) bool {
|
||||||
|
if a.Major != b.Major {
|
||||||
|
return a.Major < b.Major
|
||||||
|
}
|
||||||
|
if a.Minor != b.Minor {
|
||||||
|
return a.Minor < b.Minor
|
||||||
|
}
|
||||||
|
return a.Patch < b.Patch
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package clientver
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
|
||||||
|
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
|
||||||
|
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
|
||||||
|
// gate fails open.
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want Version
|
||||||
|
wantK bool
|
||||||
|
}{
|
||||||
|
{"plain", "1.16.0", Version{1, 16, 0}, true},
|
||||||
|
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
|
||||||
|
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
|
||||||
|
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
|
||||||
|
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
|
||||||
|
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
|
||||||
|
{"dev", "dev", Version{}, false},
|
||||||
|
{"empty", "", Version{}, false},
|
||||||
|
{"too few components", "1.16", Version{}, false},
|
||||||
|
{"non-numeric patch", "1.16.x", Version{}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got, ok := Parse(tc.in)
|
||||||
|
if ok != tc.wantK {
|
||||||
|
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
|
||||||
|
}
|
||||||
|
if ok && got != tc.want {
|
||||||
|
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
|
||||||
|
// is not Less (a client exactly at the minimum passes the gate).
|
||||||
|
func TestLess(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
a, b Version
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
|
||||||
|
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
|
||||||
|
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
|
||||||
|
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
|
||||||
|
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
|
||||||
|
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
|
||||||
|
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := Less(tc.a, tc.b); got != tc.want {
|
||||||
|
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/clientver"
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
pkgtel "scrabble/pkg/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,16 @@ type Config struct {
|
|||||||
// MaxBodyBytes caps one inbound request body on the public listener and one
|
// MaxBodyBytes caps one inbound request body on the public listener and one
|
||||||
// Connect message read; oversized requests are refused without buffering.
|
// Connect message read; oversized requests are refused without buffering.
|
||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
|
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
|
||||||
|
// the edge serves. A client reporting an older X-Client-Version is turned away with an
|
||||||
|
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
|
||||||
|
// (every client is served) — the default for web-only deployments.
|
||||||
|
MinClientVersion string
|
||||||
|
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
|
||||||
|
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
|
||||||
|
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
|
||||||
|
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
|
||||||
|
RecommendedClientVersion string
|
||||||
// RateLimit configures the in-memory anti-abuse limiter.
|
// RateLimit configures the in-memory anti-abuse limiter.
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
|
||||||
@@ -154,7 +165,12 @@ func DefaultRateLimit() RateLimitConfig {
|
|||||||
// because multi-device play tripped the old 120/40.
|
// because multi-device play tripped the old 120/40.
|
||||||
UserPerMinute: 300, UserBurst: 80,
|
UserPerMinute: 300, UserBurst: 80,
|
||||||
AdminPerMinute: 60, AdminBurst: 20,
|
AdminPerMinute: 60, AdminBurst: 20,
|
||||||
EmailPer10Min: 5, EmailBurst: 2,
|
// Email-code path (per IP), defence-in-depth over the backend's own per-code guards
|
||||||
|
// (a 5-attempt cap + 15-min TTL + per-recipient send throttle). Burst 4 so the honest flow
|
||||||
|
// — request a code, mistype once or twice, then enter the right one — is not throttled
|
||||||
|
// mid-login: a request + wrong-code + right-code sequence exhausted the old burst of 2 and
|
||||||
|
// tripped the limit on the correct code, which the client mis-read as going offline.
|
||||||
|
EmailPer10Min: 5, EmailBurst: 4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,14 +242,16 @@ func (c VKIDConfig) Enabled() bool {
|
|||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
var err error
|
var err error
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||||
|
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
|
||||||
|
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
|
||||||
VKID: VKIDConfig{
|
VKID: VKIDConfig{
|
||||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||||
@@ -332,6 +350,23 @@ func (c Config) validate() error {
|
|||||||
if c.MaxBodyBytes <= 0 {
|
if c.MaxBodyBytes <= 0 {
|
||||||
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
|
||||||
}
|
}
|
||||||
|
if c.MinClientVersion != "" {
|
||||||
|
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
|
||||||
|
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.RecommendedClientVersion != "" {
|
||||||
|
rec, ok := clientver.Parse(c.RecommendedClientVersion)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
|
||||||
|
}
|
||||||
|
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
|
||||||
|
// misconfiguration (every client below min is already turned away). An empty/unparseable
|
||||||
|
// minimum imposes no lower bound, so the recommended may stand alone.
|
||||||
|
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
|
||||||
|
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
|
||||||
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,65 @@ func TestLoadMaxBodyBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
|
||||||
|
// default, a parseable version accepted, and an unparseable one rejected.
|
||||||
|
func TestLoadMinClientVersion(t *testing.T) {
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.MinClientVersion != "" {
|
||||||
|
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
|
||||||
|
}
|
||||||
|
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
|
||||||
|
if c, err = Load(); err != nil {
|
||||||
|
t.Fatalf("Load with a valid min version: %v", err)
|
||||||
|
}
|
||||||
|
if c.MinClientVersion != "v1.16.0" {
|
||||||
|
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
|
||||||
|
}
|
||||||
|
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
|
||||||
|
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
|
||||||
|
// rejected, and a recommended below the minimum rejected.
|
||||||
|
func TestLoadRecommendedClientVersion(t *testing.T) {
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.RecommendedClientVersion != "" {
|
||||||
|
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
|
||||||
|
}
|
||||||
|
// Stands alone with no minimum configured.
|
||||||
|
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
|
||||||
|
if c, err = Load(); err != nil {
|
||||||
|
t.Fatalf("Load with a valid recommended version (no min): %v", err)
|
||||||
|
}
|
||||||
|
if c.RecommendedClientVersion != "v1.20.0" {
|
||||||
|
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
|
||||||
|
}
|
||||||
|
// At or above the minimum is accepted.
|
||||||
|
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
|
||||||
|
if _, err = Load(); err != nil {
|
||||||
|
t.Fatalf("Load with recommended >= min: %v", err)
|
||||||
|
}
|
||||||
|
// Below the minimum is rejected.
|
||||||
|
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
|
||||||
|
}
|
||||||
|
// Unparseable is rejected.
|
||||||
|
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
|
||||||
// the agreed thresholds, and no honeytoken.
|
// the agreed thresholds, and no honeytoken.
|
||||||
func TestLoadAbuseDefaults(t *testing.T) {
|
func TestLoadAbuseDefaults(t *testing.T) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ var (
|
|||||||
errInternal = errors.New("internal error")
|
errInternal = errors.New("internal error")
|
||||||
errMissingToken = errors.New("missing session token")
|
errMissingToken = errors.New("missing session token")
|
||||||
errInvalidSession = errors.New("invalid or expired session")
|
errInvalidSession = errors.New("invalid or expired session")
|
||||||
|
errUpdateRequired = errors.New("client too old, update required")
|
||||||
)
|
)
|
||||||
|
|
||||||
// errUnknownMessageType reports an unregistered message type.
|
// errUnknownMessageType reports an unregistered message type.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"golang.org/x/net/http2/h2c"
|
"golang.org/x/net/http2/h2c"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
|
"scrabble/gateway/internal/clientver"
|
||||||
"scrabble/gateway/internal/config"
|
"scrabble/gateway/internal/config"
|
||||||
"scrabble/gateway/internal/push"
|
"scrabble/gateway/internal/push"
|
||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
@@ -46,6 +47,21 @@ const heartbeatKind = "heartbeat"
|
|||||||
// spoofer, so trusting it is safe.
|
// spoofer, so trusting it is safe.
|
||||||
const honeypotHeader = "X-Scrabble-Honeypot"
|
const honeypotHeader = "X-Scrabble-Honeypot"
|
||||||
|
|
||||||
|
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
|
||||||
|
// the edge can turn away a build too old to speak the current wire contract, before it decodes
|
||||||
|
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
|
||||||
|
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
|
||||||
|
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
|
||||||
|
// on a served Execute response when the client is at or above the hard minimum but below the
|
||||||
|
// recommended version, it nudges an update without failing the call. Headers are the
|
||||||
|
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
|
||||||
|
// contract (docs/ARCHITECTURE.md §2).
|
||||||
|
const (
|
||||||
|
clientVersionHeader = "X-Client-Version"
|
||||||
|
resultUpdateRequired = "update_required"
|
||||||
|
updateRecommendedHeader = "X-Update-Recommended"
|
||||||
|
)
|
||||||
|
|
||||||
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
|
||||||
// class field of the periodic rejection report.
|
// class field of the periodic rejection report.
|
||||||
const (
|
const (
|
||||||
@@ -88,6 +104,17 @@ type Server struct {
|
|||||||
|
|
||||||
maxBodyBytes int
|
maxBodyBytes int
|
||||||
|
|
||||||
|
// minClient is the lowest client version served; gateOn is false when the gate is dormant
|
||||||
|
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
|
||||||
|
minClient clientver.Version
|
||||||
|
gateOn bool
|
||||||
|
|
||||||
|
// recClient is the version below which a served client is nudged to update (the soft tier);
|
||||||
|
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
|
||||||
|
// unparseable). It is independent of the hard gate.
|
||||||
|
recClient clientver.Version
|
||||||
|
recOn bool
|
||||||
|
|
||||||
publicPolicy ratelimit.Policy
|
publicPolicy ratelimit.Policy
|
||||||
userPolicy ratelimit.Policy
|
userPolicy ratelimit.Policy
|
||||||
emailPolicy ratelimit.Policy
|
emailPolicy ratelimit.Policy
|
||||||
@@ -128,6 +155,13 @@ type Deps struct {
|
|||||||
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
// MaxBodyBytes caps one inbound request body and one Connect message read;
|
||||||
// zero or negative selects config.DefaultMaxBodyBytes.
|
// zero or negative selects config.DefaultMaxBodyBytes.
|
||||||
MaxBodyBytes int
|
MaxBodyBytes int
|
||||||
|
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
|
||||||
|
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
|
||||||
|
// the gate dormant.
|
||||||
|
MinClientVersion string
|
||||||
|
// RecommendedClientVersion is the version below which a served client is nudged to update (the
|
||||||
|
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
|
||||||
|
RecommendedClientVersion string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer constructs the edge service.
|
// NewServer constructs the edge service.
|
||||||
@@ -160,6 +194,30 @@ func NewServer(d Deps) *Server {
|
|||||||
if rl == (config.RateLimitConfig{}) {
|
if rl == (config.RateLimitConfig{}) {
|
||||||
rl = config.DefaultRateLimit()
|
rl = config.DefaultRateLimit()
|
||||||
}
|
}
|
||||||
|
// Parse the minimum client version once. Config.validate already rejects an unparseable
|
||||||
|
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
|
||||||
|
// the gate dormant.
|
||||||
|
var minClient clientver.Version
|
||||||
|
gateOn := false
|
||||||
|
if d.MinClientVersion != "" {
|
||||||
|
if v, ok := clientver.Parse(d.MinClientVersion); ok {
|
||||||
|
minClient, gateOn = v, true
|
||||||
|
} else {
|
||||||
|
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
|
||||||
|
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
|
||||||
|
// construction; an empty value leaves the soft tier dormant.
|
||||||
|
var recClient clientver.Version
|
||||||
|
recOn := false
|
||||||
|
if d.RecommendedClientVersion != "" {
|
||||||
|
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
|
||||||
|
recClient, recOn = v, true
|
||||||
|
} else {
|
||||||
|
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
|
||||||
|
}
|
||||||
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
registry: d.Registry,
|
registry: d.Registry,
|
||||||
sessions: d.Sessions,
|
sessions: d.Sessions,
|
||||||
@@ -176,6 +234,10 @@ func NewServer(d Deps) *Server {
|
|||||||
adminProxy: d.AdminProxy,
|
adminProxy: d.AdminProxy,
|
||||||
metrics: newServerMetrics(d.Meter, blocklist),
|
metrics: newServerMetrics(d.Meter, blocklist),
|
||||||
maxBodyBytes: maxBody,
|
maxBodyBytes: maxBody,
|
||||||
|
minClient: minClient,
|
||||||
|
gateOn: gateOn,
|
||||||
|
recClient: recClient,
|
||||||
|
recOn: recOn,
|
||||||
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
|
||||||
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
|
||||||
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
|
||||||
@@ -285,15 +347,71 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clientTooOld reports whether the X-Client-Version header names a version below the configured
|
||||||
|
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
|
||||||
|
// false. The header is a client-controlled compatibility signal, not an access control (it is
|
||||||
|
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
|
||||||
|
// non-browser caller — must never be blocked spuriously.
|
||||||
|
func (s *Server) clientTooOld(header string) bool {
|
||||||
|
if !s.gateOn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, ok := clientver.Parse(header)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return clientver.Less(v, s.minClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
|
||||||
|
// band — at or above the hard minimum but below the recommended version — so a served response can
|
||||||
|
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
|
||||||
|
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
|
||||||
|
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
|
||||||
|
// leaves minClient at the zero version, which no real version is below, so the band is simply
|
||||||
|
// "below recommended").
|
||||||
|
func (s *Server) clientUpdateRecommended(header string) bool {
|
||||||
|
if !s.recOn {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, ok := clientver.Parse(header)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute runs one unary operation. Domain failures are returned in the envelope
|
// Execute runs one unary operation. Domain failures are returned in the envelope
|
||||||
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
|
||||||
// session, unknown type, internal) become Connect errors.
|
// session, unknown type, internal) become Connect errors.
|
||||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
|
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
msgType := req.Msg.GetMessageType()
|
msgType := req.Msg.GetMessageType()
|
||||||
result := "internal"
|
result := "internal"
|
||||||
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
|
||||||
|
|
||||||
|
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
|
||||||
|
// client at or above the hard minimum but below the recommended version is told an update is
|
||||||
|
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
|
||||||
|
// version yields no nudge.
|
||||||
|
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
|
||||||
|
defer func() {
|
||||||
|
if recommend && resp != nil {
|
||||||
|
resp.Header().Set(updateRecommendedHeader, "1")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
|
||||||
|
// the payload is decoded, so a too-old client makes zero successful calls but sees the
|
||||||
|
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
|
||||||
|
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||||
|
result = resultUpdateRequired
|
||||||
|
return connect.NewResponse(&edgev1.ExecuteResponse{
|
||||||
|
RequestId: req.Msg.GetRequestId(),
|
||||||
|
ResultCode: resultUpdateRequired,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
op, ok := s.registry.Lookup(msgType)
|
op, ok := s.registry.Lookup(msgType)
|
||||||
if !ok {
|
if !ok {
|
||||||
result = "unknown_type"
|
result = "unknown_type"
|
||||||
@@ -363,6 +481,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
|||||||
// Subscribe streams the authenticated user's live events with a keep-alive
|
// Subscribe streams the authenticated user's live events with a keep-alive
|
||||||
// heartbeat until the client disconnects.
|
// heartbeat until the client disconnects.
|
||||||
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
|
||||||
|
// A stream carries no result_code, so a too-old client is refused with the Connect
|
||||||
|
// FailedPrecondition counterpart of the update_required sentinel.
|
||||||
|
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
|
||||||
|
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
|
||||||
|
}
|
||||||
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -22,8 +22,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
||||||
// client plus a cleanup func.
|
// client plus a cleanup func. The client-version gate is off.
|
||||||
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||||
|
return newEdgeMin(t, "", backendHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
|
||||||
|
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
backendSrv := httptest.NewServer(backendHandler)
|
backendSrv := httptest.NewServer(backendHandler)
|
||||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||||
@@ -31,12 +36,40 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
|
|||||||
t.Fatalf("backendclient: %v", err)
|
t.Fatalf("backendclient: %v", err)
|
||||||
}
|
}
|
||||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: transcode.NewRegistry(backend, nil),
|
Registry: transcode.NewRegistry(backend, nil),
|
||||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||||
Limiter: ratelimit.New(),
|
Limiter: ratelimit.New(),
|
||||||
Hub: push.NewHub(0),
|
Hub: push.NewHub(0),
|
||||||
RateLimit: config.DefaultRateLimit(),
|
RateLimit: config.DefaultRateLimit(),
|
||||||
Heartbeat: 15 * time.Second,
|
Heartbeat: 15 * time.Second,
|
||||||
|
MinClientVersion: minVersion,
|
||||||
|
})
|
||||||
|
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||||
|
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||||
|
return client, func() {
|
||||||
|
edgeSrv.Close()
|
||||||
|
_ = backend.Close()
|
||||||
|
backendSrv.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
|
||||||
|
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
|
||||||
|
t.Helper()
|
||||||
|
backendSrv := httptest.NewServer(backendHandler)
|
||||||
|
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backendclient: %v", err)
|
||||||
|
}
|
||||||
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
|
Registry: transcode.NewRegistry(backend, nil),
|
||||||
|
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||||
|
Limiter: ratelimit.New(),
|
||||||
|
Hub: push.NewHub(0),
|
||||||
|
RateLimit: config.DefaultRateLimit(),
|
||||||
|
Heartbeat: 15 * time.Second,
|
||||||
|
MinClientVersion: minVersion,
|
||||||
|
RecommendedClientVersion: recommendedVersion,
|
||||||
})
|
})
|
||||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||||
@@ -108,6 +141,132 @@ func TestExecuteGuestGate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
|
||||||
|
// than the configured minimum is turned away with the update_required result code before the
|
||||||
|
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
|
||||||
|
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
|
||||||
|
func TestExecuteVersionGate(t *testing.T) {
|
||||||
|
// The backend answers auth.guest with a session; a gated call never reaches it.
|
||||||
|
backend := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
min string
|
||||||
|
header string
|
||||||
|
msgType string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
|
||||||
|
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
|
||||||
|
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
|
||||||
|
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
|
||||||
|
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
|
||||||
|
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, cleanup := newEdgeMin(t, tc.min, backend)
|
||||||
|
defer cleanup()
|
||||||
|
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header().Set("X-Client-Version", tc.header)
|
||||||
|
}
|
||||||
|
resp, err := client.Execute(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute: %v", err)
|
||||||
|
}
|
||||||
|
if got := resp.Msg.GetResultCode(); got != tc.want {
|
||||||
|
t.Fatalf("result_code = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
|
||||||
|
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
|
||||||
|
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
|
||||||
|
// and a dormant soft tier get no header.
|
||||||
|
func TestExecuteUpdateRecommended(t *testing.T) {
|
||||||
|
backend := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
min string
|
||||||
|
rec string
|
||||||
|
header string
|
||||||
|
wantResult string
|
||||||
|
wantHeader bool
|
||||||
|
}{
|
||||||
|
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
|
||||||
|
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
|
||||||
|
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
|
||||||
|
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
|
||||||
|
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
|
||||||
|
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
|
||||||
|
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
|
||||||
|
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
|
||||||
|
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
|
||||||
|
defer cleanup()
|
||||||
|
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header().Set("X-Client-Version", tc.header)
|
||||||
|
}
|
||||||
|
resp, err := client.Execute(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("execute: %v", err)
|
||||||
|
}
|
||||||
|
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
|
||||||
|
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
|
||||||
|
}
|
||||||
|
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
|
||||||
|
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
|
||||||
|
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
|
||||||
|
// fails Unauthenticated with no session, proving it passed the version gate).
|
||||||
|
func TestSubscribeVersionGate(t *testing.T) {
|
||||||
|
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
min string
|
||||||
|
header string
|
||||||
|
want connect.Code
|
||||||
|
}{
|
||||||
|
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
|
||||||
|
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
|
||||||
|
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
client, cleanup := newEdgeMin(t, tc.min, noBackend)
|
||||||
|
defer cleanup()
|
||||||
|
req := connect.NewRequest(&edgev1.SubscribeRequest{})
|
||||||
|
if tc.header != "" {
|
||||||
|
req.Header().Set("X-Client-Version", tc.header)
|
||||||
|
}
|
||||||
|
stream, err := client.Subscribe(context.Background(), req)
|
||||||
|
if err == nil {
|
||||||
|
for stream.Receive() {
|
||||||
|
}
|
||||||
|
err = stream.Err()
|
||||||
|
}
|
||||||
|
if got := connect.CodeOf(err); got != tc.want {
|
||||||
|
t.Fatalf("code = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
func TestExecuteAuthedRequiresSession(t *testing.T) {
|
||||||
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Error("backend must not be called without a session")
|
t.Error("backend must not be called without a session")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/config"
|
||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,3 +45,20 @@ func TestPerWindow(t *testing.T) {
|
|||||||
t.Fatalf("per-window burst = %v, want [true true false]", got)
|
t.Fatalf("per-window burst = %v, want [true true false]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestEmailBurstAllowsHonestLoginFlow guards the default email-code burst against being
|
||||||
|
// tightened back below the honest login sequence: requesting a code, submitting one wrong
|
||||||
|
// code, then the correct one are three immediate email-class events from one IP, and all
|
||||||
|
// must pass. A burst of 2 denied the correct-code login, which the client mis-read as going
|
||||||
|
// offline and could not recover from on the session-less login screen. This is defence in
|
||||||
|
// depth over the backend's own per-code attempt cap and code TTL.
|
||||||
|
func TestEmailBurstAllowsHonestLoginFlow(t *testing.T) {
|
||||||
|
rl := config.DefaultRateLimit()
|
||||||
|
p := ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst)
|
||||||
|
l := ratelimit.New()
|
||||||
|
for i, want := range []bool{true, true, true} { // request code, wrong code, right code
|
||||||
|
if got := l.Allow("email:198.51.100.7", p); got != want {
|
||||||
|
t.Fatalf("honest email event %d allowed = %v, want %v", i+1, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/
|
|||||||
validation gRPC API the gateway calls during Telegram auth, on the trusted internal
|
validation gRPC API the gateway calls during Telegram auth, on the trusted internal
|
||||||
network with no VPN. Because it needs no Telegram reachability, **game login stays up
|
network with no VPN. Because it needs no Telegram reachability, **game login stays up
|
||||||
even when the bot or the bot-link is down**.
|
even when the bot or the bot-link is down**.
|
||||||
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start`
|
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
|
||||||
deep-links) and `sendMessage`, the only component reaching the Telegram Bot API. It
|
deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
|
||||||
|
the Telegram Bot API. It
|
||||||
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
|
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
|
||||||
executes the send commands the gateway pushes, so its egress can run on a host with
|
executes the send commands the gateway pushes, so its egress can run on a host with
|
||||||
native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
|
native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
|
||||||
@@ -49,6 +50,13 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
|||||||
chat" — rather than a dangling "@"). This is otherwise **self-contained**
|
chat" — rather than a dangling "@"). This is otherwise **self-contained**
|
||||||
— the bot never calls back into the game, so `/start` onboarding works even when the game
|
— the bot never calls back into the game, so `/start` onboarding works even when the game
|
||||||
is down.
|
is down.
|
||||||
|
- **Support command.** `/support` replies with a fixed support-desk info message — the
|
||||||
|
operators' working hours and what to include (a description and, when possible,
|
||||||
|
screenshots). Like `/start` it answers only in a private chat and is **Russian or
|
||||||
|
English** by the sender's reported language, and it is listed in the bot's command menu
|
||||||
|
(localized, Russian/English). It is a dedicated command handler, so it intercepts
|
||||||
|
`/support` before the support relay below — the command line itself is not forwarded to
|
||||||
|
operators, while the user's following description still is.
|
||||||
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
|
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
|
||||||
group, the bot gates who may write there. The group **allows sending by default** (a
|
group, the bot gates who may write there. The group **allows sending by default** (a
|
||||||
human setting) and the bot only **restricts** — Telegram intersects the chat default with
|
human setting) and the bot only **restricts** — Telegram intersects the chat default with
|
||||||
@@ -62,7 +70,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
|||||||
the bot applies it — but only to a member currently in the chat (it probes one user with
|
the bot applies it — but only to a member currently in the chat (it probes one user with
|
||||||
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
|
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
|
||||||
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
|
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
|
||||||
`chat_member` updates, which Telegram delivers only to a chat admin.
|
`chat_member` updates, which Telegram delivers only to a chat admin. The bot also **removes
|
||||||
|
the automatic pin** Telegram places on every channel post auto-forwarded into this linked
|
||||||
|
chat (the `Message.is_automatic_forward` marker): it unpins only that one message
|
||||||
|
(`unpinChatMessage` by id), so a pin set by a human admin — or by the bot for another
|
||||||
|
message — is never touched. This needs the **pin-messages** right (`can_pin_messages`) in
|
||||||
|
addition to "Ban users"; a startup self-check warns when it is missing.
|
||||||
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
|
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
|
||||||
supergroup**, the bot runs a direct support channel for users who message it. A user's first
|
supergroup**, the bot runs a direct support channel for users who message it. A user's first
|
||||||
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
|
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
|
||||||
@@ -76,7 +89,8 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
|||||||
message. State (user→topic map, block list, relayed ids) is a JSON file under
|
message. State (user→topic map, block list, relayed ids) is a JSON file under
|
||||||
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
|
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
|
||||||
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
|
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
|
||||||
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
|
The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
|
||||||
|
reply (the `/support` command above aside).
|
||||||
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
|
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
|
||||||
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
|
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
|
||||||
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
|
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
||||||
// runs the long-poll update loop — replying to /start (with an optional deep-link
|
// runs the long-poll update loop — replying to /start (with an optional deep-link
|
||||||
// payload) and any other message with a Mini App launch button — and sends the
|
// payload), to /support with the support-desk info message, and to any other message
|
||||||
|
// with a Mini App launch button — and sends the
|
||||||
// notification and admin messages the connector requests. The bot token lives only
|
// notification and admin messages the connector requests. The bot token lives only
|
||||||
// in this process.
|
// in this process.
|
||||||
package bot
|
package bot
|
||||||
@@ -136,6 +137,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|||||||
opts := []tgbot.Option{
|
opts := []tgbot.Option{
|
||||||
tgbot.WithDefaultHandler(t.handleUpdate),
|
tgbot.WithDefaultHandler(t.handleUpdate),
|
||||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||||
|
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
|
||||||
}
|
}
|
||||||
if t.supportEnabled() {
|
if t.supportEnabled() {
|
||||||
t.supportLocks = newKeyedMutex()
|
t.supportLocks = newKeyedMutex()
|
||||||
@@ -186,11 +188,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|||||||
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
||||||
// long-poll update loop until ctx is cancelled.
|
// long-poll update loop until ctx is cancelled.
|
||||||
func (t *Bot) Run(ctx context.Context) {
|
func (t *Bot) Run(ctx context.Context) {
|
||||||
|
// Command menu: the default (fallback) list is English; a Russian-scoped list
|
||||||
|
// localises the labels for ru clients. A language scope replaces the whole list, so
|
||||||
|
// it repeats /start with its own Russian label.
|
||||||
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
||||||
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
|
Commands: []models.BotCommand{
|
||||||
|
{Command: "start", Description: "Play Scrabble"},
|
||||||
|
{Command: "support", Description: "Contact the administration"},
|
||||||
|
},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.log.Warn("set commands failed", zap.Error(err))
|
t.log.Warn("set commands failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
||||||
|
LanguageCode: "ru",
|
||||||
|
Commands: []models.BotCommand{
|
||||||
|
{Command: "start", Description: "Играть в «Эрудита»"},
|
||||||
|
{Command: "support", Description: "Связаться с администрацией"},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
t.log.Warn("set ru commands failed", zap.Error(err))
|
||||||
|
}
|
||||||
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
||||||
MenuButton: models.MenuButtonWebApp{
|
MenuButton: models.MenuButtonWebApp{
|
||||||
Type: models.MenuButtonTypeWebApp,
|
Type: models.MenuButtonTypeWebApp,
|
||||||
@@ -268,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
|
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
|
||||||
|
// The same moderated chat also gets the linked channel's auto-forwarded posts un-pinned,
|
||||||
|
// which needs the pin-messages right; warn (separately from gating) when it is missing.
|
||||||
|
if !m.Administrator.CanPinMessages {
|
||||||
|
t.log.Warn("auto-unpin of linked channel posts WILL NOT WORK: the bot lacks the pin-messages right",
|
||||||
|
zap.Int64("chat_id", t.chatID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify sends a notification message with a Mini App launch button that opens the
|
// Notify sends a notification message with a Mini App launch button that opens the
|
||||||
@@ -388,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
|||||||
t.handleSuccessfulPayment(ctx, update.Message)
|
t.handleSuccessfulPayment(ctx, update.Message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A channel post automatically forwarded into the moderated discussion chat is
|
||||||
|
// auto-pinned by Telegram (a non-disableable behaviour). Unpin that one message so only
|
||||||
|
// deliberate pins remain; it targets this exact message id, so a pin set by a human admin
|
||||||
|
// — or by the bot for another message — is never touched.
|
||||||
|
if m := update.Message; m != nil && m.IsAutomaticForward && t.chatID != 0 && m.Chat.ID == t.chatID {
|
||||||
|
t.handleLinkedChannelPin(ctx, m)
|
||||||
|
return
|
||||||
|
}
|
||||||
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||||
// is either an operator's reply in the support chat or a user's direct message to
|
// is either an operator's reply in the support chat or a user's direct message to
|
||||||
// relay. Everything else falls through to the Mini App launch reply.
|
// relay. Everything else falls through to the Mini App launch reply.
|
||||||
@@ -404,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
|||||||
t.handleStart(ctx, api, update)
|
t.handleStart(ctx, api, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleLinkedChannelPin removes Telegram's automatic pin from a linked channel's post
|
||||||
|
// that was auto-forwarded into the moderated discussion chat. It unpins only that exact
|
||||||
|
// message (by id), so a pin set by a human admin — or by the bot for another message — is
|
||||||
|
// left untouched. It needs the bot to hold the pin-messages right in the chat; a missing
|
||||||
|
// right surfaces as a warning (the unpin then no-ops), not a crash.
|
||||||
|
func (t *Bot) handleLinkedChannelPin(ctx context.Context, m *models.Message) {
|
||||||
|
if _, err := t.api.UnpinChatMessage(ctx, &tgbot.UnpinChatMessageParams{
|
||||||
|
ChatID: m.Chat.ID,
|
||||||
|
MessageID: m.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.log.Warn("could not unpin an auto-forwarded channel post; does the bot have the pin-messages right?",
|
||||||
|
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.log.Debug("unpinned an auto-forwarded channel post",
|
||||||
|
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID))
|
||||||
|
}
|
||||||
|
|
||||||
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
|
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
|
||||||
// bot-link client backing it is built after the bot).
|
// bot-link client backing it is built after the bot).
|
||||||
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
|
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
|
||||||
// scripted getChatMember status, and records restrictChatMember calls.
|
// scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
|
||||||
type chatAPI struct {
|
type chatAPI struct {
|
||||||
memberStatus string // the status getChatMember reports (default "left")
|
memberStatus string // the status getChatMember reports (default "left")
|
||||||
restricts []restrictCall
|
restricts []restrictCall
|
||||||
|
unpins []unpinCall
|
||||||
}
|
}
|
||||||
|
|
||||||
type restrictCall struct {
|
type restrictCall struct {
|
||||||
@@ -30,6 +31,13 @@ type restrictCall struct {
|
|||||||
canSend bool // can_send_messages in the applied permissions
|
canSend bool // can_send_messages in the applied permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unpinCall records a single unpinChatMessage request (raw form values, mirroring the
|
||||||
|
// restrictCall style).
|
||||||
|
type unpinCall struct {
|
||||||
|
chatID string
|
||||||
|
messageID string
|
||||||
|
}
|
||||||
|
|
||||||
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
||||||
@@ -47,6 +55,9 @@ func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
|
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
|
||||||
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
||||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/unpinChatMessage"):
|
||||||
|
a.unpins = append(a.unpins, unpinCall{chatID: r.FormValue("chat_id"), messageID: r.FormValue("message_id")})
|
||||||
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||||
default:
|
default:
|
||||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||||
}
|
}
|
||||||
@@ -237,3 +248,44 @@ func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoForwardUpdate builds a message update as it lands in the discussion chat: a post in
|
||||||
|
// chat chatID with id msgID and is_automatic_forward set to auto.
|
||||||
|
func autoForwardUpdate(chatID int64, msgID int, auto bool) *models.Update {
|
||||||
|
return &models.Update{Message: &models.Message{
|
||||||
|
ID: msgID,
|
||||||
|
Chat: models.Chat{ID: chatID, Type: models.ChatTypeSupergroup},
|
||||||
|
IsAutomaticForward: auto,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoForwardUnpinned(t *testing.T) {
|
||||||
|
// A channel post auto-forwarded into the moderated chat is unpinned by its exact id.
|
||||||
|
api := &chatAPI{}
|
||||||
|
b := newChatBot(t, api)
|
||||||
|
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, true))
|
||||||
|
if len(api.unpins) != 1 || api.unpins[0].chatID != "555" || api.unpins[0].messageID != "42" {
|
||||||
|
t.Fatalf("unpins = %+v, want one {555, 42}", api.unpins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonAutoForwardNotUnpinned(t *testing.T) {
|
||||||
|
// A normal message in the moderated chat (e.g. another admin's pinned message) is never
|
||||||
|
// unpinned — the is_automatic_forward filter is what guards every other pin.
|
||||||
|
api := &chatAPI{}
|
||||||
|
b := newChatBot(t, api)
|
||||||
|
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, false))
|
||||||
|
if len(api.unpins) != 0 {
|
||||||
|
t.Fatalf("unpins = %+v, want none for a non-auto-forward message", api.unpins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoForwardOtherChatIgnored(t *testing.T) {
|
||||||
|
// An auto-forward in some other chat (not the configured moderated one) is ignored.
|
||||||
|
api := &chatAPI{}
|
||||||
|
b := newChatBot(t, api)
|
||||||
|
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(999, 42, true))
|
||||||
|
if len(api.unpins) != 0 {
|
||||||
|
t.Fatalf("unpins = %+v, want none for a foreign chat", api.unpins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tgbot "github.com/go-telegram/bot"
|
||||||
|
"github.com/go-telegram/bot/models"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The /support command replies with a fixed support-desk info message — the operators'
|
||||||
|
// working hours and what to include (a description and, when possible, screenshots). It
|
||||||
|
// is a dedicated command handler (registered like /start), so it intercepts "/support"
|
||||||
|
// before the support relay: the command line itself is not forwarded into an operator
|
||||||
|
// topic, while the user's following description still is.
|
||||||
|
|
||||||
|
// handleSupport replies to /support with the localized support-desk info message. Like
|
||||||
|
// handleStart it answers only in a private chat — in the moderated group the bot never
|
||||||
|
// chats — and it sends a plain text message with no launch button.
|
||||||
|
func (t *Bot) handleSupport(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||||
|
if update.Message == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if update.Message.Chat.Type != models.ChatTypePrivate {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The sender's Telegram language rides on the message itself (Message.from.language_code);
|
||||||
|
// fall back to English when it is absent, matching startText.
|
||||||
|
lang := ""
|
||||||
|
if update.Message.From != nil {
|
||||||
|
lang = update.Message.From.LanguageCode
|
||||||
|
}
|
||||||
|
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||||
|
ChatID: update.Message.Chat.ID,
|
||||||
|
Text: supportText(lang),
|
||||||
|
}); err != nil {
|
||||||
|
t.log.Warn("reply to support failed", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// supportText returns the localized /support reply. Russian is used when lang (the IETF
|
||||||
|
// language tag the Telegram client reports on the message's sender) starts with "ru",
|
||||||
|
// English otherwise and when it is absent — mirroring startText's language choice.
|
||||||
|
func supportText(lang string) string {
|
||||||
|
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||||
|
return ruSupport
|
||||||
|
}
|
||||||
|
return enSupport
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruSupport and enSupport are the Russian and English /support replies; the English one
|
||||||
|
// is the fallback for any non-Russian or missing sender language.
|
||||||
|
const (
|
||||||
|
ruSupport = "Поддержка «Эрудита» на связи с 08:00 до 18:00 (по времени UTC). " +
|
||||||
|
"Если Вы столкнулись с проблемой, подробно опишите её и добавьте, по возможности, скриншоты. " +
|
||||||
|
"Также будем рады выслушать Ваши пожелания и предложения по функционалу игры. " +
|
||||||
|
"Ответим в самое ближайшее время."
|
||||||
|
enSupport = "“Erudite” support is available from 08:00 to 18:00 (UTC). " +
|
||||||
|
"If you have run into a problem, please describe it in detail and attach screenshots if you can. " +
|
||||||
|
"We would also be glad to hear your wishes and suggestions about the game's features. " +
|
||||||
|
"We will reply as soon as possible."
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-telegram/bot/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleSupportRepliesPrivateOnly(t *testing.T) {
|
||||||
|
t.Run("english by default", func(t *testing.T) {
|
||||||
|
api := &fakeBotAPI{}
|
||||||
|
b := newTestBot(t, api)
|
||||||
|
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
|
||||||
|
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
|
||||||
|
}})
|
||||||
|
if api.chatID != "42" {
|
||||||
|
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||||
|
}
|
||||||
|
// No reported language -> English support reply.
|
||||||
|
if !strings.Contains(api.text, "support is available") {
|
||||||
|
t.Errorf("text = %q, want the English support reply", api.text)
|
||||||
|
}
|
||||||
|
// A plain info message carries no launch button.
|
||||||
|
if api.replyMarkup != "" {
|
||||||
|
t.Errorf("reply_markup = %q, want none", api.replyMarkup)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("russian for a ru sender", func(t *testing.T) {
|
||||||
|
api := &fakeBotAPI{}
|
||||||
|
b := newTestBot(t, api)
|
||||||
|
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
|
||||||
|
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
|
||||||
|
From: &models.User{ID: 7, LanguageCode: "ru"},
|
||||||
|
}})
|
||||||
|
if !strings.Contains(api.text, "Поддержка «Эрудита»") {
|
||||||
|
t.Errorf("text = %q, want the Russian support reply", api.text)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("group ignored", func(t *testing.T) {
|
||||||
|
api := &fakeBotAPI{}
|
||||||
|
b := newTestBot(t, api)
|
||||||
|
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
|
||||||
|
Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/support",
|
||||||
|
}})
|
||||||
|
if api.chatID != "" {
|
||||||
|
t.Errorf("group /support got a reply (chat=%q); the bot never chats in the group", api.chatID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutorCreateInvoice(t *testing.T) {
|
func TestExecutorCreateInvoice(t *testing.T) {
|
||||||
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
|
sender := &fakeSender{invoiceLink: "https://telegram.me/$abc"}
|
||||||
exec := NewExecutor(sender, 0, nil)
|
exec := NewExecutor(sender, 0, nil)
|
||||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||||
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
if !delivered || result != "https://t.me/$abc" {
|
if !delivered || result != "https://telegram.me/$abc" {
|
||||||
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
||||||
}
|
}
|
||||||
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ type BotConfig struct {
|
|||||||
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
|
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
|
||||||
BotUsername string
|
BotUsername string
|
||||||
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
|
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
|
||||||
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo
|
// share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me/<bot>/<app>. The promo
|
||||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||||
BotLinkURL string
|
BotLinkURL string
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
|||||||
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
||||||
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
||||||
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
|
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
|
||||||
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app")
|
t.Setenv("TELEGRAM_BOT_LINK", "https://telegram.me/ScrabbleBot/app")
|
||||||
c, err := LoadBot()
|
c, err := LoadBot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadBot: %v", err)
|
t.Fatalf("LoadBot: %v", err)
|
||||||
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
|||||||
if c.BotUsername != "ScrabbleBot" {
|
if c.BotUsername != "ScrabbleBot" {
|
||||||
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
|
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
|
||||||
}
|
}
|
||||||
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" {
|
if c.BotLinkURL != "https://telegram.me/ScrabbleBot/app" {
|
||||||
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
|
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPromoTextLocalization(t *testing.T) {
|
func TestPromoTextLocalization(t *testing.T) {
|
||||||
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
const url = "https://telegram.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||||
// The @username is rendered as a clickable deep link (the same target as the button),
|
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||||
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
||||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||||
if got := b.launchURL(""); got != "https://t.me/bot/app" {
|
if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
|
||||||
t.Errorf("empty payload = %q, want the base link unchanged", got)
|
t.Errorf("empty payload = %q, want the base link unchanged", got)
|
||||||
}
|
}
|
||||||
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" {
|
if got := b.launchURL("g123"); got != "https://telegram.me/bot/app?startapp=g123" {
|
||||||
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
|
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLaunchMarkupIsURLButton(t *testing.T) {
|
func TestLaunchMarkupIsURLButton(t *testing.T) {
|
||||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||||
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
|
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
|
||||||
if btn.WebApp != nil {
|
if btn.WebApp != nil {
|
||||||
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
|
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
|
||||||
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
|
|||||||
api := &fakeAPI{}
|
api := &fakeAPI{}
|
||||||
srv := httptest.NewServer(api)
|
srv := httptest.NewServer(api)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop())
|
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://telegram.me/bot/app"}, zap.NewNop())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
|
|||||||
if api.chatID != "42" {
|
if api.chatID != "42" {
|
||||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
if !strings.Contains(api.text, `<a href="https://telegram.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||||
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||||
}
|
}
|
||||||
if strings.Contains(api.replyMarkup, "web_app") {
|
if strings.Contains(api.replyMarkup, "web_app") {
|
||||||
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
|
|||||||
api := &fakeAPI{}
|
api := &fakeAPI{}
|
||||||
srv := httptest.NewServer(api)
|
srv := httptest.NewServer(api)
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop())
|
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://telegram.me/b/a"}, zap.NewNop())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ ENV APP_VERSION=${VERSION}
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /src/renderer/node_modules ./node_modules
|
COPY --from=build /src/renderer/node_modules ./node_modules
|
||||||
COPY --from=build /src/renderer/dist ./dist
|
COPY --from=build /src/renderer/dist ./dist
|
||||||
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
|
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs renderer/src/legal.mjs ./src/
|
||||||
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
|
# The public legal pages: the owner-edited markdown, read once at boot. GET /offer/ also splices in
|
||||||
# price list is fetched from the backend at request time and spliced in.
|
# the live price list fetched from the backend at request time; GET /privacy/ and GET /eula/ are
|
||||||
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
|
# static (no dynamic data).
|
||||||
|
COPY ui/legal/offer_ru.md ui/legal/offer_en.md ui/legal/privacy_ru.md ui/legal/privacy_en.md ui/legal/eula_ru.md ui/legal/eula_en.md ./legal/
|
||||||
USER node
|
USER node
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
CMD ["node", "src/server.mjs"]
|
CMD ["node", "src/server.mjs"]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
# renderer — the render sidecar (finished-game image + public offer page)
|
# renderer — the render sidecar (finished-game image + public legal pages)
|
||||||
|
|
||||||
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
|
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
|
||||||
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
|
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
|
||||||
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
|
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
|
||||||
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
|
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
|
||||||
signed off) and the public **offer page**.
|
signed off) and the public **legal pages** (the offer, the privacy policy and the EULA), each
|
||||||
|
bilingual (RU + EN) with a language + theme switcher.
|
||||||
|
|
||||||
## Interface
|
## Interface
|
||||||
|
|
||||||
@@ -16,9 +17,16 @@ signed off) and the public **offer page**.
|
|||||||
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
|
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
|
||||||
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
|
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
|
||||||
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
|
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
|
||||||
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
|
and spliced in at the `<#pricing_template#>` marker of both language sources (`offer_ru.md` /
|
||||||
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
|
`offer_en.md`), then rendered as one bilingual page by the shared `ui/src/lib/offer.ts`
|
||||||
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
|
`renderLegalHtml` (the English view transliterates the Russian product names client-side).
|
||||||
|
`GET /offer` → 301 `/offer/`. Caddy routes `/offer/` here; a backend outage yields a 502, never a
|
||||||
|
stale price list.
|
||||||
|
- `GET /privacy/` — the privacy policy, and `GET /eula/` the end-user licence agreement, both as
|
||||||
|
`text/html`: the owner-edited `ui/legal/{privacy,eula}_{ru,en}.md` (baked in, read at boot),
|
||||||
|
rendered once at boot as one bilingual page by the same shared `renderLegalHtml` and served from
|
||||||
|
cache. **Static** — no backend fetch. `GET /privacy` / `GET /eula` → 301 to the trailing-slash
|
||||||
|
form. Caddy routes these here too.
|
||||||
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
|
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
|
||||||
|
|
||||||
The service renders and nothing else: for the PNG, authentication, the participant check and the
|
The service renders and nothing else: for the PNG, authentication, the participant check and the
|
||||||
@@ -32,10 +40,14 @@ page.
|
|||||||
```sh
|
```sh
|
||||||
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
|
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
|
||||||
# (allowBuilds) or the native binary is silently never fetched
|
# (allowBuilds) or the native binary is silently never fetched
|
||||||
pnpm test # bundles, then node --test (PNG smoke + offer splice)
|
pnpm test # bundles, then node --test (PNG smoke + offer/legal render)
|
||||||
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
|
# local run on :8090 (RENDERER_PORT overrides). The server reads all three legal sources at boot, so
|
||||||
# reachable backend (else the boot read / the price fetch fail):
|
# point each at its ui/legal source (the baked paths do not exist in a local checkout); /offer/ also
|
||||||
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
|
# needs a reachable backend for the price fetch:
|
||||||
|
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_OFFER_EN_MD=../ui/legal/offer_en.md \
|
||||||
|
RENDERER_PRIVACY_MD=../ui/legal/privacy_ru.md RENDERER_PRIVACY_EN_MD=../ui/legal/privacy_en.md \
|
||||||
|
RENDERER_EULA_MD=../ui/legal/eula_ru.md RENDERER_EULA_EN_MD=../ui/legal/eula_en.md \
|
||||||
|
RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
|
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
|
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
|
||||||
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
|
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
|
||||||
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
|
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
|
||||||
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
|
// - renderOfferHtml / renderLegalHtml: the public legal pages — GET /offer/ (with the live
|
||||||
// used to invoke, now server-side so the live catalog price list can be spliced in.
|
// catalog price list spliced in), GET /privacy/ and GET /eula/ (static) — server-side so one
|
||||||
|
// renderer serves them all.
|
||||||
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
|
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
|
||||||
export { setAlphabet } from '../../ui/src/lib/alphabet';
|
export { setAlphabet } from '../../ui/src/lib/alphabet';
|
||||||
export { renderOfferHtml } from '../../ui/src/lib/offer';
|
export { renderOfferHtml, renderLegalHtml } from '../../ui/src/lib/offer';
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The public legal pages (GET /privacy/, GET /eula/): the owner-edited RU + EN markdown under
|
||||||
|
// ui/legal/, rendered by the SAME shared renderLegalHtml the offer uses (bundled from ui/src/lib/offer)
|
||||||
|
// into one bilingual page with a language + theme switcher. Unlike the offer, these carry no dynamic
|
||||||
|
// data — no backend fetch, no marker splice. Kept out of server.mjs so the per-page title/canonical
|
||||||
|
// presets are unit-testable without HTTP (test/legal.test.mjs).
|
||||||
|
import { renderLegalHtml } from '../dist/gameimage.mjs';
|
||||||
|
|
||||||
|
// renderPrivacy renders the privacy-policy markdown (RU + EN) as the standalone /privacy/ page.
|
||||||
|
export function renderPrivacy(ruMd, enMd) {
|
||||||
|
return renderLegalHtml({
|
||||||
|
ru: { md: ruMd, title: 'Политика конфиденциальности — Эрудит' },
|
||||||
|
en: { md: enMd, title: 'Privacy Policy — Erudit' },
|
||||||
|
canonical: 'https://erudit-game.ru/privacy/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderEula renders the end-user licence agreement markdown (RU + EN) as the standalone /eula/ page.
|
||||||
|
export function renderEula(ruMd, enMd) {
|
||||||
|
return renderLegalHtml({
|
||||||
|
ru: { md: ruMd, title: 'Пользовательское соглашение — Эрудит' },
|
||||||
|
en: { md: enMd, title: 'Terms of Use — Erudit' },
|
||||||
|
canonical: 'https://erudit-game.ru/eula/',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
|
// The public-offer page renderer: splices the live catalog price list into BOTH language sources of
|
||||||
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
|
// the owner-edited offer markdown at the pricing marker, and renders the bilingual /offer/ page with
|
||||||
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
|
// the SAME renderOfferHtml the browser build uses (bundled from ui/src/lib/offer). The English view
|
||||||
|
// transliterates the Russian product names client-side (renderLegalHtml). Kept out of server.mjs so
|
||||||
|
// the substitution is unit-testable without HTTP.
|
||||||
import { renderOfferHtml } from '../dist/gameimage.mjs';
|
import { renderOfferHtml } from '../dist/gameimage.mjs';
|
||||||
|
|
||||||
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
|
// pricingMarker is the token ui/legal/offer_{ru,en}.md carry at §4.4 where the price list belongs. It
|
||||||
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
|
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
|
||||||
// tables before rendering.
|
// tables before rendering.
|
||||||
export const pricingMarker = '<#pricing_template#>';
|
export const pricingMarker = '<#pricing_template#>';
|
||||||
|
|
||||||
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
|
// renderOffer returns the standalone /offer/ HTML: the RU + EN offer markdown with the pricing marker
|
||||||
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
|
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
|
||||||
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
|
// rendered to a self-contained bilingual document. The replacement is literal (a function replacer)
|
||||||
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
|
// so a "$" in a product title is never read as a replacement pattern; a missing marker leaves the
|
||||||
export function renderOffer(offerMarkdown, pricingTables) {
|
// text as is.
|
||||||
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
|
export function renderOffer(ruMarkdown, enMarkdown, pricingTables) {
|
||||||
|
const splice = (md) => md.replace(pricingMarker, () => pricingTables);
|
||||||
|
return renderOfferHtml(splice(ruMarkdown), splice(enMarkdown));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,44 @@
|
|||||||
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
|
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
|
||||||
//
|
//
|
||||||
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
|
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
|
||||||
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
|
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
|
||||||
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
|
// price list spliced in; caddy routes /offer/ here)
|
||||||
// GET /offer → 301 /offer/
|
// GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static)
|
||||||
// GET /healthz → 200
|
// GET /eula/ → text/html (the EULA: ui/legal/eula_ru.md, static)
|
||||||
|
// GET /offer, /privacy, /eula → 301 to the trailing-slash form
|
||||||
|
// GET /healthz → 200
|
||||||
//
|
//
|
||||||
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
|
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
|
||||||
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
|
// public URL live in the backend). The legal pages (/offer/, /privacy/, /eula/) are reachable from the
|
||||||
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
|
// edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's
|
||||||
// markdown; no user input reaches it.
|
// internal endpoint into the committed offer markdown; /privacy/ and /eula/ are static committed
|
||||||
|
// markdown. No user input reaches any of them.
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { renderRequest } from './render.mjs';
|
import { renderRequest } from './render.mjs';
|
||||||
import { renderOffer } from './offer.mjs';
|
import { renderOffer } from './offer.mjs';
|
||||||
|
import { renderPrivacy, renderEula } from './legal.mjs';
|
||||||
|
|
||||||
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
||||||
// A render request is a finished game's journal — generously capped.
|
// A render request is a finished game's journal — generously capped.
|
||||||
const MAX_BODY = 2 * 1024 * 1024;
|
const MAX_BODY = 2 * 1024 * 1024;
|
||||||
|
|
||||||
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
|
// Each legal page is bilingual (RU + EN); both markdown sources are baked into the image
|
||||||
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
|
// (renderer/Dockerfile) and read once at boot. The offer's price list is the only dynamic part
|
||||||
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
|
// (fetched per request from the backend's internal endpoint and spliced in); privacy + eula carry no
|
||||||
// path for a local run (point it at ../ui/legal/offer_ru.md).
|
// dynamic data. The RENDERER_*_MD / RENDERER_*_EN_MD env vars override the baked paths for a local run.
|
||||||
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
|
const rd = (p) => readFileSync(p, 'utf8');
|
||||||
|
const OFFER_MD = rd(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url));
|
||||||
|
const OFFER_EN_MD = rd(process.env.RENDERER_OFFER_EN_MD || new URL('../legal/offer_en.md', import.meta.url));
|
||||||
|
const PRIVACY_MD = rd(process.env.RENDERER_PRIVACY_MD || new URL('../legal/privacy_ru.md', import.meta.url));
|
||||||
|
const PRIVACY_EN_MD = rd(process.env.RENDERER_PRIVACY_EN_MD || new URL('../legal/privacy_en.md', import.meta.url));
|
||||||
|
const EULA_MD = rd(process.env.RENDERER_EULA_MD || new URL('../legal/eula_ru.md', import.meta.url));
|
||||||
|
const EULA_EN_MD = rd(process.env.RENDERER_EULA_EN_MD || new URL('../legal/eula_en.md', import.meta.url));
|
||||||
|
// Render the static legal pages once at boot and serve the cached HTML: re-parsing the large EULA on
|
||||||
|
// every request is slow enough under deploy-time host contention (the renderer is CPU/memory-capped)
|
||||||
|
// to flake the contour probe. The offer stays per-request because it splices in the live price list.
|
||||||
|
const PRIVACY_HTML = renderPrivacy(PRIVACY_MD, PRIVACY_EN_MD);
|
||||||
|
const EULA_HTML = renderEula(EULA_MD, EULA_EN_MD);
|
||||||
const OFFER_PRICING_URL =
|
const OFFER_PRICING_URL =
|
||||||
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
|
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
|
||||||
|
|
||||||
@@ -68,12 +83,28 @@ const server = createServer(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (req.method === 'GET' && path === '/offer/') {
|
if (req.method === 'GET' && path === '/offer/') {
|
||||||
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
|
const html = renderOffer(OFFER_MD, OFFER_EN_MD, await fetchOfferPricing());
|
||||||
res
|
res
|
||||||
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
||||||
.end(html);
|
.end(html);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (req.method === 'GET' && (path === '/privacy' || path === '/eula')) {
|
||||||
|
res.writeHead(301, { location: path + '/' }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/privacy/') {
|
||||||
|
res
|
||||||
|
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
||||||
|
.end(PRIVACY_HTML);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/eula/') {
|
||||||
|
res
|
||||||
|
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
||||||
|
.end(EULA_HTML);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === 'POST' && path === '/render') {
|
if (req.method === 'POST' && path === '/render') {
|
||||||
const png = await renderRequest(JSON.parse(await readBody(req)));
|
const png = await renderRequest(JSON.parse(await readBody(req)));
|
||||||
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
|
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Unit test for the static legal pages: renderPrivacy / renderEula wrap the owner-edited RU + EN
|
||||||
|
// markdown in the shared bilingual legal-page chrome (bundled renderLegalHtml) with the right title +
|
||||||
|
// canonical URL and no dynamic data. The prose rendering itself is unit-tested in ui/ (offer.test.ts);
|
||||||
|
// here we assert the per-page presets and that both languages reach the document.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { renderPrivacy, renderEula } from '../src/legal.mjs';
|
||||||
|
|
||||||
|
test('renderPrivacy wraps the RU + EN markdown in the privacy-page chrome', () => {
|
||||||
|
const html = renderPrivacy(
|
||||||
|
'# Политика конфиденциальности\n\n**1.1.** ИНН 290210610742.',
|
||||||
|
'# Privacy Policy\n\n**1.1.** TIN 290210610742.',
|
||||||
|
);
|
||||||
|
assert.ok(html.includes('<!doctype html>'), 'a standalone document');
|
||||||
|
assert.ok(html.includes('<title>Политика конфиденциальности — Эрудит</title>'), 'the privacy title');
|
||||||
|
assert.ok(html.includes('data-title-en="Privacy Policy — Erudit"'), 'the English title for the toggle');
|
||||||
|
assert.ok(html.includes('href="https://erudit-game.ru/privacy/"'), 'the privacy canonical URL');
|
||||||
|
assert.ok(html.includes('290210610742'), 'the prose reached the document');
|
||||||
|
assert.ok(html.includes('<main data-lang="en" hidden>'), 'the English body is present, hidden by default');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderEula wraps the RU + EN markdown in the EULA-page chrome', () => {
|
||||||
|
const html = renderEula('# Лицензионное соглашение\n\nтекст', '# Terms of Use\n\ntext');
|
||||||
|
assert.ok(html.includes('<title>Пользовательское соглашение — Эрудит</title>'), 'the EULA title');
|
||||||
|
assert.ok(html.includes('data-title-en="Terms of Use — Erudit"'), 'the English title for the toggle');
|
||||||
|
assert.ok(html.includes('href="https://erudit-game.ru/eula/"'), 'the EULA canonical URL');
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list
|
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list markdown
|
||||||
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
|
// into BOTH language sources of the offer at the pricing marker and renders them with the shared
|
||||||
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
|
// renderOfferHtml (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/
|
||||||
// here we assert the substitution and that the tables reach the rendered HTML.
|
// (offer.test.ts); here we assert the substitution reaches both languages and the tables render.
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { renderOffer, pricingMarker } from '../src/offer.mjs';
|
import { renderOffer, pricingMarker } from '../src/offer.mjs';
|
||||||
@@ -11,19 +11,21 @@ const tables =
|
|||||||
'| --- | --- | --- | --- |\n' +
|
'| --- | --- | --- | --- |\n' +
|
||||||
'| 50 «Фишек» | 200.00 | 30 | 100 |';
|
'| 50 «Фишек» | 200.00 | 30 | 100 |';
|
||||||
|
|
||||||
test('splices the price list into the offer and renders the tables to HTML', () => {
|
test('splices the price list into both language sources and renders the tables', () => {
|
||||||
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
|
const ru = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
|
||||||
const html = renderOffer(md, tables);
|
const en = `# Public offer\n\n**4.4.** Cost of the Goods:\n\n${pricingMarker}\n`;
|
||||||
// The marker is gone and the projected table reached the document as a real HTML table.
|
const html = renderOffer(ru, en, tables);
|
||||||
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
|
// The marker is gone from both languages and the projected table reached the document.
|
||||||
|
assert.ok(!html.includes(pricingMarker), 'the pricing marker must be substituted in both languages');
|
||||||
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
|
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
|
||||||
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
|
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
|
||||||
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
|
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
|
||||||
// The offer chrome from the shared renderer is intact.
|
assert.ok(html.includes('<h1>Публичная оферта</h1>'), 'the Russian heading rendered');
|
||||||
|
assert.ok(html.includes('<h1>Public offer</h1>'), 'the English heading rendered');
|
||||||
assert.ok(html.includes('<!doctype html>'));
|
assert.ok(html.includes('<!doctype html>'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
|
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
|
||||||
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
|
const html = renderOffer(`ru ${pricingMarker}`, `en ${pricingMarker}`, '| $5 pack | 1 |');
|
||||||
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
|
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,8 +29,13 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
|||||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
||||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
friend-invite Mini App link for the single bot (full URL `https://telegram.me/<bot>/<app>`).
|
||||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||||
|
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
||||||
|
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
||||||
|
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
|
||||||
|
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
|
||||||
|
`.gitea/workflows/android-build.yaml`.
|
||||||
|
|
||||||
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
|
||||||
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Release signing material must never be committed (keystore loss/leak risk).
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
|
|
||||||
|
# Generated Config files
|
||||||
|
app/src/main/assets/capacitor.config.json
|
||||||
|
app/src/main/assets/capacitor.plugins.json
|
||||||
|
app/src/main/res/xml/config.xml
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.eruditgame.app"
|
||||||
|
compileSdk = rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
|
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
|
||||||
|
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
|
||||||
|
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
|
||||||
|
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
|
||||||
|
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
|
||||||
|
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "ru.eruditgame.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
|
||||||
|
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
|
||||||
|
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
|
||||||
|
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
|
||||||
|
// otherwise so it is never referenced with a null storeFile.
|
||||||
|
if (hasKeystore) {
|
||||||
|
storeFile file(keystoreFile)
|
||||||
|
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
|
||||||
|
keyAlias System.getenv('ANDROID_KEY_ALIAS')
|
||||||
|
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
|
||||||
|
// of failing.
|
||||||
|
if (hasKeystore) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir{
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
testImplementation "junit:junit:$junitVersion"
|
||||||
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-network')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||